diff --git a/README.md b/README.md index 60f172d5a8..bffd87ffa5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ThingsBoard is an open-source IoT platform for data collection, processing, visu + ## Documentation ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/docs). @@ -14,6 +15,9 @@ ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/d [**Smart energy**](https://thingsboard.io/smart-energy/) [![Smart energy](https://user-images.githubusercontent.com/8308069/152984256-eb48564a-645c-468d-912b-f554b63104a5.gif "Smart energy")](https://thingsboard.io/smart-energy/) +[**SCADA Swimming pool**](https://thingsboard.io/use-cases/scada/) +[![SCADA Swimming pool](https://github.com/user-attachments/assets/0878a2f5-d358-47c5-b295-03b4533685cf "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/) + [**Fleet tracking**](https://thingsboard.io/fleet-tracking/) [![Fleet tracking](https://user-images.githubusercontent.com/8308069/152984528-0054ed55-8b8b-4cda-ba45-02fe95a81222.gif "Fleet tracking")](https://thingsboard.io/fleet-tracking/) diff --git a/application/pom.xml b/application/pom.xml index 557179e918..e2faa21929 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard application @@ -128,6 +128,10 @@ org.thingsboard.common edqs + + org.thingsboard.common + discovery-api + org.thingsboard dao @@ -377,6 +381,44 @@ org.rocksdb rocksdbjni + + dev.langchain4j + langchain4j-open-ai + + + dev.langchain4j + langchain4j-azure-open-ai + + + dev.langchain4j + langchain4j-google-ai-gemini + + + dev.langchain4j + langchain4j-vertex-ai-gemini + + + dev.langchain4j + langchain4j-mistral-ai + + + dev.langchain4j + langchain4j-anthropic + + + dev.langchain4j + langchain4j-bedrock + + + dev.langchain4j + langchain4j-github-models + + + com.azure + azure-core-test + + + diff --git a/application/src/main/data/json/demo/dashboards/rule_engine_statistics.json b/application/src/main/data/json/demo/dashboards/rule_engine_statistics.json index 0cfcebef5d..272de5848f 100644 --- a/application/src/main/data/json/demo/dashboards/rule_engine_statistics.json +++ b/application/src/main/data/json/demo/dashboards/rule_engine_statistics.json @@ -564,6 +564,7 @@ }, "tooltipDateColor": "rgba(0, 0, 0, 0.76)", "tooltipDateInterval": true, + "tooltipHideZeroValues": true, "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", "tooltipBackgroundBlur": 4, "animation": { @@ -977,6 +978,7 @@ }, "tooltipDateColor": "rgba(0, 0, 0, 0.76)", "tooltipDateInterval": true, + "tooltipHideZeroValues": true, "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", "tooltipBackgroundBlur": 4, "animation": { diff --git a/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md b/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md index a0536f113a..f12eaa5fc0 100644 --- a/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md +++ b/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md @@ -32,19 +32,7 @@ docker run --rm -v tb-edge-postgres-data:/volume -v ~/.mytb-edge-data/db:/backup After completing the data migration to the newly created Docker volumes, you'll need to update the volume mounts in your Docker Compose configuration. Modify the `docker-compose.yml` file for ThingsBoard Edge to update the volume settings. -First, please update docker compose file version. Find next snippet: -```text -version: '3.0' -... -``` - -And replace it with: -```text -version: '3.8' -... -``` - -Then update volume mounts. Locate the following snippet: +Update volume mounts. Locate the following snippet: ```text volumes: - ~/.mytb-edge-data:/data diff --git a/application/src/main/data/json/system/scada_symbols/3-phase-voltage-relay-hp.svg b/application/src/main/data/json/system/scada_symbols/3-phase-voltage-relay-hp.svg index f6d881b172..8e1a46978e 100644 --- a/application/src/main/data/json/system/scada_symbols/3-phase-voltage-relay-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/3-phase-voltage-relay-hp.svg @@ -1,6 +1,6 @@ { "title": "HP 3 phase voltage relay", - "description": "Three phase voltage relay with various states and inications.", + "description": "Three phase voltage relay with various states and indications.", "searchTags": [ "energy", "power", @@ -39,7 +39,7 @@ }, { "tag": "firstPhaseValue", - "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.firstPhaseVoltage, 0, null, 0));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.firstPhaseVoltage, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { @@ -49,7 +49,7 @@ }, { "tag": "secondPhaseValue", - "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.secondPhaseVoltage, 0, null, 0));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.secondPhaseVoltage, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { @@ -59,12 +59,12 @@ }, { "tag": "thirdPhaseValue", - "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.thirdPhaseVoltage, 0, null, 0));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.thirdPhaseVoltage, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { @@ -488,6 +488,7 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "V", + "supportsUnitConversion": true, "disabled": false, "visible": true }, 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 index 794e6e61d9..f3c20f2324 100644 --- 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 @@ -57,7 +57,7 @@ }, { "tag": "value", - "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, '', false);\nctx.api.text(element, value);\n", + "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, {units: ctx.properties.valueUnits, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true});\nctx.api.text(element, value);\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -66,7 +66,7 @@ }, { "tag": "valueUnits", - "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "stateRenderFunction": "var units = ctx.api.unitSymbol(ctx.properties.valueUnits);\nctx.api.text(element, units || '');\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -464,8 +464,7 @@ "type": "units", "default": "m³/hr", "fieldClass": "medium-width", - "disabled": false, - "visible": true + "supportsUnitConversion": true }, { "id": "valueDecimals", diff --git a/application/src/main/data/json/system/scada_symbols/conical-tank.svg b/application/src/main/data/json/system/scada_symbols/conical-tank.svg index 6b37adc52e..592508ee30 100644 --- a/application/src/main/data/json/system/scada_symbols/conical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/conical-tank.svg @@ -159,96 +159,51 @@ "name": "Stand", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -261,64 +216,35 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true } ] } 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 index acb895cd5a..5675818fe7 100644 --- a/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg @@ -33,7 +33,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -312,8 +312,8 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", - "disabled": true, + "supportsUnitConversion": true, + "disabled": false, "visible": true }, { @@ -368,6 +368,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -390,16 +398,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/dynamic-horizontal-scale-hp.svg b/application/src/main/data/json/system/scada_symbols/dynamic-horizontal-scale-hp.svg index b3506ae54f..7b11968223 100644 --- a/application/src/main/data/json/system/scada_symbols/dynamic-horizontal-scale-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/dynamic-horizontal-scale-hp.svg @@ -43,7 +43,7 @@ }, { "tag": "maxValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.maxValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -53,7 +53,7 @@ }, { "tag": "minValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.minValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -73,12 +73,12 @@ }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { "tag": "value", - "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, null, ctx.properties.valueDecimals));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, {units: ctx.properties.units, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { @@ -329,16 +329,8 @@ "name": "{i18n:scada.symbol.min-max-value}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minValue", @@ -347,14 +339,9 @@ "default": 0, "required": true, "subLabel": "{i18n:scada.symbol.min-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "maxValue", @@ -363,30 +350,17 @@ "default": 100, "required": true, "subLabel": "{i18n:scada.symbol.max-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "value", "name": "{i18n:scada.symbol.value}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueDecimals", @@ -395,14 +369,12 @@ "default": 0, "required": true, "subLabel": "Decimals", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", "min": 0, "max": 10, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "valueFont", @@ -415,64 +387,36 @@ "weight": "400", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueColor", "name": "{i18n:scada.symbol.value}", "type": "color", "default": "#002878", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "label", "name": "{i18n:scada.symbol.label}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelText", "name": "{i18n:scada.symbol.label}", "type": "text", "default": "Outdoor", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", "fieldClass": "flex", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelFont", @@ -485,64 +429,35 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelColor", "name": "{i18n:scada.symbol.label}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showUnits", "name": "{i18n:scada.symbol.units}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "units", "name": "{i18n:scada.symbol.units}", "type": "units", "default": "°C", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "unitsFont", @@ -555,224 +470,119 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "unitsColor", "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "arrowColor", "name": "{i18n:scada.symbol.arrow-color}", "type": "color", "default": "#666666", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showTarget", "name": "{i18n:scada.symbol.target}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "targetColor", "name": "{i18n:scada.symbol.target}", "type": "color", "default": "#DEDEDE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showTarget", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "showHighCriticalScale", "name": "{i18n:scada.symbol.show-high-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showHighWarningScale", "name": "{i18n:scada.symbol.show-high-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showLowWarningScale", "name": "{i18n:scada.symbol.show-low-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showLowCriticalScale", "name": "{i18n:scada.symbol.show-low-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scaleColor", "name": "{i18n:scada.symbol.scale-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#EBEBEB", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#666666", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/dynamic-vertical-scale-hp.svg b/application/src/main/data/json/system/scada_symbols/dynamic-vertical-scale-hp.svg index 3c52dae1c3..37ba3df7c2 100644 --- a/application/src/main/data/json/system/scada_symbols/dynamic-vertical-scale-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/dynamic-vertical-scale-hp.svg @@ -43,7 +43,7 @@ }, { "tag": "maxValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.maxValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -53,7 +53,7 @@ }, { "tag": "minValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.minValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -73,12 +73,12 @@ }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { "tag": "value", - "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, null, ctx.properties.valueDecimals));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, {units: ctx.properties.units, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { @@ -329,16 +329,8 @@ "name": "{i18n:scada.symbol.min-max-value}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minValue", @@ -347,14 +339,9 @@ "default": 0, "required": true, "subLabel": "{i18n:scada.symbol.min-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "maxValue", @@ -363,30 +350,17 @@ "default": 100, "required": true, "subLabel": "{i18n:scada.symbol.max-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "value", "name": "{i18n:scada.symbol.value}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueDecimals", @@ -395,14 +369,11 @@ "default": 0, "required": true, "subLabel": "Decimals", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 10, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "valueFont", @@ -415,64 +386,33 @@ "weight": "400", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueColor", "name": "{i18n:scada.symbol.value}", "type": "color", "default": "#002878", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "label", "name": "{i18n:scada.symbol.label}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelText", "name": "{i18n:scada.symbol.label}", "type": "text", "default": "Outdoor", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", "fieldClass": "flex", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelFont", @@ -485,64 +425,34 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelColor", "name": "{i18n:scada.symbol.label}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showUnits", "name": "{i18n:scada.symbol.units}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "units", "name": "{i18n:scada.symbol.units}", "type": "units", "default": "°C", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "required": false, + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "unitsFont", @@ -555,224 +465,117 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "unitsColor", "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "arrowColor", "name": "{i18n:scada.symbol.arrow-color}", "type": "color", "default": "#666666", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showTarget", "name": "{i18n:scada.symbol.target}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "targetColor", "name": "{i18n:scada.symbol.target}", "type": "color", "default": "#DEDEDE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showTarget", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "showHighCriticalScale", "name": "{i18n:scada.symbol.show-high-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showHighWarningScale", "name": "{i18n:scada.symbol.show-high-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showLowWarningScale", "name": "{i18n:scada.symbol.show-low-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showLowCriticalScale", "name": "{i18n:scada.symbol.show-low-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scaleColor", "name": "{i18n:scada.symbol.scale-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#EBEBEB", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#666666", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> 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 index 48668e9851..0d82a10b41 100644 --- a/application/src/main/data/json/system/scada_symbols/elevated-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/elevated-tank.svg @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 815, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 815, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -308,7 +308,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -373,6 +373,15 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disableOnProperty": "scale", + "disabled": false, + "visible": true + }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", @@ -386,16 +395,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/energy-meter-hp.svg b/application/src/main/data/json/system/scada_symbols/energy-meter-hp.svg index 606eb1153d..3855d66ec8 100644 --- a/application/src/main/data/json/system/scada_symbols/energy-meter-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/energy-meter-hp.svg @@ -38,12 +38,12 @@ }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { "tag": "value", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.measured, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.measured, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { @@ -353,6 +353,7 @@ "name": "{i18n:scada.symbol.label}", "type": "text", "default": "T1", + "disableOnProperty": "showLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -368,6 +369,7 @@ "weight": "400", "style": "normal" }, + "disableOnProperty": "showLabel", "disabled": false, "visible": true }, @@ -376,6 +378,7 @@ "name": "{i18n:scada.symbol.label}", "type": "color", "default": "#000", + "disableOnProperty": "showLabel", "disabled": false, "visible": true }, @@ -392,6 +395,7 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "kWh", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -406,6 +410,7 @@ "weight": "500", "style": "normal" }, + "disableOnProperty": "showUnits", "disabled": false, "visible": true }, @@ -414,6 +419,7 @@ "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000", + "disableOnProperty": "showUnits", "disabled": false, "visible": true }, diff --git a/application/src/main/data/json/system/scada_symbols/four-rate-energy-meter-hp.svg b/application/src/main/data/json/system/scada_symbols/four-rate-energy-meter-hp.svg index bba67e5fe3..e095c1417f 100644 --- a/application/src/main/data/json/system/scada_symbols/four-rate-energy-meter-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/four-rate-energy-meter-hp.svg @@ -38,7 +38,7 @@ }, { "tag": "export-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.exportValueFont, ctx.properties.exportValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.exportRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.exportValueFont, ctx.properties.exportValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.exportRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { @@ -48,7 +48,7 @@ }, { "tag": "night-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.nightValueFont, ctx.properties.nightValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.nightRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.nightValueFont, ctx.properties.nightValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.nightRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { @@ -58,7 +58,7 @@ }, { "tag": "off-peak-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.offPeakValueFont, ctx.properties.offPeakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.offPeakRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.offPeakValueFont, ctx.properties.offPeakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.offPeakRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { @@ -68,12 +68,12 @@ }, { "tag": "peak-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.peakValueFont, ctx.properties.peakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.peakRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.peakValueFont, ctx.properties.peakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.peakRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { @@ -533,6 +533,7 @@ "group": "{i18n:scada.symbol.off-peak-rate}", "type": "text", "default": "T1", + "disableOnProperty": "showOffPeakLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -549,6 +550,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showOffPeakLabel", "disabled": false, "visible": true }, @@ -558,6 +560,7 @@ "group": "{i18n:scada.symbol.off-peak-rate}", "type": "color", "default": "#000000", + "disableOnProperty": "showOffPeakLabel", "disabled": false, "visible": true }, @@ -609,6 +612,7 @@ "group": "{i18n:scada.symbol.night-rate}", "type": "text", "default": "T2", + "disableOnProperty": "showNightLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -625,6 +629,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showNightLabel", "disabled": false, "visible": true }, @@ -634,6 +639,7 @@ "group": "{i18n:scada.symbol.night-rate}", "type": "color", "default": "#000", + "disableOnProperty": "showNightLabel", "disabled": false, "visible": true }, @@ -685,6 +691,7 @@ "group": "{i18n:scada.symbol.peak-rate}", "type": "text", "default": "T3", + "disableOnProperty": "showPeakLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -701,6 +708,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showPeakLabel", "disabled": false, "visible": true }, @@ -710,6 +718,7 @@ "group": "{i18n:scada.symbol.peak-rate}", "type": "color", "default": "#000", + "disableOnProperty": "showPeakLabel", "disabled": false, "visible": true }, @@ -761,6 +770,7 @@ "group": "{i18n:scada.symbol.export-rate}", "type": "text", "default": "Export", + "disableOnProperty": "showExportLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -777,6 +787,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showExportLabel", "disabled": false, "visible": true }, @@ -786,6 +797,7 @@ "group": "{i18n:scada.symbol.export-rate}", "type": "color", "default": "#000", + "disableOnProperty": "showExportLabel", "disabled": false, "visible": true }, @@ -835,6 +847,7 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "kWh", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -849,6 +862,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showUnits", "disabled": false, "visible": true }, @@ -857,6 +871,7 @@ "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000", + "disableOnProperty": "showUnits", "disabled": false, "visible": true } diff --git a/application/src/main/data/json/system/scada_symbols/heat-pump-hp.svg b/application/src/main/data/json/system/scada_symbols/heat-pump-hp.svg index eed54682cd..b1e643698c 100644 --- a/application/src/main/data/json/system/scada_symbols/heat-pump-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/heat-pump-hp.svg @@ -62,7 +62,7 @@ }, { "tag": "value-text", - "stateRenderFunction": "var valueTextFont = ctx.properties.valueTextFont;\nvar valueTextColor = ctx.properties.valueTextColor;\nvar units = ctx.properties.valueUnits ? ctx.properties.units : null;\nvar currentVolume = ctx.values.temperature;\nvar decimals = Math.floor(ctx.properties.temperatureStep) === ctx.properties.temperatureStep;\nvar valueText = ctx.api.formatValue(currentVolume, decimals ? 0 : 1, units, !decimals);\nctx.api.font(element, valueTextFont, valueTextColor);\nctx.api.text(element, valueText);", + "stateRenderFunction": "var valueTextFont = ctx.properties.valueTextFont;\nvar valueTextColor = ctx.properties.valueTextColor;\nvar currentVolume = ctx.values.temperature;\nvar decimals = Math.floor(ctx.properties.temperatureStep) === ctx.properties.temperatureStep;\nvar valueText = ctx.api.formatValue(currentVolume, {units: ctx.properties.units, decimals: decimals ? 0 : 1, ignoreUnitSymbol: !ctx.properties.valueUnits});\nctx.api.font(element, valueTextFont, valueTextColor);\nctx.api.text(element, valueText);", "actions": null }, { @@ -366,80 +366,49 @@ "name": "{i18n:scada.symbol.colors}", "type": "color", "default": "#FFFFFF", - "required": null, "subLabel": "{i18n:scada.symbol.running}", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "stoppedColor", "name": "{i18n:scada.symbol.colors}", "type": "color", "default": "#666666", - "required": null, "subLabel": "{i18n:scada.symbol.stopped}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "warningColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "criticalColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minTemperature", "name": "{i18n:scada.symbol.temperature}", "type": "number", "default": 10, - "required": null, "subLabel": "Min", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, "rowClass": "column-xs", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "maxTemperature", @@ -448,14 +417,9 @@ "default": 45, "required": true, "subLabel": "Max", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "temperatureStep", @@ -463,31 +427,17 @@ "type": "number", "default": 1, "required": true, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 0.5 + "step": 0.5, + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#002878", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -500,64 +450,33 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-units}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "units", "name": "{i18n:scada.symbol.value-units}", "type": "units", "default": "°C", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "valueBoxBackground", "name": "{i18n:scada.symbol.value-box-background}", "type": "color", "default": "#FFFFFF", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg index f2fc9fb2e0..fc3fbc9ec7 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "arrow", - "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", + "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n element.stroke(ctx.properties.lineColor).fill(ctx.properties.lineColor);\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", "actions": null }, { 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 index 5e0c781941..a2ca312730 100644 --- 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 @@ -57,7 +57,7 @@ }, { "tag": "value", - "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, '', false);\nctx.api.text(element, value);\n", + "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, {units: ctx.properties.valueUnits, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true});\nctx.api.text(element, value);\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -66,7 +66,7 @@ }, { "tag": "valueUnits", - "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "stateRenderFunction": "var units = ctx.api.unitSymbol(ctx.properties.valueUnits);\nctx.api.text(element, units || '');\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -464,6 +464,7 @@ "type": "units", "default": "m³/hr", "fieldClass": "medium-width", + "supportsUnitConversion": true, "disabled": false, "visible": true }, diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg index bb6f5e55a9..6b0df69f0d 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg @@ -38,7 +38,7 @@ }, { "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 = 3;\n var majorIntervalLength = 592 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(208, y, 240, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 198, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 198, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 198, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(220, minorY, 240, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "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 = 3;\n var majorIntervalLength = 592 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(208, y, 240, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.majorUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 198, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 198, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 198, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(220, minorY, 240, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -346,6 +346,24 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": false, + "supportsUnitConversion": true, + "disabled": false, + "visible": true + }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", @@ -359,16 +377,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index b6b6eede7b..c02da77258 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg @@ -33,7 +33,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -312,7 +312,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -368,6 +368,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -390,16 +398,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/large-conical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-conical-tank.svg index c8b40477ef..6789f8f7c9 100644 --- a/application/src/main/data/json/system/scada_symbols/large-conical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/large-conical-tank.svg @@ -160,96 +160,51 @@ "name": "Stand", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -262,64 +217,35 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true } ] }]]> 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 index 69360fbe9c..c9d9361d7d 100644 --- 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 @@ -33,7 +33,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -312,7 +312,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -368,6 +368,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -390,16 +398,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index 09c0e2a9e1..8e5c057209 100644 --- 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 @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -313,7 +313,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -365,7 +365,17 @@ "value": false, "label": "Absolute" } - ] + ], + "disabled": false, + "visible": true + }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true }, { "id": "transparent", @@ -389,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index be8b1207a0..9b6763e0ea 100644 --- 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 @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -313,7 +313,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index cc168915ad..75ff5ef979 100644 --- 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 @@ -33,7 +33,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -312,7 +312,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -368,6 +368,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -390,16 +398,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index 1c29a79479..e121adaffd 100644 --- 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 @@ -57,7 +57,7 @@ }, { "tag": "value", - "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, '', false);\nctx.api.text(element, value);\n", + "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, {units: ctx.properties.valueUnits, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true});\nctx.api.text(element, value);\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -66,7 +66,7 @@ }, { "tag": "valueUnits", - "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "stateRenderFunction": "var units = ctx.api.unitSymbol(ctx.properties.valueUnits);\nctx.api.text(element, units || '');\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -463,7 +463,8 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "m³/hr", - "fieldClass": "medium-width" + "fieldClass": "medium-width", + "supportsUnitConversion": true }, { "id": "valueDecimals", diff --git a/application/src/main/data/json/system/scada_symbols/left-heat-pump.svg b/application/src/main/data/json/system/scada_symbols/left-heat-pump.svg index f1e982bac0..6232f3dda3 100644 --- a/application/src/main/data/json/system/scada_symbols/left-heat-pump.svg +++ b/application/src/main/data/json/system/scada_symbols/left-heat-pump.svg @@ -72,7 +72,7 @@ }, { "tag": "value-text", - "stateRenderFunction": "var valueTextFont = ctx.properties.valueTextFont;\nvar valueTextColor = ctx.properties.valueTextColor;\nvar units = ctx.properties.valueUnits ? ctx.properties.units : null;\nvar currentVolume = ctx.values.temperature;\nvar decimals = Math.floor(ctx.properties.temperatureStep) === ctx.properties.temperatureStep;\nvar valueText = ctx.api.formatValue(currentVolume, decimals ? 0 : 1, units, !decimals);\nctx.api.font(element, valueTextFont, valueTextColor);\nctx.api.text(element, valueText);", + "stateRenderFunction": "var valueTextFont = ctx.properties.valueTextFont;\nvar valueTextColor = ctx.properties.valueTextColor;\nvar currentVolume = ctx.values.temperature;\nvar decimals = Math.floor(ctx.properties.temperatureStep) === ctx.properties.temperatureStep;\nvar valueText = ctx.api.formatValue(currentVolume, {units: ctx.properties.units, decimals: decimals ? 0 : 1, ignoreUnitSymbol: !ctx.properties.valueUnits});\nctx.api.font(element, valueTextFont, valueTextColor);\nctx.api.text(element, valueText);", "actions": null } ], @@ -432,13 +432,11 @@ "required": true, "subLabel": "Min", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, "rowClass": "column-xs", - "fieldClass": "", "min": 0, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "maxTemperature", @@ -447,14 +445,10 @@ "default": 45, "required": true, "subLabel": "Max", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "temperatureStep", @@ -462,31 +456,17 @@ "type": "number", "default": 1, "required": true, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 0.5 + "step": 0.5, + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#000000C2", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -499,192 +479,101 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-units}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "units", "name": "{i18n:scada.symbol.value-units}", "type": "units", "default": "°C", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "valueBoxBackground", "name": "{i18n:scada.symbol.value-box-background}", "type": "color", "default": "#FFFFFF", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "powerButtonBackground", "name": "{i18n:scada.symbol.power-button-background}", "type": "color", "default": "#1ABB48", - "required": null, "subLabel": "Enabled", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, "rowClass": "column-xs", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "disabledPowerButtonBackground", "name": "{i18n:scada.symbol.power-button-background}", "type": "color", "default": "#FFFFFF", - "required": null, "subLabel": "Disabled", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "id": "heatPumpColor", "name": "{i18n:scada.symbol.heat-pump-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg index f5f68c4934..c4bd3ce804 100644 --- a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "arrow", - "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", + "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n element.stroke(ctx.properties.lineColor).fill(ctx.properties.lineColor);\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", "actions": null }, { diff --git a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg index f344823082..b51c91bf92 100644 --- a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "arrow", - "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", + "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n element.stroke(ctx.properties.lineColor).fill(ctx.properties.lineColor);\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", "actions": null }, { diff --git a/application/src/main/data/json/system/scada_symbols/meter.svg b/application/src/main/data/json/system/scada_symbols/meter.svg index d5fa9f7cd7..6fdd27d2fa 100644 --- a/application/src/main/data/json/system/scada_symbols/meter.svg +++ b/application/src/main/data/json/system/scada_symbols/meter.svg @@ -51,7 +51,7 @@ }, { "tag": "scale", - "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.properties.minValue;\n var maxValue = ctx.properties.maxValue;\n \n var start = 11;\n var end = ctx.properties.valueBox ? 328 : 365;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(63, end+11, 63, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(51, y, 63, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 45, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(57, minorY, 63, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.valueUnits);\n var maxValue = ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.valueUnits);\n \n var start = 11;\n var end = ctx.properties.valueBox ? 328 : 365;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(63, end+11, 63, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(51, y, 63, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n if (ctx.properties.enableUnitScale) {\n majorText = majorText + ctx.api.unitSymbol(ctx.properties.valueUnits);\n }\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 45, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(57, minorY, 63, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -433,7 +433,7 @@ "type": "units", "default": "%", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -493,6 +493,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", @@ -510,7 +518,7 @@ "name": "{i18n:scada.symbol.major-ticks}", "type": "font", "default": { - "size": 12, + "size": 10, "sizeUnit": "px", "family": "Roboto", "weight": "500", diff --git a/application/src/main/data/json/system/scada_symbols/pool-hp.svg b/application/src/main/data/json/system/scada_symbols/pool-hp.svg index 0ce78af7d9..03865a040c 100644 --- a/application/src/main/data/json/system/scada_symbols/pool-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/pool-hp.svg @@ -38,7 +38,7 @@ }, { "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 = 3;\n var majorIntervalLength = 792 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(298, y, 330, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 288, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 288, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 288, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(310, minorY, 330, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "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 = 3;\n var majorIntervalLength = 792 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(298, y, 330, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.majorUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 288, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 288, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 288, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(310, minorY, 330, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -346,6 +346,24 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": false, + "supportsUnitConversion": true, + "disabled": false, + "visible": true + }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", @@ -394,16 +412,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/pool.svg b/application/src/main/data/json/system/scada_symbols/pool.svg index 6f8b12737c..75f21e9457 100644 --- a/application/src/main/data/json/system/scada_symbols/pool.svg +++ b/application/src/main/data/json/system/scada_symbols/pool.svg @@ -148,80 +148,43 @@ "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -234,48 +197,26 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true } ] }]]> 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 index 6a201111ef..3984083640 100644 --- 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 @@ -57,7 +57,7 @@ }, { "tag": "value", - "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, '', false);\nctx.api.text(element, value);\n", + "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, {units: ctx.properties.valueUnits, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true});\nctx.api.text(element, value);\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -66,7 +66,7 @@ }, { "tag": "valueUnits", - "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "stateRenderFunction": "var units = ctx.api.unitSymbol(ctx.properties.valueUnits);\nctx.api.text(element, units || '');\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -463,7 +463,8 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "m³/hr", - "fieldClass": "medium-width" + "fieldClass": "medium-width", + "supportsUnitConversion": true }, { "id": "valueDecimals", diff --git a/application/src/main/data/json/system/scada_symbols/right-heat-pump.svg b/application/src/main/data/json/system/scada_symbols/right-heat-pump.svg index b1aa081b82..a0802c6e66 100644 --- a/application/src/main/data/json/system/scada_symbols/right-heat-pump.svg +++ b/application/src/main/data/json/system/scada_symbols/right-heat-pump.svg @@ -72,7 +72,7 @@ }, { "tag": "value-text", - "stateRenderFunction": "var valueTextFont = ctx.properties.valueTextFont;\nvar valueTextColor = ctx.properties.valueTextColor;\nvar units = ctx.properties.valueUnits ? ctx.properties.units : null;\nvar currentVolume = ctx.values.temperature;\nvar decimals = Math.floor(ctx.properties.temperatureStep) === ctx.properties.temperatureStep;\nvar valueText = ctx.api.formatValue(currentVolume, decimals ? 0 : 1, units, !decimals);\nctx.api.font(element, valueTextFont, valueTextColor);\nctx.api.text(element, valueText);", + "stateRenderFunction": "var valueTextFont = ctx.properties.valueTextFont;\nvar valueTextColor = ctx.properties.valueTextColor;\nvar currentVolume = ctx.values.temperature;\nvar decimals = Math.floor(ctx.properties.temperatureStep) === ctx.properties.temperatureStep;\nvar valueText = ctx.api.formatValue(currentVolume, {units: ctx.properties.units, decimals: decimals ? 0 : 1, ignoreUnitSymbol: !ctx.properties.valueUnits});\nctx.api.font(element, valueTextFont, valueTextColor);\nctx.api.text(element, valueText);", "actions": null } ], @@ -432,13 +432,11 @@ "required": true, "subLabel": "Min", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, "rowClass": "column-xs", - "fieldClass": "", "min": 0, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "maxTemperature", @@ -447,14 +445,10 @@ "default": 45, "required": true, "subLabel": "Max", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "temperatureStep", @@ -462,31 +456,17 @@ "type": "number", "default": 1, "required": true, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 0.5 + "step": 0.5, + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#000000C2", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -499,192 +479,101 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-units}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "units", "name": "{i18n:scada.symbol.value-units}", "type": "units", "default": "°C", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "valueBoxBackground", "name": "{i18n:scada.symbol.value-box-background}", "type": "color", "default": "#FFFFFF", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "powerButtonBackground", "name": "{i18n:scada.symbol.power-button-background}", "type": "color", "default": "#1ABB48", - "required": null, "subLabel": "Enabled", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, "rowClass": "column-xs", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "disabledPowerButtonBackground", "name": "{i18n:scada.symbol.power-button-background}", "type": "color", "default": "#FFFFFF", - "required": null, "subLabel": "Disabled", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true }, { "id": "heatPumpColor", "name": "{i18n:scada.symbol.heat-pump-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "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 + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg b/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg index cd15a16577..d078450c9c 100644 --- a/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg @@ -38,7 +38,7 @@ }, { "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 = 3;\n var majorIntervalLength = 594 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(170, y, 202, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 160, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 160, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 160, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(182, minorY, 202, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "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 = 3;\n var majorIntervalLength = 594 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(170, y, 202, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.majorUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 160, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 160, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 160, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(182, minorY, 202, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -346,6 +346,22 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": false, + "supportsUnitConversion": true + }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", @@ -359,16 +375,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/simple-horizontal-scale-hp.svg b/application/src/main/data/json/system/scada_symbols/simple-horizontal-scale-hp.svg index 1b91a0cd4a..9425ed48f2 100644 --- a/application/src/main/data/json/system/scada_symbols/simple-horizontal-scale-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/simple-horizontal-scale-hp.svg @@ -43,7 +43,7 @@ }, { "tag": "maxValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.maxValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -53,7 +53,7 @@ }, { "tag": "minValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.minValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -73,12 +73,12 @@ }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { "tag": "value", - "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, null, ctx.properties.valueDecimals));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, {units: ctx.properties.units, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { @@ -193,16 +193,8 @@ "name": "{i18n:scada.symbol.min-max-value}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minValue", @@ -211,14 +203,9 @@ "default": 0, "required": true, "subLabel": "{i18n:scada.symbol.min-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "maxValue", @@ -227,158 +214,89 @@ "default": 100, "required": true, "subLabel": "{i18n:scada.symbol.max-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showHighCriticalScale", "name": "{i18n:scada.symbol.high-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "highCriticalScale", "name": "{i18n:scada.symbol.high-critical-scale}", "type": "number", "default": 85, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showHighCriticalScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showHighWarningScale", "name": "{i18n:scada.symbol.high-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "highWarningScale", "name": "{i18n:scada.symbol.high-warning-scale}", "type": "number", "default": 70, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showHighWarningScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showLowWarningScale", "name": "{i18n:scada.symbol.low-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "lowWarningScale", "name": "{i18n:scada.symbol.low-warning-scale}", "type": "number", "default": 30, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showLowWarningScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showLowCriticalScale", "name": "{i18n:scada.symbol.low-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "lowCriticalScale", "name": "{i18n:scada.symbol.low-critical-scale}", "type": "number", "default": 15, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showLowCriticalScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "value", "name": "{i18n:scada.symbol.value}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueDecimals", @@ -387,14 +305,12 @@ "default": 0, "required": true, "subLabel": "Decimals", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", "min": 0, "max": 10, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "valueFont", @@ -407,64 +323,36 @@ "weight": "400", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueColor", "name": "{i18n:scada.symbol.value}", "type": "color", "default": "#002878", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "label", "name": "{i18n:scada.symbol.label}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelText", "name": "{i18n:scada.symbol.label}", "type": "text", "default": "Outdoor", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", "fieldClass": "flex", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelFont", @@ -477,64 +365,35 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelColor", "name": "{i18n:scada.symbol.label}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showUnits", "name": "{i18n:scada.symbol.units}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "units", "name": "{i18n:scada.symbol.units}", "type": "units", "default": "°C", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "unitsFont", @@ -547,160 +406,87 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "unitsColor", "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "arrowColor", "name": "{i18n:scada.symbol.arrow-color}", "type": "color", "default": "#666666", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showTarget", "name": "{i18n:scada.symbol.target}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "targetColor", "name": "{i18n:scada.symbol.target}", "type": "color", "default": "#DEDEDE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showTarget", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "scaleColor", "name": "{i18n:scada.symbol.scale-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#EBEBEB", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#666666", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/simple-vertical-scale-hp.svg b/application/src/main/data/json/system/scada_symbols/simple-vertical-scale-hp.svg index 7b8e7299b8..4e14130ef3 100644 --- a/application/src/main/data/json/system/scada_symbols/simple-vertical-scale-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/simple-vertical-scale-hp.svg @@ -43,7 +43,7 @@ }, { "tag": "maxValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.maxValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -53,7 +53,7 @@ }, { "tag": "minValue", - "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.properties.minValue);\n}", + "stateRenderFunction": "if (ctx.properties.minMaxValue) {\n ctx.api.text(element, ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.units).toFixed(0));\n}", "actions": null }, { @@ -73,12 +73,12 @@ }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { "tag": "value", - "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, null, ctx.properties.valueDecimals));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.value) {\n element.show();\n ctx.api.font(element, ctx.properties.valueFont, ctx.properties.valueColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.value, {units: ctx.properties.units, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { @@ -193,16 +193,8 @@ "name": "{i18n:scada.symbol.min-max-value}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minValue", @@ -211,14 +203,9 @@ "default": 0, "required": true, "subLabel": "{i18n:scada.symbol.min-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "maxValue", @@ -227,158 +214,89 @@ "default": 100, "required": true, "subLabel": "{i18n:scada.symbol.max-value}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showHighCriticalScale", "name": "{i18n:scada.symbol.high-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "highCriticalScale", "name": "{i18n:scada.symbol.high-critical-scale}", "type": "number", "default": 85, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showHighCriticalScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showHighWarningScale", "name": "{i18n:scada.symbol.high-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "highWarningScale", "name": "{i18n:scada.symbol.high-warning-scale}", "type": "number", "default": 70, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showHighWarningScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showLowWarningScale", "name": "{i18n:scada.symbol.low-warning-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "lowWarningScale", "name": "{i18n:scada.symbol.low-warning-scale}", "type": "number", "default": 30, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showLowWarningScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "showLowCriticalScale", "name": "{i18n:scada.symbol.low-critical-scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "lowCriticalScale", "name": "{i18n:scada.symbol.low-critical-scale}", "type": "number", "default": 15, - "required": null, - "subLabel": "", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showLowCriticalScale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "value", "name": "{i18n:scada.symbol.value}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueDecimals", @@ -387,14 +305,12 @@ "default": 0, "required": true, "subLabel": "Decimals", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", "min": 0, "max": 10, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "valueFont", @@ -407,64 +323,36 @@ "weight": "400", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueColor", "name": "{i18n:scada.symbol.value}", "type": "color", "default": "#002878", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "value", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "label", "name": "{i18n:scada.symbol.label}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelText", "name": "{i18n:scada.symbol.label}", "type": "text", "default": "Outdoor", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", "fieldClass": "flex", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelFont", @@ -477,64 +365,35 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "labelColor", "name": "{i18n:scada.symbol.label}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "label", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showUnits", "name": "{i18n:scada.symbol.units}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "units", "name": "{i18n:scada.symbol.units}", "type": "units", "default": "°C", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "supportsUnitConversion": true, + "disabled": false, + "visible": true }, { "id": "unitsFont", @@ -547,160 +406,87 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "unitsColor", "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000000DE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showUnits", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "arrowColor", "name": "{i18n:scada.symbol.arrow-color}", "type": "color", "default": "#666666", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "showTarget", "name": "{i18n:scada.symbol.target}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "targetColor", "name": "{i18n:scada.symbol.target}", "type": "color", "default": "#DEDEDE", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "showTarget", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "scaleColor", "name": "{i18n:scada.symbol.scale-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#EBEBEB", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeWarningScaleColor", "name": "{i18n:scada.symbol.warning-scale-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "defaultCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#666666", - "required": null, "subLabel": "Default", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "activeCriticalScaleColor", "name": "{i18n:scada.symbol.critical-scale-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "Active", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg index 1d6785d0c1..95c4fd3eb5 100644 --- a/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg @@ -34,7 +34,7 @@ }, { "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 = 45;\n var majorIntervalLength = 525 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 = 45;\n var majorIntervalLength = 525 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -304,7 +304,7 @@ "type": "color", "default": "#F3F3F3", "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -313,8 +313,8 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", - "disabled": true, + "supportsUnitConversion": true, + "disabled": false, "visible": true }, { @@ -329,7 +329,7 @@ "style": "normal" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -338,7 +338,7 @@ "type": "color", "default": "#0000008A", "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/small-left-meter.svg b/application/src/main/data/json/system/scada_symbols/small-left-meter.svg index 129006ffd7..3d5d6fdcb9 100644 --- a/application/src/main/data/json/system/scada_symbols/small-left-meter.svg +++ b/application/src/main/data/json/system/scada_symbols/small-left-meter.svg @@ -51,7 +51,7 @@ }, { "tag": "scale", - "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.properties.minValue;\n var maxValue = ctx.properties.maxValue;\n \n var start = 11;\n var end = ctx.properties.valueBox ? 134 : 167;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(50, end+11, 50, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(38, y, 50, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 32, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(44, minorY, 50, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.valueUnits);\n var maxValue = ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.valueUnits);\n \n var start = 11;\n var end = ctx.properties.valueBox ? 134 : 167;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(50, end+11, 50, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(38, y, 50, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n if (ctx.properties.enableUnitScale) {\n majorText = majorText + ctx.api.unitSymbol(ctx.properties.valueUnits);\n }\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 32, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(44, minorY, 50, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -433,7 +433,7 @@ "type": "units", "default": "%", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -493,6 +493,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", @@ -510,7 +518,7 @@ "name": "{i18n:scada.symbol.major-ticks}", "type": "font", "default": { - "size": 12, + "size": 10, "sizeUnit": "px", "family": "Roboto", "weight": "500", diff --git a/application/src/main/data/json/system/scada_symbols/small-meter.svg b/application/src/main/data/json/system/scada_symbols/small-meter.svg index 551c8d520a..a639475227 100644 --- a/application/src/main/data/json/system/scada_symbols/small-meter.svg +++ b/application/src/main/data/json/system/scada_symbols/small-meter.svg @@ -51,7 +51,7 @@ }, { "tag": "scale", - "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.properties.minValue;\n var maxValue = ctx.properties.maxValue;\n \n var start = 11;\n var end = ctx.properties.valueBox ? 132 : 167;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(63, end+11, 63, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(51, y, 63, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 45, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(57, minorY, 63, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.valueUnits);\n var maxValue = ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.valueUnits);\n \n var start = 11;\n var end = ctx.properties.valueBox ? 132 : 167;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(63, end+11, 63, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(51, y, 63, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n if (ctx.properties.enableUnitScale) {\n majorText = majorText + ctx.api.unitSymbol(ctx.properties.valueUnits);\n }\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 45, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(57, minorY, 63, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -424,7 +424,7 @@ "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';" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -433,8 +433,8 @@ "type": "units", "default": "%", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", - "disabled": true, + "supportsUnitConversion": true, + "disabled": false, "visible": true }, { @@ -449,7 +449,7 @@ "style": "normal" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -490,7 +490,15 @@ "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';" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, + "visible": true + }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, "visible": true }, { @@ -510,14 +518,12 @@ "name": "{i18n:scada.symbol.major-ticks}", "type": "font", "default": { - "size": 12, + "size": 10, "sizeUnit": "px", "family": "Roboto", "weight": "500", "style": "normal" - }, - "disabled": false, - "visible": true + } }, { "id": "majorColor", diff --git a/application/src/main/data/json/system/scada_symbols/small-right-center.svg b/application/src/main/data/json/system/scada_symbols/small-right-center.svg index de1bee5acd..8afe7bc88e 100644 --- a/application/src/main/data/json/system/scada_symbols/small-right-center.svg +++ b/application/src/main/data/json/system/scada_symbols/small-right-center.svg @@ -51,7 +51,7 @@ }, { "tag": "scale", - "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.properties.minValue;\n var maxValue = ctx.properties.maxValue;\n \n var start = 11;\n var end = ctx.properties.valueBox ? 134 : 167;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(73, end+11, 73, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(61, y, 73, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 55, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(67, minorY, 73, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "var scaleSet = element.remember('scaleSet');\nif (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n var minValue = ctx.api.convertUnitValue(ctx.properties.minValue, ctx.properties.valueUnits);\n var maxValue = ctx.api.convertUnitValue(ctx.properties.maxValue, ctx.properties.valueUnits);\n \n var start = 11;\n var end = ctx.properties.valueBox ? 134 : 167;\n var majorIntervalLength = end / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n element.add(ctx.svg.line(73, end+11, 73, 11).stroke({ width: 1 }).attr({class: 'majorTick'}));\n for (var i = 0; i < majorIntervals+1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(61, y, 73, y).stroke({ width: 1 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (maxValue - ((maxValue - (minValue)) / (majorIntervals) * i)).toFixed(0);\n if (ctx.properties.enableUnitScale) {\n majorText = majorText + ctx.api.unitSymbol(ctx.properties.valueUnits);\n }\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 55, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n}\n\nvar majorFont = ctx.properties.majorFont;\nvar majorColor = ctx.properties.majorColor;\nvar minorColor = ctx.properties.minorColor;\nif (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\nvar majorTicks = element.find('line.majorTick');\nmajorTicks.forEach(t => t.attr({stroke: majorColor}));\n\nvar majorTicksText = element.find('text.majorTickText');\nctx.api.font(majorTicksText, majorFont, majorColor);\n\nvar minorTicks = element.find('line.minorTick');\nminorTicks.forEach(t => t.attr({stroke: minorColor}));\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\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(67, minorY, 73, minorY).stroke({ width: 1 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -375,7 +375,7 @@ "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';" }, "disableOnProperty": "progressArrow", - "disabled": false, + "disabled": true, "visible": true }, { @@ -424,7 +424,7 @@ "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';" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -433,8 +433,8 @@ "type": "units", "default": "%", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", - "disabled": true, + "supportsUnitConversion": true, + "disabled": false, "visible": true }, { @@ -449,7 +449,7 @@ "style": "normal" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -490,7 +490,15 @@ "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';" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, + "visible": true + }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, "visible": true }, { @@ -510,7 +518,7 @@ "name": "{i18n:scada.symbol.major-ticks}", "type": "font", "default": { - "size": 12, + "size": 10, "sizeUnit": "px", "family": "Roboto", "weight": "500", 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 index b8e3987188..8b0a1a20b0 100644 --- 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 @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 258, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 258, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -304,7 +304,7 @@ "type": "color", "default": "#F3F3F3", "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -313,8 +313,8 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", - "disabled": true, + "supportsUnitConversion": true, + "disabled": false, "visible": true }, { @@ -329,7 +329,7 @@ "style": "normal" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -338,7 +338,7 @@ "type": "color", "default": "#0000008A", "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index f5d679fa96..44cd98e6f9 100644 --- a/application/src/main/data/json/system/scada_symbols/spherical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/spherical-tank.svg @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 448, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 448, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -304,7 +304,7 @@ "type": "color", "default": "#F3F3F3", "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -313,8 +313,8 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", - "disabled": true, + "supportsUnitConversion": true, + "disabled": false, "visible": true }, { @@ -329,7 +329,7 @@ "style": "normal" }, "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -338,7 +338,7 @@ "type": "color", "default": "#0000008A", "disableOnProperty": "valueBox", - "disabled": true, + "disabled": false, "visible": true }, { @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index f5b8e8a892..9666f987d7 100644 --- 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 @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -313,7 +313,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index 63a21bcf08..03995acbd5 100644 --- 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 @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -313,7 +313,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index 0d56901bfe..b448d24463 100644 --- 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 @@ -35,7 +35,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -314,7 +314,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -370,6 +370,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -392,16 +400,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index 0d0e368b9b..c4dcc662fc 100644 --- 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 @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -313,7 +313,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -365,7 +365,17 @@ "value": false, "label": "Absolute" } - ] + ], + "disabled": false, + "visible": true + }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true }, { "id": "transparent", @@ -389,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/three-rate-energy-meter-hp.svg b/application/src/main/data/json/system/scada_symbols/three-rate-energy-meter-hp.svg index f2b7bf4d54..526f6aa719 100644 --- a/application/src/main/data/json/system/scada_symbols/three-rate-energy-meter-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/three-rate-energy-meter-hp.svg @@ -38,7 +38,7 @@ }, { "tag": "night-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.nightValueFont, ctx.properties.nightValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.nightRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.nightValueFont, ctx.properties.nightValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.nightRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { @@ -48,7 +48,7 @@ }, { "tag": "off-peak-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.offPeakValueFont, ctx.properties.offPeakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.offPeakRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.offPeakValueFont, ctx.properties.offPeakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.offPeakRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { @@ -58,12 +58,12 @@ }, { "tag": "peak-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.peakValueFont, ctx.properties.peakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.peakRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.peakValueFont, ctx.properties.peakValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.peakRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { @@ -480,6 +480,7 @@ "group": "{i18n:scada.symbol.off-peak-rate}", "type": "text", "default": "T1", + "disableOnProperty": "showOffPeakLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -496,6 +497,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showOffPeakLabel", "disabled": false, "visible": true }, @@ -505,6 +507,7 @@ "group": "{i18n:scada.symbol.off-peak-rate}", "type": "color", "default": "#000000", + "disableOnProperty": "showOffPeakLabel", "disabled": false, "visible": true }, @@ -556,6 +559,7 @@ "group": "{i18n:scada.symbol.night-rate}", "type": "text", "default": "T2", + "disableOnProperty": "showNightLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -572,6 +576,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showNightLabel", "disabled": false, "visible": true }, @@ -581,6 +586,7 @@ "group": "{i18n:scada.symbol.night-rate}", "type": "color", "default": "#000", + "disableOnProperty": "showNightLabel", "disabled": false, "visible": true }, @@ -632,6 +638,7 @@ "group": "{i18n:scada.symbol.peak-rate}", "type": "text", "default": "T3", + "disableOnProperty": "showPeakLabel", "fieldClass": "medium-width", "disabled": false, "visible": true @@ -648,6 +655,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showPeakLabel", "disabled": false, "visible": true }, @@ -657,6 +665,7 @@ "group": "{i18n:scada.symbol.peak-rate}", "type": "color", "default": "#000", + "disableOnProperty": "showPeakLabel", "disabled": false, "visible": true }, @@ -706,6 +715,7 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "kWh", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -720,6 +730,7 @@ "weight": "normal", "style": "normal" }, + "disableOnProperty": "showUnits", "disabled": false, "visible": true }, @@ -728,6 +739,7 @@ "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000", + "disableOnProperty": "showUnits", "disabled": false, "visible": true } 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 index 9c09a6067e..ae9b6701af 100644 --- 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 @@ -57,7 +57,7 @@ }, { "tag": "value", - "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, '', false);\nctx.api.text(element, value);\n", + "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, {units: ctx.properties.valueUnits, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true});\nctx.api.text(element, value);\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -66,7 +66,7 @@ }, { "tag": "valueUnits", - "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "stateRenderFunction": "var units = ctx.api.unitSymbol(ctx.properties.valueUnits);\nctx.api.text(element, units || '');\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -464,8 +464,7 @@ "type": "units", "default": "m³/hr", "fieldClass": "medium-width", - "disabled": false, - "visible": true + "supportsUnitConversion": true }, { "id": "valueDecimals", diff --git a/application/src/main/data/json/system/scada_symbols/two-rate-energy-meter-hp.svg b/application/src/main/data/json/system/scada_symbols/two-rate-energy-meter-hp.svg index 7adc401836..e87548f059 100644 --- a/application/src/main/data/json/system/scada_symbols/two-rate-energy-meter-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/two-rate-energy-meter-hp.svg @@ -38,7 +38,7 @@ }, { "tag": "day-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.dayValueFont, ctx.properties.dayValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.dayRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.dayValueFont, ctx.properties.dayValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.dayRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { @@ -48,12 +48,12 @@ }, { "tag": "night-rate", - "stateRenderFunction": "ctx.api.font(element, ctx.properties.nightValueFont, ctx.properties.nightValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.nightRate, 0, null, 0));", + "stateRenderFunction": "ctx.api.font(element, ctx.properties.nightValueFont, ctx.properties.nightValueColor);\nctx.api.text(element, ctx.api.formatValue(ctx.values.nightRate, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));", "actions": null }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { @@ -427,8 +427,9 @@ "group": "{i18n:scada.symbol.day-rate}", "type": "text", "default": "T1", + "disableOnProperty": "showDayLabel", "fieldClass": "medium-width", - "disabled": false, + "disabled": true, "visible": true }, { @@ -443,7 +444,8 @@ "weight": "normal", "style": "normal" }, - "disabled": false, + "disableOnProperty": "showDayLabel", + "disabled": true, "visible": true }, { @@ -452,7 +454,8 @@ "group": "{i18n:scada.symbol.day-rate}", "type": "color", "default": "#000000", - "disabled": false, + "disableOnProperty": "showDayLabel", + "disabled": true, "visible": true }, { @@ -503,8 +506,9 @@ "group": "{i18n:scada.symbol.night-rate}", "type": "text", "default": "T2", + "disableOnProperty": "showNightLabel", "fieldClass": "medium-width", - "disabled": false, + "disabled": true, "visible": true }, { @@ -519,7 +523,8 @@ "weight": "normal", "style": "normal" }, - "disabled": false, + "disableOnProperty": "showNightLabel", + "disabled": true, "visible": true }, { @@ -528,7 +533,8 @@ "group": "{i18n:scada.symbol.night-rate}", "type": "color", "default": "#000", - "disabled": false, + "disableOnProperty": "showNightLabel", + "disabled": true, "visible": true }, { @@ -577,6 +583,7 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "kWh", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -591,7 +598,8 @@ "weight": "normal", "style": "normal" }, - "disabled": false, + "disableOnProperty": "showUnits", + "disabled": true, "visible": true }, { @@ -599,7 +607,8 @@ "name": "{i18n:scada.symbol.units}", "type": "color", "default": "#000", - "disabled": false, + "disableOnProperty": "showUnits", + "disabled": true, "visible": true } ] diff --git a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg index 87c3ef23ee..a47689be2b 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "arrow", - "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", + "stateRenderFunction": "var arrow = ctx.values.arrow;\nif (arrow) {\n element.show();\n element.stroke(ctx.properties.lineColor).fill(ctx.properties.lineColor);\n var arrowDirection = ctx.values.arrowDirection;\n var direticon = element.remember('direticon');\n var angle = arrowDirection ? 0 : 180;\n if (!direticon) {\n element.transform({rotate: angle});\n } else {\n ctx.api.transform({rotate: angle, originY: 100});\n element.remember('direticon', false);\n }\n} else {\n element.hide();\n}", "actions": null }, { 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 index 8f9a30e576..e270024f8b 100644 --- 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 @@ -57,7 +57,7 @@ }, { "tag": "value", - "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, ctx.properties.valueDecimals, '', false);\nctx.api.text(element, value);\n", + "stateRenderFunction": "var value = ctx.api.formatValue(ctx.values.value, {units: ctx.properties.valueUnits, decimals: ctx.properties.valueDecimals, ignoreUnitSymbol: true});\nctx.api.text(element, value);\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -66,7 +66,7 @@ }, { "tag": "valueUnits", - "stateRenderFunction": "var units = ctx.properties.valueUnits;\nctx.api.text(element, units || '');\n", + "stateRenderFunction": "var units = ctx.api.unitSymbol(ctx.properties.valueUnits);\nctx.api.text(element, units || '');\n", "actions": { "click": { "actionFunction": "ctx.api.callAction(event, 'displayClick');" @@ -464,6 +464,7 @@ "type": "units", "default": "m³/hr", "fieldClass": "medium-width", + "supportsUnitConversion": true, "disabled": false, "visible": true }, 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 index 57a79d72d7..5d8aa42ab5 100644 --- 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 @@ -34,7 +34,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -313,7 +313,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,17 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "required": false, - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg index 9dee6cc2af..d7f0cb11d9 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg @@ -38,7 +38,7 @@ }, { "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 = 3;\n var majorIntervalLength = 994 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(160, y, 192, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 150, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 150, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 150, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(172, minorY, 192, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "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 = 3;\n var majorIntervalLength = 994 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(160, y, 192, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.majorUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 150, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 150, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 150, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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 \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\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(172, minorY, 192, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -346,6 +346,24 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": false, + "supportsUnitConversion": true, + "disabled": false, + "visible": true + }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", @@ -359,16 +377,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", 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 index 99fa9b649c..5e51330ded 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-tank.svg @@ -33,7 +33,7 @@ }, { "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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}", + "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 var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\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 currentVolume = (tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0);\n var majorText = ctx.properties.scaleDisplayFormat ? currentVolume : ctx.api.formatValue(currentVolume, {units: ctx.properties.valueUnits, decimals: 0, ignoreUnitSymbol: !ctx.properties.enableUnitScale});\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\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.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(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 }, { @@ -312,7 +312,7 @@ "type": "units", "default": "gal", "subLabel": "{i18n:scada.symbol.units}", - "disableOnProperty": "valueBox", + "supportsUnitConversion": true, "disabled": false, "visible": true }, @@ -369,6 +369,14 @@ "disabled": false, "visible": true }, + { + "id": "enableUnitScale", + "name": "{i18n:scada.symbol.enable-units-scale}", + "type": "switch", + "default": false, + "disabled": false, + "visible": true + }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", @@ -391,16 +399,6 @@ "disabled": false, "visible": true }, - { - "id": "majorUnits", - "name": "{i18n:scada.symbol.major-ticks}", - "type": "units", - "subLabel": "{i18n:scada.symbol.units}", - "divider": true, - "disableOnProperty": "scale", - "disabled": false, - "visible": true - }, { "id": "majorFont", "name": "{i18n:scada.symbol.major-ticks}", diff --git a/application/src/main/data/json/system/scada_symbols/voltage-relay-hp.svg b/application/src/main/data/json/system/scada_symbols/voltage-relay-hp.svg index eaa0b02455..fa27214864 100644 --- a/application/src/main/data/json/system/scada_symbols/voltage-relay-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/voltage-relay-hp.svg @@ -34,12 +34,12 @@ }, { "tag": "units", - "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.properties.units);\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.properties.showUnits) {\n element.show();\n ctx.api.font(element, ctx.properties.unitsFont, ctx.properties.unitsColor);\n ctx.api.text(element, ctx.api.unitSymbol(ctx.properties.units));\n} else {\n element.hide();\n}", "actions": null }, { "tag": "value", - "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.voltage, 0, null, 0));\n} else {\n element.hide();\n}", + "stateRenderFunction": "if (ctx.values.running) {\n element.show();\n ctx.api.font(element, ctx.properties.currentVoltageFont, ctx.properties.currentVoltageColor);\n ctx.api.text(element, ctx.api.formatValue(ctx.values.voltage, {units: ctx.properties.units, decimals: 0, ignoreUnitSymbol: true}));\n} else {\n element.hide();\n}", "actions": null }, { @@ -379,6 +379,7 @@ "name": "{i18n:scada.symbol.units}", "type": "units", "default": "V", + "supportsUnitConversion": true, "disabled": false, "visible": true }, diff --git a/application/src/main/data/json/system/widget_types/knob_control.json b/application/src/main/data/json/system/widget_types/knob_control.json index 876f872014..8840787f58 100644 --- a/application/src/main/data/json/system/widget_types/knob_control.json +++ b/application/src/main/data/json/system/widget_types/knob_control.json @@ -12,10 +12,9 @@ "templateHtml": "", "templateCss": "", "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", - "settingsSchema": "", - "dataKeySettingsSchema": "{}\n", + "dataKeySettingsForm": [], "settingsDirective": "tb-knob-control-widget-settings", - "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"maxValue\":100,\"initialValue\":50,\"minValue\":0,\"title\":\"Knob control\",\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\"},\"title\":\"Knob Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{},\"title\":\"Knob Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2,\"units\":null}" }, "tags": [ "dial", diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 016e786776..add832ea6e 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,3 +14,33 @@ -- limitations under the License. -- +-- UPDATE OTA PACKAGE EXTERNAL ID START + +ALTER TABLE ota_package + ADD COLUMN IF NOT EXISTS external_id uuid; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'ota_package_external_id_unq_key') THEN + ALTER TABLE ota_package ADD CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +-- UPDATE OTA PACKAGE EXTERNAL ID END + +-- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT START + +DROP INDEX IF EXISTS idx_device_external_id; +DROP INDEX IF EXISTS idx_device_profile_external_id; +DROP INDEX IF EXISTS idx_asset_external_id; +DROP INDEX IF EXISTS idx_entity_view_external_id; +DROP INDEX IF EXISTS idx_rule_chain_external_id; +DROP INDEX IF EXISTS idx_dashboard_external_id; +DROP INDEX IF EXISTS idx_customer_external_id; +DROP INDEX IF EXISTS idx_widgets_bundle_external_id; + +-- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT END + +ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255); \ No newline at end of file diff --git a/application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java b/application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java index 80192520ca..88a03f5fc8 100644 --- a/application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java +++ b/application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java @@ -103,13 +103,13 @@ public class NetworkReceive implements Receive { if (maxSize != UNLIMITED && receiveSize > maxSize) { throw new ThingsboardKafkaClientError("Invalid receive (size = " + receiveSize + " larger than " + maxSize + ")"); } - requestedBufferSize = receiveSize; //may be 0 for some payloads (SASL) + requestedBufferSize = receiveSize; // may be 0 for some payloads (SASL) if (receiveSize == 0) { buffer = EMPTY_BUFFER; } } } - if (buffer == null && requestedBufferSize != -1) { //we know the size we want but havent been able to allocate it yet + if (buffer == null && requestedBufferSize != -1) { // we know the size we want but haven't been able to allocate it yet if (requestedBufferSize > TB_LOG_REQUESTED_BUFFER_SIZE) { String stackTrace = Arrays.stream(Thread.currentThread().getStackTrace()).map(StackTraceElement::toString).collect(Collectors.joining("|")); log.error("Allocating buffer of size {} for source {}", requestedBufferSize, source); diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 61d9586095..ea46ce86eb 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -31,9 +31,11 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.DeviceStateManager; +import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; @@ -61,6 +63,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -80,6 +83,7 @@ import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; @@ -309,6 +313,14 @@ public class ActorSystemContext { @Getter private AuditLogService auditLogService; + @Autowired + @Getter + private RuleEngineAiChatModelService aiChatModelService; + + @Autowired + @Getter + private AiModelService aiModelService; + @Autowired @Getter private EntityViewService entityViewService; @@ -318,7 +330,6 @@ public class ActorSystemContext { @Getter private TbEntityViewService tbEntityViewService; - @Lazy @Autowired @Getter private TelemetrySubscriptionService tsSubService; @@ -451,12 +462,10 @@ public class ActorSystemContext { @Getter private ApiLimitService apiLimitService; - @Lazy @Autowired(required = false) @Getter private RateLimitService rateLimitService; - @Lazy @Autowired(required = false) @Getter private DebugModeRateLimitsConfig debugModeRateLimitsConfig; @@ -537,21 +546,26 @@ public class ActorSystemContext { @Getter private EntityService entityService; - @Lazy @Autowired(required = false) @Getter private CalculatedFieldProcessingService calculatedFieldProcessingService; - @Lazy @Autowired(required = false) @Getter private CalculatedFieldStateService calculatedFieldStateService; - @Lazy @Autowired(required = false) @Getter private CalculatedFieldQueueService calculatedFieldQueueService; + @Autowired + @Getter + private JobService jobService; + + @Autowired + @Getter + private JobManager jobManager; + @Value("${actors.session.max_concurrent_sessions_per_device:1}") @Getter private int maxConcurrentSessionsPerDevice; diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 5ea79e42a8..35539834c3 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -19,7 +19,6 @@ import com.google.common.util.concurrent.ListenableFuture; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.DebugModeUtil; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.TbActorCtx; import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; @@ -69,7 +68,7 @@ import java.util.stream.Collectors; */ @Slf4j public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareMsgProcessor { - // (1 for result persistence + 1 for the state persistence ) + // (1 for result persistence + 1 for the state persistence) public static final int CALLBACKS_PER_CF = 2; final TenantId tenantId; @@ -299,7 +298,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM callback.onSuccess(); } if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) { - systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, JacksonUtil.writeValueAsString(calculationResult.getResult()), null); + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.getResult().toString(), null); } } } else { 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 c07134105c..6374e4016d 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 @@ -24,9 +24,11 @@ import org.thingsboard.common.util.DebugModeUtil; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; import org.thingsboard.rule.engine.api.DeviceStateManager; +import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; @@ -75,6 +77,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgProcessingStackItem; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -93,6 +96,7 @@ import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.nosql.CassandraStatementTask; @@ -889,6 +893,16 @@ public class DefaultTbContext implements TbContext { return mainCtx.getCalculatedFieldQueueService(); } + @Override + public JobService getJobService() { + return mainCtx.getJobService(); + } + + @Override + public JobManager getJobManager() { + return mainCtx.getJobManager(); + } + @Override public boolean isExternalNodeForceAck() { return mainCtx.isExternalNodeForceAck(); @@ -1012,6 +1026,16 @@ public class DefaultTbContext implements TbContext { return mainCtx.getAuditLogService(); } + @Override + public RuleEngineAiChatModelService getAiChatModelService() { + return mainCtx.getAiChatModelService(); + } + + @Override + public AiModelService getAiModelService() { + return mainCtx.getAiModelService(); + } + @Override public MqttClientSettings getMqttClientSettings() { return mainCtx.getMqttClientSettings(); 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 6b3132a1d2..371ca4d3e4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -37,14 +37,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; 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; @@ -125,14 +124,13 @@ public class AdminController extends BaseController { @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')") - @RequestMapping(value = "/settings/{key}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/settings/{key}") public AdminSettings getAdminSettings( @Parameter(description = "A string value of the key (e.g. 'general' or 'mail').") @PathVariable("key") String key) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, key), "No Administration settings found for key: " + key); - if (adminSettings.getKey().equals("mail")) { + if (adminSettings.getKey().equals(MAIL_SETTINGS_KEY)) { ((ObjectNode) adminSettings.getJsonValue()).remove("password"); ((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken"); } @@ -144,15 +142,14 @@ public class AdminController extends BaseController { "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) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/settings") public AdminSettings saveAdminSettings( @Parameter(description = "A JSON value representing the Administration Settings.") @RequestBody AdminSettings adminSettings) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); adminSettings.setTenantId(getTenantId()); adminSettings = checkNotNull(adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings)); - if (adminSettings.getKey().equals("mail")) { + if (adminSettings.getKey().equals(MAIL_SETTINGS_KEY)) { mailService.updateMailConfiguration(); ((ObjectNode) adminSettings.getJsonValue()).remove("password"); ((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken"); @@ -165,8 +162,7 @@ public class AdminController extends BaseController { @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) - @ResponseBody + @GetMapping(value = "/securitySettings") public SecuritySettings getSecuritySettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return checkNotNull(securitySettingsService.getSecuritySettings()); @@ -175,8 +171,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Update Security Settings (saveSecuritySettings)", notes = "Updates the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/securitySettings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/securitySettings") public SecuritySettings saveSecuritySettings( @Parameter(description = "A JSON value representing the Security Settings.") @RequestBody SecuritySettings securitySettings) throws ThingsboardException { @@ -188,8 +183,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Get the JWT Settings object (getJwtSettings)", notes = "Get the JWT Settings object that contains JWT token policy, etc. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/jwtSettings", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/jwtSettings") public JwtSettings getJwtSettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return checkNotNull(jwtSettingsService.getJwtSettings()); @@ -198,8 +192,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Update JWT Settings (saveJwtSettings)", notes = "Updates the JWT Settings object that contains JWT token policy, etc. The tokenSigningKey field is a Base64 encoded string." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/jwtSettings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/jwtSettings") public JwtPair saveJwtSettings( @Parameter(description = "A JSON value representing the JWT Settings.") @RequestBody JwtSettings jwtSettings) throws ThingsboardException { @@ -213,15 +206,15 @@ public class AdminController extends BaseController { notes = "Attempts to send test email to the System Administrator User using Mail Settings provided as a parameter. " + "You may change the 'To' email in the user profile of the System Administrator. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings/testMail", method = RequestMethod.POST) + @PostMapping(value = "/settings/testMail") public void sendTestMail( @Parameter(description = "A JSON value representing the Mail Settings.") @RequestBody AdminSettings adminSettings) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); adminSettings = checkNotNull(adminSettings); - if (adminSettings.getKey().equals("mail")) { + if (adminSettings.getKey().equals(MAIL_SETTINGS_KEY)) { if (adminSettings.getJsonValue().has("enableOauth2") && adminSettings.getJsonValue().get("enableOauth2").asBoolean()) { - AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail")); + AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY)); JsonNode refreshToken = mailSettings.getJsonValue().get("refreshToken"); if (refreshToken == null) { throw new ThingsboardException("Refresh token was not generated. Please, generate refresh token.", ThingsboardErrorCode.GENERAL); @@ -230,7 +223,7 @@ public class AdminController extends BaseController { settings.put("refreshToken", refreshToken.asText()); } else { if (!adminSettings.getJsonValue().has("password")) { - AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail")); + AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY)); ((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText()); } } @@ -251,7 +244,7 @@ public class AdminController extends BaseController { 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')") - @RequestMapping(value = "/settings/testSms", method = RequestMethod.POST) + @PostMapping(value = "/settings/testSms") public void sendTestSms( @Parameter(description = "A JSON value representing the Test SMS request.") @RequestBody TestSmsRequest testSmsRequest) throws ThingsboardException { @@ -325,7 +318,7 @@ public class AdminController extends BaseController { notes = "Deletes the repository settings." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/repositorySettings", method = RequestMethod.DELETE) + @DeleteMapping(value = "/repositorySettings") @ResponseStatus(value = HttpStatus.OK) public DeferredResult deleteRepositorySettings() throws Exception { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); @@ -335,7 +328,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Check repository access (checkRepositoryAccess)", notes = "Attempts to check repository access. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/repositorySettings/checkAccess", method = RequestMethod.POST) + @PostMapping(value = "/repositorySettings/checkAccess") public DeferredResult checkRepositoryAccess( @Parameter(description = "A JSON value representing the Repository Settings.") @RequestBody RepositorySettings settings) throws Exception { @@ -376,7 +369,7 @@ public class AdminController extends BaseController { notes = "Deletes the auto commit settings." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/autoCommitSettings", method = RequestMethod.DELETE) + @DeleteMapping(value = "/autoCommitSettings") @ResponseStatus(value = HttpStatus.OK) public void deleteAutoCommitSettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); @@ -387,9 +380,8 @@ public class AdminController extends BaseController { notes = "Check notifications about new platform releases. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/updates", method = RequestMethod.GET) - @ResponseBody - public UpdateMessage checkUpdates() throws ThingsboardException { + @GetMapping(value = "/updates") + public UpdateMessage checkUpdates() { return updateService.checkUpdates(); } @@ -397,9 +389,8 @@ public class AdminController extends BaseController { notes = "Get main information about system. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/systemInfo", method = RequestMethod.GET) - @ResponseBody - public SystemInfo getSystemInfo() throws ThingsboardException { + @GetMapping(value = "/systemInfo") + public SystemInfo getSystemInfo() { return systemInfoService.getSystemInfo(); } @@ -407,8 +398,7 @@ public class AdminController extends BaseController { notes = "Get information about enabled/disabled features. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/featuresInfo", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/featuresInfo") public FeaturesInfo getFeaturesInfo() { return systemInfoService.getFeaturesInfo(); } @@ -417,8 +407,7 @@ public class AdminController extends BaseController { "double quotes. After successful authentication with OAuth2 provider and user consent for requested scope, it makes a redirect to this path so that the platform can do " + "further log in processing and generating access tokens. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/mail/oauth2/loginProcessingUrl", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/mail/oauth2/loginProcessingUrl") public String getMailProcessingUrl() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return "\"/api/admin/mail/oauth2/code\""; @@ -427,7 +416,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Redirect user to mail provider login page. ", notes = "After user logged in and provided access" + "provider sends authorization code to specified redirect uri.)") @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/mail/oauth2/authorize", method = RequestMethod.GET, produces = "application/text") + @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { String state = StringUtils.generateSafeToken(); if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { @@ -452,7 +441,7 @@ public class AdminController extends BaseController { .build() + "\""; } - @RequestMapping(value = "/mail/oauth2/code", params = {"code", "state"}, method = RequestMethod.GET) + @GetMapping(value = "/mail/oauth2/code", params = {"code", "state"}) public void codeProcessingUrl( @RequestParam(value = "code") String code, @RequestParam(value = "state") String state, HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { diff --git a/application/src/main/java/org/thingsboard/server/controller/AiModelController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelController.java new file mode 100644 index 0000000000..34a0bbebfc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AiModelController.java @@ -0,0 +1,178 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ListenableFuture; +import dev.langchain4j.model.chat.request.ChatRequest; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.dto.TbChatRequest; +import org.thingsboard.server.common.data.ai.dto.TbChatResponse; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ai.AiChatModelService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.thingsboard.server.controller.ControllerConstants.AI_MODEL_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +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.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; + +@Validated +@RestController +@TbCoreComponent +@RequiredArgsConstructor +@RequestMapping("/api/ai/model") +class AiModelController extends BaseController { + + private final AiChatModelService aiChatModelService; + + @ApiOperation( + value = "Create or update AI model (saveAiModel)", + notes = "Creates or updates an AI model record.\n\n" + + "• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new record and returns it in the `id` field of the response.\n\n" + + "• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" + + "Tenant ID for the AI model will be taken from the authenticated user making the request, regardless of any value provided in the request body." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping + public AiModel saveAiModel(@RequestBody @Valid AiModel model) throws ThingsboardException { + var user = getCurrentUser(); + model.setTenantId(user.getTenantId()); + checkEntity(model.getId(), model, Resource.AI_MODEL); + return tbAiModelService.save(model, user); + } + + @ApiOperation( + value = "Get AI model by ID (getAiModelById)", + notes = "Fetches an AI model record by its `id`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/{modelUuid}") + public AiModel getAiModelById( + @Parameter( + description = "ID of the AI model record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable UUID modelUuid + ) throws ThingsboardException { + return checkAiModelId(new AiModelId(modelUuid), Operation.READ); + } + + @ApiOperation( + value = "Get AI models (getAiModels)", + notes = "Returns a page of AI models. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping + public PageData getAiModels( + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = AI_MODEL_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "modelId"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder + ) throws ThingsboardException { + var user = getCurrentUser(); + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.READ); + var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return aiModelService.findAiModelsByTenantId(user.getTenantId(), pageLink); + } + + @ApiOperation( + value = "Delete AI model by ID (deleteAiModelById)", + notes = "Deletes the AI model record by its `id`. " + + "If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " + + "If no such record exists, the endpoint returns `false`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/{modelUuid}") + public boolean deleteAiModelById( + @Parameter( + description = "ID of the AI model record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable UUID modelUuid + ) throws ThingsboardException { + var user = getCurrentUser(); + var modelId = new AiModelId(modelUuid); + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE); + Optional toDelete = aiModelService.findAiModelByTenantIdAndId(user.getTenantId(), modelId); + if (toDelete.isEmpty()) { + return false; + } + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE, modelId, toDelete.get()); + return tbAiModelService.delete(toDelete.get(), user); + } + + @ApiOperation( + value = "Send request to AI chat model (sendChatRequest)", + notes = "Submits a single prompt - made up of an optional system message and a required user message - to the specified AI chat model " + + "and returns either the generated answer or an error envelope." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/chat") + public DeferredResult sendChatRequest(@Valid @RequestBody TbChatRequest tbChatRequest) { + ChatRequest langChainChatRequest = tbChatRequest.toLangChainChatRequest(); + AiChatModelConfig chatModelConfig = tbChatRequest.chatModelConfig(); + + ListenableFuture future = aiChatModelService.sendChatRequestAsync(chatModelConfig, langChainChatRequest) + .transform(chatResponse -> (TbChatResponse) new TbChatResponse.Success(chatResponse.aiMessage().text()), directExecutor()) + .catching(Throwable.class, ex -> new TbChatResponse.Failure(ex.getMessage()), directExecutor()); + + Integer requestTimeoutSeconds = chatModelConfig.timeoutSeconds(); + return requestTimeoutSeconds != null ? wrapFuture(future, Duration.ofSeconds(requestTimeoutSeconds).toMillis()) : wrapFuture(future); + } + +} 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 93bf5b35fe..26f116b083 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -23,8 +23,6 @@ import jakarta.servlet.ServletOutputStream; 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; @@ -39,6 +37,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; @@ -63,6 +62,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantInfo; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -77,6 +77,7 @@ 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.AiModelId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -92,6 +93,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.MobileAppBundleId; import org.thingsboard.server.common.data.id.MobileAppId; import org.thingsboard.server.common.data.id.NotificationTargetId; @@ -108,6 +110,7 @@ import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.mobile.app.MobileApp; import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundle; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; @@ -129,6 +132,7 @@ import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -146,6 +150,7 @@ import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.model.ModelConstants; @@ -174,6 +179,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.action.EntityActionService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.entitiy.ai.TbAiModelService; import org.thingsboard.server.service.entitiy.user.TbUserSettingsService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -198,6 +204,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutionException; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Function; @@ -370,9 +377,18 @@ public abstract class BaseController { @Autowired protected NotificationTargetService notificationTargetService; + @Autowired + protected JobService jobService; + @Autowired protected CalculatedFieldService calculatedFieldService; + @Autowired + protected AiModelService aiModelService; + + @Autowired + protected TbAiModelService tbAiModelService; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -416,7 +432,7 @@ public abstract class BaseController { return handleException(exception, true); } - private ThingsboardException handleException(Exception exception, boolean logException) { + private ThingsboardException handleException(Throwable exception, boolean logException) { if (logException && logControllerErrorStackTrace) { try { SecurityUser user = getCurrentUser(); @@ -427,6 +443,9 @@ public abstract class BaseController { } Throwable cause = exception.getCause(); + if (exception instanceof ExecutionException) { + exception = cause; + } if (exception instanceof ThingsboardException) { return (ThingsboardException) exception; } else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException @@ -440,6 +459,8 @@ public abstract class BaseController { return new ThingsboardException(exception, ThingsboardErrorCode.DATABASE); } else if (exception instanceof EntityVersionMismatchException) { return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.VERSION_CONFLICT); + } else if (exception instanceof MethodArgumentTypeMismatchException) { + return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.BAD_REQUEST_PARAMS); } return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.GENERAL); } @@ -595,88 +616,40 @@ public abstract class BaseController { } } - protected void checkEntityId(EntityId entityId, Operation operation) throws ThingsboardException { + protected HasId checkEntityId(EntityId entityId, Operation operation) throws ThingsboardException { try { if (entityId == null) { throw new ThingsboardException("Parameter entityId can't be empty!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); } validateId(entityId.getId(), id -> "Incorrect entityId " + id); - switch (entityId.getEntityType()) { - case ALARM: - checkAlarmId(new AlarmId(entityId.getId()), operation); - return; - case DEVICE: - checkDeviceId(new DeviceId(entityId.getId()), operation); - return; - case DEVICE_PROFILE: - checkDeviceProfileId(new DeviceProfileId(entityId.getId()), operation); - return; - case CUSTOMER: - checkCustomerId(new CustomerId(entityId.getId()), operation); - return; - case TENANT: - checkTenantId(TenantId.fromUUID(entityId.getId()), operation); - return; - case TENANT_PROFILE: - checkTenantProfileId(new TenantProfileId(entityId.getId()), operation); - return; - case RULE_CHAIN: - checkRuleChain(new RuleChainId(entityId.getId()), operation); - return; - case RULE_NODE: - checkRuleNode(new RuleNodeId(entityId.getId()), operation); - return; - case ASSET: - checkAssetId(new AssetId(entityId.getId()), operation); - return; - case ASSET_PROFILE: - checkAssetProfileId(new AssetProfileId(entityId.getId()), operation); - return; - case DASHBOARD: - checkDashboardId(new DashboardId(entityId.getId()), operation); - return; - case USER: - checkUserId(new UserId(entityId.getId()), operation); - return; - case ENTITY_VIEW: - checkEntityViewId(new EntityViewId(entityId.getId()), operation); - return; - case EDGE: - checkEdgeId(new EdgeId(entityId.getId()), operation); - return; - case WIDGETS_BUNDLE: - checkWidgetsBundleId(new WidgetsBundleId(entityId.getId()), operation); - return; - case WIDGET_TYPE: - checkWidgetTypeId(new WidgetTypeId(entityId.getId()), operation); - return; - case TB_RESOURCE: - checkResourceInfoId(new TbResourceId(entityId.getId()), operation); - return; - case OTA_PACKAGE: - checkOtaPackageId(new OtaPackageId(entityId.getId()), operation); - return; - case QUEUE: - checkQueueId(new QueueId(entityId.getId()), operation); - return; - case OAUTH2_CLIENT: - checkOauth2ClientId(new OAuth2ClientId(entityId.getId()), operation); - return; - case DOMAIN: - checkDomainId(new DomainId(entityId.getId()), operation); - return; - case MOBILE_APP: - checkMobileAppId(new MobileAppId(entityId.getId()), operation); - return; - case MOBILE_APP_BUNDLE: - checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation); - return; - case CALCULATED_FIELD: - checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); - return; - default: - checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); - } + return switch (entityId.getEntityType()) { + case ALARM -> checkAlarmId(new AlarmId(entityId.getId()), operation); + case DEVICE -> checkDeviceId(new DeviceId(entityId.getId()), operation); + case DEVICE_PROFILE -> checkDeviceProfileId(new DeviceProfileId(entityId.getId()), operation); + case CUSTOMER -> checkCustomerId(new CustomerId(entityId.getId()), operation); + case TENANT -> checkTenantId(TenantId.fromUUID(entityId.getId()), operation); + case TENANT_PROFILE -> checkTenantProfileId(new TenantProfileId(entityId.getId()), operation); + case RULE_CHAIN -> checkRuleChain(new RuleChainId(entityId.getId()), operation); + case RULE_NODE -> checkRuleNode(new RuleNodeId(entityId.getId()), operation); + case ASSET -> checkAssetId(new AssetId(entityId.getId()), operation); + case ASSET_PROFILE -> checkAssetProfileId(new AssetProfileId(entityId.getId()), operation); + case DASHBOARD -> checkDashboardId(new DashboardId(entityId.getId()), operation); + case USER -> checkUserId(new UserId(entityId.getId()), operation); + case ENTITY_VIEW -> checkEntityViewId(new EntityViewId(entityId.getId()), operation); + case EDGE -> checkEdgeId(new EdgeId(entityId.getId()), operation); + case WIDGETS_BUNDLE -> checkWidgetsBundleId(new WidgetsBundleId(entityId.getId()), operation); + case WIDGET_TYPE -> checkWidgetTypeId(new WidgetTypeId(entityId.getId()), operation); + case TB_RESOURCE -> checkResourceInfoId(new TbResourceId(entityId.getId()), operation); + case OTA_PACKAGE -> checkOtaPackageId(new OtaPackageId(entityId.getId()), operation); + case QUEUE -> checkQueueId(new QueueId(entityId.getId()), operation); + case OAUTH2_CLIENT -> checkOauth2ClientId(new OAuth2ClientId(entityId.getId()), operation); + case DOMAIN -> checkDomainId(new DomainId(entityId.getId()), operation); + case MOBILE_APP -> checkMobileAppId(new MobileAppId(entityId.getId()), operation); + case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation); + case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); + case AI_MODEL -> checkAiModelId(new AiModelId(entityId.getId()), operation); + default -> (HasId) checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); + }; } catch (Exception e) { throw handleException(e, false); } @@ -874,6 +847,14 @@ public abstract class BaseController { return checkEntityId(notificationTargetId, notificationTargetService::findNotificationTargetById, operation); } + Job checkJobId(JobId jobId, Operation operation) throws ThingsboardException { + return checkEntityId(jobId, jobService::findJobById, operation); + } + + AiModel checkAiModelId(AiModelId settingsId, Operation operation) throws ThingsboardException { + return checkEntityId(settingsId, (tenantId, id) -> aiModelService.findAiModelByTenantIdAndId(tenantId, id).orElse(null), operation); + } + protected I emptyId(EntityType entityType) { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } @@ -957,12 +938,13 @@ public abstract class BaseController { } } - private void checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException { + private CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException { validateId(calculatedFieldId, "Invalid entity id"); SecurityUser user = getCurrentUser(); CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId); checkNotNull(cf, calculatedFieldId.getEntityType().getNormalName() + " with id [" + calculatedFieldId + "] is not found"); checkEntityId(cf.getEntityId(), operation); + return cf; } protected HomeDashboardInfo getHomeDashboardInfo(SecurityUser securityUser, JsonNode additionalInfo) { diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index de7f25023f..5945355ef8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -17,19 +17,21 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.http.HttpStatus; 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.thingsboard.common.util.JacksonUtil; @@ -41,7 +43,6 @@ import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; -import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.event.EventType; @@ -49,17 +50,14 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; -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.config.annotations.ApiOperation; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldScriptEngine; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine; import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; -import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import java.util.ArrayList; @@ -98,31 +96,32 @@ public class CalculatedFieldController extends BaseController { public static final int TIMEOUT = 20; - private static final String TEST_SCRIPT_EXPRESSION = "Execute the Script expression and return the result. The format of request: \n\n" - + MARKDOWN_CODE_BLOCK_START - + "{\n" + - " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" + - " \"arguments\": {\n" + - " \"temperature\": {\n" + - " \"type\": \"TS_ROLLING\",\n" + - " \"timeWindow\": {\n" + - " \"startTs\": 1739775630002,\n" + - " \"endTs\": 65432211,\n" + - " \"limit\": 5\n" + - " },\n" + - " \"values\": [\n" + - " { \"ts\": 1739775639851, \"value\": 23 },\n" + - " { \"ts\": 1739775664561, \"value\": 43 },\n" + - " { \"ts\": 1739775713079, \"value\": 15 },\n" + - " { \"ts\": 1739775999522, \"value\": 34 },\n" + - " { \"ts\": 1739776228452, \"value\": 22 }\n" + - " ]\n" + - " },\n" + - " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" + - " }\n" + - "}" - + MARKDOWN_CODE_BLOCK_END - + "\n\n Expected result JSON contains \"output\" and \"error\"."; + private static final String TEST_SCRIPT_EXPRESSION = + "Execute the Script expression and return the result. The format of request: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" + + " \"arguments\": {\n" + + " \"temperature\": {\n" + + " \"type\": \"TS_ROLLING\",\n" + + " \"timeWindow\": {\n" + + " \"startTs\": 1739775630002,\n" + + " \"endTs\": 65432211,\n" + + " \"limit\": 5\n" + + " },\n" + + " \"values\": [\n" + + " { \"ts\": 1739775639851, \"value\": 23 },\n" + + " { \"ts\": 1739775664561, \"value\": 43 },\n" + + " { \"ts\": 1739775713079, \"value\": 15 },\n" + + " { \"ts\": 1739775999522, \"value\": 34 },\n" + + " { \"ts\": 1739776228452, \"value\": 22 }\n" + + " ]\n" + + " },\n" + + " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Expected result JSON contains \"output\" and \"error\"."; @ApiOperation(value = "Create Or Update Calculated Field (saveCalculatedField)", notes = "Creates or Updates the Calculated Field. When creating calculated field, platform generates Calculated Field Id as " + UUID_WIKI_LINK + @@ -132,13 +131,12 @@ public class CalculatedFieldController extends BaseController { "Remove 'id', 'tenantId' from the request body example (below) to create new Calculated Field entity. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/calculatedField") public CalculatedField saveCalculatedField(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the calculated field.") @RequestBody CalculatedField calculatedField) throws Exception { calculatedField.setTenantId(getTenantId()); checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); - checkReferencedEntities(calculatedField.getConfiguration(), getCurrentUser()); + checkReferencedEntities(calculatedField.getConfiguration()); return tbCalculatedFieldService.save(calculatedField, getCurrentUser()); } @@ -146,8 +144,7 @@ public class CalculatedFieldController extends BaseController { notes = "Fetch the Calculated Field object based on the provided Calculated Field Id." ) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping("/calculatedField/{calculatedFieldId}") public CalculatedField getCalculatedFieldById(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); @@ -161,8 +158,7 @@ public class CalculatedFieldController extends BaseController { notes = "Fetch the Calculated Fields based on the provided Entity Id." ) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}) public PageData getCalculatedFieldsByEntityId( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -181,8 +177,8 @@ public class CalculatedFieldController extends BaseController { @ApiOperation(value = "Delete Calculated Field (deleteCalculatedField)", notes = "Deletes the calculated field. Referencing non-existing Calculated Field Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.DELETE) - @ResponseStatus(value = HttpStatus.OK) + @DeleteMapping("/calculatedField/{calculatedFieldId}") + @ResponseStatus(HttpStatus.OK) public void deleteCalculatedField(@PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws Exception { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); @@ -195,8 +191,7 @@ public class CalculatedFieldController extends BaseController { notes = "Gets latest calculated field debug event for specified calculated field id. " + "Referencing non-existing calculated field id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/{calculatedFieldId}/debug", method = RequestMethod.GET) - @ResponseBody + @GetMapping("/calculatedField/{calculatedFieldId}/debug") public JsonNode getLatestCalculatedFieldDebugEvent(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); @@ -211,15 +206,13 @@ public class CalculatedFieldController extends BaseController { @ApiOperation(value = "Test Script expression", notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/testScript", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/calculatedField/testScript") public JsonNode testScript( @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test calculated field TBEL expression.") @RequestBody JsonNode inputParams) { String expression = inputParams.get("expression").asText(); Map arguments = Objects.requireNonNullElse( - JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() { - }), + JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}), Collections.emptyMap() ); @@ -230,12 +223,13 @@ public class CalculatedFieldController extends BaseController { String output = ""; String errorText = ""; + CalculatedFieldTbelScriptEngine engine = null; try { if (tbelInvokeService == null) { throw new IllegalArgumentException("TBEL script engine is disabled!"); } - CalculatedFieldScriptEngine calculatedFieldScriptEngine = new CalculatedFieldTbelScriptEngine( + engine = new CalculatedFieldTbelScriptEngine( getTenantId(), tbelInvokeService, expression, @@ -243,7 +237,7 @@ public class CalculatedFieldController extends BaseController { ); Object[] args = new Object[ctxAndArgNames.size()]; - args[0] = new TbelCfCtx(arguments, getLastUpdateTimestamp(arguments)); + args[0] = new TbelCfCtx(arguments, getLatestTimestamp(arguments)); for (int i = 1; i < ctxAndArgNames.size(); i++) { var arg = arguments.get(ctxAndArgNames.get(i)); if (arg instanceof TbelCfSingleValueArg svArg) { @@ -253,20 +247,23 @@ public class CalculatedFieldController extends BaseController { } } - JsonNode json = calculatedFieldScriptEngine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); + JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); output = JacksonUtil.toString(json); } catch (Exception e) { log.error("Error evaluating expression", e); - errorText = e.getMessage(); + Throwable rootCause = ExceptionUtils.getRootCause(e); + errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getMessage(), e.getClass().getSimpleName()); + } finally { + if (engine != null) { + engine.destroy(); + } } - - ObjectNode result = JacksonUtil.newObjectNode(); - result.put("output", output); - result.put("error", errorText); - return result; + return JacksonUtil.newObjectNode() + .put("output", output) + .put("error", errorText); } - private long getLastUpdateTimestamp(Map arguments) { + private long getLatestTimestamp(Map arguments) { long lastUpdateTimestamp = -1; for (TbelCfArg entry : arguments.values()) { if (entry instanceof TbelCfSingleValueArg singleValueArg) { @@ -280,7 +277,7 @@ public class CalculatedFieldController extends BaseController { return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp; } - private & HasTenantId, I extends EntityId> void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig, SecurityUser user) throws ThingsboardException { + private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { List referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); for (EntityId referencedEntityId : referencedEntityIds) { EntityType entityType = referencedEntityId.getEntityType(); @@ -289,8 +286,7 @@ public class CalculatedFieldController extends BaseController { return; } case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); - default -> - throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); + default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); } } 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 8817c24efe..a87864726b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -31,7 +31,7 @@ public class ControllerConstants { protected static final String ASSIGNEE_ID = "assigneeId"; protected static final String PAGE_DATA_PARAMETERS = "You can specify parameters to filter the results. " + "The result is wrapped with PageData object that allows you to iterate over result set using pagination. " + - "See the 'Model' tab of the Response Class for more details. "; + "See response schema for more details. "; protected static final String INLINE_IMAGES = "inlineImages"; protected static final String INLINE_IMAGES_DESCRIPTION = "Inline images as a data URL (Base64)"; @@ -90,6 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; + protected static final String AI_MODEL_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model name, provider and model ID."; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java index 7fd0b12077..70fa3a5ed0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -29,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestParam; 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.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; @@ -41,6 +42,7 @@ import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.msg.edqs.EdqsApiService; import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.config.annotations.ApiOperation; @@ -75,6 +77,7 @@ public class EntityQueryController extends BaseController { @Parameter(description = "A JSON value representing the entity count query. See API call notes above for more details.") @RequestBody EntityCountQuery query) throws ThingsboardException { checkNotNull(query); + resolveQuery(query); return this.entityQueryService.countEntitiesByQuery(getCurrentUser(), query); } @@ -86,6 +89,7 @@ public class EntityQueryController extends BaseController { @Parameter(description = "A JSON value representing the entity data query. See API call notes above for more details.") @RequestBody EntityDataQuery query) throws ThingsboardException { checkNotNull(query); + resolveQuery(query); return this.entityQueryService.findEntityDataByQuery(getCurrentUser(), query); } @@ -102,6 +106,7 @@ public class EntityQueryController extends BaseController { if (assigneeId != null) { checkUserId(assigneeId, Operation.READ); } + resolveQuery(query); return this.entityQueryService.findAlarmDataByQuery(getCurrentUser(), query); } @@ -116,6 +121,7 @@ public class EntityQueryController extends BaseController { if (assigneeId != null) { checkUserId(assigneeId, Operation.READ); } + resolveQuery(query); return this.entityQueryService.countAlarmsByQuery(getCurrentUser(), query); } @@ -135,6 +141,7 @@ public class EntityQueryController extends BaseController { @RequestParam(value = "scope", required = false) String scope) throws ThingsboardException { TenantId tenantId = getTenantId(); checkNotNull(query); + resolveQuery(query); EntityDataPageLink pageLink = query.getPageLink(); if (pageLink.getPageSize() > MAX_PAGE_SIZE) { pageLink.setPageSize(MAX_PAGE_SIZE); @@ -149,9 +156,18 @@ public class EntityQueryController extends BaseController { } @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @GetMapping("/edqs/enabled") - public boolean isEdqsApiEnabled() { - return edqsApiService.isEnabled(); + @GetMapping("/edqs/state") + public EdqsState getEdqsState() { + return edqsService.getState(); + } + + private void resolveQuery(EntityCountQuery query) throws ThingsboardException { + if (query.getEntityFilter() != null) { + var user = getCurrentUser(); + var customerId = user.getCustomerId(); + var ownerId = customerId != null && !customerId.isNullUid() ? customerId : getTenantId(); + EntityFilter.resolveEntityFilter(query.getEntityFilter(), getTenantId(), user.getId(), ownerId); + } } } diff --git a/application/src/main/java/org/thingsboard/server/controller/JobController.java b/application/src/main/java/org/thingsboard/server/controller/JobController.java new file mode 100644 index 0000000000..fbd75dfc63 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/JobController.java @@ -0,0 +1,117 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.rule.engine.api.JobManager; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobFilter; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.permission.Operation; + +import java.util.List; +import java.util.UUID; + +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.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +@Slf4j +public class JobController extends BaseController { + + private final JobManager jobManager; + + @GetMapping("/job/{id}") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + public Job getJobById(@PathVariable UUID id) throws ThingsboardException { + JobId jobId = new JobId(id); + return checkJobId(jobId, Operation.READ); + } + + @GetMapping("/jobs") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + public PageData getJobs(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Case-insensitive 'substring' filter based on job's description") + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION) + @RequestParam(required = false) String sortOrder, + @RequestParam(required = false) List types, + @RequestParam(required = false) List statuses, + @RequestParam(required = false) List entities, + @RequestParam(required = false) Long startTime, + @RequestParam(required = false) Long endTime) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + JobFilter filter = JobFilter.builder() + .types(types) + .statuses(statuses) + .entities(entities) + .startTime(startTime) + .endTime(endTime) + .build(); + return jobService.findJobsByFilter(getTenantId(), filter, pageLink); + } + + @PostMapping("/job/{id}/cancel") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + public void cancelJob(@PathVariable UUID id) throws ThingsboardException { + JobId jobId = new JobId(id); + checkJobId(jobId, Operation.WRITE); + jobManager.cancelJob(getTenantId(), jobId); + } + + @PostMapping("/job/{id}/reprocess") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + public void reprocessJob(@PathVariable UUID id) throws ThingsboardException { + JobId jobId = new JobId(id); + checkJobId(jobId, Operation.WRITE); + jobManager.reprocessJob(getTenantId(), jobId); + } + + @DeleteMapping("/job/{id}") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + public void deleteJob(@PathVariable UUID id) throws ThingsboardException { + JobId jobId = new JobId(id); + checkJobId(jobId, Operation.DELETE); + jobService.deleteJob(getTenantId(), jobId); + } + +} 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 43ebf89b41..d4f932a643 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -24,13 +24,14 @@ import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpHeaders; 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.RequestPart; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.thingsboard.server.common.data.OtaPackage; @@ -49,8 +50,6 @@ import org.thingsboard.server.service.entitiy.ota.TbOtaPackageService; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; -import java.io.IOException; - 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; @@ -80,8 +79,7 @@ public class OtaPackageController extends BaseController { @ApiOperation(value = "Download OTA Package (downloadOtaPackage)", notes = "Download OTA Package based on the provided OTA Package Id." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority( 'TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}/download", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackage/{otaPackageId}/download") public ResponseEntity downloadOtaPackage(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); @@ -105,8 +103,7 @@ public class OtaPackageController extends BaseController { notes = "Fetch the OTA Package Info object based on the provided OTA Package Id. " + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/otaPackage/info/{otaPackageId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackage/info/{otaPackageId}") public OtaPackageInfo getOtaPackageInfoById(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); @@ -118,8 +115,7 @@ public class OtaPackageController extends BaseController { notes = "Fetch the OTA Package object based on the provided OTA Package Id. " + "The server checks that the OTA Package is owned by the same tenant. " + OTA_PACKAGE_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackage/{otaPackageId}") public OtaPackage getOtaPackageById(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); @@ -134,10 +130,9 @@ public class OtaPackageController extends BaseController { "Referencing non-existing OTA Package Id will cause 'Not Found' error. " + "\n\nOTA Package combination of the title with the version is unique in the scope of tenant. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/otaPackage") public OtaPackageInfo saveOtaPackageInfo(@Parameter(description = "A JSON value representing the OTA Package.") - @RequestBody SaveOtaPackageInfoRequest otaPackageInfo) throws ThingsboardException { + @RequestBody SaveOtaPackageInfoRequest otaPackageInfo) throws Exception { otaPackageInfo.setTenantId(getTenantId()); checkEntity(otaPackageInfo.getId(), otaPackageInfo, Resource.OTA_PACKAGE); @@ -148,8 +143,7 @@ public class OtaPackageController extends BaseController { notes = "Update the OTA Package. Adds the date to the existing OTA Package Info" + TENANT_AUTHORITY_PARAGRAPH, requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = MULTIPART_FORM_DATA_VALUE))) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.POST, consumes = MULTIPART_FORM_DATA_VALUE) - @ResponseBody + @PostMapping(value = "/otaPackage/{otaPackageId}", consumes = MULTIPART_FORM_DATA_VALUE) public OtaPackageInfo saveOtaPackageData(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId, @Parameter(description = "OTA Package checksum. For example, '0xd87f7e0c'") @@ -157,7 +151,7 @@ public class OtaPackageController extends BaseController { @Parameter(description = "OTA Package checksum algorithm.", schema = @Schema(allowableValues = {"MD5", "SHA256", "SHA384", "SHA512", "CRC32", "MURMUR3_32", "MURMUR3_128"})) @RequestParam(CHECKSUM_ALGORITHM) String checksumAlgorithmStr, @Parameter(description = "OTA Package data.") - @RequestPart MultipartFile file) throws ThingsboardException, IOException { + @RequestPart MultipartFile file) throws Exception { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); checkParameter(CHECKSUM_ALGORITHM, checksumAlgorithmStr); OtaPackageId otaPackageId = new OtaPackageId(toUUID(strOtaPackageId)); @@ -172,8 +166,7 @@ public class OtaPackageController extends BaseController { notes = "Returns a page of OTA Package Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/otaPackages", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackages") public PageData getOtaPackages(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @@ -192,8 +185,7 @@ public class OtaPackageController extends BaseController { notes = "Returns a page of OTA Package Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/otaPackages/{deviceProfileId}/{type}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackages/{deviceProfileId}/{type}") public PageData getOtaPackages(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("deviceProfileId") String strDeviceProfileId, @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) @@ -219,8 +211,7 @@ public class OtaPackageController extends BaseController { notes = "Deletes the OTA Package. Referencing non-existing OTA Package Id will cause an error. " + "Can't delete the OTA Package if it is referenced by existing devices or device profile." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/otaPackage/{otaPackageId}") public void deleteOtaPackage(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable("otaPackageId") String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index 1674db8755..efe3e61893 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -23,16 +22,19 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; 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.thingsboard.common.util.JacksonUtil; @@ -155,9 +157,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chain (getRuleChainById)", notes = "Fetch the Rule Chain object based on the provided Rule Chain Id. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}") public RuleChain getRuleChainById( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -169,9 +170,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chain output labels (getRuleChainOutputLabels)", notes = "Fetch the unique labels for the \"output\" Rule Nodes that belong to the Rule Chain based on the provided Rule Chain Id. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/output/labels", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}/output/labels") public Set getRuleChainOutputLabels( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -184,9 +184,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get output labels usage (getRuleChainOutputLabelsUsage)", notes = "Fetch the list of rule chains and the relation types (labels) they use to process output of the current rule chain based on the provided Rule Chain Id. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/output/labels/usage", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}/output/labels/usage") public List getRuleChainOutputLabelsUsage( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -198,9 +197,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chain (getRuleChainById)", notes = "Fetch the Rule Chain Metadata object based on the provided Rule Chain Id. " + RULE_CHAIN_METADATA_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/metadata", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}/metadata") public RuleChainMetaData getRuleChainMetaData( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -218,9 +216,8 @@ public class RuleChainController extends BaseController { "\n\n" + RULE_CHAIN_DESCRIPTION + "Remove 'id', 'tenantId' from the request body example (below) to create new Rule Chain entity." + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain") public RuleChain saveRuleChain( @Parameter(description = "A JSON value representing the rule chain.") @RequestBody RuleChain ruleChain) throws Exception { @@ -232,9 +229,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Create Default Rule Chain", notes = "Create rule chain from template, based on the specified name in the request. " + "Creates the rule chain based on the template that is used to create root rule chain. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/device/default", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/device/default") public RuleChain saveRuleChain( @Parameter(description = "A JSON value representing the request.") @RequestBody DefaultRuleChainCreateRequest request) throws Exception { @@ -245,9 +241,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Set Root Rule Chain (setRootRuleChain)", notes = "Makes the rule chain to be root rule chain. Updates previous root rule chain as well. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/root", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/{ruleChainId}/root") public RuleChain setRootRuleChain( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -259,9 +254,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Update Rule Chain Metadata", notes = "Updates the rule chain metadata. " + RULE_CHAIN_METADATA_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/metadata", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/metadata") public RuleChainMetaData saveRuleChainMetaData( @Parameter(description = "A JSON value representing the rule chain metadata.") @RequestBody RuleChainMetaData ruleChainMetaData, @@ -284,8 +278,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chains (getRuleChains)", notes = "Returns a page of Rule Chains owned by tenant. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChains", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/ruleChains", params = {"pageSize", "page"}) public PageData getRuleChains( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -302,7 +295,7 @@ public class RuleChainController extends BaseController { TenantId tenantId = getCurrentUser().getTenantId(); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); RuleChainType type = RuleChainType.CORE; - if (typeStr != null && typeStr.trim().length() > 0) { + if (StringUtils.isNotBlank(typeStr)) { type = RuleChainType.valueOf(typeStr); } return checkNotNull(ruleChainService.findTenantRuleChainsByType(tenantId, type, pageLink)); @@ -311,9 +304,9 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Delete rule chain (deleteRuleChain)", notes = "Deletes the rule chain. Referencing non-existing rule chain Id will cause an error. " + "Referencing rule chain that is used in the device profiles will cause an error." + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.DELETE) - @ResponseStatus(value = HttpStatus.OK) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/ruleChain/{ruleChainId}") + @ResponseStatus(HttpStatus.OK) public void deleteRuleChain( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -326,9 +319,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get latest input message (getLatestRuleNodeDebugInput)", notes = "Gets the input message from the debug events for specified Rule Chain Id. " + "Referencing non-existing rule chain Id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleNode/{ruleNodeId}/debugIn", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleNode/{ruleNodeId}/debugIn") public JsonNode getLatestRuleNodeDebugInput( @Parameter(description = RULE_NODE_ID_PARAM_DESCRIPTION) @PathVariable(RULE_NODE_ID) String strRuleNodeId) throws ThingsboardException { @@ -343,8 +335,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Is TBEL script executor enabled", notes = "Returns 'True' if the TBEL script execution is enabled" + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/tbelEnabled", method = RequestMethod.GET) - @ResponseBody + @GetMapping("/ruleChain/tbelEnabled") public Boolean isTbelEnabled() { return tbelEnabled; } @@ -352,13 +343,12 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Test Script function", notes = TEST_SCRIPT_FUNCTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/testScript", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/ruleChain/testScript") public JsonNode testScript( @Parameter(description = "Script language: JS or TBEL") @RequestParam(required = false) ScriptLanguage scriptLang, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test JS request. See API call description above.") - @RequestBody JsonNode inputParams) throws ThingsboardException, JsonProcessingException { + @RequestBody JsonNode inputParams) { String script = inputParams.get("script").asText(); String scriptType = inputParams.get("scriptType").asText(); JsonNode argNamesJson = inputParams.get("argNames"); @@ -366,8 +356,7 @@ public class RuleChainController extends BaseController { String data = inputParams.get("msg").asText(); JsonNode metadataJson = inputParams.get("metadata"); - Map metadata = JacksonUtil.convertValue(metadataJson, new TypeReference>() { - }); + Map metadata = JacksonUtil.convertValue(metadataJson, new TypeReference<>() {}); String msgType = inputParams.get("msgType").asText(); String output = ""; String errorText = ""; @@ -384,55 +373,40 @@ public class RuleChainController extends BaseController { } engine = new RuleNodeTbelScriptEngine(getTenantId(), tbelInvokeService, script, argNames); } - TbMsg inMsg = TbMsg.newMsg() + + var inMsg = TbMsg.newMsg() .type(msgType) .copyMetaData(new TbMsgMetaData(metadata)) .dataType(TbMsgDataType.JSON) .data(data) .build(); - switch (scriptType) { - case "update": - output = msgToOutput(engine.executeUpdateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); - break; - case "generate": - output = msgToOutput(engine.executeGenerateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); - break; - case "filter": - boolean result = engine.executeFilterAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - output = Boolean.toString(result); - break; - case "switch": - Set states = engine.executeSwitchAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - output = JacksonUtil.toString(states); - break; - case "json": - JsonNode json = engine.executeJsonAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - output = JacksonUtil.toString(json); - break; - case "string": - output = engine.executeToStringAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - break; - default: - throw new IllegalArgumentException("Unsupported script type: " + scriptType); - } + + output = switch (scriptType) { + case "update" -> msgToOutput(engine.executeUpdateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "generate" -> msgToOutput(engine.executeGenerateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "filter" -> Boolean.toString(engine.executeFilterAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "switch" -> JacksonUtil.toString(engine.executeSwitchAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "json" -> JacksonUtil.toString(engine.executeJsonAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "string" -> engine.executeToStringAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); + default -> throw new IllegalArgumentException("Unsupported script type: " + scriptType); + }; } catch (Exception e) { log.error("Error evaluating JS function", e); - errorText = e.getMessage(); + Throwable rootCause = ExceptionUtils.getRootCause(e); + errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getMessage(), e.getClass().getSimpleName()); } finally { if (engine != null) { engine.destroy(); } } - ObjectNode result = JacksonUtil.newObjectNode(); - result.put("output", output); - result.put("error", errorText); - return result; + return JacksonUtil.newObjectNode() + .put("output", output) + .put("error", errorText); } @ApiOperation(value = "Export Rule Chains", notes = "Exports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChains/export", params = {"limit"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/ruleChains/export", params = {"limit"}) public RuleChainData exportRuleChains( @Parameter(description = "A limit of rule chains to export.", required = true) @RequestParam("limit") int limit) throws ThingsboardException { @@ -443,8 +417,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Import Rule Chains", notes = "Imports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChains/import", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/ruleChains/import") public List importRuleChains( @Parameter(description = "A JSON value representing the rule chains.") @RequestBody RuleChainData ruleChainData, @@ -454,12 +427,12 @@ public class RuleChainController extends BaseController { return ruleChainService.importTenantRuleChains(tenantId, ruleChainData, overwrite, tbRuleChainService::updateRuleNodeConfiguration); } - private String msgToOutput(TbMsg msg) throws Exception { + private String msgToOutput(TbMsg msg) { JsonNode resultNode = convertMsgToOut(msg); return JacksonUtil.toString(resultNode); } - private String msgToOutput(List msgs) throws Exception { + private String msgToOutput(List msgs) { JsonNode resultNode; if (msgs.size() > 1) { resultNode = JacksonUtil.newArrayNode(); @@ -473,7 +446,7 @@ public class RuleChainController extends BaseController { return JacksonUtil.toString(resultNode); } - private JsonNode convertMsgToOut(TbMsg msg) throws Exception { + private JsonNode convertMsgToOut(TbMsg msg) { ObjectNode msgData = JacksonUtil.newObjectNode(); if (!StringUtils.isEmpty(msg.getData())) { msgData.set("msg", JacksonUtil.toJsonNode(msg.getData())); @@ -492,8 +465,7 @@ public class RuleChainController extends BaseController { "Third, once rule chain will be delivered to edge service, it's going to start processing messages locally. " + "\n\nOnly rule chain with type 'EDGE' can be assigned to edge." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/ruleChain/{ruleChainId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/edge/{edgeId}/ruleChain/{ruleChainId}") public RuleChain assignRuleChainToEdge(@PathVariable("edgeId") String strEdgeId, @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter("edgeId", strEdgeId); @@ -514,8 +486,7 @@ public class RuleChainController extends BaseController { EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + "Third, once 'unassign' command will be delivered to edge service, it's going to remove rule chain locally." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/ruleChain/{ruleChainId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping("/edge/{edgeId}/ruleChain/{ruleChainId}") public RuleChain unassignRuleChainFromEdge(@PathVariable("edgeId") String strEdgeId, @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter("edgeId", strEdgeId); @@ -530,9 +501,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Edge Rule Chains (getEdgeRuleChains)", notes = "Returns a page of Rule Chains assigned to the specified edge. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/ruleChains", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/edge/{edgeId}/ruleChains", params = {"pageSize", "page"}) public PageData getEdgeRuleChains( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @@ -557,9 +527,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Set Edge Template Root Rule Chain (setEdgeTemplateRootRuleChain)", notes = "Makes the rule chain to be root rule chain for any new edge that will be created. " + "Does not update root rule chain for already created edges. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/edgeTemplateRoot", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/{ruleChainId}/edgeTemplateRoot") public RuleChain setEdgeTemplateRootRuleChain(@Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter(RULE_CHAIN_ID, strRuleChainId); @@ -572,8 +541,7 @@ public class RuleChainController extends BaseController { notes = "Makes the rule chain to be automatically assigned for any new edge that will be created. " + "Does not assign this rule chain for already created edges. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/autoAssignToEdge", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/ruleChain/{ruleChainId}/autoAssignToEdge") public RuleChain setAutoAssignToEdgeRuleChain(@Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter(RULE_CHAIN_ID, strRuleChainId); @@ -586,8 +554,7 @@ public class RuleChainController extends BaseController { notes = "Removes the rule chain from the list of rule chains that are going to be automatically assigned for any new edge that will be created. " + "Does not unassign this rule chain for already assigned edges. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/autoAssignToEdge", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping("/ruleChain/{ruleChainId}/autoAssignToEdge") public RuleChain unsetAutoAssignToEdgeRuleChain(@Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter(RULE_CHAIN_ID, strRuleChainId); @@ -599,9 +566,8 @@ public class RuleChainController extends BaseController { // TODO: @voba refactor this - add new config to edge rule chain to set it as auto-assign @ApiOperation(value = "Get Auto Assign To Edge Rule Chains (getAutoAssignToEdgeRuleChains)", notes = "Returns a list of Rule Chains that will be assigned to a newly created edge. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/autoAssignToEdgeRuleChains", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/autoAssignToEdgeRuleChains") public List getAutoAssignToEdgeRuleChains() throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List result = new ArrayList<>(); @@ -612,4 +578,5 @@ public class RuleChainController extends BaseController { } return checkNotNull(result); } + } 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 6ca767dc01..c98ae0dcb8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -33,11 +33,11 @@ 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; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -64,34 +64,24 @@ import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.kv.Aggregation; -import org.thingsboard.server.common.data.kv.AggregationParams; import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; -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.DeleteTsKvQuery; -import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.IntervalType; -import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.ReadTsKvQuery; -import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.timeseries.TimeseriesService; -import org.thingsboard.server.exception.InvalidParametersException; -import org.thingsboard.server.exception.UncheckedApiException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.telemetry.AttributeData; +import org.thingsboard.server.service.telemetry.TbTelemetryService; import org.thingsboard.server.service.telemetry.TsData; import java.util.ArrayList; @@ -155,8 +145,8 @@ public class TelemetryController extends BaseController { @Autowired private AccessValidator accessValidator; - @Value("${transport.json.max_string_value_length:0}") - private int maxStringValueLength; + @Autowired + private TbTelemetryService tbTelemetryService; private ExecutorService executor; @@ -323,20 +313,11 @@ public class TelemetryController extends BaseController { @RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy, @Parameter(description = STRICT_DATA_TYPES_DESCRIPTION) @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes) throws ThingsboardException { - return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_TELEMETRY, entityType, entityIdStr, - (result, tenantId, entityId) -> { - AggregationParams params; - Aggregation agg = Aggregation.valueOf(aggStr); - if (Aggregation.NONE.equals(agg)) { - params = AggregationParams.none(); - } else if (intervalType == null || IntervalType.MILLISECONDS.equals(intervalType)) { - params = interval == 0L ? AggregationParams.none() : AggregationParams.milliseconds(agg, interval); - } else { - params = AggregationParams.calendar(agg, intervalType, timeZone); - } - List queries = toKeysList(keys).stream().map(key -> new BaseReadTsKvQuery(key, startTs, endTs, params, limit, orderBy)).collect(Collectors.toList()); - Futures.addCallback(tsService.findAll(tenantId, entityId, queries), getTsKvListCallback(result, useStrictDataTypes), MoreExecutors.directExecutor()); - }); + DeferredResult response = new DeferredResult<>(); + Futures.addCallback(tbTelemetryService.getTimeseries(EntityIdFactory.getByTypeAndId(entityType, entityIdStr), toKeysList(keys), startTs, endTs, + intervalType, interval, timeZone, limit, Aggregation.valueOf(aggStr), orderBy, useStrictDataTypes, getCurrentUser()), + getTsKvListCallback(response, useStrictDataTypes), MoreExecutors.directExecutor()); + return response; } @ApiOperation(value = "Save device attributes (saveDeviceAttributes)", @@ -353,12 +334,13 @@ public class TelemetryController extends BaseController { "Platform creates an audit log event about device attributes updates with action type 'ATTRIBUTES_UPDATED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.POST) - @ResponseBody - public DeferredResult saveDeviceAttributes( - @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + @PostMapping(value = "/{deviceId}/{scope}") + public DeferredResult saveDeviceAttributes(@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("deviceId") String deviceIdStr, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) + @PathVariable("scope") AttributeScope scope, + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); return saveAttributes(getTenantId(), entityId, scope, request); } @@ -375,13 +357,15 @@ public class TelemetryController extends BaseController { @ApiResponse(responseCode = "500", description = SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/{scope}", method = RequestMethod.POST) - @ResponseBody - public DeferredResult saveEntityAttributesV1( - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + @PostMapping(value = "/{entityType}/{entityId}/{scope}") + public DeferredResult saveEntityAttributesV1(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) + @PathVariable("scope") AttributeScope scope, + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); } @@ -398,13 +382,15 @@ public class TelemetryController extends BaseController { @ApiResponse(responseCode = "500", description = SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/attributes/{scope}", method = RequestMethod.POST) - @ResponseBody - public DeferredResult saveEntityAttributesV2( - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + @PostMapping(value = "/{entityType}/{entityId}/attributes/{scope}") + public DeferredResult saveEntityAttributesV2(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) + @PathVariable("scope") AttributeScope scope, + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); } @@ -624,18 +610,24 @@ public class TelemetryController extends BaseController { }); } - private DeferredResult saveAttributes(TenantId srcTenantId, EntityId entityIdSrc, AttributeScope scope, JsonNode json) throws ThingsboardException { + private DeferredResult saveAttributes(TenantId srcTenantId, EntityId entityIdSrc, AttributeScope scope, String jsonStr) throws ThingsboardException { if (AttributeScope.SERVER_SCOPE != scope && AttributeScope.SHARED_SCOPE != scope) { return getImmediateDeferredResult("Invalid scope: " + scope, HttpStatus.BAD_REQUEST); } - if (json.isObject()) { - List attributes = extractRequestAttributes(json); + JsonElement json; + try { + json = JsonParser.parseString(jsonStr); + } catch (Exception e) { + return getImmediateDeferredResult("Invalid JSON", HttpStatus.BAD_REQUEST); + } + if (json.isJsonObject()) { + List attributes = JsonConverter.convertToAttributes(json); if (attributes.isEmpty()) { return getImmediateDeferredResult("No attributes data found in request body!", HttpStatus.BAD_REQUEST); } for (AttributeKvEntry attributeKvEntry : attributes) { - if (attributeKvEntry.getKey().isEmpty() || attributeKvEntry.getKey().trim().length() == 0) { - return getImmediateDeferredResult("Key cannot be empty or contains only spaces", HttpStatus.BAD_REQUEST); + if (attributeKvEntry.getKey().isBlank()) { + return getImmediateDeferredResult("Key cannot be blank", HttpStatus.BAD_REQUEST); } } SecurityUser user = getCurrentUser(); @@ -893,43 +885,6 @@ public class TelemetryController extends BaseController { return result; } - private List extractRequestAttributes(JsonNode jsonNode) { - long ts = System.currentTimeMillis(); - List attributes = new ArrayList<>(); - jsonNode.fields().forEachRemaining(entry -> { - String key = entry.getKey(); - JsonNode value = entry.getValue(); - if (entry.getValue().isObject() || entry.getValue().isArray()) { - attributes.add(new BaseAttributeKvEntry(new JsonDataEntry(key, toJsonStr(value)), ts)); - } else if (entry.getValue().isTextual()) { - if (maxStringValueLength > 0 && entry.getValue().textValue().length() > maxStringValueLength) { - String message = String.format("String value length [%d] for key [%s] is greater than maximum allowed [%d]", entry.getValue().textValue().length(), key, maxStringValueLength); - throw new UncheckedApiException(new InvalidParametersException(message)); - } - attributes.add(new BaseAttributeKvEntry(new StringDataEntry(key, value.textValue()), ts)); - } else if (entry.getValue().isBoolean()) { - attributes.add(new BaseAttributeKvEntry(new BooleanDataEntry(key, value.booleanValue()), ts)); - } else if (entry.getValue().isDouble()) { - attributes.add(new BaseAttributeKvEntry(new DoubleDataEntry(key, value.doubleValue()), ts)); - } else if (entry.getValue().isNumber()) { - if (entry.getValue().isBigInteger()) { - throw new UncheckedApiException(new InvalidParametersException("Big integer values are not supported!")); - } else { - attributes.add(new BaseAttributeKvEntry(new LongDataEntry(key, value.longValue()), ts)); - } - } - }); - return attributes; - } - - private String toJsonStr(JsonNode value) { - try { - return JacksonUtil.toString(value); - } catch (IllegalArgumentException e) { - throw new JsonParseException("Can't parse jsonValue: " + value, e); - } - } - private JsonNode toJsonNode(String value) { try { return JacksonUtil.toJsonNode(value); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java new file mode 100644 index 0000000000..9e00c8ddfd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; + +public interface AiChatModelService extends RuleEngineAiChatModelService {} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java new file mode 100644 index 0000000000..d6252f57a6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; + +@Service +@RequiredArgsConstructor +class AiChatModelServiceImpl implements AiChatModelService { + + private final Langchain4jChatModelConfigurer chatModelConfigurer; + private final AiRequestsExecutor aiRequestsExecutor; + + @Override + public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) { + ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java b/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java new file mode 100644 index 0000000000..75de36c36d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; + +public interface AiRequestsExecutor { + + FluentFuture sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java new file mode 100644 index 0000000000..2d0121d30a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; +import org.thingsboard.common.util.ThingsBoardThreadFactory; + +import java.time.Duration; +import java.util.concurrent.Executors; + +@Lazy +@Component +@RequiredArgsConstructor +class DefaultAiRequestsExecutor implements AiRequestsExecutor { + + private final AiRequestsExecutorProperties properties; + + @Data + @Validated + @Configuration + @ConfigurationProperties(prefix = "actors.rule.ai-requests-thread-pool") + private static class AiRequestsExecutorProperties { + + @NotBlank(message = "Pool name must be not blank") + private String poolName = "ai-requests"; + + @Min(value = 1, message = "Pool size must be at least 1") + private int poolSize = 50; + + @Min(value = 1, message = "Termination timeout must be at least 1 second") + private int terminationTimeoutSeconds = 60; + + } + + private ListeningExecutorService executorService; + + @PostConstruct + private void init() { + executorService = MoreExecutors.listeningDecorator( + Executors.newFixedThreadPool(properties.getPoolSize(), ThingsBoardThreadFactory.forName(properties.getPoolName())) + ); + } + + @Override + public FluentFuture sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest) { + return FluentFuture.from(executorService.submit(() -> chatModel.chat(chatRequest))); + } + + @PreDestroy + private void destroy() { + if (executorService != null) { + MoreExecutors.shutdownAndAwaitTermination(executorService, Duration.ofSeconds(properties.getTerminationTimeoutSeconds())); + executorService = null; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java new file mode 100644 index 0000000000..69dd98f47f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -0,0 +1,269 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.retrying.RetrySettings; +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.cloud.vertexai.Transport; +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.GenerationConfig; +import com.google.cloud.vertexai.api.PredictionServiceClient; +import com.google.cloud.vertexai.api.PredictionServiceSettings; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; +import dev.langchain4j.model.bedrock.BedrockChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.github.GitHubModelsChatModel; +import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; +import dev.langchain4j.model.mistralai.MistralAiChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.Duration; + +@Component +class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { + + @Override + public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { + return OpenAiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { + AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); + return AzureOpenAiChatModel.builder() + .endpoint(providerConfig.endpoint()) + .serviceVersion(providerConfig.serviceVersion()) + .apiKey(providerConfig.apiKey()) + .deploymentName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) { + return GoogleAiGeminiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) { + GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig(); + + // construct service account credentials using service account key JSON + ServiceAccountCredentials serviceAccountCredentials; + try { + serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes())); + } catch (IOException e) { + throw new RuntimeException("Failed to parse service account key JSON", e); + } + + PredictionServiceSettings predictionServiceClientSettings; + try { + // create prediction service settings for REST transport with service account key credentials + PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder() + .setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials)); + + // get the retry settings that control request timeout for generateContent RPC + RetrySettings.Builder retrySettings = settingsBuilder + .generateContentSettings() + .getRetrySettings() + .toBuilder(); + + // set request timeout from model config + if (chatModelConfig.timeoutSeconds() != null) { + retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds())); + } + + // set updated retry settings + settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build()); + + // build the client settings + predictionServiceClientSettings = settingsBuilder.build(); + } catch (IOException e) { + throw new RuntimeException("Failed to create prediction service client settings", e); + } + + // construct Vertex AI instance + var vertexAI = new VertexAI.Builder() + .setProjectId(providerConfig.projectId()) + .setLocation(providerConfig.location()) + .setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings)) + .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys + .build(); + + // map model config to generation config + var generationConfigBuilder = GenerationConfig.newBuilder(); + if (chatModelConfig.temperature() != null) { + generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue()); + } + if (chatModelConfig.topP() != null) { + generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue()); + } + if (chatModelConfig.topK() != null) { + generationConfigBuilder.setTopK(chatModelConfig.topK()); + } + if (chatModelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); + } + if (chatModelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue()); + } + if (chatModelConfig.maxOutputTokens() != null) { + generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); + } + var generationConfig = generationConfigBuilder.build(); + + // construct generative model instance + var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); + + return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries()); + } + + private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) { + try { + return PredictionServiceClient.create(settings); + } catch (IOException e) { + throw new RuntimeException("Failed to create prediction service client", e); + } + } + + @Override + public ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig) { + return MistralAiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig) { + return AnthropicChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig) { + AmazonBedrockProviderConfig providerConfig = chatModelConfig.providerConfig(); + + var credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(providerConfig.accessKeyId(), providerConfig.secretAccessKey()) + ); + + var bedrockClient = BedrockRuntimeClient.builder() + .region(Region.of(providerConfig.region())) + .credentialsProvider(credentialsProvider) + .build(); + + var defaultChatRequestParams = ChatRequestParameters.builder() + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .build(); + + return BedrockChatModel.builder() + .client(bedrockClient) + .modelId(chatModelConfig.modelId()) + .defaultRequestParameters(defaultChatRequestParams) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) { + return GitHubModelsChatModel.builder() + .gitHubToken(chatModelConfig.providerConfig().personalAccessToken()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + private static Duration toDuration(Integer timeoutSeconds) { + return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java index 91c08ab6e0..70b41f069e 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.cf; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.calculatedField.CalculatedFieldStateRestoreMsg; import org.thingsboard.server.common.msg.queue.TbCallback; @@ -39,6 +40,7 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; public abstract class AbstractCalculatedFieldStateService implements CalculatedFieldStateService { @Autowired + @Lazy private ActorSystemContext actorSystemContext; protected QueueStateService, TbProtoQueueMsg> stateService; @@ -72,7 +74,7 @@ public abstract class AbstractCalculatedFieldStateService implements CalculatedF @Override public void restore(QueueKey queueKey, Set partitions) { - stateService.update(queueKey, partitions); + stateService.update(queueKey, partitions, null); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java index eb86220361..f9ec8087ed 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java @@ -21,6 +21,7 @@ import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService; import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import java.util.List; @@ -35,7 +36,7 @@ public interface CalculatedFieldQueueService extends RuleEngineCalculatedFieldQu */ void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback callback); - void pushRequestToQueue(AttributesSaveRequest request, List result, FutureCallback callback); + void pushRequestToQueue(AttributesSaveRequest request, AttributesSaveResult result, FutureCallback callback); void pushRequestToQueue(AttributesDeleteRequest request, List result, FutureCallback callback); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index ed35d96cb7..95fa7aebb1 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -19,7 +19,9 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -49,14 +51,13 @@ import java.util.concurrent.locks.ReentrantLock; @RequiredArgsConstructor public class DefaultCalculatedFieldCache implements CalculatedFieldCache { - private static final Integer UNKNOWN_PARTITION = -1; - - private final Lock calculatedFieldFetchLock = new ReentrantLock(); + private final ConcurrentReferenceHashMap calculatedFieldFetchLocks = new ConcurrentReferenceHashMap<>(); private final CalculatedFieldService calculatedFieldService; private final TbelInvokeService tbelInvokeService; - private final ActorSystemContext actorSystemContext; private final ApiLimitService apiLimitService; + @Lazy + private final ActorSystemContext actorSystemContext; private final ConcurrentMap calculatedFields = new ConcurrentHashMap<>(); private final ConcurrentMap> entityIdCalculatedFields = new ConcurrentHashMap<>(); @@ -70,11 +71,12 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @AfterStartUp(order = AfterStartUp.CF_READ_CF_SERVICE) public void init() { - //TODO: move to separate place to avoid circular references with the ActorSystemContext (@Lazy for tsSubService) PageDataIterable cfs = new PageDataIterable<>(calculatedFieldService::findAllCalculatedFields, initFetchPackSize); cfs.forEach(cf -> { - calculatedFields.putIfAbsent(cf.getId(), cf); - actorSystemContext.tell(new CalculatedFieldInitMsg(cf.getTenantId(), cf)); + if (cf != null) { + calculatedFields.putIfAbsent(cf.getId(), cf); + actorSystemContext.tell(new CalculatedFieldInitMsg(cf.getTenantId(), cf)); + } }); calculatedFields.values().forEach(cf -> { entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cf); @@ -98,19 +100,20 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @Override public List getCalculatedFieldsByEntityId(EntityId entityId) { - return entityIdCalculatedFields.getOrDefault(entityId, new CopyOnWriteArrayList<>()); + return entityIdCalculatedFields.getOrDefault(entityId, Collections.emptyList()); } @Override public List getCalculatedFieldLinksByEntityId(EntityId entityId) { - return entityIdCalculatedFieldLinks.getOrDefault(entityId, new CopyOnWriteArrayList<>()); + return entityIdCalculatedFieldLinks.getOrDefault(entityId, Collections.emptyList()); } @Override public CalculatedFieldCtx getCalculatedFieldCtx(CalculatedFieldId calculatedFieldId) { CalculatedFieldCtx ctx = calculatedFieldsCtx.get(calculatedFieldId); if (ctx == null) { - calculatedFieldFetchLock.lock(); + Lock lock = getFetchLock(calculatedFieldId); + lock.lock(); try { ctx = calculatedFieldsCtx.get(calculatedFieldId); if (ctx == null) { @@ -122,7 +125,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } } } finally { - calculatedFieldFetchLock.unlock(); + lock.unlock(); } } log.trace("[{}] Found calculated field ctx in cache: {}", calculatedFieldId, ctx); @@ -141,7 +144,8 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @Override public void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId) { - calculatedFieldFetchLock.lock(); + Lock lock = getFetchLock(calculatedFieldId); + lock.lock(); try { CalculatedField calculatedField = calculatedFieldService.findById(tenantId, calculatedFieldId); if (calculatedField == null) { @@ -163,7 +167,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { .add(configuration.buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId)); }); } finally { - calculatedFieldFetchLock.unlock(); + lock.unlock(); } } @@ -187,4 +191,8 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); } + private Lock getFetchLock(CalculatedFieldId id) { + return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index e9a6cb09aa..f2a6916751 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -25,7 +25,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.math.NumberUtils; import org.springframework.stereotype.Service; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; import org.thingsboard.server.actors.calculatedField.MultipleTbCallback; @@ -66,7 +65,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNot import org.thingsboard.server.queue.TbQueueCallback; import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.discovery.PartitionService; -import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; @@ -171,7 +169,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP OutputType type = calculatedFieldResult.getType(); TbMsgType msgType = OutputType.ATTRIBUTES.equals(type) ? TbMsgType.POST_ATTRIBUTES_REQUEST : TbMsgType.POST_TELEMETRY_REQUEST; TbMsgMetaData md = OutputType.ATTRIBUTES.equals(type) ? new TbMsgMetaData(Map.of(SCOPE, calculatedFieldResult.getScope().name())) : TbMsgMetaData.EMPTY; - TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(JacksonUtil.writeValueAsString(calculatedFieldResult.getResult())).build(); + TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(calculatedFieldResult.getResult().toString()).build(); clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() { @Override public void onSuccess(TbQueueMsgMetadata metadata) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java index 8289e4db42..fc5d75be56 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java @@ -32,6 +32,7 @@ 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.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.msg.TbMsgType; @@ -86,7 +87,10 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS public void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback callback) { var tenantId = request.getTenantId(); var entityId = request.getEntityId(); - checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(request.getEntries()), cf -> cf.linkMatches(entityId, request.getEntries()), + var entries = request.getEntries(); + checkEntityAndPushToQueue(tenantId, entityId, + cf -> cf.matches(entries), + cf -> cf.linkMatches(entityId, entries), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } @@ -96,10 +100,14 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } @Override - public void pushRequestToQueue(AttributesSaveRequest request, List result, FutureCallback callback) { + public void pushRequestToQueue(AttributesSaveRequest request, AttributesSaveResult result, FutureCallback callback) { var tenantId = request.getTenantId(); var entityId = request.getEntityId(); - checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(request.getEntries(), request.getScope()), cf -> cf.linkMatches(entityId, request.getEntries(), request.getScope()), + var entries = request.getEntries(); + var scope = request.getScope(); + checkEntityAndPushToQueue(tenantId, entityId, + cf -> cf.matches(entries, scope), + cf -> cf.linkMatches(entityId, entries, scope), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } @@ -112,7 +120,10 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS public void pushRequestToQueue(AttributesDeleteRequest request, List result, FutureCallback callback) { var tenantId = request.getTenantId(); var entityId = request.getEntityId(); - checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result, request.getScope()), cf -> cf.linkMatchesAttrKeys(entityId, result, request.getScope()), + var scope = request.getScope(); + checkEntityAndPushToQueue(tenantId, entityId, + cf -> cf.matchesKeys(result, scope), + cf -> cf.linkMatchesAttrKeys(entityId, result, scope), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } @@ -120,8 +131,9 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS public void pushRequestToQueue(TimeseriesDeleteRequest request, List result, FutureCallback callback) { var tenantId = request.getTenantId(); var entityId = request.getEntityId(); - - checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result), cf -> cf.linkMatchesTsKeys(entityId, result), + checkEntityAndPushToQueue(tenantId, entityId, + cf -> cf.matchesKeys(result), + cf -> cf.linkMatchesTsKeys(entityId, result), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } @@ -133,7 +145,8 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } boolean send = checkEntityForCalculatedFields(tenantId, entityId, mainEntityFilter, linkedEntityFilter); if (send) { - clusterService.pushMsgToCalculatedFields(tenantId, entityId, msg.get(), wrap(callback)); + ToCalculatedFieldMsg calculatedFieldMsg = msg.get(); + clusterService.pushMsgToCalculatedFields(tenantId, entityId, calculatedFieldMsg, wrap(callback)); } else { if (callback != null) { callback.onSuccess(null); @@ -142,20 +155,35 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter, Predicate linkedEntityFilter) { - boolean send = false; - if (supportedReferencedEntities.contains(entityId.getEntityType())) { - send = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(entityId).stream().anyMatch(filter); - if (!send) { - send = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(getProfileId(tenantId, entityId)).stream().anyMatch(filter); + if (!supportedReferencedEntities.contains(entityId.getEntityType())) { + return false; + } + List entityCfs = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(entityId); + for (CalculatedFieldCtx ctx : entityCfs) { + if (filter.test(ctx)) { + return true; } - if (!send) { - send = calculatedFieldCache.getCalculatedFieldLinksByEntityId(entityId).stream() - .map(CalculatedFieldLink::getCalculatedFieldId) - .map(calculatedFieldCache::getCalculatedFieldCtx) - .anyMatch(linkedEntityFilter); + } + + EntityId profileId = getProfileId(tenantId, entityId); + if (profileId != null) { + List profileCfs = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(profileId); + for (CalculatedFieldCtx ctx : profileCfs) { + if (filter.test(ctx)) { + return true; + } + } + } + + List links = calculatedFieldCache.getCalculatedFieldLinksByEntityId(entityId); + for (CalculatedFieldLink link : links) { + CalculatedFieldCtx ctx = calculatedFieldCache.getCalculatedFieldCtx(link.getCalculatedFieldId()); + if (ctx != null && linkedEntityFilter.test(ctx)) { + return true; } } - return send; + + return false; } private EntityId getProfileId(TenantId tenantId, EntityId entityId) { @@ -167,8 +195,6 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(TimeseriesSaveRequest request, TimeseriesSaveResult result) { - ToCalculatedFieldMsg.Builder msg = ToCalculatedFieldMsg.newBuilder(); - CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType()); List entries = request.getEntries(); @@ -176,25 +202,27 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS for (int i = 0; i < entries.size(); i++) { TsKvProto.Builder tsProtoBuilder = toTsKvProto(entries.get(i)).toBuilder(); - if (result != null) { + if (versions != null && !versions.isEmpty() && versions.get(i) != null) { tsProtoBuilder.setVersion(versions.get(i)); } telemetryMsg.addTsData(tsProtoBuilder.build()); } - msg.setTelemetryMsg(telemetryMsg.build()); - return msg.build(); + return ToCalculatedFieldMsg.newBuilder().setTelemetryMsg(telemetryMsg).build(); } - private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(AttributesSaveRequest request, List versions) { + private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(AttributesSaveRequest request, AttributesSaveResult result) { ToCalculatedFieldMsg.Builder msg = ToCalculatedFieldMsg.newBuilder(); CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType()); telemetryMsg.setScope(AttributeScopeProto.valueOf(request.getScope().name())); + List entries = request.getEntries(); + List versions = result.versions(); + for (int i = 0; i < entries.size(); i++) { AttributeValueProto.Builder attrProtoBuilder = ProtoUtils.toProto(entries.get(i)).toBuilder(); - if (versions != null) { + if (versions != null && !versions.isEmpty() && versions.get(i) != null) { attrProtoBuilder.setVersion(versions.get(i)); } telemetryMsg.addAttrData(attrProtoBuilder.build()); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index e4b03b4cab..e21d56b6d2 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -35,7 +35,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { protected Map arguments; protected boolean sizeExceedsLimit; - protected long lastUpdateTimestamp = -1; + protected long latestTimestamp = -1; public BaseCalculatedFieldState(List requiredArguments) { this.requiredArguments = requiredArguments; @@ -110,12 +110,14 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { protected abstract void validateNewEntry(ArgumentEntry newEntry); private void updateLastUpdateTimestamp(ArgumentEntry entry) { + long newTs = this.latestTimestamp; if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { - this.lastUpdateTimestamp = singleValueArgumentEntry.getTs(); + newTs = singleValueArgumentEntry.getTs(); } else if (entry instanceof TsRollingArgumentEntry tsRollingArgumentEntry) { Map.Entry lastEntry = tsRollingArgumentEntry.getTsRecords().lastEntry(); - this.lastUpdateTimestamp = (lastEntry != null) ? lastEntry.getKey() : System.currentTimeMillis(); + newTs = (lastEntry != null) ? lastEntry.getKey() : System.currentTimeMillis(); } + this.latestTimestamp = Math.max(this.latestTimestamp, newTs); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index a3fdae319d..2e3321eece 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -62,7 +62,7 @@ public class CalculatedFieldCtx { private final List argNames; private Output output; private String expression; - private boolean preserveMsgTs; + private boolean useLatestTs; private TbelInvokeService tbelInvokeService; private CalculatedFieldScriptEngine calculatedFieldScriptEngine; private ThreadLocal customExpression; @@ -96,7 +96,7 @@ public class CalculatedFieldCtx { this.argNames = new ArrayList<>(arguments.keySet()); this.output = configuration.getOutput(); this.expression = configuration.getExpression(); - this.preserveMsgTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) configuration).isPreserveMsgTs(); + this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) configuration).isUseLatestTs(); this.tbelInvokeService = tbelInvokeService; this.maxDataPointsPerRollingArg = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); @@ -181,26 +181,37 @@ public class CalculatedFieldCtx { } private boolean matchesAttributes(Map argMap, List values, AttributeScope scope) { + if (argMap.isEmpty() || values.isEmpty()) { + return false; + } + for (AttributeKvEntry attrKv : values) { - ReferencedEntityKey attrKey = new ReferencedEntityKey(attrKv.getKey(), ArgumentType.ATTRIBUTE, scope); - if (argMap.containsKey(attrKey)) { + if (argMap.containsKey(new ReferencedEntityKey(attrKv.getKey(), ArgumentType.ATTRIBUTE, scope))) { return true; } } + return false; } private boolean matchesTimeSeries(Map argMap, List values) { + if (argMap.isEmpty() || values.isEmpty()) { + return false; + } + for (TsKvEntry tsKv : values) { + ReferencedEntityKey latestKey = new ReferencedEntityKey(tsKv.getKey(), ArgumentType.TS_LATEST, null); if (argMap.containsKey(latestKey)) { return true; } + ReferencedEntityKey rollingKey = new ReferencedEntityKey(tsKv.getKey(), ArgumentType.TS_ROLLING, null); if (argMap.containsKey(rollingKey)) { return true; } } + return false; } @@ -213,26 +224,38 @@ public class CalculatedFieldCtx { } private boolean matchesAttributesKeys(Map argMap, List keys, AttributeScope scope) { + if (argMap.isEmpty() || keys.isEmpty()) { + return false; + } + for (String key : keys) { ReferencedEntityKey attrKey = new ReferencedEntityKey(key, ArgumentType.ATTRIBUTE, scope); if (argMap.containsKey(attrKey)) { return true; } } + return false; } private boolean matchesTimeSeriesKeys(Map argMap, List keys) { + if (argMap.isEmpty() || keys.isEmpty()) { + return false; + } + for (String key : keys) { + ReferencedEntityKey latestKey = new ReferencedEntityKey(key, ArgumentType.TS_LATEST, null); if (argMap.containsKey(latestKey)) { return true; } + ReferencedEntityKey rollingKey = new ReferencedEntityKey(key, ArgumentType.TS_ROLLING, null); if (argMap.containsKey(rollingKey)) { return true; } } + return false; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index 6eac3358ba..0de354bbb0 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -42,7 +42,7 @@ public interface CalculatedFieldState { Map getArguments(); - long getLastUpdateTimestamp(); + long getLatestTimestamp(); void setRequiredArguments(List requiredArguments); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java index 533b487d38..2b52892744 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java @@ -73,7 +73,7 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta .queueKey(queueKey) .topic(partitionService.getTopic(queueKey)) .pollInterval(pollInterval) - .msgPackProcessor((msgs, consumer, config) -> { + .msgPackProcessor((msgs, consumer, consumerKey, config) -> { for (TbProtoQueueMsg msg : msgs) { try { if (msg.getValue() != null) { @@ -114,18 +114,14 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta stateProducer.send(tpi, stateId.toKey(), msg, new TbQueueCallback() { @Override public void onSuccess(TbQueueMsgMetadata metadata) { - if (callback != null) { - callback.onSuccess(); - } } @Override public void onFailure(Throwable t) { - if (callback != null) { - callback.onFailure(t); - } + log.error("Failed to send state message: {}", stateId, t); } }); + callback.onSuccess(); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java index 65ef40330c..84dce627ae 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java @@ -66,7 +66,7 @@ public class ScriptCalculatedFieldState extends BaseCalculatedFieldState { args.add(arg); } } - args.set(0, new TbelCfCtx(arguments, getLastUpdateTimestamp())); + args.set(0, new TbelCfCtx(arguments, getLatestTimestamp())); ListenableFuture resultFuture = ctx.getCalculatedFieldScriptEngine().executeJsonAsync(args.toArray()); Output output = ctx.getOutput(); return Futures.transform(resultFuture, diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index d0eba5031c..577ff80219 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -58,7 +58,13 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { for (Map.Entry entry : this.arguments.entrySet()) { try { BasicKvEntry kvEntry = ((SingleValueArgumentEntry) entry.getValue()).getKvEntryValue(); - expr.setVariable(entry.getKey(), Double.parseDouble(kvEntry.getValueAsString())); + double value = switch (kvEntry.getDataType()) { + case LONG -> kvEntry.getLongValue().map(Long::doubleValue).orElseThrow(); + case DOUBLE -> kvEntry.getDoubleValue().orElseThrow(); + case BOOLEAN -> kvEntry.getBooleanValue().map(b -> b ? 1.0 : 0.0).orElseThrow(); + case STRING, JSON -> Double.parseDouble(kvEntry.getValueAsString()); + }; + expr.setVariable(entry.getKey(), value); } catch (NumberFormatException e) { throw new IllegalArgumentException("Argument '" + entry.getKey() + "' is not a number."); } @@ -68,7 +74,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { Output output = ctx.getOutput(); Object result = formatResult(expressionResult, output.getDecimalsByDefault()); - JsonNode outputResult = createResultJson(ctx.isPreserveMsgTs(), output.getName(), result); + JsonNode outputResult = createResultJson(ctx.isUseLatestTs(), output.getName(), result); return Futures.immediateFuture(new CalculatedFieldResult(output.getType(), output.getScope(), outputResult)); } @@ -83,14 +89,20 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { return TbUtils.toFixed(expressionResult, decimals); } - private JsonNode createResultJson(boolean preserveMsgTs, String outputName, Object result) { + private JsonNode createResultJson(boolean useLatestTs, String outputName, Object result) { ObjectNode valuesNode = JacksonUtil.newObjectNode(); - valuesNode.set(outputName, JacksonUtil.valueToTree(result)); + if (result instanceof Double doubleValue) { + valuesNode.put(outputName, doubleValue); + } else if (result instanceof Integer integerValue) { + valuesNode.put(outputName, integerValue); + } else { + valuesNode.set(outputName, JacksonUtil.valueToTree(result)); + } - long lastTimestamp = getLastUpdateTimestamp(); - if (preserveMsgTs && lastTimestamp != -1) { + long latestTs = getLatestTimestamp(); + if (useLatestTs && latestTs != -1) { ObjectNode resultNode = JacksonUtil.newObjectNode(); - resultNode.put("ts", lastTimestamp); + resultNode.put("ts", latestTs); resultNode.set("values", valuesNode); return resultNode; } else { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java index bfe9eed24f..1585c9b2a9 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java @@ -16,13 +16,16 @@ package org.thingsboard.server.service.cf.ctx.state; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.core.type.TypeReference; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicKvEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.util.ProtoUtils; @@ -90,13 +93,21 @@ public class SingleValueArgumentEntry implements ArgumentEntry { @Override public TbelCfArg toTbelCfArg() { - return new TbelCfSingleValueArg(ts, kvEntryValue.getValue()); + Object value = kvEntryValue.getValue(); + if (kvEntryValue instanceof JsonDataEntry) { + try { + value = JacksonUtil.readValue(kvEntryValue.getValueAsString(), new TypeReference<>() { + }); + } catch (Exception e) { + } + } + return new TbelCfSingleValueArg(ts, value); } @Override public boolean updateEntry(ArgumentEntry entry) { if (entry instanceof SingleValueArgumentEntry singleValueEntry) { - if (singleValueEntry.getTs() == this.ts) { + if (singleValueEntry.getTs() <= this.ts) { return false; } 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 173ba742af..0778d61ee7 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 @@ -33,6 +33,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.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.msg.TbMsgType; @@ -62,8 +63,6 @@ import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; -import java.util.Collections; -import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.regex.Matcher; @@ -240,10 +239,11 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); } - 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()))); + private ListenableFuture saveProvisionStateAttribute(Device device) { + return attributesService.save( + device.getTenantId(), device.getId(), AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new StringDataEntry(DEVICE_PROVISION_STATE, PROVISIONED_STATE), System.currentTimeMillis()) + ); } private DeviceCredentials getDeviceCredentials(Device device) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java index 00ae4a45bd..1193f935a0 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java @@ -29,6 +29,7 @@ import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceCredentialsService; @@ -61,6 +62,7 @@ import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor; import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor; import org.thingsboard.server.service.edge.rpc.processor.asset.profile.AssetProfileEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.cf.CalculatedFieldProcessor; import org.thingsboard.server.service.edge.rpc.processor.dashboard.DashboardEdgeProcessor; import org.thingsboard.server.service.edge.rpc.processor.device.DeviceEdgeProcessor; import org.thingsboard.server.service.edge.rpc.processor.device.profile.DeviceProfileEdgeProcessor; @@ -248,6 +250,12 @@ public class EdgeContextComponent { @Autowired private GrpcCallbackExecutorService grpcCallbackExecutorService; + @Autowired + private CalculatedFieldService calculatedFieldService; + + @Autowired + private CalculatedFieldProcessor calculatedFieldProcessor; + public EdgeProcessor getProcessor(EdgeEventType edgeEventType) { EdgeProcessor processor = processorMap.get(edgeEventType); if (processor == null) { 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 7bafd53644..fda9a1d17b 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 @@ -33,6 +33,7 @@ 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.cf.CalculatedField; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEventActionType; @@ -112,7 +113,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT.equals(entityType) || EntityType.EDGE.equals(entityType)) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) { return; } log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event); @@ -226,7 +227,7 @@ public class EdgeEventSourcingListener { break; case TENANT: return !event.getCreated(); - case API_USAGE_STATE, EDGE: + case API_USAGE_STATE, EDGE, AI_MODEL: return false; case DOMAIN: if (entity instanceof Domain domain) { @@ -262,6 +263,8 @@ public class EdgeEventSourcingListener { private String getBodyMsgForEntityEvent(Object entity) { if (entity instanceof AlarmComment) { return JacksonUtil.toString(entity); + } else if (entity instanceof CalculatedField calculatedField) { + return JacksonUtil.toString(calculatedField.getEntityId()); } return null; } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java index 5f5fd771cf..192c56692d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java @@ -48,11 +48,13 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.domain.DomainInfo; import org.thingsboard.server.common.data.edge.Edge; 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.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; @@ -89,6 +91,7 @@ import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.AttributeDeleteMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; @@ -638,4 +641,17 @@ public class EdgeMsgConstructorUtils { .build(); } + public static CalculatedFieldUpdateMsg constructCalculatedFieldUpdatedMsg(UpdateMsgType msgType, CalculatedField calculatedField) { + return CalculatedFieldUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(calculatedField)) + .setIdMSB(calculatedField.getId().getId().getMostSignificantBits()) + .setIdLSB(calculatedField.getId().getId().getLeastSignificantBits()).build(); + } + + public static CalculatedFieldUpdateMsg constructCalculatedFieldDeleteMsg(CalculatedFieldId calculatedFieldId) { + return CalculatedFieldUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(calculatedFieldId.getId().getMostSignificantBits()) + .setIdLSB(calculatedFieldId.getId().getLeastSignificantBits()).build(); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java index 61405e17e1..8a111e4d9d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java @@ -58,8 +58,7 @@ public class RelatedEdgesSourcingListener { log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); try { switch (event.getActionType()) { - case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> - relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); + case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); } } catch (Exception e) { log.error("[{}] failed to process ActionEntityEvent: {}", event.getTenantId(), event, e); @@ -67,7 +66,10 @@ public class RelatedEdgesSourcingListener { }); } - @TransactionalEventListener(fallbackExecution = true) + @TransactionalEventListener( + fallbackExecution = true, + condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_MODEL" + ) public void handleEvent(DeleteEntityEvent event) { executorService.submit(() -> { log.trace("[{}] DeleteEntityEvent called: {}", event.getTenantId(), event); 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 5f137848b1..eaef1f7c7d 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 @@ -22,7 +22,6 @@ 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; @@ -63,13 +62,16 @@ import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.kafka.TbKafkaSettings; import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; import org.thingsboard.server.queue.provider.TbCoreQueueFactory; +import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeContextComponent; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -92,6 +94,8 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS @TbCoreComponent public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService { + private static final int DESTROY_SESSION_MAX_ATTEMPTS = 10; + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>(); private final Map sessionNewEvents = new HashMap<>(); @@ -162,8 +166,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private ScheduledExecutorService executorService; - @PostConstruct - public void init() { + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void onStartUp() { log.info("Initializing Edge RPC service!"); NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort) .permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS) @@ -193,6 +197,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler"); this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler"); this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service"); + this.executorService.scheduleAtFixedRate(this::destroyKafkaSessionIfDisconnectedAndConsumerActive, 60, 60, TimeUnit.SECONDS); log.info("Edge RPC service initialized!"); } @@ -262,6 +267,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i @Override public void updateEdge(TenantId tenantId, Edge edge) { + if (edge == null) { + log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId); + return; + } EdgeGrpcSession session = sessions.get(edge.getId()); if (session != null && session.isConnected()) { log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId()); @@ -276,9 +285,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i EdgeGrpcSession session = sessions.get(edgeId); if (session != null && session.isConnected()) { log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId); - session.destroy(); + destroySession(session); session.cleanUp(); - session.close(); sessions.remove(edgeId); final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); newEventLock.lock(); @@ -328,6 +336,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i Edge edge = edgeGrpcSession.getEdge(); TenantId tenantId = edge.getTenantId(); log.info("[{}][{}] edge [{}] connected successfully.", tenantId, edgeGrpcSession.getSessionId(), edgeId); + if (sessions.containsKey(edgeId)) { + destroySession(sessions.get(edgeId)); + } sessions.put(edgeId, edgeGrpcSession); final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); newEventLock.lock(); @@ -459,11 +470,14 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void processEdgeEventMigrationIfNeeded(EdgeGrpcSession session, EdgeId edgeId) throws Exception { boolean isMigrationProcessed = edgeEventsMigrationProcessed.getOrDefault(edgeId, Boolean.FALSE); if (!isMigrationProcessed) { + log.info("Starting edge event migration for edge [{}]", edgeId.getId()); Boolean eventsExist = session.migrateEdgeEvents().get(); if (Boolean.TRUE.equals(eventsExist)) { + log.info("Migration still in progress for edge [{}]", edgeId.getId()); sessionNewEvents.put(edgeId, true); scheduleEdgeEventsCheck(session); } else if (Boolean.FALSE.equals(eventsExist)) { + log.info("Migration completed for edge [{}]", edgeId.getId()); edgeEventsMigrationProcessed.put(edgeId, true); } } @@ -493,7 +507,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } finally { newEventLock.unlock(); } - toRemove.destroy(); + destroySession(toRemove); TenantId tenantId = toRemove.getEdge().getTenantId(); save(tenantId, edgeId, ACTIVITY_STATE, false); long lastDisconnectTs = System.currentTimeMillis(); @@ -506,6 +520,20 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i edgeIdServiceIdCache.evict(edgeId); } + private void destroySession(EdgeGrpcSession session) { + try (session) { + for (int i = 0; i < DESTROY_SESSION_MAX_ATTEMPTS; i++) { + if (session.destroy()) { + break; + } else { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) {} + } + } + } + } + private void save(TenantId tenantId, EdgeId edgeId, String key, long value) { log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value); if (persistToTelemetry) { @@ -610,4 +638,29 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } + private void destroyKafkaSessionIfDisconnectedAndConsumerActive() { + try { + List toRemove = new ArrayList<>(); + for (EdgeGrpcSession session : sessions.values()) { + if (session instanceof KafkaEdgeGrpcSession kafkaSession && + !kafkaSession.isConnected() && + kafkaSession.getConsumer() != null && + kafkaSession.getConsumer().getConsumer() != null && + !kafkaSession.getConsumer().getConsumer().isStopped()) { + toRemove.add(kafkaSession.getEdge().getId()); + } + } + for (EdgeId edgeId : toRemove) { + log.info("[{}] Destroying session for edge because edge is not connected", edgeId); + EdgeGrpcSession removed = sessions.get(edgeId); + if (removed instanceof KafkaEdgeGrpcSession kafkaSession) { + if (kafkaSession.destroy()) { + sessions.remove(edgeId); + } + } + } + } catch (Exception e) { + log.warn("Failed to cleanup kafka sessions", e); + } + } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 4a9b68fc6d..be65dd13a1 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 @@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; @@ -42,7 +43,6 @@ import org.thingsboard.server.common.data.limit.LimitedApi; import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; @@ -50,6 +50,8 @@ import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg; import org.thingsboard.server.gen.edge.v1.ConnectResponseCode; import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg; @@ -292,11 +294,11 @@ public abstract class EdgeGrpcSession implements Closeable { protected void processEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture> result) { try { - log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", sessionId, fetcher.getClass().getSimpleName(), pageLink); + log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink); processHighPriorityEvents(); PageData pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink); if (isConnected() && !pageData.getData().isEmpty()) { - log.trace("[{}][{}][{}] event(s) are going to be processed.", tenantId, sessionId, pageData.getData().size()); + log.trace("[{}][{}][{}] event(s) are going to be processed.", tenantId, edge.getId(), pageData.getData().size()); List downlinkMsgsPack = convertToDownlinkMsgsPack(pageData.getData()); Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback<>() { @Override @@ -323,16 +325,16 @@ public abstract class EdgeGrpcSession implements Closeable { @Override public void onFailure(Throwable t) { - log.error("[{}] Failed to send downlink msgs pack", sessionId, t); + log.error("[{}] Failed to send downlink msgs pack", edge.getId(), t); result.setException(t); } }, ctx.getGrpcCallbackExecutorService()); } else { - log.trace("[{}] no event(s) found. Stop processing edge events, fetcher = {}, pageLink = {}", sessionId, fetcher.getClass().getSimpleName(), pageLink); + log.trace("[{}] no event(s) found. Stop processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink); result.set(null); } } catch (Exception e) { - log.error("[{}] Failed to fetch edge events", sessionId, e); + log.error("[{}] Failed to fetch edge events", edge.getId(), e); result.setException(e); } } @@ -452,16 +454,17 @@ public abstract class EdgeGrpcSession implements Closeable { List copy = new ArrayList<>(sessionState.getPendingMsgsMap().values()); if (attempt > 1) { String error = "Failed to deliver the batch"; - String failureMsg = String.format("{%s}: {%s}", error, copy); + String failureMsg = String.format("{%s} (size: {%s})", error, copy.size()); if (attempt == 2) { // Send a failure notification only on the second attempt. // This ensures that failure alerts are sent just once to avoid redundant notifications. ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId) .edgeId(edge.getId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build()); } - log.warn("[{}][{}] {}, attempt: {}", tenantId, sessionId, failureMsg, attempt); + log.warn("[{}][{}] {} on attempt {}", tenantId, edge.getId(), failureMsg, attempt); + log.debug("[{}][{}] entities in failed batch: {}", tenantId, edge.getId(), copy); } - log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", tenantId, sessionId, copy.size()); + log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", tenantId, edge.getId(), copy.size()); for (DownlinkMsg downlinkMsg : copy) { if (clientMaxInboundMessageSize != 0 && downlinkMsg.getSerializedSize() > clientMaxInboundMessageSize) { String error = String.format("Client max inbound message size %s is exceeded. Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE " + @@ -483,7 +486,7 @@ public abstract class EdgeGrpcSession implements Closeable { } else { String failureMsg = String.format("Failed to deliver messages: %s", copy); log.warn("[{}][{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}", - tenantId, sessionId, MAX_DOWNLINK_ATTEMPTS, copy); + tenantId, edge.getId(), MAX_DOWNLINK_ATTEMPTS, copy); ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg) .error("Failed to deliver messages after " + MAX_DOWNLINK_ATTEMPTS + " attempts").build()); @@ -493,7 +496,7 @@ public abstract class EdgeGrpcSession implements Closeable { stopCurrentSendDownlinkMsgsTask(false); } } catch (Exception e) { - log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", tenantId, sessionId, e.getMessage(), e); + log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", tenantId, edge.getId(), e.getMessage(), e); stopCurrentSendDownlinkMsgsTask(true); } }; @@ -540,7 +543,7 @@ public abstract class EdgeGrpcSession implements Closeable { stopCurrentSendDownlinkMsgsTask(false); } } catch (Exception e) { - log.error("[{}][{}] Can't process downlink response message [{}]", tenantId, sessionId, msg, e); + log.error("[{}][{}] Can't process downlink response message [{}]", tenantId, edge.getId(), msg, e); } } @@ -555,12 +558,12 @@ public abstract class EdgeGrpcSession implements Closeable { while ((event = highPriorityQueue.poll()) != null) { highPriorityEvents.add(event); } - log.trace("[{}][{}] Sending high priority events {}", tenantId, sessionId, highPriorityEvents.size()); + log.trace("[{}][{}] Sending high priority events {}", tenantId, edge.getId(), highPriorityEvents.size()); List downlinkMsgsPack = convertToDownlinkMsgsPack(highPriorityEvents); sendDownlinkMsgsPack(downlinkMsgsPack).get(); } } catch (Exception e) { - log.error("[{}] Failed to process high priority events", sessionId, e); + log.error("[{}] Failed to process high priority events", edge.getId(), e); } } @@ -577,16 +580,16 @@ public abstract class EdgeGrpcSession implements Closeable { Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()), ctx.getEdgeEventService()); log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}", - tenantId, sessionId, previousStartTs, previousStartSeqId); + tenantId, edge.getId(), previousStartTs, previousStartSeqId); Futures.addCallback(startProcessingEdgeEvents(fetcher), new FutureCallback<>() { @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) { - log.debug("[{}][{}] queue offset was updated [{}]", tenantId, sessionId, newStartTsAndSeqId); + public void onSuccess(@Nullable AttributesSaveResult saveResult) { + log.debug("[{}][{}] queue offset was updated [{}]", tenantId, edge.getId(), newStartTsAndSeqId); boolean newEventsAvailable; if (fetcher.isSeqIdNewCycleStarted()) { newEventsAvailable = isNewEdgeEventsAvailable(); @@ -601,28 +604,28 @@ public abstract class EdgeGrpcSession implements Closeable { @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, sessionId, newStartTsAndSeqId, t); + log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, edge.getId(), newStartTsAndSeqId, t); result.setException(t); } }, ctx.getGrpcCallbackExecutorService()); } else { - log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, sessionId); + log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, edge.getId()); result.set(Boolean.FALSE); } } @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to process events", tenantId, sessionId, t); + log.error("[{}][{}] Failed to process events", tenantId, edge.getId(), t); result.setException(t); } }, ctx.getGrpcCallbackExecutorService()); } else { if (isSyncInProgress()) { - log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, sessionId); + log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, edge.getId()); result.set(Boolean.TRUE); } else { - log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, sessionId); + log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, edge.getId()); result.set(null); } } @@ -632,7 +635,7 @@ public abstract class EdgeGrpcSession implements Closeable { protected List convertToDownlinkMsgsPack(List edgeEvents) { List result = new ArrayList<>(); for (EdgeEvent edgeEvent : edgeEvents) { - log.trace("[{}][{}] converting edge event to downlink msg [{}]", tenantId, sessionId, edgeEvent); + log.trace("[{}][{}] converting edge event to downlink msg [{}]", tenantId, edge.getId(), edgeEvent); DownlinkMsg downlinkMsg = null; try { switch (edgeEvent.getAction()) { @@ -641,17 +644,16 @@ public abstract class EdgeGrpcSession implements Closeable { ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> { downlinkMsg = convertEntityEventToDownlink(edgeEvent); if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) { - log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}", tenantId, sessionId, downlinkMsg.getDownlinkMsgId()); + log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}", tenantId, edge.getId(), downlinkMsg.getDownlinkMsgId()); } else { - log.trace("[{}][{}] entity message processed [{}]", tenantId, sessionId, downlinkMsg); + log.trace("[{}][{}] entity message processed [{}]", tenantId, edge.getId(), downlinkMsg); } } - case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED -> - downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(edge, edgeEvent); - default -> log.warn("[{}][{}] Unsupported action type [{}]", tenantId, sessionId, edgeEvent.getAction()); + case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED -> downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(edge, edgeEvent); + default -> log.warn("[{}][{}] Unsupported action type [{}]", tenantId, edge.getId(), edgeEvent.getAction()); } } catch (Exception e) { - log.trace("[{}][{}] Exception during converting edge event to downlink msg", tenantId, sessionId, e); + log.trace("[{}][{}] Exception during converting edge event to downlink msg", tenantId, edge.getId(), e); } if (downlinkMsg != null) { result.add(downlinkMsg); @@ -723,13 +725,14 @@ public abstract class EdgeGrpcSession implements Closeable { return startSeqId; } - private ListenableFuture> updateQueueStartTsAndSeqId(Pair pair) { + private ListenableFuture updateQueueStartTsAndSeqId(Pair pair) { newStartTs = pair.getFirst(); newStartSeqId = pair.getSecond(); log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", sessionId, edge.getId(), newStartTs, newStartSeqId); - List attributes = Arrays.asList( + List attributes = List.of( new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_TS_ATTR_KEY, newStartTs), System.currentTimeMillis()), - new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_SEQ_ID_ATTR_KEY, newStartSeqId), System.currentTimeMillis())); + new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_SEQ_ID_ATTR_KEY, newStartSeqId), System.currentTimeMillis()) + ); return ctx.getAttributesService().save(edge.getTenantId(), edge.getId(), AttributeScope.SERVER_SCOPE, attributes); } @@ -757,19 +760,19 @@ public abstract class EdgeGrpcSession implements Closeable { private void sendDownlinkMsg(ResponseMsg responseMsg) { if (isConnected()) { String responseMsgStr = StringUtils.truncate(responseMsg.toString(), 10000); - log.trace("[{}][{}] Sending downlink msg [{}]", tenantId, sessionId, responseMsgStr); + log.trace("[{}][{}] Sending downlink msg [{}]", tenantId, edge.getId(), responseMsgStr); downlinkMsgLock.lock(); String downlinkMsgStr = responseMsg.hasDownlinkMsg() ? String.valueOf(responseMsg.getDownlinkMsg().getDownlinkMsgId()) : responseMsgStr; try { outputStream.onNext(responseMsg); } catch (Exception e) { - log.trace("[{}][{}] Failed to send downlink message [{}]", tenantId, sessionId, downlinkMsgStr, e); + log.trace("[{}][{}] Failed to send downlink message [{}]", tenantId, edge.getId(), downlinkMsgStr, e); connected = false; sessionCloseListener.accept(edge, sessionId); } finally { downlinkMsgLock.unlock(); } - log.trace("[{}][{}] downlink msg successfully sent [{}]", tenantId, sessionId, downlinkMsgStr); + log.trace("[{}][{}] downlink msg successfully sent [{}]", tenantId, edge.getId(), downlinkMsgStr); } } @@ -882,6 +885,11 @@ public abstract class EdgeGrpcSession implements Closeable { result.add(ctx.getEdgeRequestsService().processRelationRequestMsg(edge.getTenantId(), edge, relationRequestMsg)); } } + if (uplinkMsg.getCalculatedFieldRequestMsgCount() > 0) { + for (CalculatedFieldRequestMsg calculatedFieldRequestMsg : uplinkMsg.getCalculatedFieldRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processCalculatedFieldRequestMsg(edge.getTenantId(), edge, calculatedFieldRequestMsg)); + } + } if (uplinkMsg.getUserCredentialsRequestMsgCount() > 0) { for (UserCredentialsRequestMsg userCredentialsRequestMsg : uplinkMsg.getUserCredentialsRequestMsgList()) { result.add(ctx.getEdgeRequestsService().processUserCredentialsRequestMsg(edge.getTenantId(), edge, userCredentialsRequestMsg)); @@ -907,17 +915,24 @@ public abstract class EdgeGrpcSession implements Closeable { result.add(ctx.getEdgeRequestsService().processEntityViewsRequestMsg(edge.getTenantId(), edge, entityViewRequestMsg)); } } + if (uplinkMsg.getCalculatedFieldUpdateMsgCount() > 0) { + for (CalculatedFieldUpdateMsg calculatedFieldUpdateMsg : uplinkMsg.getCalculatedFieldUpdateMsgList()) { + result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg)); + } + } } catch (Exception e) { String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg); - log.trace("[{}][{}] Can't process uplink msg [{}]", edge.getTenantId(), sessionId, uplinkMsg, e); - ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(edge.getTenantId()).edgeId(edge.getId()) + log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e); + ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build()); return Futures.immediateFailedFuture(e); } return Futures.allAsList(result); } - protected void destroy() {} + protected boolean destroy() { + return true; + } protected void cleanUp() {} 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 351c9b411b..adab9b812f 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 @@ -107,4 +107,5 @@ public class EdgeSyncCursor { currentIdx++; return edgeEventFetcher; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java index 37687f14a9..ab0b42abb4 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.edge.rpc; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import io.grpc.stub.StreamObserver; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.edge.Edge; @@ -56,6 +57,7 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { private volatile boolean isHighPriorityProcessing; + @Getter private QueueConsumerManager> consumer; private ExecutorService consumerExecutor; @@ -72,31 +74,28 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { } private void processMsgs(List> msgs, TbQueueConsumer> consumer) { - log.trace("[{}][{}] starting processing edge events", tenantId, sessionId); - if (isConnected() && !isSyncInProgress() && !isHighPriorityProcessing) { - List edgeEvents = new ArrayList<>(); - for (TbProtoQueueMsg msg : msgs) { - EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg()); - edgeEvents.add(edgeEvent); - } - List downlinkMsgsPack = convertToDownlinkMsgsPack(edgeEvents); - try { - boolean isInterrupted = sendDownlinkMsgsPack(downlinkMsgsPack).get(); - if (isInterrupted) { - log.debug("[{}][{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId(), sessionId); - } else { - consumer.commit(); - } - } catch (Exception e) { - log.error("[{}] Failed to process all downlink messages", sessionId, e); - } - } else { - try { - Thread.sleep(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval()); - } catch (InterruptedException interruptedException) { - log.trace("Failed to wait until the server has capacity to handle new requests", interruptedException); + log.trace("[{}][{}] starting processing edge events", tenantId, edge.getId()); + if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) { + log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " + + "connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration", + tenantId, edge.getId(), isConnected(), isSyncInProgress(), isHighPriorityProcessing); + return; + } + List edgeEvents = new ArrayList<>(); + for (TbProtoQueueMsg msg : msgs) { + EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg()); + edgeEvents.add(edgeEvent); + } + List downlinkMsgsPack = convertToDownlinkMsgsPack(edgeEvents); + try { + boolean isInterrupted = sendDownlinkMsgsPack(downlinkMsgsPack).get(); + if (isInterrupted) { + log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId()); + } else { + consumer.commit(); } - log.trace("[{}][{}] edge is not connected or sync is not completed. Skipping iteration", tenantId, sessionId); + } catch (Exception e) { + log.error("[{}][{}] Failed to process downlink messages", tenantId, edge.getId(), e); } } @@ -107,18 +106,23 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { @Override public ListenableFuture processEdgeEvents() { - if (consumer == null) { - this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer")); - this.consumer = QueueConsumerManager.>builder() - .name("TB Edge events") - .msgPackProcessor(this::processMsgs) - .pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval()) - .consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edge.getId())) - .consumerExecutor(consumerExecutor) - .threadPrefix("edge-events") - .build(); - consumer.subscribe(); - consumer.launch(); + if (consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped())) { + try { + this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer")); + this.consumer = QueueConsumerManager.>builder() + .name("TB Edge events [" + edge.getId() + "]") + .msgPackProcessor(this::processMsgs) + .pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval()) + .consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edge.getId())) + .consumerExecutor(consumerExecutor) + .threadPrefix("edge-events-" + edge.getId()) + .build(); + consumer.subscribe(); + consumer.launch(); + } catch (Exception e) { + destroy(); + log.warn("[{}][{}] Failed to start edge event consumer", sessionId, edge.getId(), e); + } } return Futures.immediateFuture(Boolean.FALSE); } @@ -131,9 +135,25 @@ public class KafkaEdgeGrpcSession extends EdgeGrpcSession { } @Override - public void destroy() { - consumer.stop(); - consumerExecutor.shutdown(); + public boolean destroy() { + try { + if (consumer != null) { + consumer.stop(); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to stop edge event consumer", tenantId, edge.getId(), e); + return false; + } + consumer = null; + try { + if (consumerExecutor != null) { + consumerExecutor.shutdown(); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to shutdown consumer executor", tenantId, edge.getId(), e); + return false; + } + return true; } @Override 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 6fcb02e4bc..a2243a88d2 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 @@ -139,8 +139,8 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { UPDATED_COMMENT, DELETED -> true; default -> switch (type) { case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE, - WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, NOTIFICATION_TEMPLATE, NOTIFICATION_TARGET, - NOTIFICATION_RULE -> true; + WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, NOTIFICATION_TEMPLATE, + NOTIFICATION_TARGET, NOTIFICATION_RULE -> true; default -> false; }; }; @@ -219,10 +219,10 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { case CREDENTIALS_UPDATED: case ASSIGNED_TO_CUSTOMER: case UNASSIGNED_FROM_CUSTOMER: - if (edgeId != null) { + if (edgeId != null && !edgeId.equals(originatorEdgeId)) { return saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, body); } else { - return processNotificationToRelatedEdges(tenantId, entityId, type, actionType, originatorEdgeId); + return processNotificationToRelatedEdges(tenantId, entityId, entityId, type, actionType, originatorEdgeId); } case DELETED: EdgeEventActionType deleted = EdgeEventActionType.DELETED; @@ -260,11 +260,11 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { } } - private ListenableFuture processNotificationToRelatedEdges(TenantId tenantId, EntityId entityId, EdgeEventType type, - EdgeEventActionType actionType, EdgeId sourceEdgeId) { + protected ListenableFuture processNotificationToRelatedEdges(TenantId tenantId, EntityId ownerEntityId, EntityId entityId, EdgeEventType type, + EdgeEventActionType actionType, EdgeId sourceEdgeId) { List> futures = new ArrayList<>(); PageDataIterableByTenantIdEntityId edgeIds = - new PageDataIterableByTenantIdEntityId<>(edgeCtx.getEdgeService()::findRelatedEdgeIdsByEntityId, tenantId, entityId, RELATED_EDGES_CACHE_ITEMS); + new PageDataIterableByTenantIdEntityId<>(edgeCtx.getEdgeService()::findRelatedEdgeIdsByEntityId, tenantId, ownerEntityId, RELATED_EDGES_CACHE_ITEMS); for (EdgeId relatedEdgeId : edgeIds) { if (!relatedEdgeId.equals(sourceEdgeId)) { futures.add(saveEdgeEvent(tenantId, relatedEdgeId, type, actionType, entityId, null)); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java index 47f4c11362..cba2b62af1 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java @@ -119,6 +119,7 @@ public class AssetEdgeProcessor extends BaseAssetProcessor implements AssetProce DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) .addAssetUpdateMsg(assetUpdateMsg); + if (UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE.equals(msgType)) { AssetProfile assetProfile = edgeCtx.getAssetProfileService().findAssetProfileById(edgeEvent.getTenantId(), asset.getAssetProfileId()); builder.addAssetProfileUpdateMsg(EdgeMsgConstructorUtils.constructAssetProfileUpdatedMsg(msgType, assetProfile)); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/BaseCalculatedFieldProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/BaseCalculatedFieldProcessor.java new file mode 100644 index 0000000000..4ef6ec7ba2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/BaseCalculatedFieldProcessor.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge.rpc.processor.cf; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +@Slf4j +public abstract class BaseCalculatedFieldProcessor extends BaseEdgeProcessor { + + @Autowired + private DataValidator calculatedFieldValidator; + + protected Pair saveOrUpdateCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId, CalculatedFieldUpdateMsg calculatedFieldUpdateMsg) { + boolean isCreated = false; + boolean isNameUpdated = false; + try { + CalculatedField calculatedField = JacksonUtil.fromString(calculatedFieldUpdateMsg.getEntity(), CalculatedField.class, true); + if (calculatedField == null) { + throw new RuntimeException("[{" + tenantId + "}] calculatedFieldUpdateMsg {" + calculatedFieldUpdateMsg + " } cannot be converted to calculatedField"); + } + + CalculatedField calculatedFieldById = edgeCtx.getCalculatedFieldService().findById(tenantId, calculatedFieldId); + if (calculatedFieldById == null) { + calculatedField.setCreatedTime(Uuids.unixTimestamp(calculatedFieldId.getId())); + isCreated = true; + calculatedField.setId(null); + } else { + calculatedField.setId(calculatedFieldId); + } + + String calculatedFieldName = calculatedField.getName(); + CalculatedField calculatedFieldByName = edgeCtx.getCalculatedFieldService().findByEntityIdAndName(calculatedField.getEntityId(), calculatedFieldName); + if (calculatedFieldByName != null && !calculatedFieldByName.getId().equals(calculatedFieldId)) { + calculatedFieldName = calculatedFieldName + "_" + StringUtils.randomAlphabetic(15); + log.warn("[{}] calculatedField with name {} already exists. Renaming calculatedField name to {}", + tenantId, calculatedField.getName(), calculatedFieldByName.getName()); + isNameUpdated = true; + } + calculatedField.setName(calculatedFieldName); + + calculatedFieldValidator.validate(calculatedField, CalculatedField::getTenantId); + + if (isCreated) { + calculatedField.setId(calculatedFieldId); + } + + edgeCtx.getCalculatedFieldService().save(calculatedField, false); + } catch (Exception e) { + log.error("[{}] Failed to process calculatedField update msg [{}]", tenantId, calculatedFieldUpdateMsg, e); + throw e; + } + return Pair.of(isCreated, isNameUpdated); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldEdgeProcessor.java new file mode 100644 index 0000000000..cab4b5ecc1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldEdgeProcessor.java @@ -0,0 +1,169 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge.rpc.processor.cf; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EdgeId; +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.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; + +import java.util.UUID; + +@Slf4j +@Component +@TbCoreComponent +public class CalculatedFieldEdgeProcessor extends BaseCalculatedFieldProcessor implements CalculatedFieldProcessor { + + @Override + public ListenableFuture processCalculatedFieldMsgFromEdge(TenantId tenantId, Edge edge, CalculatedFieldUpdateMsg calculatedFieldUpdateMsg) { + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(new UUID(calculatedFieldUpdateMsg.getIdMSB(), calculatedFieldUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + switch (calculatedFieldUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + processCalculatedField(tenantId, calculatedFieldId, calculatedFieldUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + CalculatedField calculatedField = edgeCtx.getCalculatedFieldService().findById(tenantId, calculatedFieldId); + if (calculatedField != null) { + edgeCtx.getCalculatedFieldService().deleteCalculatedField(tenantId, calculatedFieldId); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(calculatedFieldUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed calculatedField violated {}", tenantId, calculatedFieldUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + @Override + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(edgeEvent.getEntityId()); + switch (edgeEvent.getAction()) { + case ADDED, UPDATED -> { + CalculatedField calculatedField = edgeCtx.getCalculatedFieldService().findById(edgeEvent.getTenantId(), calculatedFieldId); + if (calculatedField != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = EdgeMsgConstructorUtils.constructCalculatedFieldUpdatedMsg(msgType, calculatedField); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addCalculatedFieldUpdateMsg(calculatedFieldUpdateMsg) + .build(); + } + } + case DELETED -> { + CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = EdgeMsgConstructorUtils.constructCalculatedFieldDeleteMsg(calculatedFieldId); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addCalculatedFieldUpdateMsg(calculatedFieldUpdateMsg) + .build(); + } + } + return null; + } + + @Override + public EdgeEventType getEdgeEventType() { + return EdgeEventType.CALCULATED_FIELD; + } + + @Override + public ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); + EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); + EntityId entityId = EntityIdFactory.getByEdgeEventTypeAndUuid(type, new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); + EdgeId originatorEdgeId = safeGetEdgeId(edgeNotificationMsg.getOriginatorEdgeIdMSB(), edgeNotificationMsg.getOriginatorEdgeIdLSB()); + + switch (actionType) { + case UPDATED: + case ADDED: + EntityId calculatedFieldOwnerId = JacksonUtil.fromString(edgeNotificationMsg.getBody(), EntityId.class); + if (calculatedFieldOwnerId != null && + (EntityType.DEVICE.equals(calculatedFieldOwnerId.getEntityType()) || EntityType.ASSET.equals(calculatedFieldOwnerId.getEntityType()))) { + JsonNode body = JacksonUtil.toJsonNode(edgeNotificationMsg.getBody()); + EdgeId edgeId = safeGetEdgeId(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB()); + + return edgeId != null ? + saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, body) : + processNotificationToRelatedEdges(tenantId, calculatedFieldOwnerId, entityId, type, actionType, originatorEdgeId); + } else { + return processActionForAllEdges(tenantId, type, actionType, entityId, null, originatorEdgeId); + } + default: + return super.processEntityNotification(tenantId, edgeNotificationMsg); + } + } + + private void processCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId, CalculatedFieldUpdateMsg calculatedFieldUpdateMsg, Edge edge) { + Pair resultPair = super.saveOrUpdateCalculatedField(tenantId, calculatedFieldId, calculatedFieldUpdateMsg); + Boolean wasCreated = resultPair.getFirst(); + if (wasCreated) { + pushCalculatedFieldCreatedEventToRuleEngine(tenantId, edge, calculatedFieldId); + } + Boolean nameWasUpdated = resultPair.getSecond(); + if (nameWasUpdated) { + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.CALCULATED_FIELD, EdgeEventActionType.UPDATED, calculatedFieldId, null); + } + } + + private void pushCalculatedFieldCreatedEventToRuleEngine(TenantId tenantId, Edge edge, CalculatedFieldId calculatedFieldId) { + try { + CalculatedField calculatedField = edgeCtx.getCalculatedFieldService().findById(tenantId, calculatedFieldId); + String calculatedFieldAsString = JacksonUtil.toString(calculatedField); + TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId()); + pushEntityEventToRuleEngine(tenantId, calculatedFieldId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, calculatedFieldAsString, msgMetaData); + } catch (Exception e) { + log.warn("[{}][{}] Failed to push calculatedField action to rule engine: {}", tenantId, calculatedFieldId, TbMsgType.ENTITY_CREATED.name(), e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldProcessor.java new file mode 100644 index 0000000000..d21af858f0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldProcessor.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge.rpc.processor.cf; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; + +public interface CalculatedFieldProcessor extends EdgeProcessor { + + ListenableFuture processCalculatedFieldMsgFromEdge(TenantId tenantId, Edge edge, CalculatedFieldUpdateMsg calculatedFieldUpdateMsg); + +} 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 ab01f83cd8..763821f11c 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 @@ -243,6 +243,7 @@ public class DeviceEdgeProcessor extends BaseDeviceProcessor implements DevicePr DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = EdgeMsgConstructorUtils.constructDeviceCredentialsUpdatedMsg(deviceCredentials); builder.addDeviceCredentialsUpdateMsg(deviceCredentialsUpdateMsg).build(); } + if (UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE.equals(msgType)) { DeviceProfile deviceProfile = edgeCtx.getDeviceProfileService().findDeviceProfileById(edgeEvent.getTenantId(), device.getDeviceProfileId()); builder.addDeviceProfileUpdateMsg(EdgeMsgConstructorUtils.constructDeviceProfileUpdatedMsg(msgType, deviceProfile)); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java index eeaa80d6a2..068af492bc 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java @@ -266,7 +266,7 @@ public abstract class BaseTelemetryProcessor extends BaseEdgeProcessor { SettableFuture futureToSet = SettableFuture.create(); JsonObject json = JsonUtils.getJsonObject(msg.getKvList()); AttributeScope scope = AttributeScope.valueOf(metaData.getValue(DataConstants.SCOPE)); - List attributes = new ArrayList<>(JsonConverter.convertToAttributes(json, ts)); + List attributes = JsonConverter.convertToAttributes(json, ts); ListenableFuture> future = filterAttributesByTs(tenantId, entityId, scope, attributes); Futures.addCallback(future, new FutureCallback<>() { @Override @@ -314,7 +314,7 @@ public abstract class BaseTelemetryProcessor extends BaseEdgeProcessor { SettableFuture futureToSet = SettableFuture.create(); JsonObject json = JsonUtils.getJsonObject(msg.getKvList()); AttributeScope scope = AttributeScope.valueOf(metaData.getValue(DataConstants.SCOPE)); - List attributes = new ArrayList<>(JsonConverter.convertToAttributes(json, ts)); + List attributes = JsonConverter.convertToAttributes(json, ts); ListenableFuture> future = filterAttributesByTs(tenantId, entityId, scope, attributes); Futures.addCallback(future, new FutureCallback<>() { @Override 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 6fc6d5bad9..66ff05b45a 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 @@ -54,12 +54,14 @@ import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.edge.EdgeEventService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; @@ -90,7 +92,7 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { @Autowired private TimeseriesService timeseriesService; - + @Autowired private RelationService relationService; @@ -104,6 +106,9 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { @Autowired private WidgetTypeService widgetTypeService; + @Autowired + private CalculatedFieldService calculatedFieldService; + @Autowired private DbCallbackExecutorService dbCallbackExecutorService; @@ -293,6 +298,44 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { return futureToSet; } + @Override + public ListenableFuture processCalculatedFieldRequestMsg(TenantId tenantId, Edge edge, CalculatedFieldRequestMsg calculatedFieldRequestMsg) { + log.trace("[{}] processCalculatedFieldRequestMsg [{}][{}]", tenantId, edge.getName(), calculatedFieldRequestMsg); + + EntityId entityId = EntityIdFactory.getByTypeAndUuid( + EntityType.valueOf(calculatedFieldRequestMsg.getEntityType()), + new UUID(calculatedFieldRequestMsg.getEntityIdMSB(), calculatedFieldRequestMsg.getEntityIdLSB())); + + log.trace("[{}] processCalculatedField [{}][{}] for entity [{}][{}]", tenantId, edge.getName(), calculatedFieldRequestMsg, entityId.getEntityType(), entityId.getId()); + return saveCalculatedFieldsToEdge(tenantId, edge.getId(), entityId); + } + + private ListenableFuture saveCalculatedFieldsToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId) { + return Futures.transformAsync( + dbCallbackExecutorService.submit(() -> calculatedFieldService.findCalculatedFieldsByEntityId(tenantId, entityId)), + calculatedFields -> { + log.trace("[{}][{}][{}][{}] calculatedField(s) are going to be pushed to edge.", tenantId, edgeId, entityId, calculatedFields.size()); + + List> futures = calculatedFields.stream().map(calculatedField -> { + try { + return saveEdgeEvent(tenantId, edgeId, EdgeEventType.CALCULATED_FIELD, + EdgeEventActionType.ADDED, calculatedField.getId(), JacksonUtil.valueToTree(calculatedField)); + } catch (Exception e) { + log.error("[{}][{}] Exception during loading calculatedField [{}] to edge on sync!", tenantId, edgeId, calculatedField, e); + return Futures.immediateFailedFuture(e); + } + }).toList(); + + return Futures.transform( + Futures.allAsList(futures), + voids -> null, + dbCallbackExecutorService + ); + }, + dbCallbackExecutorService + ); + } + private ListenableFuture> findRelationByQuery(TenantId tenantId, Edge edge, EntityId entityId, EntitySearchDirection direction) { EntityRelationsQuery query = new EntityRelationsQuery(); query.setParameters(new RelationsSearchParameters(entityId, direction, 1, false)); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java index 2a115eeace..a147a7054b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; @@ -35,6 +36,8 @@ public interface EdgeRequestsService { ListenableFuture processRelationRequestMsg(TenantId tenantId, Edge edge, RelationRequestMsg relationRequestMsg); + ListenableFuture processCalculatedFieldRequestMsg(TenantId tenantId, Edge edge, CalculatedFieldRequestMsg calculatedFieldRequestMsg); + @Deprecated(since = "3.9.1", forRemoval = true) ListenableFuture processDeviceCredentialsRequestMsg(TenantId tenantId, Edge edge, DeviceCredentialsRequestMsg deviceCredentialsRequestMsg); @@ -46,4 +49,5 @@ public interface EdgeRequestsService { @Deprecated(since = "3.9.1", forRemoval = true) ListenableFuture processEntityViewsRequestMsg(TenantId tenantId, Edge edge, EntityViewsRequestMsg entityViewsRequestMsg); + } diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsApiService.java b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsApiService.java index c7e17b62ae..e0f0db82cc 100644 --- a/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsApiService.java @@ -22,7 +22,6 @@ 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.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; @@ -51,11 +50,6 @@ public class DefaultEdqsApiService implements EdqsApiService { private final EdqsClientQueueFactory queueFactory; private TbQueueRequestTemplate, TbProtoQueueMsg> requestTemplate; - @Value("${queue.edqs.api.auto_enable:true}") - private boolean autoEnable; - - private Boolean apiEnabled = null; - @PostConstruct private void init() { requestTemplate = queueFactory.createEdqsRequestTemplate(); @@ -85,31 +79,11 @@ public class DefaultEdqsApiService implements EdqsApiService { }, MoreExecutors.directExecutor()); } - @Override - public boolean isEnabled() { - return Boolean.TRUE.equals(apiEnabled); - } - - @Override - public void setEnabled(boolean enabled) { - if (enabled) { - log.info("Enabling EDQS API"); - } else { - log.info("Disabling EDQS API"); - } - apiEnabled = enabled; - } - @Override public boolean isSupported() { return true; } - @Override - public boolean isAutoEnable() { - return autoEnable; - } - @PreDestroy private void stop() { requestTemplate.stop(); diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsService.java b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsService.java index cd46c4ba96..617902bd3d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsService.java +++ b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsService.java @@ -20,11 +20,13 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; 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.ConditionalOnProperty; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -36,6 +38,9 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.edqs.EdqsEventType; import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.EdqsState; +import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; +import org.thingsboard.server.common.data.edqs.EdqsState.EdqsSyncStatus; import org.thingsboard.server.common.data.edqs.EdqsSyncRequest; import org.thingsboard.server.common.data.edqs.Entity; import org.thingsboard.server.common.data.edqs.ToCoreEdqsMsg; @@ -51,20 +56,25 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.edqs.processor.EdqsProducer; import org.thingsboard.server.edqs.state.EdqsPartitionService; -import org.thingsboard.server.edqs.util.EdqsConverter; +import org.thingsboard.server.edqs.util.DefaultEdqsMapper; +import org.thingsboard.server.edqs.util.EdqsMapper; import org.thingsboard.server.gen.transport.TransportProtos.EdqsEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ServiceInfo; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsCoreServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.discovery.DiscoveryService; import org.thingsboard.server.queue.discovery.HashPartitionService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; -import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.environment.DistributedLock; import org.thingsboard.server.queue.environment.DistributedLockService; import org.thingsboard.server.queue.provider.EdqsClientQueueFactory; import org.thingsboard.server.queue.util.AfterStartUp; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @Service @@ -74,31 +84,42 @@ import java.util.concurrent.TimeUnit; public class DefaultEdqsService implements EdqsService { private final EdqsClientQueueFactory queueFactory; - private final EdqsConverter edqsConverter; + private final EdqsMapper edqsMapper; private final EdqsSyncService edqsSyncService; private final EdqsApiService edqsApiService; private final DistributedLockService distributedLockService; private final AttributesService attributesService; private final EdqsPartitionService edqsPartitionService; - private final TopicService topicService; private final TbServiceInfoProvider serviceInfoProvider; + private final DiscoveryService discoveryService; @Autowired @Lazy private TbClusterService clusterService; @Autowired @Lazy private HashPartitionService hashPartitionService; + @Value("${queue.edqs.api.auto_enable:true}") + private boolean autoEnableApi; + @Value("${queue.edqs.readiness_check_interval:60000}") + private int edqsReadinessCheckInterval; + private EdqsProducer eventsProducer; private ExecutorService executor; + private ScheduledExecutorService scheduler; private DistributedLock syncLock; + @Getter + private EdqsState state; + @PostConstruct private void init() { executor = ThingsBoardExecutors.newWorkStealingPool(12, getClass()); + scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edqs-check"); eventsProducer = EdqsProducer.builder() .producer(queueFactory.createEdqsEventsProducer()) .partitionService(edqsPartitionService) .build(); syncLock = distributedLockService.getLock("edqs_sync"); + state = new EdqsState(); } @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) @@ -106,6 +127,26 @@ public class DefaultEdqsService implements EdqsService { if (!serviceInfoProvider.isService(ServiceType.TB_CORE)) { return; } + if (edqsApiService.isSupported()) { + scheduler.scheduleWithFixedDelay(() -> { + if (!hashPartitionService.isSystemPartitionMine(ServiceType.TB_CORE)) { + return; + } + + List servers = new ArrayList<>(discoveryService.getOtherServers()); + servers.add(serviceInfoProvider.getServiceInfo()); + + List readyEdqsServers = servers.stream() + .filter(serviceInfo -> serviceInfo.getServiceTypesList().contains(ServiceType.EDQS.name())) + .filter(ServiceInfo::getReady) + .toList(); + boolean changed = state.updateEdqsReady(!readyEdqsServers.isEmpty()); + if (changed) { + broadcastEdqsReady(state.getEdqsReady()); + } + }, 0, edqsReadinessCheckInterval, TimeUnit.MILLISECONDS); + } + executor.submit(() -> { try { EdqsSyncState syncState = getSyncState(); @@ -115,9 +156,9 @@ public class DefaultEdqsService implements EdqsService { .syncRequest(new EdqsSyncRequest()) .build()); } - } else if (edqsApiService.isSupported() && edqsApiService.isAutoEnable()) { + } else { // only if topic/RocksDB is not empty and sync is finished - edqsApiService.setEnabled(true); + onSyncStatusUpdate(EdqsSyncStatus.FINISHED); } } catch (Throwable e) { log.error("Failed to start EDQS service", e); @@ -131,7 +172,10 @@ public class DefaultEdqsService implements EdqsService { if (request.getSyncRequest() != null) { saveSyncState(EdqsSyncStatus.REQUESTED); } - broadcast(request.toInternalMsg()); + broadcast(ToCoreEdqsMsg.builder() + .syncRequest(request.getSyncRequest()) + .apiEnabled(request.getApiEnabled()) + .build()); } @Override @@ -140,7 +184,18 @@ public class DefaultEdqsService implements EdqsService { log.info("Processing system msg {}", msg); try { if (msg.getApiEnabled() != null) { - edqsApiService.setEnabled(msg.getApiEnabled()); + if (msg.getApiEnabled()) { + state.setApiMode(EdqsApiMode.ENABLED); + } else { + state.setApiMode(EdqsApiMode.DISABLED); + } + log.info("New state: {}", state); + } + if (msg.getEdqsReady() != null) { + onEdqsReady(msg.getEdqsReady()); + } + if (msg.getSyncStatus() != null) { + onSyncStatusUpdate(msg.getSyncStatus()); } if (msg.getSyncRequest() != null) { @@ -154,23 +209,16 @@ public class DefaultEdqsService implements EdqsService { return; } } - saveSyncState(EdqsSyncStatus.STARTED); + edqsSyncService.sync(); - saveSyncState(EdqsSyncStatus.FINISHED); - if (edqsApiService.isSupported()) - if (edqsApiService.isAutoEnable()) { - log.info("EDQS sync is finished, auto-enabling API"); - broadcast(ToCoreEdqsMsg.builder() - .apiEnabled(Boolean.TRUE) - .build()); - } else { - log.info("EDQS sync is finished, but leaving API disabled"); - } + saveSyncState(EdqsSyncStatus.FINISHED); + broadcastSyncStatusUpdate(EdqsSyncStatus.FINISHED); } catch (Exception e) { log.error("Failed to complete sync", e); saveSyncState(EdqsSyncStatus.FAILED); + broadcastSyncStatusUpdate(EdqsSyncStatus.FAILED); } finally { syncLock.unlock(); } @@ -181,6 +229,60 @@ public class DefaultEdqsService implements EdqsService { }); } + private void broadcastEdqsReady(boolean ready) { + broadcast(ToCoreEdqsMsg.builder() + .edqsReady(ready) + .build()); + } + + private void onEdqsReady(boolean ready) { + state.updateEdqsReady(ready); + checkState(); + } + + private void broadcastSyncStatusUpdate(EdqsSyncStatus status) { + broadcast(ToCoreEdqsMsg.builder() + .syncStatus(status) + .build()); + } + + private void onSyncStatusUpdate(EdqsSyncStatus status) { + state.setSyncStatus(status); + checkState(); + } + + private void checkState() { + if (!edqsApiService.isSupported()) { + log.info("New state: {}. EDQS API not supported", state); + return; + } + + if (state.isApiReady()) { + if (autoEnableApi) { + if (state.getApiMode() == null || state.getApiMode() == EdqsApiMode.AUTO_DISABLED) { + state.setApiMode(EdqsApiMode.AUTO_ENABLED); + log.info("New state: {}. Auto-enabled EDQS API", state); + } else { + log.info("New state: {}. API mode left as is", state); + } + } else { + log.info("New state: {}. API auto-enabling is disabled", state); + } + } else { + if (state.isApiEnabled()) { + state.setApiMode(EdqsApiMode.AUTO_DISABLED); + log.info("New state: {}. Disabled EDQS API", state); + } else { + log.info("New state: {}. API left disabled", state); + } + } + } + + @Override + public boolean isApiEnabled() { + return state.isApiEnabled(); + } + @Override public void onUpdate(TenantId tenantId, EntityId entityId, Object entity) { EntityType entityType = entityId.getEntityType(); @@ -189,7 +291,7 @@ public class DefaultEdqsService implements EdqsService { log.trace("[{}][{}] Ignoring update event, type {} not supported", tenantId, entityId, entityType); return; } - onUpdate(tenantId, objectType, edqsConverter.toEntity(entityType, entity)); + onUpdate(tenantId, objectType, DefaultEdqsMapper.toEntity(entityType, entity)); } @Override @@ -216,12 +318,11 @@ public class DefaultEdqsService implements EdqsService { protected void processEvent(TenantId tenantId, ObjectType objectType, EdqsEventType eventType, EdqsObject object) { executor.submit(() -> { try { - String key = object.key(); + String key = object.stringKey(); Long version = object.version(); EdqsEventMsg.Builder eventMsg = EdqsEventMsg.newBuilder() - .setKey(key) .setObjectType(objectType.name()) - .setData(ByteString.copyFrom(edqsConverter.serialize(objectType, object))) + .setData(ByteString.copyFrom(edqsMapper.serialize(object))) .setEventType(eventType.name()); if (version != null) { eventMsg.setVersion(version); @@ -278,6 +379,7 @@ public class DefaultEdqsService implements EdqsService { @PreDestroy private void stop() { executor.shutdown(); + scheduler.shutdownNow(); eventsProducer.stop(); } @@ -288,11 +390,4 @@ public class DefaultEdqsService implements EdqsService { private EdqsSyncStatus status; } - private enum EdqsSyncStatus { - REQUESTED, - STARTED, - FINISHED, - FAILED - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java index 871e1fa5e7..88f344d048 100644 --- a/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java +++ b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java @@ -95,7 +95,7 @@ public abstract class EdqsSyncService { syncLatestTimeseries(); counters.clear(); - log.info("Finishing synchronizing data to EDQS in {} ms", (System.currentTimeMillis() - startTs)); + log.info("Finished synchronizing data to EDQS in {} ms", (System.currentTimeMillis() - startTs)); } private void process(TenantId tenantId, ObjectType type, EdqsObject object) { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index 476a4ef5ca..81fd1478b5 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -97,7 +97,7 @@ public abstract class AbstractTbEntityService { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } - protected ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + protected ListenableFuture autoCommit(User user, EntityId entityId) { if (vcService != null) { return vcService.autoCommit(user, entityId); } else { @@ -106,7 +106,7 @@ public abstract class AbstractTbEntityService { } } - protected ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + protected ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) { if (vcService != null) { return vcService.autoCommit(user, entityType, entityIds); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 648e89adc9..03ab77ac09 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; @@ -58,6 +59,7 @@ import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.rule.engine.api.JobManager; import java.util.Set; @@ -69,6 +71,7 @@ public class EntityStateSourcingListener { private final TenantService tenantService; private final TbClusterService tbClusterService; private final EdgeSynchronizationManager edgeSynchronizationManager; + private final JobManager jobManager; @PostConstruct public void init() { @@ -134,6 +137,9 @@ public class EntityStateSourcingListener { case CALCULATED_FIELD -> { onCalculatedFieldUpdate(event.getEntity(), event.getOldEntity()); } + case JOB -> { + onJobUpdate((Job) event.getEntity()); + } default -> { } } @@ -212,8 +218,8 @@ public class EntityStateSourcingListener { public void handleEvent(ActionEntityEvent event) { log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); if (ActionType.CREDENTIALS_UPDATED.equals(event.getActionType()) && - EntityType.DEVICE.equals(event.getEntityId().getEntityType()) - && event.getEntity() instanceof DeviceCredentials) { + EntityType.DEVICE.equals(event.getEntityId().getEntityType()) + && event.getEntity() instanceof DeviceCredentials) { tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(event.getTenantId(), (DeviceId) event.getEntityId(), (DeviceCredentials) event.getEntity()), null); } else if (ActionType.ASSIGNED_TO_TENANT.equals(event.getActionType()) && event.getEntity() instanceof Device device) { @@ -295,6 +301,28 @@ public class EntityStateSourcingListener { tbClusterService.onCalculatedFieldUpdated(calculatedField, oldCalculatedField, TbQueueCallback.EMPTY); } + private void onJobUpdate(Job job) { + jobManager.onJobUpdate(job); + + ComponentLifecycleEvent event; + if (job.getResult().getCancellationTs() > 0) { + event = ComponentLifecycleEvent.STOPPED; + } else if (job.getResult().getGeneralError() != null) { + event = ComponentLifecycleEvent.FAILED; + } else { + return; + } + ComponentLifecycleMsg msg = ComponentLifecycleMsg.builder() + .tenantId(job.getTenantId()) + .entityId(job.getId()) + .event(event) + .info(JacksonUtil.newObjectNode() + .put("tasksKey", job.getConfiguration().getTasksKey())) + .build(); + // task processors will add this job to the list of discarded + tbClusterService.broadcast(msg); + } + private void pushAssignedFromNotification(Tenant currentTenant, TenantId newTenantId, Device assignedDevice) { String data = JacksonUtil.toString(JacksonUtil.valueToTree(assignedDevice)); if (data != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java new file mode 100644 index 0000000000..264b82dd33 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.entitiy.ai; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import static java.util.Objects.requireNonNullElseGet; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +class DefaultTbAiModelService extends AbstractTbEntityService implements TbAiModelService { + + private final AiModelService aiModelService; + + @Override + public AiModel save(AiModel model, User user) { + var actionType = model.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + + var tenantId = user.getTenantId(); + model.setTenantId(tenantId); + + AiModel savedModel; + try { + savedModel = aiModelService.save(model); + autoCommit(user, savedModel.getId()); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(model.getId(), () -> emptyId(EntityType.AI_MODEL)), model, actionType, user, e); + throw e; + } + + logEntityActionService.logEntityAction(tenantId, savedModel.getId(), savedModel, actionType, user); + + return savedModel; + } + + @Override + public boolean delete(AiModel model, User user) { + var actionType = ActionType.DELETED; + + var tenantId = user.getTenantId(); + var modelId = model.getId(); + + boolean deleted; + try { + deleted = aiModelService.deleteByTenantIdAndId(tenantId, modelId); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, e, modelId.toString()); + throw e; + } + + if (deleted) { + logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, modelId.toString()); + } + + return deleted; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java new file mode 100644 index 0000000000..0b09423ffa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.entitiy.ai; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; + +public interface TbAiModelService { + + AiModel save(AiModel model, User user); + + boolean delete(AiModel model, User user); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java index af8bbeb669..2d597f7053 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java @@ -110,4 +110,5 @@ public class DefaultTbOtaPackageService extends AbstractTbEntityService implemen throw e; } } + } diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java index 727e27c971..f2d3fad357 100644 --- a/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java +++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java @@ -165,6 +165,7 @@ public class HousekeeperService { private void stop() { consumer.stop(); consumerExecutor.shutdownNow(); + taskExecutor.shutdownNow(); log.info("Stopped Housekeeper service"); } diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/JobsDeletionTaskProcessor.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/JobsDeletionTaskProcessor.java new file mode 100644 index 0000000000..f0f829770a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/JobsDeletionTaskProcessor.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.housekeeper.processor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.housekeeper.HousekeeperTask; +import org.thingsboard.server.common.data.housekeeper.HousekeeperTaskType; +import org.thingsboard.server.dao.job.JobService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JobsDeletionTaskProcessor extends HousekeeperTaskProcessor { + + private final JobService jobService; + + @Override + public void process(HousekeeperTask task) throws Exception { + int deletedCount = jobService.deleteJobsByEntityId(task.getTenantId(), task.getEntityId()); + log.debug("[{}][{}][{}] Deleted {} jobs", task.getTenantId(), task.getEntityId().getEntityType(), task.getEntityId(), deletedCount); + } + + @Override + public HousekeeperTaskType getTaskType() { + return HousekeeperTaskType.DELETE_JOBS; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index 90c5966f3f..e5bd026fb7 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -32,7 +32,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // This list should include all versions which are compatible for the upgrade. // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.0.0", "4.0.1"); + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.1.0"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; 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 e9ef8c5ace..d580175aa0 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 @@ -64,6 +64,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; @@ -581,9 +582,9 @@ 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, - Collections.singletonList(new BaseAttributeKvEntry(new BooleanDataEntry(key, value) - , System.currentTimeMillis()))); + ListenableFuture saveFuture = attributesService.save( + TenantId.SYS_TENANT_ID, deviceId, AttributeScope.SERVER_SCOPE, new BaseAttributeKvEntry(new BooleanDataEntry(key, value), System.currentTimeMillis()) + ); addTsCallback(saveFuture, new TelemetrySaveCallback<>(deviceId, key, value)); } } @@ -611,7 +612,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { } private void addTsCallback(ListenableFuture saveFuture, final FutureCallback callback) { - Futures.addCallback(saveFuture, new FutureCallback() { + Futures.addCallback(saveFuture, new FutureCallback<>() { @Override public void onSuccess(@Nullable S result) { callback.onSuccess(result); diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java index d8ddd7cd9f..972d5ff36c 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java @@ -20,21 +20,16 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.collect.Lists; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.alarm.AlarmSeverity; -import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.FilterPredicateValue; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.RelationTypeGroup; -import org.thingsboard.server.common.data.rule.RuleNode; -import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.component.RuleNodeClassInfo; @@ -43,65 +38,27 @@ import org.thingsboard.server.utils.TbNodeUpgradeUtils; import java.util.ArrayList; import java.util.List; -import java.util.Optional; -import java.util.UUID; import java.util.concurrent.ExecutionException; -import static org.thingsboard.server.dao.rule.BaseRuleChainService.TB_RULE_CHAIN_INPUT_NODE; - @Service @Profile("install") @Slf4j +@RequiredArgsConstructor public class DefaultDataUpdateService implements DataUpdateService { private static final int MAX_PENDING_SAVE_RULE_NODE_FUTURES = 256; private static final int DEFAULT_PAGE_SIZE = 1024; - @Autowired - private RuleChainService ruleChainService; - - @Autowired - private RelationService relationService; - - @Autowired - private ComponentDiscoveryService componentDiscoveryService; - - @Autowired - private DbUpgradeExecutorService executorService; + private final RuleChainService ruleChainService; + private final ComponentDiscoveryService componentDiscoveryService; + private final DbUpgradeExecutorService executorService; @Override public void updateData() throws Exception { log.info("Updating data ..."); //TODO: should be cleaned after each release - updateInputNodes(); - log.info("Data updated."); - } - - private void updateInputNodes() { - log.info("Creating relations for input nodes..."); - int n = 0; - var inputNodes = new PageDataIterable<>(pageLink -> ruleChainService.findAllRuleNodesByType(TB_RULE_CHAIN_INPUT_NODE, pageLink), 1024); - for (RuleNode inputNode : inputNodes) { - try { - RuleChainId targetRuleChainId = Optional.ofNullable(inputNode.getConfiguration().get("ruleChainId")) - .filter(JsonNode::isTextual).map(JsonNode::asText).map(id -> new RuleChainId(UUID.fromString(id))) - .orElse(null); - if (targetRuleChainId == null) { - continue; - } - EntityRelation relation = new EntityRelation(); - relation.setFrom(inputNode.getRuleChainId()); - relation.setTo(targetRuleChainId); - relation.setType(EntityRelation.USES_TYPE); - relation.setTypeGroup(RelationTypeGroup.COMMON); - relationService.saveRelation(TenantId.SYS_TENANT_ID, relation); - n++; - } catch (Exception e) { - log.error("Failed to save relation for input node: {}", inputNode, e); - } - } - log.info("Created {} relations for input nodes", n); + log.info("Data updated."); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java b/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java new file mode 100644 index 0000000000..ed8e613f38 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java @@ -0,0 +1,207 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.rule.engine.api.JobManager; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobResult; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.job.task.Task; +import org.thingsboard.server.common.data.job.task.TaskResult; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.job.JobService; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.settings.TasksQueueConfig; +import org.thingsboard.server.queue.task.JobStatsService; +import org.thingsboard.server.queue.task.TaskProducerQueueFactory; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@Slf4j +public class DefaultJobManager implements JobManager { + + private final JobService jobService; + private final JobStatsService jobStatsService; + private final PartitionService partitionService; + private final TasksQueueConfig queueConfig; + private final Map jobProcessors; + private final Map>> taskProducers; + private final ExecutorService executor; + + public DefaultJobManager(JobService jobService, JobStatsService jobStatsService, PartitionService partitionService, + TaskProducerQueueFactory queueFactory, TasksQueueConfig queueConfig, + List jobProcessors) { + this.jobService = jobService; + this.jobStatsService = jobStatsService; + this.partitionService = partitionService; + this.queueConfig = queueConfig; + this.jobProcessors = jobProcessors.stream().collect(Collectors.toMap(JobProcessor::getType, Function.identity())); + this.taskProducers = Arrays.stream(JobType.values()).collect(Collectors.toMap(Function.identity(), queueFactory::createTaskProducer)); + this.executor = ThingsBoardExecutors.newWorkStealingPool(Math.max(4, Runtime.getRuntime().availableProcessors()), getClass()); + } + + @Override + public ListenableFuture submitJob(Job job) { + log.debug("Submitting job: {}", job); + return Futures.submit(() -> jobService.saveJob(job.getTenantId(), job), executor); + } + + @Override + public void onJobUpdate(Job job) { + JobStatus status = job.getStatus(); + switch (status) { + case PENDING -> { + executor.execute(() -> { + try { + processJob(job); + } catch (Throwable e) { + log.error("Failed to process job update: {}", job, e); + } + }); + } + case COMPLETED, FAILED -> { + executor.execute(() -> { + try { + getJobProcessor(job.getType()).onJobFinished(job); + } catch (Throwable e) { + log.error("Failed to process job update: {}", job, e); + } + }); + } + } + } + + private void processJob(Job job) { + TenantId tenantId = job.getTenantId(); + JobId jobId = job.getId(); + try { + JobProcessor processor = getJobProcessor(job.getType()); + List toReprocess = job.getConfiguration().getToReprocess(); + if (toReprocess == null) { + int tasksCount = processor.process(job, this::submitTask); + log.info("[{}][{}][{}] Submitted {} tasks", tenantId, jobId, job.getType(), tasksCount); + jobStatsService.reportAllTasksSubmitted(tenantId, jobId, tasksCount); + } else { + processor.reprocess(job, toReprocess, this::submitTask); + log.info("[{}][{}][{}] Submitted {} tasks for reprocessing", tenantId, jobId, job.getType(), toReprocess.size()); + } + } catch (Throwable e) { + log.error("[{}][{}][{}] Failed to submit tasks", tenantId, jobId, job.getType(), e); + jobService.markAsFailed(tenantId, jobId, e.getMessage()); + } + } + + @Override + public void cancelJob(TenantId tenantId, JobId jobId) { + log.info("[{}][{}] Cancelling job", tenantId, jobId); + jobService.cancelJob(tenantId, jobId); + } + + @Override + public void reprocessJob(TenantId tenantId, JobId jobId) { + log.info("[{}][{}] Reprocessing job", tenantId, jobId); + Job job = jobService.findJobById(tenantId, jobId); + if (job.getStatus() != JobStatus.FAILED) { + throw new IllegalArgumentException("Job is not failed"); + } + + JobResult result = job.getResult(); + if (result.getGeneralError() != null) { + job.presetResult(); + } else { + List taskFailures = result.getResults().stream() + .filter(taskResult -> !taskResult.isSuccess() && !taskResult.isDiscarded()) + .toList(); + if (result.getFailedCount() > taskFailures.size()) { + throw new IllegalArgumentException("Reprocessing not allowed since there are too many failures (more than " + taskFailures.size() + ")"); + } + result.setFailedCount(0); + result.setResults(result.getResults().stream() + .filter(TaskResult::isSuccess) + .toList()); + job.getConfiguration().setToReprocess(taskFailures); + } + job.getConfiguration().setTasksKey(UUID.randomUUID().toString()); + jobService.saveJob(tenantId, job); + } + + private void submitTask(Task task) { + if (ObjectUtils.anyNull(task.getTenantId(), task.getJobId(), task.getKey())) { + throw new IllegalArgumentException("Task " + task + " missing required fields"); + } + + log.debug("[{}][{}] Submitting task: {}", task.getTenantId(), task.getJobId(), task); + TaskProto taskProto = TaskProto.newBuilder() + .setValue(JacksonUtil.toString(task)) + .build(); + + TbQueueProducer> producer = taskProducers.get(task.getJobType()); + EntityId entityId = null; + if (queueConfig.getPartitioningStrategy().equals("entity")) { + entityId = task.getEntityId(); + } + if (entityId == null) { + entityId = task.getTenantId(); + } + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TASK_PROCESSOR, task.getJobType().name(), task.getTenantId(), entityId); + producer.send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), taskProto), new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.trace("Submitted task to {}: {}", tpi, taskProto); + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to submit task: {}", task, t); + } + }); + } + + private JobProcessor getJobProcessor(JobType jobType) { + return jobProcessors.get(jobType); + } + + @PreDestroy + private void destroy() { + executor.shutdownNow(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/job/DummyJobProcessor.java b/application/src/main/java/org/thingsboard/server/service/job/DummyJobProcessor.java new file mode 100644 index 0000000000..d38cdfb6f5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/job/DummyJobProcessor.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.job.DummyJobConfiguration; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.job.task.DummyTask; +import org.thingsboard.server.common.data.job.task.DummyTaskResult; +import org.thingsboard.server.common.data.job.task.DummyTaskResult.DummyTaskFailure; +import org.thingsboard.server.common.data.job.task.Task; +import org.thingsboard.server.common.data.job.task.TaskResult; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +@Component +@RequiredArgsConstructor +public class DummyJobProcessor implements JobProcessor { + + @Override + public int process(Job job, Consumer> taskConsumer) throws Exception { + DummyJobConfiguration configuration = job.getConfiguration(); + if (configuration.getGeneralError() != null) { + for (int number = 1; number <= configuration.getSubmittedTasksBeforeGeneralError(); number++) { + taskConsumer.accept(createTask(job, configuration, number, null, false)); + } + Thread.sleep(configuration.getTaskProcessingTimeMs() * (configuration.getSubmittedTasksBeforeGeneralError() / 2)); // sleeping so that some tasks are processed + throw new RuntimeException(configuration.getGeneralError()); + } + + int taskNumber = 1; + for (int i = 0; i < configuration.getSuccessfulTasksCount(); i++) { + taskConsumer.accept(createTask(job, configuration, taskNumber, null, false)); + taskNumber++; + } + if (configuration.getErrors() != null) { + for (int i = 0; i < configuration.getFailedTasksCount(); i++) { + taskConsumer.accept(createTask(job, configuration, taskNumber, configuration.getErrors(), false)); + taskNumber++; + } + for (int i = 0; i < configuration.getPermanentlyFailedTasksCount(); i++) { + taskConsumer.accept(createTask(job, configuration, taskNumber, configuration.getErrors(), true)); + taskNumber++; + } + } + return configuration.getSuccessfulTasksCount() + configuration.getFailedTasksCount() + configuration.getPermanentlyFailedTasksCount(); + } + + @Override + public void reprocess(Job job, List taskFailures, Consumer> taskConsumer) throws Exception { + for (TaskResult taskFailure : taskFailures) { + DummyTaskFailure failure = ((DummyTaskResult) taskFailure).getFailure(); + taskConsumer.accept(createTask(job, job.getConfiguration(), failure.getNumber(), failure.isFailAlways() ? + List.of(failure.getError()) : Collections.emptyList(), failure.isFailAlways())); + } + } + + private DummyTask createTask(Job job, DummyJobConfiguration configuration, int number, List errors, boolean failAlways) { + return DummyTask.builder() + .tenantId(job.getTenantId()) + .jobId(job.getId()) + .key(configuration.getTasksKey()) + .retries(configuration.getRetries()) + .number(number) + .processingTimeMs(configuration.getTaskProcessingTimeMs()) + .errors(errors) + .failAlways(failAlways) + .processingTimeoutMs(configuration.getTaskProcessingTimeoutMs()) + .build(); + } + + @Override + public JobType getType() { + return JobType.DUMMY; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/job/JobProcessor.java b/application/src/main/java/org/thingsboard/server/service/job/JobProcessor.java new file mode 100644 index 0000000000..e33878d008 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/job/JobProcessor.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.job.task.Task; +import org.thingsboard.server.common.data.job.task.TaskResult; + +import java.util.List; +import java.util.function.Consumer; + +public interface JobProcessor { + + int process(Job job, Consumer> taskConsumer) throws Exception; + + void reprocess(Job job, List taskFailures, Consumer> taskConsumer) throws Exception; + + default void onJobFinished(Job job) {} + + JobType getType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/job/JobStatsProcessor.java b/application/src/main/java/org/thingsboard/server/service/job/JobStatsProcessor.java new file mode 100644 index 0000000000..c5b2577819 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/job/JobStatsProcessor.java @@ -0,0 +1,115 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import jakarta.annotation.PreDestroy; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.JobStats; +import org.thingsboard.server.common.data.job.task.TaskResult; +import org.thingsboard.server.dao.job.JobService; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; +import org.thingsboard.server.queue.provider.TbCoreQueueFactory; +import org.thingsboard.server.queue.settings.TasksQueueConfig; +import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@TbCoreComponent +@Component +@Slf4j +public class JobStatsProcessor { + + private final JobService jobService; + private final TasksQueueConfig queueConfig; + private final QueueConsumerManager> jobStatsConsumer; + private final ExecutorService consumerExecutor; + + public JobStatsProcessor(JobService jobService, + TasksQueueConfig queueConfig, + TbCoreQueueFactory queueFactory) { + this.jobService = jobService; + this.queueConfig = queueConfig; + this.consumerExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("job-stats-consumer")); + this.jobStatsConsumer = QueueConsumerManager.>builder() + .name("job-stats") + .msgPackProcessor(this::processStats) + .pollInterval(queueConfig.getStatsPollInterval()) + .consumerCreator(queueFactory::createJobStatsConsumer) + .consumerExecutor(consumerExecutor) + .build(); + } + + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void afterStartUp() { + jobStatsConsumer.subscribe(); + jobStatsConsumer.launch(); + } + + @SneakyThrows + private void processStats(List> msgs, TbQueueConsumer> consumer) { + Map stats = new HashMap<>(); + + for (TbProtoQueueMsg msg : msgs) { + JobStatsMsg statsMsg = msg.getValue(); + TenantId tenantId = TenantId.fromUUID(new UUID(statsMsg.getTenantIdMSB(), statsMsg.getTenantIdLSB())); + JobId jobId = new JobId(new UUID(statsMsg.getJobIdMSB(), statsMsg.getJobIdLSB())); + JobStats jobStats = stats.computeIfAbsent(jobId, __ -> new JobStats(tenantId, jobId)); + + if (statsMsg.hasTaskResult()) { + TaskResult taskResult = JacksonUtil.fromString(statsMsg.getTaskResult().getValue(), TaskResult.class); + jobStats.getTaskResults().add(taskResult); + } + if (statsMsg.hasTotalTasksCount()) { + jobStats.setTotalTasksCount(statsMsg.getTotalTasksCount()); + } + } + + stats.forEach((jobId, jobStats) -> { + TenantId tenantId = jobStats.getTenantId(); + try { + log.debug("[{}][{}] Processing job stats: {}", tenantId, jobId, stats); + jobService.processStats(tenantId, jobId, jobStats); + } catch (Exception e) { + log.error("[{}][{}] Failed to process job stats: {}", tenantId, jobId, jobStats, e); + } + }); + consumer.commit(); + + Thread.sleep(queueConfig.getStatsProcessingInterval()); + } + + @PreDestroy + private void destroy() { + jobStatsConsumer.stop(); + consumerExecutor.shutdownNow(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/job/task/DummyTaskProcessor.java b/application/src/main/java/org/thingsboard/server/service/job/task/DummyTaskProcessor.java new file mode 100644 index 0000000000..0177e70b10 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/job/task/DummyTaskProcessor.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job.task; + +import org.apache.commons.lang3.tuple.Pair; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.job.task.DummyTask; +import org.thingsboard.server.common.data.job.task.DummyTaskResult; +import org.thingsboard.server.common.data.job.task.Task; +import org.thingsboard.server.queue.task.TaskProcessor; + +import java.util.Map; +import java.util.concurrent.Future; + +public class DummyTaskProcessor extends TaskProcessor { + + @Override + public DummyTaskResult process(DummyTask task) throws Exception { + if (task.getProcessingTimeMs() > 0) { + Thread.sleep(task.getProcessingTimeMs()); + } + if (task.isFailAlways()) { + throw new RuntimeException(task.getErrors().get(0)); + } + if (task.getErrors() != null && task.getAttempt() <= task.getErrors().size()) { + String error = task.getErrors().get(task.getAttempt() - 1); + throw new RuntimeException(error); + } + return DummyTaskResult.success(task); + } + + @Override + public long getProcessingTimeout(DummyTask task) { + return task.getProcessingTimeoutMs() > 0 ? task.getProcessingTimeoutMs() : 2000; + } + + public Map, Future>> getCurrentTasks() { + return currentTasks; + } + + @Override + public JobType getJobType() { + return JobType.DUMMY; + } + +} 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 99faf1a1d0..c1cfe791da 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 @@ -22,9 +22,9 @@ import freemarker.template.Template; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Lazy; @@ -64,55 +64,37 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -@Service @Slf4j +@Service +@RequiredArgsConstructor public class DefaultMailService implements MailService { - public static final String TARGET_EMAIL = "targetEmail"; - public static final String UTF_8 = "UTF-8"; + private static final String TARGET_EMAIL = "targetEmail"; + private static final String UTF_8 = "UTF-8"; + private static final long DEFAULT_TIMEOUT = 10_000; + + private final ScheduledExecutorService timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); private final MessageSource messages; private final Configuration freemarkerConfig; private final AdminSettingsService adminSettingsService; private final TbApiUsageReportClient apiUsageClient; - - private static final long DEFAULT_TIMEOUT = 10_000; - @Lazy - @Autowired - private TbApiUsageStateService apiUsageStateService; - - @Autowired - private MailSenderInternalExecutorService mailExecutorService; - - @Autowired - private PasswordResetExecutorService passwordResetExecutorService; - - @Autowired - private TbMailContextComponent ctx; - - @Autowired - private RateLimitService rateLimitService; + private final TbApiUsageStateService apiUsageStateService; + private final MailSenderInternalExecutorService mailExecutorService; + private final PasswordResetExecutorService passwordResetExecutorService; + private final TbMailContextComponent ctx; + private final RateLimitService rateLimitService; @Value("${mail.per_tenant_rate_limits:}") private String perTenantRateLimitConfig; - private final ScheduledExecutorService timeoutScheduler; - private TbMailSender mailSender; private String mailFrom; private long timeout; - public DefaultMailService(MessageSource messages, Configuration freemarkerConfig, AdminSettingsService adminSettingsService, TbApiUsageReportClient apiUsageClient) { - this.messages = messages; - this.freemarkerConfig = freemarkerConfig; - this.adminSettingsService = adminSettingsService; - this.apiUsageClient = apiUsageClient; - this.timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); - } - @PostConstruct private void init() { updateMailConfiguration(); @@ -120,9 +102,7 @@ public class DefaultMailService implements MailService { @PreDestroy public void destroy() { - if (timeoutScheduler != null) { - timeoutScheduler.shutdownNow(); - } + timeoutScheduler.shutdownNow(); } @Override @@ -311,22 +291,21 @@ public class DefaultMailService implements MailService { model.put("apiFeature", apiFeature.getLabel()); model.put(TARGET_EMAIL, email); - String message = null; - - switch (stateValue) { - case ENABLED: + String message = switch (stateValue) { + case ENABLED -> { model.put("apiLabel", toEnabledValueLabel(apiFeature)); - message = mergeTemplateIntoString("state.enabled.ftl", model); - break; - case WARNING: + yield mergeTemplateIntoString("state.enabled.ftl", model); + } + case WARNING -> { model.put("apiValueLabel", toDisabledValueLabel(apiFeature) + " " + toWarningValueLabel(recordState)); - message = mergeTemplateIntoString("state.warning.ftl", model); - break; - case DISABLED: + yield mergeTemplateIntoString("state.warning.ftl", model); + } + case DISABLED -> { model.put("apiLimitValueLabel", toDisabledValueLabel(apiFeature) + " " + toDisabledValueLabel(recordState)); - message = mergeTemplateIntoString("state.disabled.ftl", model); - break; - } + yield mergeTemplateIntoString("state.disabled.ftl", model); + } + }; + sendMail(mailSender, mailFrom, email, subject, message, timeout); } @@ -341,89 +320,55 @@ public class DefaultMailService implements MailService { } private String toEnabledValueLabel(ApiFeature apiFeature) { - switch (apiFeature) { - case DB: - return "save"; - case TRANSPORT: - return "receive"; - case JS: - return "invoke"; - case RE: - return "process"; - case EMAIL: - case SMS: - return "send"; - case ALARM: - return "create"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (apiFeature) { + case DB -> "save"; + case TRANSPORT -> "receive"; + case JS -> "invoke"; + case RE -> "process"; + case EMAIL, SMS -> "send"; + case ALARM -> "create"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toDisabledValueLabel(ApiFeature apiFeature) { - switch (apiFeature) { - case DB: - return "saved"; - case TRANSPORT: - return "received"; - case JS: - return "invoked"; - case RE: - return "processed"; - case EMAIL: - case SMS: - return "sent"; - case ALARM: - return "created"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (apiFeature) { + case DB -> "saved"; + case TRANSPORT -> "received"; + case JS -> "invoked"; + case RE -> "processed"; + case EMAIL, SMS -> "sent"; + case ALARM -> "created"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toWarningValueLabel(ApiUsageRecordState recordState) { String valueInM = recordState.getValueAsString(); String thresholdInM = recordState.getThresholdAsString(); - switch (recordState.getKey()) { - case STORAGE_DP_COUNT: - case TRANSPORT_DP_COUNT: - return valueInM + " out of " + thresholdInM + " allowed data points"; - case TRANSPORT_MSG_COUNT: - return valueInM + " out of " + thresholdInM + " allowed messages"; - case JS_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; - case TBEL_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Tbel functions"; - case RE_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; - case EMAIL_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Email messages"; - case SMS_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed SMS messages"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (recordState.getKey()) { + case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> valueInM + " out of " + thresholdInM + " allowed data points"; + case TRANSPORT_MSG_COUNT -> valueInM + " out of " + thresholdInM + " allowed messages"; + case JS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; + case TBEL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Tbel functions"; + case RE_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; + case EMAIL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Email messages"; + case SMS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed SMS messages"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toDisabledValueLabel(ApiUsageRecordState recordState) { - switch (recordState.getKey()) { - case STORAGE_DP_COUNT: - case TRANSPORT_DP_COUNT: - return recordState.getValueAsString() + " data points"; - case TRANSPORT_MSG_COUNT: - return recordState.getValueAsString() + " messages"; - case JS_EXEC_COUNT: - return "JavaScript functions " + recordState.getValueAsString() + " times"; - case TBEL_EXEC_COUNT: - return "TBEL functions " + recordState.getValueAsString() + " times"; - case RE_EXEC_COUNT: - return recordState.getValueAsString() + " Rule Engine messages"; - case EMAIL_EXEC_COUNT: - return recordState.getValueAsString() + " Email messages"; - case SMS_EXEC_COUNT: - return recordState.getValueAsString() + " SMS messages"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (recordState.getKey()) { + case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> recordState.getValueAsString() + " data points"; + case TRANSPORT_MSG_COUNT -> recordState.getValueAsString() + " messages"; + case JS_EXEC_COUNT -> "JavaScript functions " + recordState.getValueAsString() + " times"; + case TBEL_EXEC_COUNT -> "TBEL functions " + recordState.getValueAsString() + " times"; + case RE_EXEC_COUNT -> recordState.getValueAsString() + " Rule Engine messages"; + case EMAIL_EXEC_COUNT -> recordState.getValueAsString() + " Email messages"; + case SMS_EXEC_COUNT -> recordState.getValueAsString() + " SMS messages"; + default -> throw new RuntimeException("Not implemented!"); + }; } private void sendMail(JavaMailSenderImpl mailSender, String mailFrom, String email, 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 8d7356a507..914a53e8a6 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 @@ -25,6 +25,7 @@ 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.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.mail.MailException; @@ -50,8 +51,10 @@ public class TbMailSender extends JavaMailSenderImpl { private final TbMailContextComponent ctx; private final Lock lock; + @Getter private final Boolean oauth2Enabled; private volatile String accessToken; + @Getter private volatile long tokenExpires; public TbMailSender(TbMailContextComponent ctx, JsonNode jsonConfig) { @@ -70,14 +73,6 @@ public class TbMailSender extends JavaMailSenderImpl { setJavaMailProperties(createJavaMailProperties(jsonConfig)); } - public Boolean getOauth2Enabled() { - return oauth2Enabled; - } - - public long getTokenExpires() { - return tokenExpires; - } - @Override protected void doSend(MimeMessage[] mimeMessages, @Nullable Object[] originalMessages) throws MailException { updateOauth2PasswordIfExpired(); @@ -98,8 +93,8 @@ public class TbMailSender extends JavaMailSenderImpl { super.testConnection(); } - public void updateOauth2PasswordIfExpired() { - if (getOauth2Enabled() && (System.currentTimeMillis() > getTokenExpires())){ + public void updateOauth2PasswordIfExpired() { + if (getOauth2Enabled() && (System.currentTimeMillis() > getTokenExpires())) { refreshAccessToken(); setPassword(accessToken); } @@ -168,8 +163,8 @@ public class TbMailSender extends JavaMailSenderImpl { .setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret)) .execute(); if (MailOauth2Provider.OFFICE_365.name().equals(providerId)) { - ((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); - ((ObjectNode)jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); + ((ObjectNode) jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); + ((ObjectNode) jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); ctx.getAdminSettingsService().saveAdminSettings(TenantId.SYS_TENANT_ID, settings); } accessToken = tokenResponse.getAccessToken(); @@ -190,4 +185,5 @@ public class TbMailSender extends JavaMailSenderImpl { throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort)); } } -} \ No newline at end of file + +} 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 62455117e0..d6cee80ebf 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 @@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.notification.NotificationRequestStatus import org.thingsboard.server.common.data.notification.info.NotificationInfo; import org.thingsboard.server.common.data.notification.rule.NotificationRule; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger.DeduplicationStrategy; 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.plugin.ComponentLifecycleEvent; @@ -66,8 +67,8 @@ public class DefaultNotificationRuleProcessor implements NotificationRuleProcess private final NotificationDeduplicationService deduplicationService; private final PartitionService partitionService; private final RateLimitService rateLimitService; - @Autowired @Lazy - private NotificationCenter notificationCenter; + @Lazy + private final NotificationCenter notificationCenter; private final NotificationExecutorService notificationExecutor; private final Map triggerProcessors = new EnumMap<>(NotificationRuleTriggerType.class); @@ -82,14 +83,11 @@ public class DefaultNotificationRuleProcessor implements NotificationRuleProcess if (enabledRules.isEmpty()) { return; } - if (trigger.deduplicate()) { - enabledRules = new ArrayList<>(enabledRules); - enabledRules.removeIf(rule -> deduplicationService.alreadyProcessed(trigger, rule)); - } - final List rules = enabledRules; - for (NotificationRule rule : rules) { + + List rulesToProcess = filterNotificationRules(trigger, enabledRules); + for (NotificationRule rule : rulesToProcess) { try { - processNotificationRule(rule, trigger); + processNotificationRule(rule, trigger, DeduplicationStrategy.ONLY_MATCHING.equals(trigger.getDeduplicationStrategy())); } catch (Throwable e) { log.error("Failed to process notification rule {} for trigger type {} with trigger object {}", rule.getId(), rule.getTriggerType(), trigger, e); } @@ -100,7 +98,20 @@ public class DefaultNotificationRuleProcessor implements NotificationRuleProcess }); } - private void processNotificationRule(NotificationRule rule, NotificationRuleTrigger trigger) { + private List filterNotificationRules(NotificationRuleTrigger trigger, List enabledRules) { + List rulesToProcess = new ArrayList<>(enabledRules); + rulesToProcess.removeIf(rule -> switch (trigger.getDeduplicationStrategy()) { + case ONLY_MATCHING -> { + boolean matched = matchesFilter(trigger, rule.getTriggerConfig()); + yield !matched || deduplicationService.alreadyProcessed(trigger, rule); + } + case ALL -> deduplicationService.alreadyProcessed(trigger, rule); + default -> false; + }); + return rulesToProcess; + } + + private void processNotificationRule(NotificationRule rule, NotificationRuleTrigger trigger, boolean alreadyMatched) { NotificationRuleTriggerConfig triggerConfig = rule.getTriggerConfig(); log.debug("Processing notification rule '{}' for trigger type {}", rule.getName(), rule.getTriggerType()); @@ -114,7 +125,7 @@ public class DefaultNotificationRuleProcessor implements NotificationRuleProcess return; } - if (matchesFilter(trigger, triggerConfig)) { + if (alreadyMatched || matchesFilter(trigger, triggerConfig)) { if (!rateLimitService.checkRateLimit(LimitedApi.NOTIFICATION_REQUESTS_PER_RULE, rule.getTenantId(), rule.getId())) { log.debug("[{}] Rate limit for notification requests per rule was exceeded (rule '{}')", rule.getTenantId(), rule.getName()); return; diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/ResourcesShortageTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/ResourcesShortageTriggerProcessor.java new file mode 100644 index 0000000000..09c8d76eca --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/ResourcesShortageTriggerProcessor.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.notification.rule.trigger; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.notification.info.ResourcesShortageNotificationInfo; +import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; +import org.thingsboard.server.common.data.notification.rule.trigger.ResourcesShortageTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import org.thingsboard.server.common.data.notification.rule.trigger.config.ResourcesShortageNotificationRuleTriggerConfig; + +@Service +@RequiredArgsConstructor +public class ResourcesShortageTriggerProcessor implements NotificationRuleTriggerProcessor { + + @Override + public boolean matchesFilter(ResourcesShortageTrigger trigger, ResourcesShortageNotificationRuleTriggerConfig triggerConfig) { + float usagePercent = trigger.getUsage() / 100.0f; + return switch (trigger.getResource()) { + case CPU -> usagePercent >= triggerConfig.getCpuThreshold(); + case RAM -> usagePercent >= triggerConfig.getRamThreshold(); + case STORAGE -> usagePercent >= triggerConfig.getStorageThreshold(); + }; + } + + @Override + public RuleOriginatedNotificationInfo constructNotificationInfo(ResourcesShortageTrigger trigger) { + return ResourcesShortageNotificationInfo.builder() + .resource(trigger.getResource().name()) + .usage(trigger.getUsage()) + .serviceId(trigger.getServiceId()) + .serviceType(trigger.getServiceType()) + .build(); + } + + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RESOURCES_SHORTAGE; + } + +} 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 f90c90df54..b3fe76f154 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 @@ -208,9 +208,26 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) { + if (query.getEntityFilter() != null) { + EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); + PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), + securityUser.getCustomerId(), entityDataQuery); + if (entities.getTotalElements() > 0) { + List entityIds = entities.getData().stream().map(EntityData::getEntityId).toList(); + return alarmService.countAlarmsByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query, entityIds); + } else { + return 0; + } + } return alarmService.countAlarmsByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); } + private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) { + EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, + new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY))); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); + } + private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); EntityDataSortOrder entitiesSortOrder; @@ -220,7 +237,7 @@ public class DefaultEntityQueryService implements EntityQueryService { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index f8c398b016..8d4ab25578 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -141,7 +141,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa } } - private void processMsgs(List> msgs, TbQueueConsumer> consumer, QueueConfig config) throws Exception { + private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); 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 561dc7122a..265f14c4e2 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 @@ -130,11 +130,9 @@ public class DefaultTbClusterService implements TbClusterService { private final AtomicInteger toEdgeNfs = new AtomicInteger(0); @Autowired - @Lazy private PartitionService partitionService; @Autowired - @Lazy private TbQueueProducerProvider producerProvider; @Autowired @@ -579,21 +577,24 @@ public class DefaultTbClusterService implements TbClusterService { } } - private void broadcast(ComponentLifecycleMsg msg) { + @Override + public void broadcast(ComponentLifecycleMsg msg) { ComponentLifecycleMsgProto componentLifecycleMsgProto = toProto(msg); TbQueueProducer> toRuleEngineProducer = producerProvider.getRuleEngineNotificationsMsgProducer(); Set tbRuleEngineServices = partitionService.getAllServiceIds(ServiceType.TB_RULE_ENGINE); EntityType entityType = msg.getEntityId().getEntityType(); - if (entityType.equals(EntityType.TENANT) - || entityType.equals(EntityType.TENANT_PROFILE) - || entityType.equals(EntityType.DEVICE_PROFILE) - || (entityType.equals(EntityType.ASSET) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) - || entityType.equals(EntityType.ASSET_PROFILE) - || entityType.equals(EntityType.API_USAGE_STATE) - || (entityType.equals(EntityType.DEVICE) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) - || entityType.equals(EntityType.ENTITY_VIEW) - || entityType.equals(EntityType.NOTIFICATION_RULE) - || entityType.equals(EntityType.CALCULATED_FIELD) + if (entityType.isOneOf( + EntityType.TENANT, + EntityType.API_USAGE_STATE, + EntityType.ENTITY_VIEW, + EntityType.NOTIFICATION_RULE, + EntityType.CALCULATED_FIELD, + EntityType.TENANT_PROFILE, + EntityType.DEVICE_PROFILE, + EntityType.ASSET_PROFILE, + EntityType.JOB) + || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) + || (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED) ) { TbQueueProducer> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer(); Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); 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 264fa86456..f0b1a4d7d2 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 @@ -255,7 +255,7 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, QueueConfig config) throws Exception { + private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java index a9ae18a11f..3dd6993962 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java @@ -126,7 +126,7 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, QueueConfig edgeQueueConfig) throws InterruptedException { + private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig edgeQueueConfig) throws InterruptedException { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java index 381d49a8ec..2d9b737f25 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java @@ -16,7 +16,9 @@ package org.thingsboard.server.service.queue; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.exception.TenantNotFoundException; @@ -30,11 +32,9 @@ import org.thingsboard.server.queue.discovery.TenantRoutingInfoService; @ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core' || '${service.type:null}'=='tb-rule-engine'") public class DefaultTenantRoutingInfoService implements TenantRoutingInfoService { - private final TbTenantProfileCache tenantProfileCache; - - public DefaultTenantRoutingInfoService(TbTenantProfileCache tenantProfileCache) { - this.tenantProfileCache = tenantProfileCache; - } + @Lazy + @Autowired + private TbTenantProfileCache tenantProfileCache; @Override public TenantRoutingInfo getRoutingInfo(TenantId tenantId) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java index 3aa0d2eabd..d067be49a0 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java @@ -127,6 +127,7 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager> msgs, TbQueueConsumer> consumer, + Object consumerKey, Queue queue) throws Exception { TbRuleEngineSubmitStrategy submitStrategy = getSubmitStrategy(queue); TbRuleEngineProcessingStrategy ackStrategy = getProcessingStrategy(queue); diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index b7490af487..e0b8351ff6 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -17,18 +17,16 @@ package org.thingsboard.server.service.script; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.RuleNodeScriptFactory; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.js.JsInvokeService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; -import javax.script.ScriptException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -36,8 +34,6 @@ import java.util.List; import java.util.Map; import java.util.Set; - -@Slf4j public class RuleNodeJsScriptEngine extends RuleNodeScriptEngine { public RuleNodeJsScriptEngine(TenantId tenantId, JsInvokeService scriptInvokeService, String script, String... argNames) { @@ -45,87 +41,81 @@ public class RuleNodeJsScriptEngine extends RuleNodeScriptEngine executeJsonAsync(TbMsg msg) { - return executeScriptAsync(msg); + protected Object[] prepareArgs(TbMsg msg) { + String[] args = new String[3]; + if (msg.getData() != null) { + args[0] = msg.getData(); + } else { + args[0] = ""; + } + args[1] = JacksonUtil.toString(msg.getMetaData().getData()); + args[2] = msg.getType(); + return args; } @Override - protected ListenableFuture> executeUpdateTransform(TbMsg msg, JsonNode json) { + protected List executeUpdateTransform(TbMsg msg, JsonNode json) { if (json.isObject()) { - return Futures.immediateFuture(Collections.singletonList(unbindMsg(json, msg))); + return Collections.singletonList(unbindMsg(json, msg)); } else if (json.isArray()) { List res = new ArrayList<>(json.size()); json.forEach(jsonObject -> res.add(unbindMsg(jsonObject, msg))); - return Futures.immediateFuture(res); + return res; } - log.warn("Wrong result type: {}", json.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + json.getNodeType())); + throw wrongResultType(json); } @Override - protected ListenableFuture executeGenerateTransform(TbMsg prevMsg, JsonNode result) { + protected TbMsg executeGenerateTransform(TbMsg prevMsg, JsonNode result) { if (!result.isObject()) { - log.warn("Wrong result type: {}", result.getNodeType()); - Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); - } - return Futures.immediateFuture(unbindMsg(result, prevMsg)); - } - - @Override - protected JsonNode convertResult(Object result) { - return JacksonUtil.toJsonNode(result != null ? result.toString() : null); - } - - @Override - protected ListenableFuture executeToStringTransform(JsonNode result) { - if (result.isTextual()) { - return Futures.immediateFuture(result.asText()); + throw wrongResultType(result); } - log.warn("Wrong result type: {}", result.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); + return unbindMsg(result, prevMsg); } @Override - protected ListenableFuture executeFilterTransform(JsonNode json) { + protected boolean executeFilterTransform(JsonNode json) { if (json.isBoolean()) { - return Futures.immediateFuture(json.asBoolean()); + return json.asBoolean(); } - log.warn("Wrong result type: {}", json.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + json.getNodeType())); + throw wrongResultType(json); } @Override - protected ListenableFuture> executeSwitchTransform(JsonNode result) { + protected Set executeSwitchTransform(JsonNode result) { if (result.isTextual()) { - return Futures.immediateFuture(Collections.singleton(result.asText())); + return Collections.singleton(result.asText()); } if (result.isArray()) { Set nextStates = new HashSet<>(); for (JsonNode val : result) { if (!val.isTextual()) { - log.warn("Wrong result type: {}", val.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + val.getNodeType())); + throw wrongResultType(val); } else { nextStates.add(val.asText()); } } - return Futures.immediateFuture(nextStates); + return nextStates; } - log.warn("Wrong result type: {}", result.getNodeType()); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); + throw wrongResultType(result); } @Override - protected Object[] prepareArgs(TbMsg msg) { - String[] args = new String[3]; - if (msg.getData() != null) { - args[0] = msg.getData(); - } else { - args[0] = ""; + public ListenableFuture executeJsonAsync(TbMsg msg) { + return executeScriptAsync(msg); + } + + @Override + protected String executeToStringTransform(JsonNode result) { + if (result.isTextual()) { + return result.asText(); } - args[1] = JacksonUtil.toString(msg.getMetaData().getData()); - args[2] = msg.getType(); - return args; + throw wrongResultType(result); + } + + @Override + protected JsonNode convertResult(Object result) { + return JacksonUtil.toJsonNode(result != null ? result.toString() : null); } private static TbMsg unbindMsg(JsonNode msgData, TbMsg msg) { @@ -138,19 +128,23 @@ public class RuleNodeJsScriptEngine extends RuleNodeScriptEngine() { - }); + metadata = JacksonUtil.convertValue(msgMetadata, new TypeReference<>() {}); } if (msgData.has(RuleNodeScriptFactory.MSG_TYPE)) { messageType = msgData.get(RuleNodeScriptFactory.MSG_TYPE).asText(); } String newData = data != null ? data : msg.getData(); TbMsgMetaData newMetadata = metadata != null ? new TbMsgMetaData(metadata) : msg.getMetaData().copy(); - String newMessageType = !StringUtils.isEmpty(messageType) ? messageType : msg.getType(); + String newMessageType = StringUtils.isNotEmpty(messageType) ? messageType : msg.getType(); return msg.transform() .type(newMessageType) .metaData(newMetadata) .data(newData) .build(); } + + private TbScriptException wrongResultType(JsonNode result) { + return new TbScriptException(scriptId, TbScriptException.ErrorCode.RUNTIME, null, new ClassCastException("Wrong result type: " + result.getNodeType())); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java index 8f19aeb0a3..ec9c2fd983 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java @@ -17,41 +17,44 @@ 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 lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.ScriptEngine; import org.thingsboard.script.api.ScriptInvokeService; import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbMsg; -import javax.script.ScriptException; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; @Slf4j public abstract class RuleNodeScriptEngine implements ScriptEngine { private final T scriptInvokeService; - private final UUID scriptId; + protected final UUID scriptId; private final TenantId tenantId; public RuleNodeScriptEngine(TenantId tenantId, T scriptInvokeService, String script, String... argNames) { this.tenantId = tenantId; this.scriptInvokeService = scriptInvokeService; try { - this.scriptId = this.scriptInvokeService.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, script, argNames).get(); + scriptId = this.scriptInvokeService.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, script, argNames).get(); } catch (Exception e) { Throwable t = e; if (e instanceof ExecutionException) { t = e.getCause(); } - throw new IllegalArgumentException("Can't compile script: " + t.getMessage(), t); + if (t instanceof TbScriptException scriptException) { + throw scriptException; + } + throw new RuntimeException("Unexpected error when creating script engine: " + t.getMessage(), t); } } @@ -60,74 +63,53 @@ public abstract class RuleNodeScriptEngine imp @Override public ListenableFuture> executeUpdateAsync(TbMsg msg) { ListenableFuture result = executeScriptAsync(msg); - return Futures.transformAsync(result, - json -> executeUpdateTransform(msg, json), - MoreExecutors.directExecutor()); + return Futures.transform(result, json -> executeUpdateTransform(msg, json), directExecutor()); } - protected abstract ListenableFuture> executeUpdateTransform(TbMsg msg, R result); + protected abstract List executeUpdateTransform(TbMsg msg, R result); @Override public ListenableFuture executeGenerateAsync(TbMsg prevMsg) { - return Futures.transformAsync(executeScriptAsync(prevMsg), - result -> executeGenerateTransform(prevMsg, result), - MoreExecutors.directExecutor()); + return Futures.transform(executeScriptAsync(prevMsg), result -> executeGenerateTransform(prevMsg, result), directExecutor()); } - protected abstract ListenableFuture executeGenerateTransform(TbMsg prevMsg, R result); + protected abstract TbMsg executeGenerateTransform(TbMsg prevMsg, R result); @Override - public ListenableFuture executeToStringAsync(TbMsg msg) { - return Futures.transformAsync(executeScriptAsync(msg), this::executeToStringTransform, MoreExecutors.directExecutor()); + public ListenableFuture executeFilterAsync(TbMsg msg) { + return Futures.transform(executeScriptAsync(msg), this::executeFilterTransform, directExecutor()); } + protected abstract boolean executeFilterTransform(R result); @Override - public ListenableFuture executeFilterAsync(TbMsg msg) { - return Futures.transformAsync(executeScriptAsync(msg), - this::executeFilterTransform, - MoreExecutors.directExecutor()); + public ListenableFuture> executeSwitchAsync(TbMsg msg) { + return Futures.transform(executeScriptAsync(msg), this::executeSwitchTransform, directExecutor()); // usually runs on a callbackExecutor } - protected abstract ListenableFuture executeToStringTransform(R result); - - protected abstract ListenableFuture executeFilterTransform(R result); - - protected abstract ListenableFuture> executeSwitchTransform(R result); + protected abstract Set executeSwitchTransform(R result); @Override - public ListenableFuture> executeSwitchAsync(TbMsg msg) { - return Futures.transformAsync(executeScriptAsync(msg), - this::executeSwitchTransform, - MoreExecutors.directExecutor()); //usually runs in a callbackExecutor + public ListenableFuture executeToStringAsync(TbMsg msg) { + return Futures.transform(executeScriptAsync(msg), this::executeToStringTransform, directExecutor()); } + protected abstract String executeToStringTransform(R result); + ListenableFuture executeScriptAsync(TbMsg msg) { log.trace("execute script async, msg {}", msg); Object[] inArgs = prepareArgs(msg); return executeScriptAsync(msg.getCustomerId(), inArgs[0], inArgs[1], inArgs[2]); } - ListenableFuture executeScriptAsync(CustomerId customerId, Object... args) { - return Futures.transformAsync(scriptInvokeService.invokeScript(tenantId, customerId, this.scriptId, args), - o -> { - try { - return Futures.immediateFuture(convertResult(o)); - } catch (Exception e) { - if (e.getCause() instanceof ScriptException) { - return Futures.immediateFailedFuture(e.getCause()); - } else if (e.getCause() instanceof RuntimeException) { - return Futures.immediateFailedFuture(new ScriptException(e.getCause().getMessage())); - } else { - return Futures.immediateFailedFuture(new ScriptException(e)); - } - } - }, MoreExecutors.directExecutor()); + private ListenableFuture executeScriptAsync(CustomerId customerId, Object... args) { + return Futures.transform(scriptInvokeService.invokeScript(tenantId, customerId, scriptId, args), this::convertResult, directExecutor()); } public void destroy() { - scriptInvokeService.release(this.scriptId); + scriptInvokeService.release(scriptId); } protected abstract R convertResult(Object result); + } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java index 5e197d0e5d..991be63f81 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java @@ -19,17 +19,15 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.RuleNodeScriptFactory; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; -import javax.script.ScriptException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -40,8 +38,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; -@Slf4j public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine { public RuleNodeTbelScriptEngine(TenantId tenantId, TbelInvokeService scriptInvokeService, String script, String... argNames) { @@ -49,70 +47,74 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine executeFilterTransform(Object result) { - if (result instanceof Boolean) { - return Futures.immediateFuture((Boolean) result); + protected Object[] prepareArgs(TbMsg msg) { + Object[] args = new Object[3]; + if (msg.getData() != null) { + args[0] = JacksonUtil.fromString(msg.getData(), Object.class); + } else { + args[0] = new HashMap<>(); } - return wrongResultType(result); + args[1] = new HashMap<>(msg.getMetaData().getData()); + args[2] = msg.getType(); + return args; } @Override - protected ListenableFuture> executeUpdateTransform(TbMsg msg, Object result) { - if (result instanceof Map) { - return Futures.immediateFuture(Collections.singletonList(unbindMsg((Map) result, msg))); - } else if (result instanceof Collection) { - List res = new ArrayList<>(); - for (Object resObject : (Collection) result) { - if (resObject instanceof Map) { - res.add(unbindMsg((Map) resObject, msg)); + protected List executeUpdateTransform(TbMsg msg, Object result) { + if (result instanceof Map msgData) { + return Collections.singletonList(unbindMsg(msgData, msg)); + } else if (result instanceof Collection resultCollection) { + List res = new ArrayList<>(resultCollection.size()); + for (Object resObject : resultCollection) { + if (resObject instanceof Map msgData) { + res.add(unbindMsg(msgData, msg)); } else { - return wrongResultType(resObject); + throw wrongResultType(resObject); } } - return Futures.immediateFuture(res); + return res; } - return wrongResultType(result); + throw wrongResultType(result); } @Override - protected ListenableFuture executeGenerateTransform(TbMsg prevMsg, Object result) { - if (result instanceof Map) { - return Futures.immediateFuture(unbindMsg((Map) result, prevMsg)); + protected TbMsg executeGenerateTransform(TbMsg prevMsg, Object result) { + if (result instanceof Map msgData) { + return unbindMsg(msgData, prevMsg); } - return wrongResultType(result); + throw wrongResultType(result); } @Override - protected ListenableFuture executeToStringTransform(Object result) { - if (result instanceof String) { - return Futures.immediateFuture((String) result); - } else { - return Futures.immediateFuture(JacksonUtil.toString(result)); + protected boolean executeFilterTransform(Object result) { + if (result instanceof Boolean b) { + return b; } + throw wrongResultType(result); } @Override - protected ListenableFuture> executeSwitchTransform(Object result) { - if (result instanceof String) { - return Futures.immediateFuture(Collections.singleton((String) result)); - } else if (result instanceof Collection) { - Set res = new HashSet<>(); - for (Object resObject : (Collection) result) { - if (resObject instanceof String) { - res.add((String) resObject); + protected Set executeSwitchTransform(Object result) { + if (result instanceof String str) { + return Collections.singleton(str); + } + if (result instanceof Collection resultCollection) { + Set res = new HashSet<>(resultCollection.size()); + for (Object resObject : resultCollection) { + if (resObject instanceof String str) { + res.add(str); } else { - return wrongResultType(resObject); + throw wrongResultType(resObject); } } - return Futures.immediateFuture(res); + return res; } - return wrongResultType(result); + throw wrongResultType(result); } @Override public ListenableFuture executeJsonAsync(TbMsg msg) { - return Futures.transform(executeScriptAsync(msg), JacksonUtil::valueToTree, MoreExecutors.directExecutor()); - + return Futures.transform(executeScriptAsync(msg), JacksonUtil::valueToTree, directExecutor()); } @Override @@ -121,16 +123,8 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine(); - } - args[1] = new HashMap<>(msg.getMetaData().getData()); - args[2] = msg.getType(); - return args; + protected String executeToStringTransform(Object result) { + return result instanceof String str ? str : JacksonUtil.toString(result); } private static TbMsg unbindMsg(Map msgData, TbMsg msg) { @@ -142,12 +136,12 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine) msgMetadataObj).entrySet().stream().filter(e -> e.getValue() != null) + if (msgMetadataObj instanceof Map msgMetadataObjAsMap) { + metadata = msgMetadataObjAsMap.entrySet().stream() + .filter(e -> e.getValue() != null) .collect(Collectors.toMap(e -> e.getKey().toString(), e -> e.getValue().toString())); } else { - metadata = JacksonUtil.convertValue(msgMetadataObj, new TypeReference<>() { - }); + metadata = JacksonUtil.convertValue(msgMetadataObj, new TypeReference<>() {}); } } if (msgData.containsKey(RuleNodeScriptFactory.MSG_TYPE)) { @@ -155,7 +149,7 @@ public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine ListenableFuture wrongResultType(Object result) { + private TbScriptException wrongResultType(Object result) { String className = toClassName(result); - log.warn("Wrong result type: {}", className); - return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + className)); + return new TbScriptException(scriptId, TbScriptException.ErrorCode.RUNTIME, null, new ClassCastException("Wrong result type: " + className)); } private static String toClassName(Object result) { return result != null ? result.getClass().getSimpleName() : "null"; } + } 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 522255e012..e397a6f1a2 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 @@ -20,7 +20,6 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -34,8 +33,8 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.IdBased; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2Client; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; @@ -48,17 +47,16 @@ import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.entitiy.tenant.TbTenantService; import org.thingsboard.server.service.entitiy.user.TbUserService; -import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; -import java.util.List; import java.util.Optional; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @Slf4j public abstract class AbstractOAuth2ClientMapper { + private static final int DASHBOARDS_REQUEST_LIMIT = 10; @Autowired @@ -79,18 +77,12 @@ public abstract class AbstractOAuth2ClientMapper { @Autowired private DashboardService dashboardService; - @Autowired - private InstallScripts installScripts; - @Autowired private TbUserService tbUserService; @Autowired protected TbTenantProfileCache tenantProfileCache; - @Autowired - private ApplicationEventPublisher eventPublisher; - @Value("${edges.enabled}") @Getter private boolean edgesEnabled; @@ -120,8 +112,7 @@ public abstract class AbstractOAuth2ClientMapper { } else { user.setAuthority(Authority.CUSTOMER_USER); } - TenantId tenantId = oauth2User.getTenantId() != null ? - oauth2User.getTenantId() : getTenantId(oauth2User.getTenantName()); + TenantId tenantId = oauth2User.getTenantId() != null ? oauth2User.getTenantId() : getTenantId(oauth2User.getTenantName()); user.setTenantId(tenantId); CustomerId customerId = oauth2User.getCustomerId() != null ? oauth2User.getCustomerId() : getCustomerId(user.getTenantId(), oauth2User.getCustomerName()); @@ -173,17 +164,15 @@ public abstract class AbstractOAuth2ClientMapper { } } - private TenantId getTenantId(String tenantName) throws Exception { - List tenants = tenantService.findTenants(new PageLink(1, 0, tenantName)).getData(); - Tenant tenant; - if (tenants == null || tenants.isEmpty()) { - tenant = new Tenant(); - tenant.setTitle(tenantName); - tenant = tbTenantService.save(tenant); - } else { - tenant = tenants.get(0); + private TenantId getTenantId(String name) throws Exception { + Tenant tenant = tenantService.findTenantByName(name); + if (tenant != null) { + return tenant.getId(); } - return tenant.getTenantId(); + tenant = new Tenant(); + tenant.setTitle(name); + tenant = tbTenantService.save(tenant); + return tenant.getId(); } private CustomerId getCustomerId(TenantId tenantId, String customerName) { @@ -220,4 +209,5 @@ public abstract class AbstractOAuth2ClientMapper { } while (dashboardsPage.hasNext()); return Optional.empty(); } + } 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 9a34fa08df..111234500f 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 @@ -121,7 +121,7 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS errorPrefix = "/login?loginError="; } getRedirectStrategy().sendRedirect(request, response, baseUrl + errorPrefix + - URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8.toString())); + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8)); } } @@ -138,4 +138,5 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS } return baseUrl + "accessToken=" + tokenPair.getToken() + "&refreshToken=" + tokenPair.getRefreshToken(); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 4cb281a719..8a4208c457 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -21,7 +21,8 @@ import java.util.Collections; import java.util.Set; public enum Resource { - ADMIN_SETTINGS(), + + ADMIN_SETTINGS(EntityType.ADMIN_SETTINGS), ALARM(EntityType.ALARM), DEVICE(EntityType.DEVICE), ASSET(EntityType.ASSET), @@ -50,7 +51,9 @@ public enum Resource { VERSION_CONTROL, NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), - MOBILE_APP_SETTINGS; + MOBILE_APP_SETTINGS, + JOB(EntityType.JOB), + AI_MODEL(EntityType.AI_MODEL); private final Set entityTypes; @@ -74,4 +77,5 @@ public enum Resource { } throw new IllegalArgumentException("Unknown EntityType: " + entityType.name()); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 7a67d6739e..7a824ca735 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -18,6 +18,8 @@ package org.thingsboard.server.service.security.permission; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; @@ -55,6 +57,8 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.OAUTH2_CONFIGURATION_TEMPLATE, new PermissionChecker.GenericPermissionChecker(Operation.READ)); put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); + put(Resource.JOB, tenantEntityPermissionChecker); + put(Resource.AI_MODEL, aiModelPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @@ -145,4 +149,18 @@ public class TenantAdminPermissions extends AbstractPermissions { }; + private static final PermissionChecker aiModelPermissionChecker = new PermissionChecker<>() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, AiModelId entityId, AiModel entity) { + return user.getTenantId().equals(entity.getTenantId()); + } + + }; + } diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java index 5522047deb..475c9c2c75 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java @@ -31,16 +31,12 @@ public class DefaultSmsSenderFactory implements SmsSenderFactory { @Override public SmsSender createSmsSender(SmsProviderConfiguration config) { - switch (config.getType()) { - case AWS_SNS: - return new AwsSmsSender((AwsSnsSmsProviderConfiguration)config); - case TWILIO: - return new TwilioSmsSender((TwilioSmsProviderConfiguration)config); - case SMPP: - return new SmppSmsSender((SmppSmsProviderConfiguration) config); - default: - throw new RuntimeException("Unknown SMS provider type " + config.getType()); - } + return switch (config.getType()) { + case AWS_SNS -> new AwsSmsSender((AwsSnsSmsProviderConfiguration) config); + case TWILIO -> new TwilioSmsSender((TwilioSmsProviderConfiguration) config); + case SMPP -> new SmppSmsSender((SmppSmsProviderConfiguration) config); + default -> throw new RuntimeException("Unknown SMS provider type " + config.getType()); + }; } } 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 779a3adfa6..a313dcb9a6 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 @@ -18,6 +18,7 @@ package org.thingsboard.server.service.sms; import com.fasterxml.jackson.databind.JsonNode; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedRuntimeException; import org.springframework.stereotype.Service; @@ -37,8 +38,9 @@ import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -@Service @Slf4j +@Service +@RequiredArgsConstructor public class DefaultSmsService implements SmsService { private final SmsSenderFactory smsSenderFactory; @@ -48,13 +50,6 @@ public class DefaultSmsService implements SmsService { private SmsSender smsSender; - public DefaultSmsService(SmsSenderFactory smsSenderFactory, AdminSettingsService adminSettingsService, TbApiUsageStateService apiUsageStateService, TbApiUsageReportClient apiUsageClient) { - this.smsSenderFactory = smsSenderFactory; - this.adminSettingsService = adminSettingsService; - this.apiUsageStateService = apiUsageStateService; - this.apiUsageClient = apiUsageClient; - } - @PostConstruct private void init() { updateSmsConfiguration(); @@ -148,4 +143,5 @@ public class DefaultSmsService implements SmsService { return new ThingsboardException(String.format("Unable to send SMS: %s", message), ThingsboardErrorCode.GENERAL); } + } 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 cc476d377d..23fdc6b8c0 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 @@ -27,11 +27,10 @@ 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; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; +import org.checkerframework.checker.nullness.qual.NonNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; @@ -170,35 +169,22 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService> stats = new HashMap<>(); for (DeviceStateData stateData : deviceStates.values()) { @@ -574,7 +559,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService() { + @Override + public void onSuccess(Void success) { + stateData.getState().setLastInactivityAlarmTime(ts); + onDeviceActivityStatusChange(false, stateData); + } + + @Override + public void onFailure(@NonNull Throwable t) { + log.error("[{}][{}] Failed to update device last inactivity alarm time to '{}'. Device state data: {}", tenantId, deviceId, ts, stateData, t); + } + }, deviceStateCallbackExecutor); } - boolean isActive(long ts, DeviceState state) { + private static boolean isActive(long ts, DeviceState state) { return ts < state.getLastActivityTime() + state.getInactivityTimeout(); } @@ -616,17 +611,32 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService() { + @Override + public void onSuccess(Void success) { + stateData.getState().setActive(active); + pushRuleEngineMessage(stateData, active ? TbMsgType.ACTIVITY_EVENT : TbMsgType.INACTIVITY_EVENT); + TbMsgMetaData metaData = stateData.getMetaData(); + notificationRuleProcessor.process(DeviceActivityTrigger.builder() + .tenantId(tenantId) + .customerId(stateData.getCustomerId()) + .deviceId(deviceId) + .active(active) + .deviceName(metaData.getValue("deviceName")) + .deviceType(metaData.getValue("deviceType")) + .deviceLabel(metaData.getValue("deviceLabel")) + .build()); + } + + @Override + public void onFailure(@NonNull Throwable t) { + log.error("[{}][{}] Failed to change device activity status to '{}'. Device state data: {}", tenantId, deviceId, active, stateData, t); + } + }, deviceStateCallbackExecutor); } boolean cleanDeviceStateIfBelongsToExternalPartition(TenantId tenantId, final DeviceId deviceId) { @@ -634,8 +644,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService save(TenantId tenantId, DeviceId deviceId, String key, long value) { + return save(tenantId, deviceId, new LongDataEntry(key, value), getCurrentTimeMillis()); } - private void save(TenantId tenantId, DeviceId deviceId, String key, boolean value) { - save(tenantId, deviceId, new BooleanDataEntry(key, value), getCurrentTimeMillis()); + private ListenableFuture save(TenantId tenantId, DeviceId deviceId, String key, boolean value) { + return save(tenantId, deviceId, new BooleanDataEntry(key, value), getCurrentTimeMillis()); } - private void save(TenantId tenantId, DeviceId deviceId, KvEntry kvEntry, long ts) { + private ListenableFuture save(TenantId tenantId, DeviceId deviceId, KvEntry kvEntry, long ts) { + ListenableFuture future; if (persistToTelemetry) { - tsSubService.saveTimeseriesInternal(TimeseriesSaveRequest.builder() + future = tsSubService.saveTimeseriesInternal(TimeseriesSaveRequest.builder() .tenantId(tenantId) .entityId(deviceId) .entry(new BasicTsKvEntry(ts, kvEntry)) @@ -895,7 +908,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService(deviceId, kvEntry)) .build()); } else { - tsSubService.saveAttributes(AttributesSaveRequest.builder() + future = tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() .tenantId(tenantId) .entityId(deviceId) .scope(AttributeScope.SERVER_SCOPE) @@ -903,20 +916,14 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService(deviceId, kvEntry)) .build()); } + return Futures.transform(future, __ -> null, MoreExecutors.directExecutor()); } long getCurrentTimeMillis() { return System.currentTimeMillis(); } - private static class TelemetrySaveCallback implements FutureCallback { - private final DeviceId deviceId; - private final KvEntry kvEntry; - - TelemetrySaveCallback(DeviceId deviceId, KvEntry kvEntry) { - this.deviceId = deviceId; - this.kvEntry = kvEntry; - } + private record TelemetrySaveCallback(DeviceId deviceId, KvEntry kvEntry) implements FutureCallback { @Override public void onSuccess(@Nullable T result) { @@ -924,9 +931,10 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService stateTs) { + if (stateTs == null || kv.getTs() >= stateTs) { if (updateData == null) { updateData = new ArrayList<>(); } @@ -362,7 +362,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer for (TsKvEntry kv : data) { Long stateTs = keyStates.get(kv.getKey()); if (stateTs != null) { - if (!sub.isLatestValues() || kv.getTs() > stateTs) { + if (!sub.isLatestValues() || kv.getTs() >= stateTs) { if (updateData == null) { updateData = new ArrayList<>(); } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java index 39e486e0a8..b471bf418a 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.FilterPredicateType; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.KeyFilterPredicate; @@ -44,6 +45,7 @@ import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -93,9 +95,14 @@ public abstract class TbAbstractEntityQuerySubCtx ex public void setAndResolveQuery(T query) { dynamicValues.clear(); this.query = query; - if (query != null && query.getKeyFilters() != null) { - for (KeyFilter filter : query.getKeyFilters()) { - registerDynamicValues(filter.getPredicate()); + if (query != null) { + if (query.getEntityFilter() != null) { + EntityFilter.resolveEntityFilter(query.getEntityFilter(), getTenantId(), getUserId(), getOwnerId()); + } + if (query.getKeyFilters() != null) { + for (KeyFilter filter : query.getKeyFilters()) { + registerDynamicValues(filter.getPredicate()); + } } } resolve(getTenantId(), getCustomerId(), getUserId()); @@ -154,9 +161,8 @@ public abstract class TbAbstractEntityQuerySubCtx ex private void dynamicValueSubUpdate(String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, Map dynamicValueKeySubMap) { Map latestUpdate = new HashMap<>(); - subscriptionUpdate.getData().forEach((k, v) -> { - Object[] data = (Object[]) v.get(0); - latestUpdate.put(k, new TsValue((Long) data[0], (String) data[1])); + subscriptionUpdate.getValues().forEach((key, values) -> { + latestUpdate.put(key, getLatest(values)); }); boolean invalidateFilter = false; @@ -283,6 +289,12 @@ public abstract class TbAbstractEntityQuerySubCtx ex } } + protected TsValue getLatest(List values) { + return values.stream() + .max(Comparator.comparing(TsValue::getTs)) + .orElse(null); + } + @Data public static class DynamicValueKey { @Getter diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java index 6d4b6e8ed3..70bd34886a 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java @@ -107,6 +107,11 @@ public abstract class TbAbstractSubCtx { return sessionRef.getSecurityCtx().getId(); } + public EntityId getOwnerId() { + var customerId = getCustomerId(); + return customerId != null && !customerId.isNullUid() ? customerId : getTenantId(); + } + public void sendWsMsg(CmdUpdate update) { wsLock.lock(); try { 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 f6b4067543..e896b520bc 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 @@ -38,7 +38,6 @@ import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.entity.EntityService; -import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.sql.query.EntityKeyMapping; import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.WebSocketSessionRef; @@ -191,9 +190,8 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx { EntityId entityId = subToEntityIdMap.get(subscriptionUpdate.getSubscriptionId()); if (entityId != null) { Map latestUpdate = new HashMap<>(); - subscriptionUpdate.getData().forEach((k, v) -> { - Object[] data = (Object[]) v.get(0); - latestUpdate.put(k, new TsValue((Long) data[0], (String) data[1])); + subscriptionUpdate.getValues().forEach((key, values) -> { + latestUpdate.put(key, getLatest(values)); }); EntityData entityData = entitiesMap.get(entityId); entityData.getLatest().computeIfAbsent(keyType, tmp -> new HashMap<>()).putAll(latestUpdate); 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 d397bbc05b..3df03283d7 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 @@ -43,7 +43,6 @@ 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.stream.Collectors; @@ -94,35 +93,32 @@ public class TbEntityDataSubCtx extends TbAbstractDataSubCtx { private void sendLatestWsMsg(EntityId entityId, String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType) { Map latestUpdate = new HashMap<>(); - subscriptionUpdate.getData().forEach((k, v) -> { - Object[] data = (Object[]) v.get(0); - latestUpdate.put(k, new TsValue((Long) data[0], (String) data[1])); + subscriptionUpdate.getValues().forEach((key, values) -> { + latestUpdate.put(key, getLatest(values)); }); EntityData entityData = getDataForEntity(entityId); if (entityData != null && entityData.getLatest() != null) { - Map latestCtxValues = entityData.getLatest().get(keyType); + Map latestCtxValues = entityData.getLatest().computeIfAbsent(keyType, __ -> new HashMap<>()); log.trace("[{}][{}][{}] Going to compare update with {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), latestCtxValues); - if (latestCtxValues != null) { - latestCtxValues.forEach((k, v) -> { - TsValue update = latestUpdate.get(k); - if (update != null) { - //Ignore notifications about deleted keys - if (!(update.getTs() == 0 && (update.getValue() == null || update.getValue().isEmpty()))) { - if (update.getTs() < v.getTs()) { - log.trace("[{}][{}][{}] Removed stale update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); - latestUpdate.remove(k); - } else if ((update.getTs() == v.getTs() && update.getValue().equals(v.getValue()))) { - log.trace("[{}][{}][{}] Removed duplicate update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); - latestUpdate.remove(k); - } - } else { - log.trace("[{}][{}][{}] Received deleted notification for: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k); + latestCtxValues.forEach((k, v) -> { + TsValue update = latestUpdate.get(k); + if (update != null) { + //Ignore notifications about deleted keys + if (!(update.getTs() == 0 && (update.getValue() == null || update.getValue().isEmpty()))) { + if (update.getTs() < v.getTs()) { + log.trace("[{}][{}][{}] Removed stale update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + latestUpdate.remove(k); + } else if ((update.getTs() == v.getTs() && update.getValue().equals(v.getValue()))) { + log.trace("[{}][{}][{}] Removed duplicate update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + latestUpdate.remove(k); } + } else { + log.trace("[{}][{}][{}] Received deleted notification for: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k); } - }); - //Setting new values - latestUpdate.forEach(latestCtxValues::put); - } + } + }); + //Setting new values + latestCtxValues.putAll(latestUpdate); } if (!latestUpdate.isEmpty()) { Map> latestMap = Collections.singletonMap(keyType, latestUpdate); @@ -133,40 +129,39 @@ public class TbEntityDataSubCtx extends TbAbstractDataSubCtx { private void sendTsWsMsg(EntityId entityId, String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType) { Map> tsUpdate = new HashMap<>(); - subscriptionUpdate.getData().forEach((k, v) -> { - Object[] data = (Object[]) v.get(0); - tsUpdate.computeIfAbsent(k, key -> new ArrayList<>()).add(new TsValue((Long) data[0], (String) data[1])); + subscriptionUpdate.getValues().forEach((key, values) -> { + tsUpdate.put(key, new ArrayList<>(values)); }); Map latestCtxValues = getLatestTsValuesForEntity(entityId); log.trace("[{}][{}][{}] Going to compare update with {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), latestCtxValues); if (latestCtxValues != null) { - latestCtxValues.forEach((k, v) -> { - List updateList = tsUpdate.get(k); + latestCtxValues.forEach((key, latest) -> { + List updateList = tsUpdate.get(key); if (updateList != null) { for (TsValue update : new ArrayList<>(updateList)) { - if (update.getTs() < v.getTs()) { - log.trace("[{}][{}][{}] Removed stale update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + if (update.getTs() < latest.getTs()) { + log.trace("[{}][{}][{}] Removed stale update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), key, update.getTs()); // Looks like this is redundant feature and our UI is ready to merge the updates. //updateList.remove(update); - } else if ((update.getTs() == v.getTs() && update.getValue().equals(v.getValue()))) { - log.trace("[{}][{}][{}] Removed duplicate update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + } else if ((update.getTs() == latest.getTs() && update.getValue().equals(latest.getValue()))) { + log.trace("[{}][{}][{}] Removed duplicate update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), key, update.getTs()); updateList.remove(update); } if (updateList.isEmpty()) { - tsUpdate.remove(k); + tsUpdate.remove(key); } } } }); //Setting new values - tsUpdate.forEach((k, v) -> { - Optional maxValue = v.stream().max(Comparator.comparingLong(TsValue::getTs)); - maxValue.ifPresent(max -> latestCtxValues.put(k, max)); + tsUpdate.forEach((key, values) -> { + values.stream().max(Comparator.comparingLong(TsValue::getTs)) + .ifPresent(latest -> latestCtxValues.put(key, latest)); }); } if (!tsUpdate.isEmpty()) { Map tsMap = new HashMap<>(); - tsUpdate.forEach((key, tsValue) -> tsMap.put(key, tsValue.toArray(new TsValue[tsValue.size()]))); + tsUpdate.forEach((key, values) -> tsMap.put(key, values.toArray(new TsValue[0]))); EntityData entityData = new EntityData(entityId, null, tsMap); sendWsMsg(new EntityDataUpdate(cmdId, null, Collections.singletonList(entityData), maxEntitiesPerDataSubscription)); } 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 db7e37b368..506b48bf8c 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 @@ -67,9 +67,10 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS protected static final List SUPPORTED_ENTITY_TYPES = List.of( EntityType.CUSTOMER, EntityType.RULE_CHAIN, EntityType.TB_RESOURCE, EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET, - EntityType.DEVICE_PROFILE, EntityType.DEVICE, + EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE, EntityType.DEVICE, EntityType.ENTITY_VIEW, EntityType.WIDGET_TYPE, EntityType.WIDGETS_BUNDLE, - EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, + EntityType.AI_MODEL ); @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java new file mode 100644 index 0000000000..8d6097b726 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.exporting.impl; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.Set; + +@Service +@TbCoreComponent +class AiModelExportService extends BaseEntityExportService> { + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.AI_MODEL); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java index 7d5f7ee57e..2f5355f637 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java @@ -38,6 +38,8 @@ public class DeviceExportService extends BaseEntityExportService ctx, Device device, DeviceExportData exportData) { device.setCustomerId(getExternalIdOrElseInternal(ctx, device.getCustomerId())); device.setDeviceProfileId(getExternalIdOrElseInternal(ctx, device.getDeviceProfileId())); + device.setFirmwareId(getExternalIdOrElseInternal(ctx, device.getFirmwareId())); + device.setSoftwareId(getExternalIdOrElseInternal(ctx, device.getSoftwareId())); if (ctx.getSettings().isExportCredentials()) { var credentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(ctx.getTenantId(), device.getId()); credentials.setId(null); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java index 6c212f0684..6a98bd2a7c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java @@ -34,6 +34,8 @@ public class DeviceProfileExportService extends BaseEntityExportService { + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, OtaPackage otaPackage, OtaPackageExportData exportData) { + otaPackage.setDeviceProfileId(getExternalIdOrElseInternal(ctx, otaPackage.getDeviceProfileId())); + } + + @Override + protected OtaPackageExportData newExportData() { + return new OtaPackageExportData(); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.OTA_PACKAGE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java index 3ea50ea327..9850e2d1a1 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java @@ -67,7 +67,6 @@ import org.thingsboard.server.service.security.permission.Resource; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.utils.CsvUtils; -import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; @@ -235,7 +234,7 @@ public abstract class AbstractBulkImportService kvsEntry, BulkImportColumnType kvType) { String scope = kvType.getKey(); - List attributes = new ArrayList<>(JsonConverter.convertToAttributes(kvsEntry.getValue())); + List attributes = JsonConverter.convertToAttributes(kvsEntry.getValue()); accessValidator.validateEntityAndCallback(user, Operation.WRITE_ATTRIBUTES, entity.getId(), (result, tenantId, entityId) -> { tsSubscriptionService.saveAttributes(AttributesSaveRequest.builder() diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java new file mode 100644 index 0000000000..34e70adb11 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +class AiModelImportService extends BaseEntityImportService> { + + private final AiModelService aiModelService; + + @Override + protected void setOwner( + TenantId tenantId, + AiModel model, + BaseEntityImportService>.IdProvider idProvider + ) { + model.setTenantId(tenantId); + } + + @Override + protected AiModel prepare( + EntitiesImportCtx ctx, + AiModel model, + AiModel oldModel, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider + ) { + return model; + } + + @Override + protected AiModel deepCopy(AiModel model) { + return new AiModel(model); + } + + @Override + protected AiModel saveOrUpdate( + EntitiesImportCtx ctx, + AiModel model, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider, + CompareResult compareResult + ) { + return aiModelService.save(model); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index 57b4737be6..92fdcb09c4 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -71,7 +71,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -148,6 +147,7 @@ public abstract class BaseEntityImportService importResult, D exportData, IdProvider idProvider) throws ThingsboardException { E savedEntity = importResult.getSavedEntity(); E oldEntity = importResult.getOldEntity(); @@ -405,7 +404,9 @@ public abstract class BaseEntityImportService ID getInternalId(ID externalId, boolean throwExceptionIfNotFound) { - if (externalId == null || externalId.isNullUid()) return null; + if (externalId == null || externalId.isNullUid()) { + return null; + } if (EntityType.TENANT.equals(externalId.getEntityType())) { return (ID) ctx.getTenantId(); @@ -432,7 +433,9 @@ public abstract class BaseEntityImportService getInternalIdByUuid(UUID externalUuid, boolean fetchAllUUIDs, Set hints) { - if (externalUuid.equals(EntityId.NULL_UUID)) return Optional.empty(); + if (externalUuid.equals(EntityId.NULL_UUID)) { + return Optional.empty(); + } for (EntityType entityType : EntityType.values()) { Optional externalId = buildEntityId(entityType, externalUuid); @@ -483,10 +486,6 @@ public abstract class BaseEntityImportService T getOldEntityField(O oldEntity, Function getter) { - return oldEntity == null ? null : getter.apply(oldEntity); - } - protected void replaceIdsRecursively(EntitiesImportCtx ctx, IdProvider idProvider, JsonNode json, Set skippedRootFields, Pattern includedFieldsPattern, LinkedHashSet hints) { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java index 4ace9ff938..0cbb471b6f 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java @@ -44,8 +44,8 @@ public class DeviceImportService extends BaseEntityImportService exportData, IdProvider idProvider, CompareResult compareResult) { + boolean toUpdate = ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds(); + if (toUpdate) { + deviceProfile.setFirmwareId(idProvider.getInternalId(deviceProfile.getFirmwareId())); + deviceProfile.setSoftwareId(idProvider.getInternalId(deviceProfile.getSoftwareId())); + } DeviceProfile saved = deviceProfileService.saveDeviceProfile(deviceProfile); - if (ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds()) { + if (toUpdate) { importCalculatedFields(ctx, saved, exportData, idProvider); } return saved; @@ -73,8 +78,6 @@ public class DeviceProfileImportService extends BaseEntityImportService { + + private final OtaPackageService otaPackageService; + + @Override + protected void setOwner(TenantId tenantId, OtaPackage otaPackage, IdProvider idProvider) { + otaPackage.setTenantId(tenantId); + } + + @Override + protected OtaPackage prepare(EntitiesImportCtx ctx, OtaPackage otaPackage, OtaPackage oldOtaPackage, OtaPackageExportData exportData, IdProvider idProvider) { + otaPackage.setDeviceProfileId(idProvider.getInternalId(otaPackage.getDeviceProfileId())); + return otaPackage; + } + + @Override + protected OtaPackage findExistingEntity(EntitiesImportCtx ctx, OtaPackage otaPackage, IdProvider idProvider) { + OtaPackage existingOtaPackage = super.findExistingEntity(ctx, otaPackage, idProvider); + if (existingOtaPackage == null && ctx.isFindExistingByName()) { + existingOtaPackage = otaPackageService.findOtaPackageByTenantIdAndTitleAndVersion(ctx.getTenantId(), otaPackage.getTitle(), otaPackage.getVersion()); + } + return existingOtaPackage; + } + + @Override + protected OtaPackage deepCopy(OtaPackage otaPackage) { + return new OtaPackage(otaPackage); + } + + @Override + protected OtaPackage saveOrUpdate(EntitiesImportCtx ctx, OtaPackage otaPackage, OtaPackageExportData exportData, IdProvider idProvider, CompareResult compareResult) { + if (otaPackage.hasUrl()) { + OtaPackageInfo info = new OtaPackageInfo(otaPackage); + return new OtaPackage(otaPackageService.saveOtaPackageInfo(info, info.hasUrl())); + } + return otaPackageService.saveOtaPackage(otaPackage); + } + + @Override + public EntityType getEntityType() { + return EntityType.OTA_PACKAGE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ResourceImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ResourceImportService.java index 96fcdd5425..37639f6a90 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ResourceImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ResourceImportService.java @@ -66,7 +66,7 @@ public class ResourceImportService extends BaseEntityImportService taskCache; private final VersionControlExecutor executor; - @SuppressWarnings("UnstableApiUsage") @Override - public ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) throws Exception { + public ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) { checkBranchName(request.getBranch()); var pendingCommit = gitServiceQueue.prepareCommit(user, request); DonAsynchron.withCallback(pendingCommit, commit -> { @@ -546,7 +545,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont } @Override - public ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + public ListenableFuture autoCommit(User user, EntityId entityId) { var repositorySettings = repositorySettingsService.get(user.getTenantId()); if (repositorySettings == null || repositorySettings.isReadOnly()) { return Futures.immediateFuture(null); @@ -573,7 +572,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont } @Override - public ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + public ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) { var repositorySettings = repositorySettingsService.get(user.getTenantId()); if (repositorySettings == null || repositorySettings.isReadOnly()) { return Futures.immediateFuture(null); 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 3f22120d9c..e5ff89ebbd 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 @@ -69,9 +69,9 @@ public interface EntitiesVersionControlService { ListenableFuture checkVersionControlAccess(TenantId tenantId, RepositorySettings settings) throws Exception; - ListenableFuture autoCommit(User user, EntityId entityId) throws Exception; + ListenableFuture autoCommit(User user, EntityId entityId); - ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception; + ListenableFuture autoCommit(User user, EntityType entityType, List entityIds); ListenableFuture getEntityDataInfo(User user, EntityId entityId, String versionId); 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 4d2e641a91..a38c2721c1 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 @@ -39,6 +39,9 @@ import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.notification.rule.trigger.ResourcesShortageTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.ResourcesShortageTrigger.Resource; +import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.stats.TbApiUsageStateClient; import org.thingsboard.server.dao.domain.DomainService; @@ -92,6 +95,7 @@ public class DefaultSystemInfoService extends TbApplicationEventListener 0; + return !providers.isEmpty(); } } return false; @@ -180,6 +184,16 @@ public class DefaultSystemInfoService extends TbApplicationEventListener clusterSystemData = getSystemData(serviceInfoProvider.getServiceInfo()); + clusterSystemData.forEach(data -> { + Arrays.stream(Resource.values()).forEach(resource -> { + notificationRuleProcessor.process(ResourcesShortageTrigger.builder() + .resource(resource) + .serviceId(data.getServiceId()) + .serviceType(data.getServiceType()) + .usage(extractResourceUsage(data, resource)) + .build()); + }); + }); BasicTsKvEntry clusterDataKv = new BasicTsKvEntry(ts, new JsonDataEntry("clusterSystemData", JacksonUtil.toString(clusterSystemData))); doSave(Arrays.asList(new BasicTsKvEntry(ts, new BooleanDataEntry("clusterMode", true)), clusterDataKv)); } @@ -188,9 +202,21 @@ public class DefaultSystemInfoService extends TbApplicationEventListener tsList = new ArrayList<>(); tsList.add(new BasicTsKvEntry(ts, new BooleanDataEntry("clusterMode", false))); - getCpuUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("cpuUsage", (long) v)))); - getMemoryUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("memoryUsage", (long) v)))); - getDiscSpaceUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("discUsage", (long) v)))); + getCpuUsage().ifPresent(v -> { + long value = (long) v; + tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("cpuUsage", value))); + notificationRuleProcessor.process(ResourcesShortageTrigger.builder().resource(Resource.CPU).usage(value).serviceId(serviceInfoProvider.getServiceId()).serviceType(serviceInfoProvider.getServiceType()).build()); + }); + getMemoryUsage().ifPresent(v -> { + long value = (long) v; + tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("memoryUsage", value))); + notificationRuleProcessor.process(ResourcesShortageTrigger.builder().resource(Resource.RAM).usage(value).serviceId(serviceInfoProvider.getServiceId()).serviceType(serviceInfoProvider.getServiceType()).build()); + }); + getDiscSpaceUsage().ifPresent(v -> { + long value = (long) v; + tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("discUsage", value))); + notificationRuleProcessor.process(ResourcesShortageTrigger.builder().resource(Resource.STORAGE).usage(value).serviceId(serviceInfoProvider.getServiceId()).serviceType(serviceInfoProvider.getServiceType()).build()); + }); getCpuCount().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("cpuCount", (long) v)))); getTotalMemory().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("totalMemory", v)))); @@ -237,6 +263,14 @@ public class DefaultSystemInfoService extends TbApplicationEventListener info.getCpuUsage(); + case RAM -> info.getMemoryUsage(); + case STORAGE -> info.getDiscUsage(); + }; + } + @PreDestroy private void destroy() { if (scheduler != null) { @@ -244,4 +278,5 @@ public class DefaultSystemInfoService extends TbApplicationEventListener findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { + return alarmService.findLatestActiveByOriginatorAndTypeAsync(tenantId, originator, type); + } + @Override public Alarm findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type) { return alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, type); diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTbTelemetryService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTbTelemetryService.java new file mode 100644 index 0000000000..d55f44a027 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTbTelemetryService.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.telemetry; + +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 com.google.common.util.concurrent.SettableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.AggregationParams; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.IntervalType; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.ValidationResult; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DefaultTbTelemetryService implements TbTelemetryService { + + private final TimeseriesService tsService; + private final AccessValidator accessValidator; + + @Override + public ListenableFuture> getTimeseries(EntityId entityId, List keys, Long startTs, Long endTs, IntervalType intervalType, + Long interval, String timeZone, Integer limit, Aggregation agg, String orderBy, + Boolean useStrictDataTypes, SecurityUser currentUser) { + SettableFuture> future = SettableFuture.create(); + accessValidator.validate(currentUser, Operation.READ_TELEMETRY, entityId, new FutureCallback<>() { + @Override + public void onSuccess(ValidationResult validationResult) { + try { + AggregationParams params; + if (Aggregation.NONE.equals(agg)) { + params = AggregationParams.none(); + } else if (intervalType == null || IntervalType.MILLISECONDS.equals(intervalType)) { + params = interval == 0L ? AggregationParams.none() : AggregationParams.milliseconds(agg, interval); + } else { + params = AggregationParams.calendar(agg, intervalType, timeZone); + } + List queries = keys.stream().map(key -> new BaseReadTsKvQuery(key, startTs, endTs, params, limit, orderBy)).collect(Collectors.toList()); + Futures.addCallback(tsService.findAll(currentUser.getTenantId(), entityId, queries), new FutureCallback<>() { + @Override + public void onSuccess(List result) { + future.set(result); + } + + @Override + public void onFailure(Throwable t) { + future.setException(t); + } + }, MoreExecutors.directExecutor()); + } catch (Throwable e) { + onFailure(e); + } + } + + @Override + public void onFailure(Throwable t) { + future.setException(t); + } + }); + return future; + } + +} 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 30f2885e78..69b41addf9 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 @@ -28,7 +28,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.common.util.DonAsynchron; -import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.DeviceStateManager; @@ -45,6 +45,7 @@ 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.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; @@ -62,14 +63,12 @@ import org.thingsboard.server.service.state.DefaultDeviceStateService; import org.thingsboard.server.service.subscription.TbSubscriptionUtils; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.function.Consumer; import static java.util.Comparator.comparing; @@ -96,6 +95,8 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Value("${sql.ts.value_no_xss_validation:false}") private boolean valueNoXssValidation; + @Value("${sql.ts.callback_thread_pool_size:12}") + private int callbackThreadPoolSize; public DefaultTelemetrySubscriptionService(AttributesService attrService, TimeseriesService tsService, @@ -116,7 +117,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @PostConstruct public void initExecutor() { super.initExecutor(); - tsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("ts-service-ts-callback")); + tsCallBackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, "ts-service-ts-callback"); } @Override @@ -190,17 +191,16 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } @Override - public void saveAttributesInternal(AttributesSaveRequest request) { - log.trace("Executing saveInternal [{}]", request); + public ListenableFuture saveAttributesInternal(AttributesSaveRequest request) { TenantId tenantId = request.getTenantId(); EntityId entityId = request.getEntityId(); AttributesSaveRequest.Strategy strategy = request.getStrategy(); - ListenableFuture> resultFuture; + ListenableFuture resultFuture; if (strategy.saveAttributes()) { resultFuture = attrService.save(tenantId, entityId, request.getScope(), request.getEntries()); } else { - resultFuture = Futures.immediateFuture(Collections.emptyList()); + resultFuture = Futures.immediateFuture(AttributesSaveResult.EMPTY); } addMainCallback(resultFuture, result -> { @@ -228,6 +228,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer if (strategy.sendWsUpdate()) { addWsCallback(resultFuture, success -> onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries())); } + return resultFuture; } private static boolean shouldSendSharedAttributesUpdatedNotification(AttributesSaveRequest request) { diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java index 8a76aa1d14..2d3be5a0ba 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java @@ -21,6 +21,7 @@ import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; /** @@ -30,7 +31,7 @@ public interface InternalTelemetryService extends RuleEngineTelemetryService { ListenableFuture saveTimeseriesInternal(TimeseriesSaveRequest request); - void saveAttributesInternal(AttributesSaveRequest request); + ListenableFuture saveAttributesInternal(AttributesSaveRequest request); void deleteTimeseriesInternal(TimeseriesDeleteRequest request); diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TbTelemetryService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TbTelemetryService.java new file mode 100644 index 0000000000..9820e62592 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TbTelemetryService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.telemetry; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.IntervalType; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.List; + +public interface TbTelemetryService { + + ListenableFuture> getTimeseries(EntityId entityId, + List keys, + Long startTs, + Long endTs, + IntervalType intervalType, + Long interval, + String timeZone, + Integer limit, + Aggregation agg, + String orderBy, + Boolean useStrictDataTypes, + SecurityUser currentUser) throws ThingsboardException; + +} 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 2e4fe9730a..283e3baf76 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 @@ -36,6 +36,7 @@ import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.exception.RateLimitExceededException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -52,7 +53,6 @@ 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; -import org.thingsboard.server.dao.util.TenantRateLimitException; import org.thingsboard.server.exception.UnauthorizedException; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -274,7 +274,7 @@ public class DefaultWebSocketService implements WebSocketService { @Override public void sendUpdate(String sessionId, int cmdId, TelemetrySubscriptionUpdate update) { // We substitute the subscriptionId with cmdId for old-style subscriptions. - doSendUpdate(sessionId, cmdId, update.copyWithNewSubscriptionId(cmdId)); + doSendUpdate(sessionId, cmdId, update.withSubscriptionId(cmdId)); } @Override @@ -742,7 +742,7 @@ public class DefaultWebSocketService implements WebSocketService { @Override public void onFailure(Throwable e) { - if (e instanceof TenantRateLimitException || e.getCause() instanceof TenantRateLimitException) { + if (e instanceof RateLimitExceededException || e.getCause() instanceof RateLimitExceededException) { log.trace("[{}] Tenant rate limit detected for subscription: [{}]:{}", sessionRef.getSecurityCtx().getTenantId(), entityId, cmd); } else { log.info(FAILED_TO_FETCH_DATA, e); diff --git a/application/src/main/java/org/thingsboard/server/service/ws/telemetry/sub/TelemetrySubscriptionUpdate.java b/application/src/main/java/org/thingsboard/server/service/ws/telemetry/sub/TelemetrySubscriptionUpdate.java index b22b021a03..4959c5a348 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/telemetry/sub/TelemetrySubscriptionUpdate.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/telemetry/sub/TelemetrySubscriptionUpdate.java @@ -15,20 +15,28 @@ */ package org.thingsboard.server.service.ws.telemetry.sub; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.With; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.service.subscription.SubscriptionErrorCode; 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.TreeMap; import java.util.stream.Collectors; +@Getter @AllArgsConstructor public class TelemetrySubscriptionUpdate { + + @With private final int subscriptionId; private int errorCode; private String errorMsg; @@ -66,11 +74,28 @@ public class TelemetrySubscriptionUpdate { this.errorMsg = errorMsg != null ? errorMsg : errorCode.getDefaultMsg(); } - public int getSubscriptionId() { - return subscriptionId; - } + @JsonIgnore + public Map> getValues() { + if (data == null || data.isEmpty()) { + return Collections.emptyMap(); + } - public Map> getData() { + Map> data = new HashMap<>(); + this.data.forEach((key, entries) -> { + if (entries.isEmpty()) { + return; + } + + List values = new ArrayList<>(entries.size()); + entries.forEach(object -> { + if (!(object instanceof Object[] entry) || entry.length < 2) { + return; + } + TsValue tsValue = new TsValue((Long) entry[0], (String) entry[1]); + values.add(tsValue); + }); + data.put(key, values); + }); return data; } @@ -86,28 +111,17 @@ public class TelemetrySubscriptionUpdate { } } - public int getErrorCode() { - return errorCode; - } - - public String getErrorMsg() { - return errorMsg; - } - - public TelemetrySubscriptionUpdate copyWithNewSubscriptionId(int subscriptionId){ - return new TelemetrySubscriptionUpdate(subscriptionId, errorCode, errorMsg, data); - } - @Override public String toString() { StringBuilder result = new StringBuilder("TelemetrySubscriptionUpdate [subscriptionId=" + subscriptionId + ", errorCode=" + errorCode + ", errorMsg=" + errorMsg + ", data="); data.forEach((k, v) -> { result.append(k).append("=["); - for(Object a : v){ - result.append(Arrays.toString((Object[])a)).append("|"); + for (Object a : v) { + result.append(Arrays.toString((Object[]) a)).append("|"); } result.append("]"); }); return result.toString(); } + } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 2909e7cb05..a6ffc333b6 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -323,7 +323,7 @@ cassandra: poll_ms: "${CASSANDRA_QUERY_POLL_MS:50}" # Interval in milliseconds for printing Cassandra query queue statistic rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:10000}" - # set all data type values except target to null for the same ts on save + # When saving a value, set other data types to null (to avoid having multiple telemetry values with the same timestamp). set_null_values_enabled: "${CASSANDRA_QUERY_SET_NULL_VALUES_ENABLED:true}" # log one of cassandra queries with specified frequency (0 - logging is disabled) print_queries_freq: "${CASSANDRA_QUERY_PRINT_FREQ:0}" @@ -346,6 +346,7 @@ sql: stats_print_interval_ms: "${SQL_TS_BATCH_STATS_PRINT_MS:10000}" # Interval in milliseconds for printing timeseries insert statistic batch_threads: "${SQL_TS_BATCH_THREADS:3}" # batch thread count has to be a prime number like 3 or 5 to gain perfect hash distribution value_no_xss_validation: "${SQL_TS_VALUE_NO_XSS_VALIDATION:false}" # If true telemetry values will be checked for XSS vulnerability + callback_thread_pool_size: "${SQL_TS_CALLBACK_THREAD_POOL_SIZE:12}" # Thread pool size for telemetry callback executor ts_latest: batch_size: "${SQL_TS_LATEST_BATCH_SIZE:1000}" # Batch size for persisting latest telemetry updates batch_max_delay: "${SQL_TS_LATEST_BATCH_MAX_DELAY_MS:50}" # Maximum timeout for latest telemetry entries queue polling. The value set in milliseconds @@ -463,6 +464,14 @@ actors: allow_system_sms_service: "${ACTORS_RULE_ALLOW_SYSTEM_SMS_SERVICE:true}" # Specify thread pool size for external call service external_call_thread_pool_size: "${ACTORS_RULE_EXTERNAL_CALL_THREAD_POOL_SIZE:50}" + # Configuration for the thread pool that executes HTTP calls to AI provider APIs + ai-requests-thread-pool: + # The base name for threads + pool-name: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_NAME:ai-requests}" + # The maximum number of concurrent HTTP requests + pool-size: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_SIZE:50}" + # The maximum time in seconds to wait for active tasks to complete during graceful shutdown + termination-timeout-seconds: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_TERMINATION_TIMEOUT_SECONDS:60}" chain: # Errors for particular actors are persisted once per specified amount of milliseconds error_persist_frequency: "${ACTORS_RULE_CHAIN_ERROR_FREQUENCY:3000}" @@ -647,6 +656,9 @@ cache: trendzSettings: timeToLiveInMinutes: "${CACHE_SPECS_TRENDZ_SETTINGS_TTL:1440}" # Trendz settings cache TTL maxSize: "${CACHE_SPECS_TRENDZ_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled + aiModel: + timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL + maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: @@ -846,22 +858,23 @@ audit-log: # Allowed values: OFF (disable), W (log write operations), RW (log read and write operations) logging-level: mask: - "device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation + "device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels. + "asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels. + "dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels. + "widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels. + "widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels. + "customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels. + "user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels. + "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels. + "alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels. + "entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels. + "device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels. + "asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels. + "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. + "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. + "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. + "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. + "ai_model": "${AUDIT_LOG_MASK_AI_MODEL:W}" # AI model logging levels. sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" @@ -898,6 +911,8 @@ state: # Used only when state.persistToTelemetry is set to 'true' and Cassandra is used for timeseries data. # 0 means time-to-live mechanism is disabled. telemetryTtl: "${STATE_TELEMETRY_TTL:0}" + # Number of device records to fetch per batch when initializing device activity states + initFetchPackSize: "${TB_DEVICE_STATE_INIT_FETCH_PACK_SIZE:50000}" # Configuration properties for rule nodes related to device activity state rule: node: @@ -1631,6 +1646,11 @@ queue: - key: max.poll.records # Max poll records for edqs.state topic value: "${TB_QUEUE_KAFKA_EDQS_STATE_MAX_POLL_RECORDS:512}" + tasks: + # Key-value properties for Kafka consumer for tasks topics + - key: max.poll.records + # Max poll records for tasks topics + value: "${TB_QUEUE_KAFKA_TASKS_MAX_POLL_RECORDS:1}" # If you override any default Kafka topic name using environment variables, you must also specify the related consumer properties # for the new topic in `consumer-properties-per-topic-inline`. Otherwise, the topic will not inherit its expected configuration (e.g., max.poll.records, timeouts, etc). # Each entry sets a single property for a specific topic. To define multiple properties for a topic, repeat the topic key. @@ -1653,7 +1673,7 @@ queue: # Kafka properties for Notifications topics notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for JS Executor topics - js-executor: "${TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1}" + js-executor: "${TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES:retention.ms:86400000;segment.bytes:52428800;retention.bytes:104857600;partitions:30;min.insync.replicas:1}" # Kafka properties for OTA updates topic ota-updates: "${TB_QUEUE_KAFKA_OTA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Version Control topic @@ -1676,6 +1696,8 @@ queue: edqs-requests: "${TB_QUEUE_KAFKA_EDQS_REQUESTS_TOPIC_PROPERTIES:retention.ms:180000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for EDQS state topic (infinite retention, compaction) edqs-state: "${TB_QUEUE_KAFKA_EDQS_STATE_TOPIC_PROPERTIES:retention.ms:-1;segment.bytes:52428800;retention.bytes:-1;partitions:1;min.insync.replicas:1;cleanup.policy:compact}" + # Kafka properties for tasks topics + tasks: "${TB_QUEUE_KAFKA_TASKS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:1;min.insync.replicas:1}" consumer-stats: # Prints lag between consumer group offset and last messages offset in Kafka topics enabled: "${TB_QUEUE_KAFKA_CONSUMER_STATS_ENABLED:true}" @@ -1764,6 +1786,8 @@ queue: supported: "${TB_EDQS_API_SUPPORTED:false}" # Whether to auto-enable EDQS API (if queue.edqs.api.supported is true) when sync of data to Kafka is finished auto_enable: "${TB_EDQS_API_AUTO_ENABLE:true}" + # Interval in milliseconds to check for ready EDQS servers + readiness_check_interval: "${TB_EDQS_READINESS_CHECK_INTERVAL_MS:60000}" # Mode of EDQS: local (for monolith) or remote (with separate EDQS microservices) mode: "${TB_EDQS_MODE:local}" local: @@ -1787,6 +1811,10 @@ queue: max_pending_requests: "${TB_EDQS_MAX_PENDING_REQUESTS:10000}" # Maximum timeout for requests to EDQS max_request_timeout: "${TB_EDQS_MAX_REQUEST_TIMEOUT:20000}" + # Thread pool size for EDQS requests executor + request_executor_size: "${TB_EDQS_REQUEST_EXECUTOR_SIZE:50}" + # Time to live for EDQS versions cache in minutes. Must be bigger than the time taken for the sync process. + versions_cache_ttl: "${TB_EDQS_VERSIONS_CACHE_TTL_MINUTES:60}" # Strings longer than this threshold will be compressed string_compression_length_threshold: "${TB_EDQS_STRING_COMPRESSION_LENGTH_THRESHOLD:512}" stats: @@ -1890,6 +1918,23 @@ queue: enabled: "${TB_QUEUE_EDGE_STATS_ENABLED:true}" # Statistics printing interval for Edge services print-interval-ms: "${TB_QUEUE_EDGE_STATS_PRINT_INTERVAL_MS:60000}" + tasks: + # Poll interval in milliseconds for tasks topics + poll_interval: "${TB_QUEUE_TASKS_POLL_INTERVAL_MS:500}" + # Partitions count for tasks queues + partitions: "${TB_QUEUE_TASKS_PARTITIONS:12}" + # Custom partitions count for tasks queues per type. Format: 'TYPE1:24;TYPE2:36', e.g. 'CF_REPROCESSING:24;TENANT_EXPORT:6' + partitions_per_type: "${TB_QUEUE_TASKS_PARTITIONS_PER_TYPE:}" + # Tasks partitioning strategy: 'tenant' or 'entity'. By default, using 'tenant' - tasks of a specific tenant are processed in the same partition. + # In a single-tenant environment, use 'entity' strategy to distribute the tasks among multiple partitions. + partitioning_strategy: "${TB_QUEUE_TASKS_PARTITIONING_STRATEGY:tenant}" + stats: + # Name for the tasks stats topic + topic: "${TB_QUEUE_TASKS_STATS_TOPIC:jobs.stats}" + # Poll interval in milliseconds for tasks stats topic + poll_interval: "${TB_QUEUE_TASKS_STATS_POLL_INTERVAL_MS:500}" + # Interval in milliseconds to process job stats + processing_interval: "${TB_QUEUE_TASKS_STATS_PROCESSING_INTERVAL_MS:1000}" # Event configuration parameters event: diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index f65f6bc629..c8b8b0244b 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -464,7 +464,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes } @Test - public void testSimpleCalculatedFieldWhenPreserveMsgTsIsTrue() throws Exception { + public void testSimpleCalculatedFieldWhenUseLatestTsIsTrue() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); long ts = System.currentTimeMillis() - 300000L; doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); @@ -489,7 +489,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes output.setType(OutputType.TIME_SERIES); config.setOutput(output); - config.setPreserveMsgTs(true); + config.setUseLatestTs(true); calculatedField.setConfiguration(config); @@ -506,7 +506,69 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes } @Test - public void testScriptCalculatedFieldWhenUsedMsgTsInScript() throws Exception { + public void testSimpleCalculatedFieldWhenUseLatestTsIsTrueAndTelemetryBeforeLatest() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + long ts = System.currentTimeMillis(); + + long tsA = ts - 300000L; + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":1}}", tsA))); + + long tsB = ts - 300L; + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"b\":5}}", tsB))); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("a + b"); + calculatedField.setDebugSettings(DebugSettings.all()); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument1 = new Argument(); + ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null); + argument1.setRefEntityKey(refEntityKey1); + Argument argument2 = new Argument(); + ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("b", ArgumentType.TS_LATEST, null); + argument2.setRefEntityKey(refEntityKey2); + config.setArguments(Map.of("a", argument1, "b", argument2)); + config.setExpression("a + b"); + + Output output = new Output(); + output.setName("c"); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + config.setUseLatestTs(true); + + calculatedField.setConfiguration(config); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode c = getLatestTelemetry(testDevice.getId(), "c"); + assertThat(c).isNotNull(); + assertThat(c.get("c").get(0).get("ts").asText()).isEqualTo(Long.toString(tsB)); + assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("6.0"); + }); + + long tsABeforeTsB = tsB - 300L; + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":10}}", tsABeforeTsB))); + + await().alias("update telemetry with ts less than latest -> save result with latest ts").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode c = getLatestTelemetry(testDevice.getId(), "c"); + assertThat(c).isNotNull(); + assertThat(c.get("c").get(0).get("ts").asText()).isEqualTo(Long.toString(tsB));// also tsB, since this is the latest timestamp + assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("15.0"); + }); + } + + @Test + public void testScriptCalculatedFieldWhenUsedLatestTsInScript() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); long ts = System.currentTimeMillis() - 300000L; doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); @@ -524,7 +586,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); argument.setRefEntityKey(refEntityKey); config.setArguments(Map.of("T", argument)); - config.setExpression("return {\"ts\": ctx.msgTs, \"values\": {\"fahrenheitTemp\": (T * 1.8) + 32}};"); + config.setExpression("return {\"ts\": ctx.latestTs, \"values\": {\"fahrenheitTemp\": (T * 1.8) + 32}};"); Output output = new Output(); output.setType(OutputType.TIME_SERIES); 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 33f2209eca..b93ff0f623 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -48,6 +48,7 @@ import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.http.MockHttpInputMessage; import org.springframework.mock.http.MockHttpOutputMessage; import org.springframework.mock.web.MockMultipartFile; @@ -102,10 +103,13 @@ 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.HasId; +import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationType; @@ -128,6 +132,7 @@ import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.oauth2.PlatformType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.security.Authority; @@ -164,6 +169,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentMap; 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.mockito.ArgumentMatchers.any; @@ -278,6 +284,9 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @Autowired protected InMemoryStorage storage; + @Autowired + protected JdbcTemplate jdbcTemplate; + @MockBean protected CfRocksDb cfRocksDb; @@ -389,6 +398,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { tenantProfileService.deleteTenantProfiles(TenantId.SYS_TENANT_ID); + jdbcTemplate.execute("TRUNCATE TABLE notification"); + log.info("Executed web test teardown"); } @@ -1253,4 +1264,26 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { new PageLink(limit, 0), unreadOnly, deliveryMethod).getData(); } + protected Job findJobById(JobId jobId) throws Exception { + return doGet("/api/job/" + jobId, Job.class); + } + + protected List findJobs() throws Exception { + return doGetTypedWithPageLink("/api/jobs?", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); + } + + protected List findJobs(List types, List entities) throws Exception { + return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); + } + + protected void cancelJob(JobId jobId) throws Exception { + doPost("/api/job/" + jobId + "/cancel").andExpect(status().isOk()); + } + + protected void reprocessJob(JobId jobId) throws Exception { + doPost("/api/job/" + jobId + "/reprocess").andExpect(status().isOk()); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java new file mode 100644 index 0000000000..ae2972b0cc --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -0,0 +1,615 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.Test; +import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AiModelControllerTest extends AbstractControllerTest { + + /* --- Save API tests --- */ + + @Test + public void saveAiModel_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenCreatingValidModelAsTenantAdmin_shouldSucceed() throws Exception { + // GIVEN + loginTenantAdmin(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + var savedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(savedModel.getId()).isNotNull(); + assertThat(savedModel.getUuidId()).isNotNull().isNotEqualTo(EntityId.NULL_UUID); + assertThat(savedModel.getId().getEntityType()).isEqualTo(EntityType.AI_MODEL); + assertThat(savedModel.getCreatedTime()).isPositive(); + assertThat(savedModel.getVersion()).isEqualTo(1); + assertThat(savedModel.getTenantId()).isEqualTo(tenantId); + assertThat(savedModel.getName()).isEqualTo("Test model"); + assertThat(savedModel.getConfiguration()).isEqualTo(model.getConfiguration()); + assertThat(savedModel.getExternalId()).isNull(); + } + + @Test + public void saveAiModel_whenUpdatingExistingModelAsTenantAdmin_shouldSucceed() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + var newModelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key-updated")) + .modelId("o4-mini") + .temperature(0.2) + .topP(0.4) + .frequencyPenalty(0.2) + .presencePenalty(0.5) + .maxOutputTokens(2000) + .timeoutSeconds(20) + .maxRetries(0) + .build(); + + model.setName("Test model updated"); + model.setConfiguration(newModelConfig); + + // WHEN + var updatedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(updatedModel.getId()).isEqualTo(model.getId()); + assertThat(updatedModel.getCreatedTime()).isEqualTo(model.getCreatedTime()); + assertThat(updatedModel.getVersion()).isEqualTo(2); + assertThat(updatedModel.getTenantId()).isEqualTo(tenantId); + assertThat(updatedModel.getName()).isEqualTo("Test model updated"); + assertThat(updatedModel.getConfiguration()).isEqualTo(newModelConfig); + assertThat(updatedModel.getExternalId()).isNull(); + } + + /* --- Get by ID API tests --- */ + + @Test + public void getAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModelById_whenGettingExistingModelAsTenantAdmin_shouldReturnModel() throws Exception { + // GIVEN + loginTenantAdmin(); + + var saved = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + // WHEN + AiModel actual = doGet("/api/ai/model/" + saved.getId(), AiModel.class); + + // THEN + assertThat(actual).isEqualTo(saved); + } + + @Test + public void getAiModelById_whenGettingNonexistentModelAsTenantAdmin_shouldReturnNotFound() throws Exception { + // GIVEN + loginTenantAdmin(); + + var nonexistentModelId = new AiModelId(Uuids.timeBased()); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + nonexistentModelId); + + // THEN + result.andExpect(status().isNotFound()) + .andExpect(statusReason(is("AI model with id [" + nonexistentModelId + "] is not found"))); + } + + /* --- Get paged API tests --- */ + + @Test + public void getAiModels_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doGet("/api/ai/model?pageSize=10&page=0"); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModels_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doGet("/api/ai/model?pageSize=10&page=0"); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModels_testPagination() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class); + var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class); + var model3 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 3"), AiModel.class); + var model4 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 4"), AiModel.class); + var model5 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 5"), AiModel.class); + + // WHEN + PageData result = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(2, 1)); + + // THEN + assertThat(result.getData()).containsExactly(model3, model4); + assertThat(result.getTotalPages()).isEqualTo(3); + assertThat(result.getTotalElements()).isEqualTo(5); + assertThat(result.hasNext()).isTrue(); + } + + @Test + public void getAiModels_testSearchAndSortAppliedBeforePagination() throws Exception { + // GIVEN + loginTenantAdmin(); + + // Create 5 models: 3 with "Alpha" in name, 2 with "Beta" in name + var alpha1 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 1"), AiModel.class); + var beta1 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 1"), AiModel.class); + var alpha2 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 2"), AiModel.class); + var beta2 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 2"), AiModel.class); + var alpha3 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 3"), AiModel.class); + + // WHEN + // Search for "Alpha", sort by name DESC, get the first page with size 2 + PageData result = doGetTypedWithPageLink("/api/ai/model?", + new TypeReference<>() {}, + new PageLink(2, 0, "Alpha", SortOrder.of("name", SortOrder.Direction.DESC))); + + // THEN + // Should find only 3 "Alpha" models, sort them DESC (3, 2, 1), then return first 2 + assertThat(result.getData()).containsExactly(alpha3, alpha2); + assertThat(result.getTotalPages()).isEqualTo(2); // One more "Alpha" model on the next page + assertThat(result.getTotalElements()).isEqualTo(3); // Only 3 models match "Alpha", not 5 + assertThat(result.hasNext()).isTrue(); // One more "Alpha" model on the next page + } + + @Test + public void getAiModels_testTextSearch() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("o3-pro") + .build()) + .build(), AiModel.class); + var model2 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-flash") + .build()) + .build(), AiModel.class); + var model3 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 3") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-pro") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 10; + int page = 0; + SortOrder sortOrder = null; + + PageData result1 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "google ai", sortOrder)); + + PageData result2 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "pro", sortOrder)); + + PageData result3 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "test", sortOrder)); + + PageData result4 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "anthropic", sortOrder)); + + // THEN + + // should find google models + assertThat(result1.getData()).containsExactly(model2, model3); + assertThat(result1.getTotalPages()).isEqualTo(1); + assertThat(result1.getTotalElements()).isEqualTo(2); + assertThat(result1.hasNext()).isFalse(); + + // should find "o3-pro" and "gemini-2.5-pro" models + assertThat(result2.getData()).containsExactly(model1, model3); + assertThat(result2.getTotalPages()).isEqualTo(1); + assertThat(result2.getTotalElements()).isEqualTo(2); + assertThat(result2.hasNext()).isFalse(); + + // should find all models (all contain "Test" in their names) + assertThat(result3.getData()).containsExactly(model1, model2, model3); + assertThat(result3.getTotalPages()).isEqualTo(1); + assertThat(result3.getTotalElements()).isEqualTo(3); + assertThat(result3.hasNext()).isFalse(); + + // should find no models (nothing matches "anthropic") + assertThat(result4.getData()).isEmpty(); + assertThat(result4.getTotalPages()).isEqualTo(0); + assertThat(result4.getTotalElements()).isEqualTo(0); + assertThat(result4.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByCreatedTime() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class); + var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(model1, model2); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(model2, model1); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByName() throws Exception { + // GIVEN + loginTenantAdmin(); + + var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class); + var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelB, modelA); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByProvider() throws Exception { + // GIVEN + loginTenantAdmin(); + + var anthropicModel = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-0") + .build()) + .build(), AiModel.class); + var geminiModel = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-pro") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(anthropicModel, geminiModel); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(geminiModel, anthropicModel); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByModelId() throws Exception { + // GIVEN + loginTenantAdmin(); + + var modelA = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("model-a") + .build()) + .build(), AiModel.class); + + var modelB = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("model-b") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelB, modelA); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByIdTieBreaker() throws Exception { + // GIVEN + loginTenantAdmin(); + + // Both models are from OpenAI and sorting will be done on provider + var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class); + var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC)) + ); + + // THEN + + // in both cases result should be the same since in case of ties (both models have OpenAI as provider, sorting by ID ascending is used) + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelA, modelB); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + /* --- Delete API tests --- */ + + @Test + public void deleteAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void deleteAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void deleteAiModelById_whenDeletingExistingModelAsTenantAdmin_shouldSucceedAndReturnTrue() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + // WHEN + boolean deleted = doDelete("/api/ai/model/" + model.getId(), Boolean.class); + + // THEN + assertThat(deleted).isTrue(); + + // verify model cannot be found anymore + doGet("/api/ai/model/" + model.getId()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is("AI model with id [" + model.getId() + "] is not found"))); + } + + @Test + public void deleteAiModelById_whenDeletingNonexistentModelAsTenantAdmin_shouldSucceedAndReturnFalse() throws Exception { + // GIVEN + loginTenantAdmin(); + + var nonexistentModelId = new AiModelId(Uuids.timeBased()); + + // WHEN + boolean deleted = doDelete("/api/ai/model/" + nonexistentModelId, Boolean.class); + + // THEN + assertThat(deleted).isFalse(); + } + + private AiModel constructValidOpenAiModel(String name) { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build(); + + return AiModel.builder() + .tenantId(tenantId) + .name(name) + .configuration(modelConfig) + .build(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java index 153ec2d26f..89bbe5e499 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java @@ -15,21 +15,27 @@ */ package org.thingsboard.server.controller; +import org.assertj.core.api.ThrowingConsumer; import org.junit.Before; +import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.edqs.EdqsState; +import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; -import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.edqs.state.EdqsStateService; import org.thingsboard.server.edqs.util.EdqsRocksDb; +import org.thingsboard.server.queue.discovery.DiscoveryService; import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @DaoSqlTest @@ -39,22 +45,23 @@ import static org.awaitility.Awaitility.await; "queue.edqs.sync.enabled=true", "queue.edqs.api.supported=true", "queue.edqs.api.auto_enable=true", - "queue.edqs.mode=local" + "queue.edqs.mode=local", + "queue.edqs.readiness_check_interval=500" }) public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { @Autowired - private EdqsApiService edqsApiService; + private EdqsService edqsService; @Autowired - private EdqsStateService edqsStateService; + private DiscoveryService discoveryService; @MockBean // so that we don't do backup for tests private EdqsRocksDb edqsRocksDb; @Before public void before() { - await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsApiService.isEnabled() && edqsStateService.isReady()); + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsService.getState().isApiEnabled()); } @Override @@ -69,4 +76,66 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { result -> result == expectedResult); } + @Test + public void testEdqsState() throws Exception { + loginSysAdmin(); + assertThat(getEdqsState().getApiMode()).isEqualTo(EdqsApiMode.AUTO_ENABLED); + + // notifying EDQS is not ready: API should be auto-disabled + discoveryService.setReady(false); + verifyState(state -> { + assertThat(state.getApiMode()).isEqualTo(EdqsApiMode.AUTO_DISABLED); + assertThat(state.getEdqsReady()).isFalse(); + assertThat(state.isApiEnabled()).isFalse(); + }); + + // manually disabling API + edqsService.processSystemRequest(ToCoreEdqsRequest.builder() + .apiEnabled(false) + .build()); + verifyState(state -> { + assertThat(state.getApiMode()).isEqualTo(EdqsApiMode.DISABLED); + assertThat(state.getEdqsReady()).isFalse(); + assertThat(state.isApiEnabled()).isFalse(); + }); + + // notifying EDQS is ready: API should not be enabled automatically because manually disabled previously + discoveryService.setReady(true); + verifyState(state -> { + assertThat(state.getEdqsReady()).isTrue(); + assertThat(state.getApiMode()).isEqualTo(EdqsApiMode.DISABLED); + assertThat(state.isApiEnabled()).isFalse(); + }); + + // manually enabling API + edqsService.processSystemRequest(ToCoreEdqsRequest.builder() + .apiEnabled(true) + .build()); + verifyState(state -> { + assertThat(state.getApiMode()).isEqualTo(EdqsApiMode.ENABLED); + assertThat(state.getEdqsReady()).isTrue(); + assertThat(state.isApiEnabled()).isTrue(); + }); + + // notifying EDQS is not ready: API should be auto-disabled + discoveryService.setReady(false); + verifyState(state -> { + assertThat(state.getApiMode()).isEqualTo(EdqsApiMode.AUTO_DISABLED); + assertThat(state.getEdqsReady()).isFalse(); + assertThat(state.isApiEnabled()).isFalse(); + }); + + discoveryService.setReady(true); + } + + private void verifyState(ThrowingConsumer assertion) { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(getEdqsState()).satisfies(assertion); + }); + } + + private EdqsState getEdqsState() throws Exception { + return doGet("/api/edqs/state", EdqsState.class); + } + } 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 29a208a805..ee18543796 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -22,6 +22,7 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.ResultActions; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; @@ -35,10 +36,15 @@ 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.asset.Asset; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.common.data.query.AlarmDataPageLink; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; @@ -78,6 +84,10 @@ import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest +@TestPropertySource(properties = { + "queue.edqs.sync.enabled=true", // only enabling sync + "queue.edqs.api.supported=false", +}) public class EntityQueryControllerTest extends AbstractControllerTest { private static final String CUSTOMER_USER_EMAIL = "entityQueryCustomer@thingsboard.org"; @@ -225,6 +235,60 @@ public class EntityQueryControllerTest extends AbstractControllerTest { testCountAlarmsByQuery(alarms); } + @Test + public void testTenantCountAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + Device savedDevice = doPost("/api/device", device, Device.class); + devices.add(savedDevice); + Thread.sleep(1); + + Alarm alarm = new Alarm(); + alarm.setOriginator(savedDevice.getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + assets.add(savedAsset); + Thread.sleep(1); + + Alarm alarm = new Alarm(); + alarm.setOriginator(savedAsset.getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmCountQuery assetAlarmQuery = new AlarmCountQuery(assetTypeFilter); + + Long assetAlamCount = doPostWithResponse("/api/alarmsQuery/count", assetAlarmQuery, Long.class); + Assert.assertEquals(assets.size(), assetAlamCount.longValue()); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmCountQuery filteredAssetAlarmQuery = new AlarmCountQuery(assetTypeFilter, keyFilters); + + Long filteredAssetAlamCount = doPostWithResponse("/api/alarmsQuery/count", filteredAssetAlarmQuery, Long.class); + Assert.assertEquals(1, filteredAssetAlamCount.longValue()); + } + @Test public void testCustomerCountAlarmsByQuery() throws Exception { loginTenantAdmin(); @@ -254,6 +318,213 @@ public class EntityQueryControllerTest extends AbstractControllerTest { testCountAlarmsByQuery(alarms); } + @Test + public void testCustomerCountAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setCustomerId(customerId); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + assets.add(doPost("/api/asset", asset, Asset.class)); + Thread.sleep(1); + } + + loginCustomerUser(); + + for (int i = 0; i < devices.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(devices.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + for (int i = 0; i < assets.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(assets.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmCountQuery assetAlarmQuery = new AlarmCountQuery(assetTypeFilter); + + Long assetAlamCount = doPostWithResponse("/api/alarmsQuery/count", assetAlarmQuery, Long.class); + Assert.assertEquals(10, assetAlamCount.longValue()); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmCountQuery filteredAssetAlarmQuery = new AlarmCountQuery(assetTypeFilter, keyFilters); + + Long filteredAssetAlamCount = doPostWithResponse("/api/alarmsQuery/count", filteredAssetAlarmQuery, Long.class); + Assert.assertEquals(1, filteredAssetAlamCount.longValue()); + } + + @Test + public void testFindTenantAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setCustomerId(customerId); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + assets.add(doPost("/api/asset", asset, Asset.class)); + Thread.sleep(1); + } + + for (int i = 0; i < devices.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setOriginator(devices.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + List assetAlarmTypes = new ArrayList<>(); + for (int i = 0; i < assets.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setOriginator(assets.get(i).getId()); + String type = "asset alarm" + i; + alarm.setType(type); + assetAlarmTypes.add(type); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "assignee"))); + + List alarmFields = new ArrayList<>(); + alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); + + PageData alarmPageData = doPostWithTypedResponse("/api/alarmsQuery/find", assetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(10, alarmPageData.getTotalElements()); + List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); + assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); + } + + @Test + public void testFindCustomerAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setCustomerId(customerId); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + assets.add(doPost("/api/asset", asset, Asset.class)); + Thread.sleep(1); + } + + loginCustomerUser(); + + for (int i = 0; i < devices.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(devices.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + List assetAlarmTypes = new ArrayList<>(); + for (int i = 0; i < assets.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(assets.get(i).getId()); + String type = "asset alarm" + i; + alarm.setType(type); + assetAlarmTypes.add(type); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "assignee"))); + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList()); + + PageData alarmPageData = doPostWithTypedResponse("/api/alarmsQuery/find", assetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(10, alarmPageData.getTotalElements()); + List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); + assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList()); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); + } + private void testCountAlarmsByQuery(List alarms) throws Exception { AlarmCountQuery countQuery = new AlarmCountQuery(); @@ -437,7 +708,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { } RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(mainDevice.getId()); + filter.setRootEntity(AliasEntityId.fromEntityId(mainDevice.getId())); filter.setDirection(EntitySearchDirection.FROM); filter.setNegate(true); filter.setFilters(List.of(new RelationEntityTypeFilter("CONTAINS", List.of(EntityType.DEVICE), false))); @@ -803,7 +1074,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { //assign dashboard doPost("/api/customer/" + savedCustomer.getId().getId().toString() - + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); // check entity data query by customer User customerUser = new User(); @@ -908,4 +1179,14 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return numericFilter; } + private KeyFilter buildStringKeyFilter(EntityKeyType entityKeyType, String name, StringFilterPredicate.StringOperation operation, String value) { + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(entityKeyType, name)); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setOperation(operation); + predicate.setValue(FilterPredicateValue.fromString(value)); + nameFilter.setPredicate(predicate); + return nameFilter; + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java index 569ff840ca..3b194de7b4 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java @@ -202,6 +202,60 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertEquals(savedResource.getFileName(), foundResource.getFileName()); } + @Test + public void testFindSystemResourceInfoById() throws Exception { + loginSysAdmin(); + TbResource resource = new TbResource(); + resource.setResourceType(ResourceType.JS_MODULE); + resource.setTitle("My system resource"); + resource.setFileName(DEFAULT_FILE_NAME); + resource.setEncodedData(TEST_DATA); + TbResourceInfo savedResourceInfo = save(resource); + assertThat(savedResourceInfo.getFileName()).isEqualTo(DEFAULT_FILE_NAME); + + TbResourceInfo resourceInfo = findResourceInfo(savedResourceInfo.getId()); + assertThat(resourceInfo).isEqualTo(savedResourceInfo); + loginTenantAdmin(); + resourceInfo = findResourceInfo(savedResourceInfo.getId()); + assertThat(resourceInfo).isEqualTo(savedResourceInfo); + + loginSysAdmin(); + resource = new TbResource(savedResourceInfo); + resource.setFileName(DEFAULT_FILE_NAME_2); + resource.setEncodedData(TEST_DATA); + savedResourceInfo = save(resource); + assertThat(savedResourceInfo.getFileName()).isEqualTo(DEFAULT_FILE_NAME_2); + + resourceInfo = findResourceInfo(savedResourceInfo.getId()); + assertThat(resourceInfo).isEqualTo(savedResourceInfo); + loginTenantAdmin(); + resourceInfo = findResourceInfo(savedResourceInfo.getId()); + assertThat(resourceInfo).isEqualTo(savedResourceInfo); + } + + @Test + public void testFindTenantResourceInfoById() throws Exception { + TbResource resource = new TbResource(); + resource.setResourceType(ResourceType.JS_MODULE); + resource.setTitle("My tenant resource"); + resource.setFileName(DEFAULT_FILE_NAME); + resource.setEncodedData(TEST_DATA); + TbResourceInfo savedResourceInfo = save(resource); + assertThat(savedResourceInfo.getFileName()).isEqualTo(DEFAULT_FILE_NAME); + + TbResourceInfo resourceInfo = findResourceInfo(savedResourceInfo.getId()); + assertThat(resourceInfo).isEqualTo(savedResourceInfo); + + resource = new TbResource(savedResourceInfo); + resource.setFileName(DEFAULT_FILE_NAME_2); + resource.setEncodedData(TEST_DATA); + savedResourceInfo = save(resource); + assertThat(savedResourceInfo.getFileName()).isEqualTo(DEFAULT_FILE_NAME_2); + + resourceInfo = findResourceInfo(savedResourceInfo.getId()); + assertThat(resourceInfo).isEqualTo(savedResourceInfo); + } + @Test public void testDeleteTbResource() throws Exception { TbResource resource = new TbResource(); @@ -878,6 +932,10 @@ public class TbResourceControllerTest extends AbstractControllerTest { }); } + private TbResourceInfo findResourceInfo(TbResourceId id) throws Exception { + return doGet("/api/resource/info/" + id, TbResourceInfo.class); + } + private byte[] download(TbResourceId resourceId) throws Exception { return doGet("/api/resource/" + resourceId + "/download") .andExpect(status().isOk()) 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 a1570c4903..ad3c6d5312 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java @@ -19,10 +19,12 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Assert; import org.junit.Test; import org.springframework.test.context.TestPropertySource; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; 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.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.security.DeviceCredentials; @@ -32,6 +34,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.query.EntityKeyType.TIME_SERIES; @@ -115,7 +118,7 @@ public class TelemetryControllerTest extends AbstractControllerTest { Device device = createDevice(); SingleEntityFilter filter = new SingleEntityFilter(); - filter.setSingleEntity(device.getId()); + filter.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); getWsClient().subscribeLatestUpdate(List.of(new EntityKey(TIME_SERIES, "data")), filter); @@ -159,7 +162,7 @@ public class TelemetryControllerTest extends AbstractControllerTest { Device device = createDevice(); SingleEntityFilter filter = new SingleEntityFilter(); - filter.setSingleEntity(device.getId()); + filter.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); getWsClient().subscribeLatestUpdate(List.of(new EntityKey(TIME_SERIES, "data")), filter); @@ -207,6 +210,15 @@ public class TelemetryControllerTest extends AbstractControllerTest { doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody, String.class, status().isBadRequest()); } + @Test + public void testBadRequestReturnedWhenMethodArgumentTypeMismatch() throws Exception { + loginTenantAdmin(); + String content = "{\"key\": \"value\"}"; + doPost("/api/plugins/telemetry/DEVICE/20b559f5-849f-4361-b4f6-b6d0b76687e9/INVALID_SCOPE", content, (String) null) + .andExpect(status().isBadRequest()) + .andExpect(result -> assertThat(result.getResolvedException()).isInstanceOf(MethodArgumentTypeMismatchException.class)); + } + @Test public void testEmptyKeyIsProhibited() throws Exception { loginTenantAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java index 0daa56728b..8f83f2c186 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java @@ -36,9 +36,11 @@ import org.thingsboard.server.common.data.queue.SubmitStrategyType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; +import org.thingsboard.server.common.data.validation.RateLimit; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.queue.TbQueueCallback; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -314,6 +316,42 @@ public class TenantProfileControllerTest extends AbstractControllerTest { Assert.assertEquals(1, pageData.getTotalElements()); } + @Test + public void testRateLimitValidationAllFields() throws Exception { + loginSysAdmin(); + Mockito.reset(tbClusterService); + + List failedFields = new ArrayList<>(); + + for (Field field : DefaultTenantProfileConfiguration.class.getDeclaredFields()) { + RateLimit rateLimit = field.getAnnotation(RateLimit.class); + if (rateLimit == null) continue; + + String fieldName = field.getName(); + String expectedLabel = rateLimit.fieldName(); + + + TenantProfile tenantProfile = createTenantProfile("Invalid RateLimit - " + fieldName); + DefaultTenantProfileConfiguration config = (DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); + + field.setAccessible(true); + field.set(config, "10:1,10:1"); // Set invalid duplicate value + + try { + doPost("/api/tenantProfile", tenantProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(expectedLabel + " rate limit has duplicate 'Per seconds' configuration."))); + } catch (AssertionError e) { + failedFields.add(fieldName + " (label: " + expectedLabel + ")"); + } + } + + if (!failedFields.isEmpty()) { + throw new AssertionError("RateLimit validation failed for fields: " + String.join(", ", failedFields)); + } + testBroadcastEntityStateChangeEventNeverTenantProfile(); + } + private TenantProfile createTenantProfile(String name) { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java index 3842cf917b..bebdd5ecb9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; @@ -332,7 +333,7 @@ public class WebsocketApiTest extends AbstractControllerTest { loginTenantAdmin(); SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(tenantId); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); AlarmCountQuery alarmCountQuery = new AlarmCountQuery(singleEntityFilter); AlarmCountCmd cmd1 = new AlarmCountCmd(1, alarmCountQuery); @@ -356,7 +357,7 @@ public class WebsocketApiTest extends AbstractControllerTest { Assert.assertEquals(1, update.getCount()); // set wrong entity id in filter, check count = 0 - singleEntityFilter.setSingleEntity(tenantAdminUserId); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantAdminUserId)); AlarmCountCmd cmd3 = new AlarmCountCmd(2, alarmCountQuery); getWsClient().send(cmd3); @@ -865,7 +866,7 @@ public class WebsocketApiTest extends AbstractControllerTest { public void testAttributesSubscription_sysAdmin() throws Exception { loginSysAdmin(); SingleEntityFilter entityFilter = new SingleEntityFilter(); - entityFilter.setSingleEntity(tenantId); + entityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); assertThatNoException().as("subscribeForAttributes").isThrownBy(() -> { JsonNode update = getWsClient().subscribeForAttributes(tenantId, TbAttributeSubscriptionScope.SERVER_SCOPE.name(), List.of("attr")); 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 c02e72a35f..84d879c993 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -75,6 +75,7 @@ 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.rule.RuleChainType; +import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.edge.EdgeEventService; @@ -565,7 +566,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { protected Device saveDeviceOnCloudAndVerifyDeliveryToEdge() throws Exception { // create device and assign to edge Device savedDevice = saveDevice(StringUtils.randomAlphanumeric(15), thermostatDeviceProfile.getName()); - edgeImitator.expectMessageAmount(2); // device and device profile messages + DeviceCredentials deviceCredentials = doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + edgeImitator.expectMessageAmount(3); // device and device profile messages and device credentials doPost("/api/edge/" + edge.getUuidId() + "/device/" + savedDevice.getUuidId(), Device.class); Assert.assertTrue(edgeImitator.waitForMessages()); @@ -582,6 +584,15 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(thermostatDeviceProfile.getUuidId().getMostSignificantBits(), deviceProfileUpdateMsg.getIdMSB()); Assert.assertEquals(thermostatDeviceProfile.getUuidId().getLeastSignificantBits(), deviceProfileUpdateMsg.getIdLSB()); + + Optional deviceCredentialsUpdateMsgOpt = edgeImitator.findMessageByType(DeviceCredentialsUpdateMsg.class); + Assert.assertTrue(deviceCredentialsUpdateMsgOpt.isPresent()); + DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = deviceCredentialsUpdateMsgOpt.get(); + DeviceCredentials deviceCredentialsMsg = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); + Assert.assertNotNull(deviceCredentialsMsg); + Assert.assertEquals(savedDevice.getId(), deviceCredentialsMsg.getDeviceId()); + Assert.assertEquals(deviceCredentials, deviceCredentialsMsg); + return savedDevice; } diff --git a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java new file mode 100644 index 0000000000..268e19345c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java @@ -0,0 +1,267 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class CalculatedFieldEdgeTest extends AbstractEdgeTest { + private static final String DEFAULT_CF_NAME = "Edge Test CalculatedField"; + private static final String UPDATED_CF_NAME = "Updated Edge Test CalculatedField"; + + @Test + public void testCalculatedField_create_update_delete() throws Exception { + Device savedDevice = saveDeviceOnCloudAndVerifyDeliveryToEdge(); + + // create calculatedField + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + CalculatedField calculatedField = createSimpleCalculatedField(savedDevice.getId(), config); + + edgeImitator.expectMessageAmount(1); + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof CalculatedFieldUpdateMsg); + CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = (CalculatedFieldUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, calculatedFieldUpdateMsg.getMsgType()); + Assert.assertEquals(savedCalculatedField.getUuidId().getMostSignificantBits(), calculatedFieldUpdateMsg.getIdMSB()); + Assert.assertEquals(savedCalculatedField.getUuidId().getLeastSignificantBits(), calculatedFieldUpdateMsg.getIdLSB()); + CalculatedField calculatedFieldFromMsg = JacksonUtil.fromString(calculatedFieldUpdateMsg.getEntity(), CalculatedField.class, true); + Assert.assertNotNull(calculatedFieldFromMsg); + + Assert.assertEquals(DEFAULT_CF_NAME, calculatedFieldFromMsg.getName()); + Assert.assertEquals(savedDevice.getId(), calculatedFieldFromMsg.getEntityId()); + Assert.assertEquals(config, calculatedFieldFromMsg.getConfiguration()); + + edgeImitator.expectMessageAmount(1); + savedCalculatedField.setName(UPDATED_CF_NAME); + savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof CalculatedFieldUpdateMsg); + calculatedFieldUpdateMsg = (CalculatedFieldUpdateMsg) latestMessage; + calculatedFieldFromMsg = JacksonUtil.fromString(calculatedFieldUpdateMsg.getEntity(), CalculatedField.class, true); + Assert.assertNotNull(calculatedFieldFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, calculatedFieldUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_CF_NAME, calculatedFieldFromMsg.getName()); + + // delete calculatedField + edgeImitator.expectMessageAmount(1); + doDelete("/api/calculatedField/" + savedCalculatedField.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof CalculatedFieldUpdateMsg); + calculatedFieldUpdateMsg = (CalculatedFieldUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, calculatedFieldUpdateMsg.getMsgType()); + Assert.assertEquals(savedCalculatedField.getUuidId().getMostSignificantBits(), calculatedFieldUpdateMsg.getIdMSB()); + Assert.assertEquals(savedCalculatedField.getUuidId().getLeastSignificantBits(), calculatedFieldUpdateMsg.getIdLSB()); + } + + @Test + public void testSendCalculatedFieldToCloud() throws Exception { + Device savedDevice = saveDeviceOnCloudAndVerifyDeliveryToEdge(); + + // create calculatedField + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + CalculatedField calculatedField = createSimpleCalculatedField(savedDevice.getId(), config); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, calculatedField, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkCalculatedFieldOnCloud(uplinkMsg, uuid, calculatedField.getName()); + } + + @Test + public void testSendCalculatedFieldRequestToCloud() throws Exception { + Device savedDevice = saveDeviceOnCloudAndVerifyDeliveryToEdge(); + + // create calculatedField + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + CalculatedField calculatedField = createSimpleCalculatedField(savedDevice.getId(), config); + + edgeImitator.expectMessageAmount(1); + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + CalculatedFieldRequestMsg.Builder calculatedFieldRequestMsgBuilder = CalculatedFieldRequestMsg.newBuilder(); + calculatedFieldRequestMsgBuilder.setEntityIdMSB(savedDevice.getId().getId().getMostSignificantBits()); + calculatedFieldRequestMsgBuilder.setEntityIdLSB(savedDevice.getId().getId().getLeastSignificantBits()); + calculatedFieldRequestMsgBuilder.setEntityType(savedDevice.getId().getEntityType().name()); + testAutoGeneratedCodeByProtobuf(calculatedFieldRequestMsgBuilder); + + uplinkMsgBuilder.addCalculatedFieldRequestMsg(calculatedFieldRequestMsgBuilder.build()); + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + 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 CalculatedFieldUpdateMsg); + CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = (CalculatedFieldUpdateMsg) latestMessage; + CalculatedField calculatedFieldFromEdge = JacksonUtil.fromString(calculatedFieldUpdateMsg.getEntity(), CalculatedField.class, true); + Assert.assertNotNull(calculatedFieldFromEdge); + Assert.assertEquals(savedCalculatedField, calculatedFieldFromEdge); + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, calculatedFieldUpdateMsg.getMsgType()); + } + + @Test + public void testUpdateCalculatedFieldNameOnCloud() throws Exception { + Device savedDevice = saveDeviceOnCloudAndVerifyDeliveryToEdge(); + + // create calculatedField + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + CalculatedField calculatedField = createSimpleCalculatedField(savedDevice.getId(), config); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, calculatedField, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkCalculatedFieldOnCloud(uplinkMsg, uuid, calculatedField.getName()); + + calculatedField.setName(UPDATED_CF_NAME); + UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, calculatedField, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + + checkCalculatedFieldOnCloud(updatedUplinkMsg, uuid, calculatedField.getName()); + } + + @Test + public void testCalculatedFieldToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { + Device savedDevice = saveDeviceOnCloudAndVerifyDeliveryToEdge(); + + // create calculatedField + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + CalculatedField calculatedField = createSimpleCalculatedField(savedDevice.getId(), config); + + edgeImitator.expectMessageAmount(1); + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg uplinkMsg = getUplinkMsg(uuid, calculatedField, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Optional calculatedFieldUpdateMsgOpt = edgeImitator.findMessageByType(CalculatedFieldUpdateMsg.class); + Assert.assertTrue(calculatedFieldUpdateMsgOpt.isPresent()); + CalculatedFieldUpdateMsg latestCalculatedFieldUpdateMsg = calculatedFieldUpdateMsgOpt.get(); + CalculatedField calculatedFieldFromMsg = JacksonUtil.fromString(latestCalculatedFieldUpdateMsg.getEntity(), CalculatedField.class, true); + Assert.assertNotNull(calculatedFieldFromMsg); + Assert.assertNotEquals(DEFAULT_CF_NAME, calculatedFieldFromMsg.getName()); + + Assert.assertNotEquals(savedCalculatedField.getUuidId(), uuid); + + CalculatedField calculatedFieldFromCloud = doGet("/api/calculatedField/" + uuid, CalculatedField.class); + Assert.assertNotNull(calculatedFieldFromCloud); + Assert.assertNotEquals(DEFAULT_CF_NAME, calculatedFieldFromCloud.getName()); + } + + private CalculatedField createSimpleCalculatedField(EntityId entityId, SimpleCalculatedFieldConfiguration config) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setTenantId(tenantId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName(DEFAULT_CF_NAME); + calculatedField.setDebugSettings(DebugSettings.all()); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + argument.setDefaultValue("12"); // not used because real telemetry value in db is present + config.setArguments(Map.of("T", argument)); + + config.setExpression("(T * 9/5) + 32"); + + Output output = new Output(); + output.setName("fahrenheitTemp"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(2); + config.setOutput(output); + + calculatedField.setConfiguration(config); + + return calculatedField; + } + + private UplinkMsg getUplinkMsg(UUID uuid, CalculatedField calculatedField, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + CalculatedFieldUpdateMsg.Builder calculatedFieldUpdateMsgBuilder = CalculatedFieldUpdateMsg.newBuilder(); + calculatedFieldUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + calculatedFieldUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + calculatedFieldUpdateMsgBuilder.setEntity(JacksonUtil.toString(calculatedField)); + calculatedFieldUpdateMsgBuilder.setMsgType(updateMsgType); + testAutoGeneratedCodeByProtobuf(calculatedFieldUpdateMsgBuilder); + uplinkMsgBuilder.addCalculatedFieldUpdateMsg(calculatedFieldUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + return uplinkMsgBuilder.build(); + } + + private void checkCalculatedFieldOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + CalculatedField calculatedField = doGet("/api/calculatedField/" + uuid, CalculatedField.class); + Assert.assertNotNull(calculatedField); + Assert.assertEquals(resourceTitle, calculatedField.getName()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 8b259cf8fc..16d10d9b6e 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -33,6 +33,7 @@ import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; @@ -352,6 +353,11 @@ public class EdgeImitator { result.add(saveDownlinkMsg(notificationTargetUpdateMsg)); } } + if (downlinkMsg.getCalculatedFieldUpdateMsgCount() > 0) { + for (CalculatedFieldUpdateMsg calculatedFieldUpdateMsg : downlinkMsg.getCalculatedFieldUpdateMsgList()) { + result.add(saveDownlinkMsg(calculatedFieldUpdateMsg)); + } + } if (downlinkMsg.hasEdgeConfiguration()) { result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration())); } 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 dace159774..e21457f65d 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 @@ -49,6 +49,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Random; import java.util.Set; import java.util.UUID; @@ -419,10 +420,11 @@ public class HashPartitionServiceTest { } private HashPartitionService createPartitionService() { - HashPartitionService partitionService = new HashPartitionService(serviceInfoProvider, - routingInfoService, + HashPartitionService partitionService = new HashPartitionService( applicationEventPublisher, - queueRoutingInfoService, + serviceInfoProvider, + Optional.of(routingInfoService), + Optional.of(queueRoutingInfoService), topicService); ReflectionTestUtils.setField(partitionService, "coreTopic", "tb.core"); ReflectionTestUtils.setField(partitionService, "corePartitions", 10); @@ -434,6 +436,7 @@ public class HashPartitionServiceTest { ReflectionTestUtils.setField(partitionService, "edgeTopic", "tb.edge"); ReflectionTestUtils.setField(partitionService, "edgePartitions", 10); ReflectionTestUtils.setField(partitionService, "edqsPartitions", 12); + ReflectionTestUtils.setField(partitionService, "defaultTasksPartitions", 12); partitionService.init(); partitionService.partitionsInit(); return partitionService; diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index f0059e4f6f..c1616a85db 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedField import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +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; @@ -143,7 +144,7 @@ public class SimpleCalculatedFieldStateTest { } @Test - void testPerformCalculationWhenPassedNotNumber() { + void testPerformCalculationWhenPassedString() { state.arguments = new HashMap<>(Map.of( "key1", key1ArgEntry, "key2", new SingleValueArgumentEntry(System.currentTimeMillis() - 9, new StringDataEntry("key2", "string"), 124L), @@ -155,6 +156,23 @@ public class SimpleCalculatedFieldStateTest { .hasMessage("Argument 'key2' is not a number."); } + @Test + void testPerformCalculationWhenPassedBoolean() throws ExecutionException, InterruptedException { + state.arguments = new HashMap<>(Map.of( + "key1", key1ArgEntry, + "key2", new SingleValueArgumentEntry(System.currentTimeMillis() - 9, new BooleanDataEntry("key2", true), 124L),// true is parsed as 1 + "key3", key3ArgEntry + )); + + CalculatedFieldResult result = state.performCalculation(ctx).get(); + + assertThat(result).isNotNull(); + Output output = getCalculatedFieldConfig().getOutput(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35))); + } + @Test void testPerformCalculationWhenDecimalsByDefault() throws ExecutionException, InterruptedException { state.arguments = new HashMap<>(Map.of( diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java index 2c48ed9167..5ea3808468 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java @@ -17,8 +17,15 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -46,7 +53,7 @@ public class SingleValueArgumentEntryTest { } @Test - void testUpdateEntryWithThaSameTs() { + void testUpdateEntryWithTheSameTs() { assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L))).isFalse(); } @@ -73,4 +80,39 @@ public class SingleValueArgumentEntryTest { void testUpdateEntryWhenValueWasNotChanged() { assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L))).isTrue(); } -} \ No newline at end of file + + @Test + void testUpdateEntryWithOldTs() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts - 10, new LongDataEntry("key", 14L), 365L))).isFalse(); + } + + @Test + void testToTbelCfArgWhenJsonIsObject() { + entry = new SingleValueArgumentEntry(ts, new JsonDataEntry("key", "{\"test\": 10}"), 370L); + TbelCfArg tbelCfArg = entry.toTbelCfArg(); + assertThat(tbelCfArg).isNotNull(); + assertThat(tbelCfArg).isInstanceOf(TbelCfSingleValueArg.class); + + TbelCfSingleValueArg singleValueArg = (TbelCfSingleValueArg) tbelCfArg; + + assertThat(singleValueArg.getValue()).isInstanceOf(Map.class); + Map expectedMap = Map.of("test", 10); + assertThat(singleValueArg.getValue()).isEqualTo(expectedMap); + } + + @Test + void testToTbelCfArgWhenJsonIsArray() { + entry = new SingleValueArgumentEntry(ts, new JsonDataEntry("key", "[{\"test\": 10}, {\"test2\": 20}]"), 371L); + TbelCfArg tbelCfArg = entry.toTbelCfArg(); + assertThat(tbelCfArg).isNotNull(); + assertThat(tbelCfArg).isInstanceOf(TbelCfSingleValueArg.class); + + TbelCfSingleValueArg singleValueArg = (TbelCfSingleValueArg) tbelCfArg; + + assertThat(singleValueArg.getValue()).isInstanceOf(List.class); + List> expectedList = new ArrayList<>(); + expectedList.add(Map.of("test", 10)); + expectedList.add(Map.of("test2", 20)); + assertThat(singleValueArg.getValue()).isEqualTo(expectedList); + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java index 5264e69bd3..2f244807bd 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java @@ -33,7 +33,7 @@ import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; -import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edqs.util.EdqsRocksDb; @@ -53,19 +53,20 @@ import static org.awaitility.Awaitility.await; "queue.edqs.sync.enabled=true", "queue.edqs.api.supported=true", "queue.edqs.api.auto_enable=true", - "queue.edqs.mode=local" + "queue.edqs.mode=local", + "queue.edqs.readiness_check_interval=1000" }) public class EdqsEntityServiceTest extends EntityServiceTest { @Autowired - private EdqsApiService edqsApiService; + private EdqsService edqsService; @MockBean private EdqsRocksDb edqsRocksDb; @Before public void beforeEach() { - await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsApiService.isEnabled()); + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsService.isApiEnabled()); } // sql implementation has a bug with data duplication, edqs implementation returns correct value diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index 264ac70443..14f90822bf 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.IdBased; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; @@ -53,6 +54,7 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; import org.thingsboard.server.common.data.query.AssetTypeFilter; @@ -216,7 +218,7 @@ public class EntityServiceTest extends AbstractControllerTest { createTestHierarchy(tenantId, assets, devices, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); EntityCountQuery countQuery = new EntityCountQuery(filter); @@ -225,13 +227,13 @@ public class EntityServiceTest extends AbstractControllerTest { filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Contains", Collections.singletonList(EntityType.DEVICE)))); countByQueryAndCheck(countQuery, 25); - filter.setRootEntity(devices.get(0).getId()); + filter.setRootEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); filter.setDirection(EntitySearchDirection.TO); filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Manages", Collections.singletonList(EntityType.TENANT)))); countByQueryAndCheck(countQuery, 1); DeviceSearchQueryFilter filter2 = new DeviceSearchQueryFilter(); - filter2.setRootEntity(tenantId); + filter2.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter2.setDirection(EntitySearchDirection.FROM); filter2.setRelationType("Contains"); @@ -241,12 +243,12 @@ public class EntityServiceTest extends AbstractControllerTest { filter2.setDeviceTypes(Arrays.asList("default0", "default1")); countByQueryAndCheck(countQuery, 10); - filter2.setRootEntity(devices.get(0).getId()); + filter2.setRootEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); filter2.setDirection(EntitySearchDirection.TO); countByQueryAndCheck(countQuery, 0); AssetSearchQueryFilter filter3 = new AssetSearchQueryFilter(); - filter3.setRootEntity(tenantId); + filter3.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter3.setDirection(EntitySearchDirection.FROM); filter3.setRelationType("Manages"); @@ -256,7 +258,7 @@ public class EntityServiceTest extends AbstractControllerTest { filter3.setAssetTypes(Arrays.asList("type0", "type1")); countByQueryAndCheck(countQuery, 2); - filter3.setRootEntity(devices.get(0).getId()); + filter3.setRootEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); filter3.setDirection(EntitySearchDirection.TO); countByQueryAndCheck(countQuery, 0); } @@ -267,7 +269,7 @@ public class EntityServiceTest extends AbstractControllerTest { createTestUserRelations(tenantId, users); RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); @@ -351,7 +353,7 @@ public class EntityServiceTest extends AbstractControllerTest { } EdgeSearchQueryFilter filter = new EdgeSearchQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setRelationType("Manages"); @@ -395,7 +397,7 @@ public class EntityServiceTest extends AbstractControllerTest { 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)); @@ -403,7 +405,7 @@ public class EntityServiceTest extends AbstractControllerTest { Futures.allAsList(attributeFutures).get(); RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Contains", Collections.singletonList(EntityType.DEVICE)))); filter.setMaxLevel(maxLevel); @@ -545,7 +547,7 @@ public class EntityServiceTest extends AbstractControllerTest { 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)); @@ -553,7 +555,7 @@ public class EntityServiceTest extends AbstractControllerTest { Futures.allAsList(attributeFutures).get(); DeviceSearchQueryFilter filter = new DeviceSearchQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setRelationType("Contains"); filter.setMaxLevel(2); @@ -599,7 +601,7 @@ public class EntityServiceTest extends AbstractControllerTest { 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)); @@ -607,7 +609,7 @@ public class EntityServiceTest extends AbstractControllerTest { Futures.allAsList(attributeFutures).get(); AssetSearchQueryFilter filter = new AssetSearchQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setRelationType("Manages"); @@ -1100,7 +1102,7 @@ public class EntityServiceTest extends AbstractControllerTest { } SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(devices.get(0).getId()); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); List entityFields = List.of( new EntityKey(EntityKeyType.ENTITY_FIELD, "name") @@ -1119,7 +1121,7 @@ public class EntityServiceTest extends AbstractControllerTest { @Test public void testFindCustomerBySingleEntityFilter() { SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(customerId); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(customerId)); List entityFields = List.of( new EntityKey(EntityKeyType.ENTITY_FIELD, "name") ); @@ -1191,7 +1193,7 @@ public class EntityServiceTest extends AbstractControllerTest { List keyFiltersEqualString = createStringKeyFilters("name", EntityKeyType.ENTITY_FIELD, StringOperation.STARTS_WITH, "Test device "); for (Asset asset : assets) { - filter.setRootEntity(asset.getId()); + filter.setRootEntity(AliasEntityId.fromEntityId(asset.getId())); EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); findByQueryAndCheck(customer.getId(), query, relationsCnt); @@ -1383,7 +1385,7 @@ public class EntityServiceTest extends AbstractControllerTest { } SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(customerDevices.get(0).getId()); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(customerDevices.get(0).getId())); List entityFields = List.of( new EntityKey(EntityKeyType.ENTITY_FIELD, "name") ); @@ -1402,7 +1404,7 @@ public class EntityServiceTest extends AbstractControllerTest { // try to find tenant device by customer user SingleEntityFilter tenantDeviceFilter = new SingleEntityFilter(); - tenantDeviceFilter.setSingleEntity(tenantDevices.get(0).getId()); + tenantDeviceFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantDevices.get(0).getId())); EntityDataQuery customerQuery2 = new EntityDataQuery(tenantDeviceFilter, pageLink, entityFields, null, null); findByQueryAndCheck(customerId, customerQuery2, 0); } @@ -1506,7 +1508,7 @@ public class EntityServiceTest extends AbstractControllerTest { } } - 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()) { @@ -1578,7 +1580,7 @@ public class EntityServiceTest extends AbstractControllerTest { } } - 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)); @@ -1808,7 +1810,7 @@ public class EntityServiceTest extends AbstractControllerTest { } } - 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)); @@ -2269,16 +2271,16 @@ public class EntityServiceTest extends AbstractControllerTest { 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(tenantId, entityId, scope, Collections.singletonList(attr)); + return attributesService.save(tenantId, entityId, scope, List.of(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(tenantId, entityId, scope, Collections.singletonList(attr)); + return attributesService.save(tenantId, entityId, scope, List.of(attr)); } private ListenableFuture saveTimeseries(EntityId entityId, String key, Double value) { @@ -2294,8 +2296,8 @@ public class EntityServiceTest extends AbstractControllerTest { } protected void createMultiRootHierarchy(List buildings, List apartments, - Map> entityNameByTypeMap, - Map childParentRelationMap) throws InterruptedException { + Map> entityNameByTypeMap, + Map childParentRelationMap) throws InterruptedException { for (int k = 0; k < 3; k++) { Asset building = new Asset(); building.setTenantId(tenantId); diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java new file mode 100644 index 0000000000..2321446b44 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java @@ -0,0 +1,268 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.entitiy.ai; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.UUID; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class DefaultTbAiModelServiceTest { + + @Mock + EntitiesVersionControlService vcServiceMock; + + @Mock + AiModelService aiModelServiceMock; + + @Mock + TbLogEntityActionService logEntityActionServiceMock; + + @Spy + @InjectMocks + DefaultTbAiModelService service; + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + User user; + + @BeforeEach + void setUp() { + user = new User(); + user.setTenantId(tenantId); + + service = new DefaultTbAiModelService(aiModelServiceMock); + ReflectionTestUtils.setField(service, "vcService", vcServiceMock); + ReflectionTestUtils.setField(service, "logEntityActionService", logEntityActionServiceMock); + } + + @Test + void save_whenCreatingNewModel_shouldAutoCommitAndLogAddedActionAndUseTenantIdFromUser() { + // GIVEN + var modelToSave = AiModel.builder() + .name("Model to save") + .configuration(constructValidOpenAiModelConfig()) + .build(); + + var savedModel = new AiModel(modelToSave); + savedModel.setId(new AiModelId(UUID.randomUUID())); + savedModel.setTenantId(user.getTenantId()); + savedModel.setVersion(1L); + savedModel.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.save(modelToSave)).willReturn(savedModel); + + // WHEN + AiModel result = service.save(modelToSave, user); + + // THEN + assertThat(result).isEqualTo(savedModel); + + then(aiModelServiceMock).should().save(modelToSave); + then(vcServiceMock).should().autoCommit(user, savedModel.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, savedModel.getId(), savedModel, ActionType.ADDED, user); + } + + @Test + void save_whenUpdatingExistingModel_shouldAutoCommitAndLogUpdatedAction() { + // GIVEN + var modelToUpdate = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to update") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToUpdate.setId(new AiModelId(UUID.randomUUID())); + modelToUpdate.setCreatedTime(System.currentTimeMillis()); + + var updatedModel = new AiModel(modelToUpdate); + updatedModel.setVersion(2L); + updatedModel.setName("Updated model"); + + given(aiModelServiceMock.save(modelToUpdate)).willReturn(updatedModel); + + // WHEN + AiModel result = service.save(modelToUpdate, user); + + // THEN + assertThat(result).isEqualTo(updatedModel); + + then(aiModelServiceMock).should().save(modelToUpdate); + then(vcServiceMock).should().autoCommit(user, updatedModel.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, updatedModel.getId(), updatedModel, ActionType.UPDATED, user); + } + + @Test + void save_whenCreatingNewModelThrowsException_shouldUseEmptyIdAndLogError() { + // GIVEN + var modelToSave = AiModel.builder() + .tenantId(tenantId) + .name("Model to save") + .configuration(constructValidOpenAiModelConfig()) + .build(); + + var exception = new RuntimeException("Failed to save"); + + given(aiModelServiceMock.save(modelToSave)).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.save(modelToSave, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to save"); + + then(aiModelServiceMock).should().save(modelToSave); + then(vcServiceMock).should(never()).autoCommit(any(), any()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, new AiModelId(EntityId.NULL_UUID), modelToSave, ActionType.ADDED, user, exception); + } + + @Test + void save_whenUpdatingExistingModelThrowsException_shouldUseExistingModelIdAndLogError() { + // GIVEN + var modelToUpdate = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to update") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToUpdate.setId(new AiModelId(UUID.randomUUID())); + modelToUpdate.setCreatedTime(System.currentTimeMillis()); + + var exception = new RuntimeException("Failed to save"); + + given(aiModelServiceMock.save(modelToUpdate)).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.save(modelToUpdate, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to save"); + + then(aiModelServiceMock).should().save(modelToUpdate); + then(vcServiceMock).should(never()).autoCommit(any(), any()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToUpdate.getId(), modelToUpdate, ActionType.UPDATED, user, exception); + } + + @Test + void delete_whenDeleteSuccessful_shouldLogDeletedAction() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(true); + + // WHEN + boolean result = service.delete(modelToDelete, user); + + // THEN + assertThat(result).isTrue(); + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString()); + } + + @Test + void delete_whenDeleteReturnsFalse_shouldNotLogAction() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(false); + + // WHEN + boolean result = service.delete(modelToDelete, user); + + // THEN + assertThat(result).isFalse(); + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should(never()).logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString()); + } + + @Test + void delete_whenDeleteThrowsException_shouldLogError() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + var exception = new RuntimeException("Failed to delete"); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.delete(modelToDelete, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to delete"); + + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, exception, modelToDelete.getId().toString()); + } + + private static AiModelConfig constructValidOpenAiModelConfig() { + return OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java new file mode 100644 index 0000000000..0069310b5d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java @@ -0,0 +1,503 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import lombok.SneakyThrows; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.rule.engine.api.JobManager; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.DummyJobConfiguration; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobFilter; +import org.thingsboard.server.common.data.job.JobResult; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.job.task.DummyTaskResult; +import org.thingsboard.server.common.data.job.task.DummyTaskResult.DummyTaskFailure; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.job.JobDao; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.queue.task.JobStatsService; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +@TestPropertySource(properties = { + "queue.tasks.stats.processing_interval=0" +}) +public class JobManagerTest extends AbstractControllerTest { + + @Autowired + private JobManager jobManager; + + @SpyBean + private TestTaskProcessor taskProcessor; + + @SpyBean + private JobStatsService jobStatsService; + + @Autowired + private JobDao jobDao; + + private TenantId tenantId; + private Device jobEntity; + + @Before + public void setUp() throws Exception { + loginTenantAdmin(); + tenantId = super.tenantId; + jobEntity = createDevice("Test", "Test"); + } + + @After + public void tearDown() throws Exception { + } + + @Test + public void testSubmitJob_allTasksSuccessful() { + int tasksCount = 7; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(tasksCount) + .taskProcessingTimeMs(1000) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.RUNNING); + assertThat(job.getResult().getSuccessfulCount()).isBetween(1, tasksCount - 1); + assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); + }); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(job.getResult().getSuccessfulCount()).isEqualTo(tasksCount); + assertThat(job.getResult().getResults()).isEmpty(); + assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount); + }); + } + + @Test + public void testSubmitJob_someTasksPermanentlyFailed() { + int successfulTasks = 3; + int failedTasks = 2; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(successfulTasks) + .failedTasksCount(failedTasks) + .errors(List.of("error1", "error2", "error3")) + .retries(2) + .taskProcessingTimeMs(100) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + JobResult jobResult = job.getResult(); + assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks); + assertThat(jobResult.getFailedCount()).isEqualTo(failedTasks); + assertThat(jobResult.getTotalCount()).isEqualTo(successfulTasks + failedTasks); + assertThat(getFailures(jobResult)).hasSize(2).allSatisfy(failure -> { + assertThat(failure.getError()).isEqualTo("error3"); // last error + }); + assertThat(jobResult.getCompletedCount()).isEqualTo(jobResult.getTotalCount()); + }); + } + + @Test + public void testSubmitJob_taskTimeout() { + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(1) + .taskProcessingTimeMs(5000) // bigger than DummyTaskProcessor.getTaskProcessingTimeout() + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + JobResult jobResult = job.getResult(); + assertThat(jobResult.getFailedCount()).isEqualTo(1); + assertThat(((DummyTaskResult) jobResult.getResults().get(0)).getFailure().getError()).isEqualTo("Timeout after 2000 ms"); // last error + }); + } + + @Test + public void testCancelJob_whileRunning() throws Exception { + int tasksCount = 200; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(tasksCount) + .taskProcessingTimeMs(50) + .build()).getId(); + + Thread.sleep(500); + cancelJob(jobId); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.CANCELLED); + assertThat(job.getResult().getDiscardedCount()).isBetween(1, tasksCount); + assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); + assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount); + }); + } + + @Test + public void testCancelJob_whileTaskRunning() throws Exception { + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(1) + .taskProcessingTimeMs(TimeUnit.HOURS.toMillis(1)) + .taskProcessingTimeoutMs(TimeUnit.HOURS.toMillis(1)) + .build()).getId(); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(taskProcessor.getCurrentTasks()).isNotEmpty(); + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.RUNNING); + assertThat(job.getResult().getTotalCount()).isEqualTo(1); + assertThat(job.getResult().getCompletedCount()).isZero(); + }); + + cancelJob(jobId); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.CANCELLED); + assertThat(job.getResult().getTotalCount()).isEqualTo(1); + assertThat(job.getResult().getDiscardedCount()).isEqualTo(1); + assertThat(job.getResult().getFailedCount()).isZero(); + }); + } + + @Test + public void testCancelJob_simulateTaskProcessorRestart() throws Exception { + int tasksCount = 10; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(tasksCount) + .taskProcessingTimeMs(500) + .build()).getId(); + + // simulate cancelled jobs are forgotten + AtomicInteger cancellationRenotifyAttempt = new AtomicInteger(0); + doAnswer(inv -> { + if (cancellationRenotifyAttempt.incrementAndGet() >= 5) { + inv.callRealMethod(); + } + return null; + }).when(taskProcessor).addToDiscarded(any()); // ignoring cancellation event, + cancelJob(jobId); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.CANCELLED); + assertThat(job.getResult().getSuccessfulCount()).isBetween(1, tasksCount - 1); + assertThat(job.getResult().getDiscardedCount()).isBetween(1, tasksCount - 1); + assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); + assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount); + }); + } + + @Test + public void whenTenantIsDeleted_thenCancelAllTheJobs() throws Exception { + loginSysAdmin(); + createDifferentTenant(); + + this.tenantId = this.differentTenantId; + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(1000) + .taskProcessingTimeMs(500) + .build()); + + Thread.sleep(2000); + deleteDifferentTenant(); + Mockito.reset(jobStatsService); + + Thread.sleep(3000); + verify(jobStatsService, never()).reportTaskResult(any(), any(), any()); + assertThat(jobDao.findByTenantIdAndFilter(tenantId, JobFilter.builder().build(), new PageLink(100)).getData()).isEmpty(); + } + + @Test + public void testSubmitMultipleJobs() throws Exception { + int tasksCount = 3; + int jobsCount = 3; + for (int i = 1; i <= jobsCount; i++) { + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(tasksCount) + .taskProcessingTimeMs(1000) + .build(), "test-job-" + i); + } + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + List jobs = findJobs(List.of(JobType.DUMMY), List.of(jobEntity.getUuidId())); + assertThat(jobs).hasSize(jobsCount); + Job firstJob = jobs.get(2); // ordered by createdTime descending + assertThat(firstJob.getStatus()).isEqualTo(JobStatus.RUNNING); + Job secondJob = jobs.get(1); + assertThat(secondJob.getStatus()).isEqualTo(JobStatus.QUEUED); + Job thirdJob = jobs.get(0); + assertThat(thirdJob.getStatus()).isEqualTo(JobStatus.QUEUED); + }); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + List jobs = findJobs(); + for (Job job : jobs) { + assertThat(job.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(job.getResult().getSuccessfulCount()).isEqualTo(tasksCount); + assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); + assertThat(job.getEntityId()).isEqualTo(jobEntity.getId()); + assertThat(job.getEntityName()).isEqualTo(jobEntity.getName()); + } + }); + + doDelete("/api/device/" + jobEntity.getId()).andExpect(status().isOk()); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(findJobs(List.of(JobType.DUMMY), List.of(jobEntity.getUuidId()))).isEmpty(); + }); + } + + @Test + public void testCancelQueuedJob() throws Exception { + int tasksCount = 3; + int jobsCount = 3; + List jobIds = new ArrayList<>(); + for (int i = 1; i <= jobsCount; i++) { + Job job = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(tasksCount) + .taskProcessingTimeMs(1000) + .build(), "test-job-" + i); + jobIds.add(job.getId()); + } + + for (int i = 1; i < jobIds.size(); i++) { + cancelJob(jobIds.get(i)); + } + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + List jobs = findJobs(); + + Job firstJob = jobs.get(2); + assertThat(firstJob.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(firstJob.getResult().getSuccessfulCount()).isEqualTo(tasksCount); + assertThat(firstJob.getResult().getTotalCount()).isEqualTo(tasksCount); + + Job secondJob = jobs.get(1); + assertThat(secondJob.getStatus()).isEqualTo(JobStatus.CANCELLED); + assertThat(secondJob.getResult().getCompletedCount()).isZero(); + + Job thirdJob = jobs.get(0); + assertThat(thirdJob.getStatus()).isEqualTo(JobStatus.CANCELLED); + assertThat(thirdJob.getResult().getCompletedCount()).isZero(); + }); + } + + @Test + public void testSubmitJob_generalError() { + int submittedTasks = 100; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .generalError("Some error while submitting tasks") + .submittedTasksBeforeGeneralError(submittedTasks) + .taskProcessingTimeMs(10) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + assertThat(job.getResult().getSuccessfulCount()).isBetween(1, submittedTasks); + assertThat(job.getResult().getDiscardedCount()).isZero(); + assertThat(job.getResult().getTotalCount()).isNull(); + }); + } + + @Test + public void testSubmitJob_immediateGeneralError() { + JobId jobId = submitJob(DummyJobConfiguration.builder() + .generalError("Some error while submitting tasks") + .submittedTasksBeforeGeneralError(0) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + assertThat(job.getResult().getSuccessfulCount()).isZero(); + assertThat(job.getResult().getDiscardedCount()).isZero(); + assertThat(job.getResult().getFailedCount()).isZero(); + assertThat(job.getResult().getTotalCount()).isNull(); + }); + } + + @Test + public void testReprocessJob_generalError() throws Exception { + int submittedTasks = 100; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .generalError("Some error while submitting tasks") + .submittedTasksBeforeGeneralError(submittedTasks) + .taskProcessingTimeMs(10) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + assertThat(job.getResult().getGeneralError()).isEqualTo("Some error while submitting tasks"); + }); + + Job savedJob = jobDao.findById(tenantId, jobId.getId()); + DummyJobConfiguration configuration = savedJob.getConfiguration(); + configuration.setGeneralError(null); + configuration.setSuccessfulTasksCount(submittedTasks); + jobDao.save(tenantId, savedJob); + + reprocessJob(jobId); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(job.getResult().getGeneralError()).isNull(); + assertThat(job.getResult().getSuccessfulCount()).isEqualTo(submittedTasks); + assertThat(job.getResult().getTotalCount()).isEqualTo(submittedTasks); + assertThat(job.getResult().getFailedCount()).isZero(); + assertThat(job.getResult().getDiscardedCount()).isZero(); + }); + } + + @Test + public void testReprocessJob() throws Exception { + int successfulTasks = 3; + int failedTasks = 2; + int totalTasksCount = successfulTasks + failedTasks; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(successfulTasks) + .failedTasksCount(failedTasks) + .errors(List.of("error")) + .taskProcessingTimeMs(100) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + JobResult jobResult = job.getResult(); + assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks); + assertThat(jobResult.getFailedCount()).isEqualTo(failedTasks); + + List failures = getFailures(jobResult); + for (int i = 0, taskNumber = successfulTasks + 1; taskNumber <= totalTasksCount; i++, taskNumber++) { + DummyTaskFailure failure = failures.get(i); + assertThat(failure.getNumber()).isEqualTo(taskNumber); + assertThat(failure.getError()).isEqualTo("error"); + } + }); + + reprocessJob(jobId); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(job.getResult().getSuccessfulCount()).isEqualTo(totalTasksCount); + assertThat(job.getResult().getFailedCount()).isZero(); + assertThat(job.getResult().getTotalCount()).isEqualTo(totalTasksCount); + assertThat(job.getResult().getResults()).isEmpty(); + assertThat(job.getConfiguration().getToReprocess()).isNullOrEmpty(); + }); + } + + @Test + public void testReprocessJob_somePermanentlyFailed() throws Exception { + int successfulTasks = 3; + int failedTasks = 2; + int permanentlyFailedTasks = 1; + int totalTasksCount = successfulTasks + failedTasks + permanentlyFailedTasks; + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(successfulTasks) + .failedTasksCount(failedTasks) + .permanentlyFailedTasksCount(permanentlyFailedTasks) + .errors(List.of("error")) + .taskProcessingTimeMs(100) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + JobResult jobResult = job.getResult(); + assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks); + assertThat(jobResult.getFailedCount()).isEqualTo(failedTasks + permanentlyFailedTasks); + assertThat(jobResult.getTotalCount()).isEqualTo(totalTasksCount); + + List failures = getFailures(jobResult); + for (int i = 0, taskNumber = successfulTasks + 1; taskNumber <= totalTasksCount; i++, taskNumber++) { + DummyTaskFailure failure = failures.get(i); + assertThat(failure.getNumber()).isEqualTo(taskNumber); + assertThat(failure.getError()).isEqualTo("error"); + } + }); + + reprocessJob(jobId); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.FAILED); + JobResult jobResult = job.getResult(); + assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks + failedTasks); + assertThat(jobResult.getFailedCount()).isEqualTo(permanentlyFailedTasks); + assertThat(jobResult.getTotalCount()).isEqualTo(totalTasksCount); + + List failures = getFailures(jobResult); + for (int i = 0, taskNumber = successfulTasks + failedTasks + 1; taskNumber <= totalTasksCount; i++, taskNumber++) { + DummyTaskFailure failure = failures.get(i); + assertThat(failure.getNumber()).isEqualTo(taskNumber); + assertThat(failure.getError()).isEqualTo("error"); + assertThat(failure.isFailAlways()).isTrue(); + } + }); + } + + private Job submitJob(DummyJobConfiguration configuration) { + return submitJob(configuration, "test-job"); + } + + @SneakyThrows + private Job submitJob(DummyJobConfiguration configuration, String key) { + return jobManager.submitJob(Job.builder() + .tenantId(tenantId) + .type(JobType.DUMMY) + .key(key) + .entityId(jobEntity.getId()) + .configuration(configuration) + .build()).get(); + } + + private List getFailures(JobResult jobResult) { + return jobResult.getResults().stream() + .map(taskResult -> ((DummyTaskResult) taskResult).getFailure()) + .sorted(Comparator.comparingInt(DummyTaskFailure::getNumber)) + .toList(); + } + +} \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java new file mode 100644 index 0000000000..59093ad802 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +@TestPropertySource(properties = { + "queue.tasks.stats.processing_interval=0", + "queue.tasks.partitioning_strategy=entity", + "queue.tasks.partitions_per_type=DUMMY:100;DUMMY:50" +}) +public class JobManagerTest_EntityPartitioningStrategy extends JobManagerTest { + + /* + * Some tests are overridden because they are based on + * tenant partitioning strategy (subsequent tasks processing within a tenant) + * */ + + @Override + public void testCancelJob_simulateTaskProcessorRestart() throws Exception { + } + + @Override + public void testSubmitJob_generalError() { + + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/job/TestTaskProcessor.java b/application/src/test/java/org/thingsboard/server/service/job/TestTaskProcessor.java new file mode 100644 index 0000000000..fdaec648b5 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/job/TestTaskProcessor.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.service.job.task.DummyTaskProcessor; + +@Component +public class TestTaskProcessor extends DummyTaskProcessor { +} diff --git a/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java b/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java index 3eb5793c74..674b7bfdb9 100644 --- a/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java @@ -52,7 +52,7 @@ public class RateLimitServiceTest { public void beforeEach() { tenantProfileCache = Mockito.mock(DefaultTbTenantProfileCache.class); rateLimitService = new DefaultRateLimitService(tenantProfileCache, mock(NotificationRuleProcessor.class), 60, 100); - tenantId = new TenantId(UUID.randomUUID()); + tenantId = TenantId.fromUUID(UUID.randomUUID()); } @Test @@ -67,7 +67,10 @@ public class RateLimitServiceTest { profileConfiguration.setTenantServerRestLimitsConfiguration(rateLimit); profileConfiguration.setCustomerServerRestLimitsConfiguration(rateLimit); profileConfiguration.setWsUpdatesPerSessionRateLimit(rateLimit); - profileConfiguration.setCassandraQueryTenantRateLimitsConfiguration(rateLimit); + profileConfiguration.setCassandraReadQueryTenantCoreRateLimits(rateLimit); + profileConfiguration.setCassandraWriteQueryTenantCoreRateLimits(rateLimit); + profileConfiguration.setCassandraReadQueryTenantRuleEngineRateLimits(rateLimit); + profileConfiguration.setCassandraWriteQueryTenantRuleEngineRateLimits(rateLimit); profileConfiguration.setEdgeEventRateLimits(rateLimit); profileConfiguration.setEdgeEventRateLimitsPerEdge(rateLimit); profileConfiguration.setEdgeUplinkMessagesRateLimits(rateLimit); @@ -79,7 +82,10 @@ public class RateLimitServiceTest { LimitedApi.ENTITY_IMPORT, LimitedApi.NOTIFICATION_REQUESTS, LimitedApi.REST_REQUESTS_PER_CUSTOMER, - LimitedApi.CASSANDRA_QUERIES, + LimitedApi.CASSANDRA_READ_QUERIES_CORE, + LimitedApi.CASSANDRA_WRITE_QUERIES_CORE, + LimitedApi.CASSANDRA_READ_QUERIES_RULE_ENGINE, + LimitedApi.CASSANDRA_WRITE_QUERIES_RULE_ENGINE, LimitedApi.EDGE_EVENTS, LimitedApi.EDGE_EVENTS_PER_EDGE, LimitedApi.EDGE_UPLINK_MESSAGES, @@ -88,6 +94,13 @@ public class RateLimitServiceTest { testRateLimits(limitedApi, max, tenantId); } + for (LimitedApi limitedApi : List.of( + LimitedApi.CASSANDRA_READ_QUERIES_MONOLITH, + LimitedApi.CASSANDRA_WRITE_QUERIES_MONOLITH + )) { + testRateLimits(limitedApi, max * 2, tenantId); + } + CustomerId customerId = new CustomerId(UUID.randomUUID()); testRateLimits(LimitedApi.REST_REQUESTS_PER_CUSTOMER, max, customerId); 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 b7e4013bb9..f238a1bbc1 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 @@ -21,7 +21,6 @@ import org.junit.After; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.util.Pair; -import org.springframework.jdbc.core.JdbcTemplate; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.server.common.data.User; @@ -89,8 +88,6 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest protected SqlPartitioningRepository partitioningRepository; @Autowired protected DefaultNotifications defaultNotifications; - @Autowired - private JdbcTemplate jdbcTemplate; public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test"; public static final NotificationType DEFAULT_NOTIFICATION_TYPE = NotificationType.GENERAL; @@ -101,7 +98,6 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest notificationRuleService.deleteNotificationRulesByTenantId(TenantId.SYS_TENANT_ID); notificationTemplateService.deleteNotificationTemplatesByTenantId(TenantId.SYS_TENANT_ID); notificationTargetService.deleteNotificationTargetsByTenantId(TenantId.SYS_TENANT_ID); - jdbcTemplate.execute("TRUNCATE TABLE notification"); partitioningRepository.cleanupPartitionsCache("notification", Long.MAX_VALUE, 0); notificationSettingsService.deleteNotificationSettings(TenantId.SYS_TENANT_ID); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index b905f0e43d..bab70ea505 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -68,6 +68,8 @@ import org.thingsboard.server.common.data.notification.rule.NotificationRule; import org.thingsboard.server.common.data.notification.rule.NotificationRuleInfo; import org.thingsboard.server.common.data.notification.rule.trigger.NewPlatformVersionTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.RateLimitsTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.ResourcesShortageTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.ResourcesShortageTrigger.Resource; import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmAssignmentNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmCommentNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmNotificationRuleTriggerConfig; @@ -78,6 +80,7 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.Entit 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.config.RateLimitsNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.config.ResourcesShortageNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.platform.AffectedTenantAdministratorsFilter; import org.thingsboard.server.common.data.notification.targets.platform.SystemAdministratorsFilter; @@ -91,6 +94,7 @@ 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.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.controller.TbTestWebSocketClient; import org.thingsboard.server.dao.notification.DefaultNotifications; import org.thingsboard.server.dao.notification.NotificationRequestService; import org.thingsboard.server.dao.rule.RuleChainService; @@ -98,8 +102,10 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.queue.notification.DefaultNotificationDeduplicationService; import org.thingsboard.server.service.notification.rule.cache.DefaultNotificationRulesCache; import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.system.DefaultSystemInfoService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -134,6 +140,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @SpyBean private AlarmSubscriptionService alarmSubscriptionService; @Autowired + private DefaultSystemInfoService systemInfoService; + @Autowired private NotificationRequestService notificationRequestService; @Autowired private RateLimitService rateLimitService; @@ -254,7 +262,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(info.getAlarmStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); - clients.values().forEach(wsClient -> wsClient.registerWaitForUpdate()); + clients.values().forEach(TbTestWebSocketClient::registerWaitForUpdate); alarmSubscriptionService.acknowledgeAlarm(tenantId, alarm.getId(), System.currentTimeMillis()); AlarmStatus expectedStatus = AlarmStatus.ACTIVE_ACK; AlarmSeverity expectedSeverity = AlarmSeverity.CRITICAL; @@ -638,7 +646,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { rule = saveNotificationRule(rule); NotificationRuleInfo ruleInfo = findNotificationRules().getData().get(0); - assertThat(ruleInfo.getId()).isEqualTo(ruleInfo.getId()); + assertThat(ruleInfo.getId()).isEqualTo(rule.getId()); assertThat(ruleInfo.getTemplateName()).isEqualTo(template.getName()); assertThat(ruleInfo.getDeliveryMethods()).containsOnly(deliveryMethods); } @@ -780,6 +788,99 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { }); } + @Test + public void testNotificationRuleProcessing_resourcesShortage() throws Exception { + loginSysAdmin(); + ResourcesShortageNotificationRuleTriggerConfig triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() + .ramThreshold(0.01f) + .cpuThreshold(1f) + .storageThreshold(1f) + .build(); + createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); + loginTenantAdmin(); + + Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); + method.setAccessible(true); + method.invoke(systemInfoService); + + await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); + Notification notification = getMyNotifications(false, 100).get(0); + assertThat(notification.getSubject()).isEqualTo("Warning: RAM shortage"); + assertThat(notification.getText()).isEqualTo("RAM shortage"); + } + + @Test + public void testNotificationsDeduplication_resourcesShortage() throws Exception { + loginSysAdmin(); + ResourcesShortageNotificationRuleTriggerConfig triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() + .ramThreshold(0.01f) + .cpuThreshold(1f) + .storageThreshold(1f) + .build(); + createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); + loginTenantAdmin(); + + assertThat(getMyNotifications(false, 100)).size().isZero(); + for (int i = 0; i < 10; i++) { + notificationRuleProcessor.process(ResourcesShortageTrigger.builder() + .resource(Resource.RAM) + .usage(15L) + .serviceType("serviceType") + .serviceId("serviceId") + .build()); + TimeUnit.MILLISECONDS.sleep(300); + } + await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); + Notification notification = getMyNotifications(false, 100).get(0); + assertThat(notification.getSubject()).isEqualTo("Warning: RAM shortage"); + assertThat(notification.getText()).isEqualTo("RAM shortage"); + + // deduplication is 1 minute, no new message is exp + notificationRuleProcessor.process(ResourcesShortageTrigger.builder() + .resource(Resource.RAM) + .usage(5L) + .serviceType("serviceType") + .serviceId("serviceId") + .build()); + await("").atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertThat(getMyNotifications(false, 100)).size().isOne()); + } + + @Test + public void testNotificationsResourcesShortage_whenThresholdChangeToMatchingFilter_thenSendNotification() throws Exception { + loginSysAdmin(); + ResourcesShortageNotificationRuleTriggerConfig triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() + .ramThreshold(1f) + .cpuThreshold(1f) + .storageThreshold(1f) + .build(); + NotificationRule rule = createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); + loginTenantAdmin(); + + Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); + method.setAccessible(true); + method.invoke(systemInfoService); + + TimeUnit.SECONDS.sleep(5); + assertThat(getMyNotifications(false, 100)).size().isZero(); + + loginSysAdmin(); + triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() + .ramThreshold(0.01f) + .cpuThreshold(1f) + .storageThreshold(1f) + .build(); + rule.setTriggerConfig(triggerConfig); + saveNotificationRule(rule); + loginTenantAdmin(); + + method.invoke(systemInfoService); + + await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); + Notification notification = getMyNotifications(false, 100).get(0); + assertThat(notification.getSubject()).isEqualTo("Warning: RAM shortage"); + assertThat(notification.getText()).isEqualTo("RAM shortage"); + } + @Test public void testNotificationRuleDisabling() throws Exception { EntityActionNotificationRuleTriggerConfig triggerConfig = new EntityActionNotificationRuleTriggerConfig(); diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index dd9c4bebcf..09bd02e5a4 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -467,7 +467,7 @@ public class TbRuleEngineQueueConsumerManagerTest { consumerManager.delete(true); - await().atMost(2, TimeUnit.SECONDS) + await().atMost(5, TimeUnit.SECONDS) .untilAsserted(() -> { verify(ruleEngineMsgProducer).send(any(), any(), any()); }); 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 index 8c019029a5..1106fad5b6 100644 --- 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 @@ -238,7 +238,7 @@ public class TbRuleEngineStrategyTest { .map(this::toProto) .toList(); - consumerManager.processMsgs(protoMsgs, consumer, queue); + consumerManager.processMsgs(protoMsgs, consumer, queueKey, queue); processingData.forEach(data -> { verify(actorContext, times(data.attempts)).tell(argThat(msg -> 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 0942cef75d..b8ab48b38d 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 @@ -25,11 +25,13 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.TbStopWatch; import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.js.NashornJsInvokeService; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; +import javax.script.ScriptException; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -39,6 +41,7 @@ import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; @@ -59,6 +62,25 @@ class NashornJsInvokeServiceTest extends AbstractControllerTest { @Value("${js.local.max_errors}") private int maxJsErrors; + @Test + void givenUncompilableScript_whenEvaluating_thenThrowsErrorWithCompilationErrorCode() { + // GIVEN + var uncompilableScript = "return msg.temperature?.value;"; + + // WHEN-THEN + assertThatThrownBy(() -> evalScript(uncompilableScript)) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getScriptId()).isNotNull(); + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.COMPILATION); + assertThat(ex.getBody()).contains(uncompilableScript); + assertThat(ex.getCause()).isInstanceOf(ScriptException.class); + }); + } + @Test void givenSimpleScriptTestPerformance() throws ExecutionException, InterruptedException { int iterations = 1000; diff --git a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java index 363f21fa10..36990d9768 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java @@ -23,9 +23,9 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.stats.DefaultStatsFactory; import org.thingsboard.server.common.stats.StatsCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.TbApiUsageReportClient; @@ -42,8 +42,11 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutionException; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.doAnswer; @@ -60,7 +63,6 @@ class RemoteJsInvokeServiceTest { private RemoteJsInvokeService remoteJsInvokeService; private TbQueueRequestTemplate, TbProtoQueueMsg> jsRequestTemplate; - @BeforeEach public void beforeEach() { TbApiUsageStateClient apiUsageStateClient = mock(TbApiUsageStateClient.class); @@ -74,7 +76,7 @@ class RemoteJsInvokeServiceTest { remoteJsInvokeService.requestTemplate = jsRequestTemplate; StatsFactory statsFactory = mock(StatsFactory.class); when(statsFactory.createStatsCounter(any(), any())).thenReturn(mock(StatsCounter.class)); - ReflectionTestUtils.setField(remoteJsInvokeService, "statsFactory",statsFactory); + ReflectionTestUtils.setField(remoteJsInvokeService, "statsFactory", statsFactory); remoteJsInvokeService.init(); } @@ -84,7 +86,36 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenInvokingFunction_thenDoNotSendScriptBody() throws Exception { + void givenUncompilableScript_whenEvaluating_thenThrowsErrorWithCompilationErrorCode() { + // GIVEN + doAnswer(methodCall -> Futures.immediateFuture(new TbProtoJsQueueMsg<>(UUID.randomUUID(), RemoteJsResponse.newBuilder() + .setCompileResponse(JsInvokeProtos.JsCompileResponse.newBuilder() + .setSuccess(false) + .setErrorCode(JsInvokeProtos.JsInvokeErrorCode.COMPILATION_ERROR) + .setErrorDetails("SyntaxError: Unexpected token 'const'") + .setScriptHash(methodCall.>getArgument(0).getValue().getCompileRequest().getScriptHash()) + .build()) + .build()))) + .when(jsRequestTemplate).send(argThat(jsQueueMsg -> jsQueueMsg.getValue().hasCompileRequest())); + + var uncompilableScript = "let const = 'this is not allowed';"; + + // WHEN-THEN + assertThatThrownBy(() -> remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, uncompilableScript).get()) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getScriptId()).isNotNull(); + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.COMPILATION); + assertThat(ex.getBody()).contains(uncompilableScript); + assertThat(ex.getCause()).isInstanceOf(RuntimeException.class).hasMessage("SyntaxError: Unexpected token 'const'"); + }); + } + + @Test + void whenInvokingFunction_thenDoNotSendScriptBody() throws Exception { mockJsEvalResponse(); String scriptBody = "return { a: 'b'};"; UUID scriptId = remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptBody).get(); @@ -110,7 +141,7 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenInvokingFunctionAndRemoteJsExecutorRemovedScript_thenHandleNotFoundErrorAndMakeInvokeRequestWithScriptBody() throws Exception { + void whenInvokingFunctionAndRemoteJsExecutorRemovedScript_thenHandleNotFoundErrorAndMakeInvokeRequestWithScriptBody() throws Exception { mockJsEvalResponse(); String scriptBody = "return { a: 'b'};"; UUID scriptId = remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptBody).get(); @@ -156,7 +187,7 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenDoingEval_thenSaveScriptByHashOfTenantIdAndScriptBody() throws Exception { + void whenDoingEval_thenSaveScriptByHashOfTenantIdAndScriptBody() throws Exception { mockJsEvalResponse(); TenantId tenantId1 = TenantId.fromUUID(UUID.randomUUID()); @@ -187,7 +218,7 @@ class RemoteJsInvokeServiceTest { } @Test - public void whenReleasingScript_thenCheckForHashUsages() throws Exception { + void whenReleasingScript_thenCheckForHashUsages() throws Exception { mockJsEvalResponse(); String scriptBody = "return { a: 'b'};"; UUID scriptId1 = remoteJsInvokeService.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptBody).get(); diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java index a90f9f19b0..a7affe6dff 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java @@ -26,8 +26,10 @@ import java.util.Base64; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicReference; @@ -750,6 +752,284 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { assertEquals(expected, actual); } + + // Sets + @Test + public void setsCreateNewSetFromMap_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["B", "A", "C", "A"]} + """; + decoderStr = """ + var originalMap = {}; + var set1 = originalMap.entrySet(); // create new Set from map, Empty + var set2 = set1.clone(); // clone new Set, Empty + var result1 = set1.addAll(msg.list); // addAll list, no sort, size = 3 ("A" - duplicate) + return {set1: set1, + set2: set2, + result1: result1 + } + """; + Set expectedSet1 = new LinkedHashSet(List.of("B", "A", "C", "A")); + Set expectedSet2 = new LinkedHashSet(); + Map expected = new LinkedHashMap<>(); + expected.put("set1", expectedSet1); + expected.put("set2", expectedSet2); + expected.put("result1", true); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void setsCreateNewSetFromCreateSetTbMethod_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["B", "A", "C", "A"]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new Set from toSet() with list, no sort, size = 3 ("A" - duplicate) + var set2 = newSet(); // create new Set from newSet(), Empty + return {set1: set1, + set2: set2 + } + """; + Set expectedSet1 = new LinkedHashSet(List.of("B", "A", "C", "A")); + Set expectedSet2 = new LinkedHashSet(); + Map expected = new LinkedHashMap<>(); + expected.put("set1", expectedSet1); + expected.put("set2", expectedSet2); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void setsForeachForLoop_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["A", "B", "C"]} + """; + decoderStr = """ + var set2 = toSet(msg.list); // create new from list, size = 3 + var set2_0 = set2.toArray()[0]; // return "A", value with index = 0 from Set + var set2Size = set2.size(); // return size = 3 + var smthForeach = ""; + foreach (item : set2) { // foreach for Set + smthForeach += item; // return "ABC" + } + var smthForLoop= ""; + var set2Array = set2.toArray(); // for loop for Set (Set to array)) + for (var i =0; i < set2.size; i++) { + smthForLoop += set2Array[i]; // return "ABC" + } + return { + set2: set2, + set2_0: set2_0, + set2Size: set2Size, + smthForeach: smthForeach, + smthForLoop: smthForLoop + } + """; + Set expectedSet2 = new LinkedHashSet(List.of("A", "B", "C")); + Map expected = new LinkedHashMap<>(); + expected.put("set2", expectedSet2); + expected.put("set2_0", expectedSet2.toArray()[0]); + expected.put("set2Size", expectedSet2.size()); + AtomicReference smth = new AtomicReference<>(""); + expectedSet2.forEach(s -> smth.updateAndGet(v -> v + s)); + expected.put("smthForeach", smth.get()); + expected.put("smthForLoop", smth.get()); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + /** + * add + * delete/remove + * setCreate, setCreatList + */ + @Test + public void setsAddRemove_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["B", "C", "A", "B", "C", "hello", 34567]} + """; + decoderStr = """ + // add + var setAdd = toSet(["thigsboard", 4, 67]); // create new, size = 3 + var setAdd1_value = setAdd.clone(); // clone setAdd, size = 3 + var setAdd2_result = setAdd.add(35); // add value = 35, result = true + var setAdd2_value = setAdd.clone(); // clone setAdd (fixing the result add = 35), size = 4 + var setAddList1 = toSet(msg.list); // create new from list without duplicate value ("B" and "C" - only one), size = 5 + var setAdd3_result = setAdd.addAll(setAddList1); // add all without duplicate values, result = true + var setAdd3_value = setAdd.clone(); // clone setAdd (with addAll), size = 9 + var setAdd4_result = setAdd.add(35); // add duplicate value = 35, result = false + var setAdd4_value = setAdd.clone(); // clone setAdd (after add duplicate value = 35), size = 9 + var setAddList2 = toSet(msg.list); // create new from list without duplicate value ("B" and "C" - only one), start: size = 5, finish: size = 7 + var setAdd5_result1 = setAddList2.add(72); // add is not duplicate value = 72, result = true + var setAdd5_result2 = setAddList2.add(72); // add duplicate value = 72, result = false + var setAdd5_result3 = setAddList2.add("hello25"); // add is not duplicate value = "hello25", result = true + var setAdd5_value = setAddList2.clone(); // clone setAddList2, size = 7 + var setAdd6_result = setAdd.addAll(setAddList2); // add all with duplicate values, result = true + var setAdd6_value = setAdd.clone(); // clone setAdd (after addAll setAddList2), before size = 9, after size = 11, added only is not duplicate values {"hello25", 72} + + // remove + var setAdd7_value = setAdd6_value.clone(); // clone setAdd6_value, before size = 11, after remove value = 4 size = 10 + var setAdd7_result = setAdd7_value.remove(4); // remove value = 4, result = true + var setAdd8_value = setAdd7_value.clone(); // clone setAdd7_value, before size = 10, after clear size = 0 + setAdd8_value.clear(); // setAdd8_value clear, result size = 0 + return { + "setAdd1_value": setAdd1_value, + "setAdd2_result": setAdd2_result, + "setAdd2_value": setAdd2_value, + "setAddList1": setAddList1, + "setAdd3_result": setAdd3_result, + "setAdd3_value": setAdd3_value, + "setAdd4_result": setAdd4_result, + "setAdd4_value": setAdd4_value, + "setAdd5_result1": setAdd5_result1, + "setAdd5_result2": setAdd5_result2, + "setAdd5_result3": setAdd5_result3, + "setAddList2": setAddList2, + "setAdd5_value": setAdd5_value, + "setAdd6_result": setAdd6_result, + "setAdd6_value": setAdd6_value, + "setAdd7_result": setAdd7_result, + "setAdd7_value": setAdd7_value, + "setAdd8_value": setAdd8_value + }; + """; + ArrayList list = new ArrayList<>(List.of("B", "C", "A", "B", "C", "hello", 34567)); + ArrayList listAdd = new ArrayList<>(List.of("thigsboard", 4, 67)); + Set setAdd = new LinkedHashSet<>(listAdd); + Set setAdd1_value = new LinkedHashSet<>(setAdd); + boolean setAdd2_result = setAdd.add(35); + Set setAdd2_value = new LinkedHashSet<>(setAdd); + Set setAddList1 = new LinkedHashSet<>(list); + boolean setAdd3_result = setAdd.addAll(setAddList1); + Set setAdd3_value = new LinkedHashSet<>(setAdd); + boolean setAdd4_result = setAdd.add(35); + Set setAdd4_value = new LinkedHashSet<>(setAdd); + Set setAddList2 = new LinkedHashSet<>(list); + boolean setAdd5_result1 = setAddList2.add(72); + boolean setAdd5_result2 = setAddList2.add(72); + boolean setAdd5_result3 = setAddList2.add("hello25"); + Set setAdd5_value = new LinkedHashSet<>(setAddList2); + boolean setAdd6_result = setAdd.addAll(setAddList2); + Set setAdd6_value = new LinkedHashSet<>(setAdd); + // remove + Set setAdd7_value = new LinkedHashSet<>(setAdd6_value); + boolean setAdd7_result = setAdd7_value.remove(4); + Set setAdd8_value = new LinkedHashSet<>(setAdd7_value); + setAdd8_value.clear(); + + LinkedHashMap expected = new LinkedHashMap<>(); + expected.put("setAdd1_value", setAdd1_value); + expected.put("setAdd2_result", setAdd2_result); + expected.put("setAdd2_value", setAdd2_value); + expected.put("setAddList1", setAddList1); + expected.put("setAdd3_result", setAdd3_result); + expected.put("setAdd3_value", setAdd3_value); + expected.put("setAdd4_result", setAdd4_result); + expected.put("setAdd4_value", setAdd4_value); + expected.put("setAdd5_result1", setAdd5_result1); + expected.put("setAdd5_result2", setAdd5_result2); + expected.put("setAdd5_result3", setAdd5_result3); + expected.put("setAddList2", setAddList2); + expected.put("setAdd5_value", setAdd5_value); + expected.put("setAdd6_result", setAdd6_result); + expected.put("setAdd6_value", setAdd6_value); + expected.put("setAdd7_result", setAdd7_result); + expected.put("setAdd7_value", setAdd7_value); + expected.put("setAdd8_value", setAdd8_value); + + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void setsSort_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var set2 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var set1_asc = set1.clone(); // clone set1, size = 6 + var set1_desc = set1.clone(); // clone set1, size = 6 + set1.sort(); // sort set1 -> asc + set1_asc.sort(true); // sort set1_asc -> asc + set1_desc.sort(false); // sort set1_desc -> desc + var set3 = set2.toSorted(); // toSorted set3 -> asc + var set3_asc = set2.toSorted(true); // toSorted set3 -> asc + var set3_desc = set2.toSorted(false); // toSorted set3 -> desc + return { + "set1": set1, + "set1_asc": set1_asc, + "set1_desc": set1_desc, + "set2": set2, + "set3": set3, + "set3_asc": set3_asc, + "set3_desc": set3_desc, + } + """; + ArrayList list = new ArrayList<>(List.of("C", "B", "A", 34567, "hello", 34)); + Set expected = new LinkedHashSet<>(list); + ArrayList listSortAsc = new ArrayList<>(List.of(34, 34567, "A", "B", "C", "hello")); + Set expectedAsc = new LinkedHashSet<>(listSortAsc); + ArrayList listSortDesc = new ArrayList<>(List.of("hello", "C", "B", "A", 34567, 34)); + Set expectedDesc = new LinkedHashSet<>(listSortDesc); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set1").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set1_asc").toString()); + assertEquals(expectedDesc.toString(), ((LinkedHashMap)actual).get("set1_desc").toString()); + assertEquals(expected.toString(), ((LinkedHashMap)actual).get("set2").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set3").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set3_asc").toString()); + assertEquals(expectedDesc.toString(), ((LinkedHashMap)actual).get("set3_desc").toString()); + } + + @Test + public void setsContains_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var result1 = set1.contains("A"); // return true + var result2 = set1.contains("H"); // return false + return { + "set1": set1, + "result1": result1, + "result2": result2 + } + """; + List listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34)); + Set expectedSet = new LinkedHashSet<>(listOrigin); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expectedSet.toString(), ((LinkedHashMap)actual).get("set1").toString()); + assertEquals(true, ((LinkedHashMap)actual).get("result1")); + assertEquals(false, ((LinkedHashMap)actual).get("result2")); + } + + @Test + public void setsToList_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var tolist = set1.toList(); // create new List from Set, size = 6 + return { + "list": msg.list, + "set1": set1, + "tolist": tolist + } + """; + List listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34)); + Set expectedSet = new LinkedHashSet<>(listOrigin); + List expectedToList = new ArrayList<>(expectedSet); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(listOrigin.toString(), ((LinkedHashMap)actual).get("list").toString()); + assertEquals(expectedSet.toString(), ((LinkedHashMap)actual).get("set1").toString()); + assertEquals(expectedToList.toString(), ((LinkedHashMap)actual).get("tolist").toString()); + } + @Test public void arraysWillCauseArrayIndexOutOfBoundsException_Test() throws ExecutionException, InterruptedException { msgStr = """ @@ -2399,25 +2679,19 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { list.add(0x35); return isList(list); """); + } + + @Test + public void isSet_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + return isSet(toSet(msg.list)); // return true + """; Object actual = invokeScript(evalScript(decoderStr), msgStr); assertInstanceOf(Boolean.class, actual); assertTrue((Boolean) actual); - decoderStr = String.format(""" - var list = []; - list.add(0x35); - return isMap(list); - """); - actual = invokeScript(evalScript(decoderStr), msgStr); - assertInstanceOf(Boolean.class, actual); - assertFalse((Boolean) actual); - decoderStr = String.format(""" - var list = []; - list.add(0x35); - return isArray(list); - """); - actual = invokeScript(evalScript(decoderStr), msgStr); - assertInstanceOf(Boolean.class, actual); - assertFalse((Boolean) actual); } @Test @@ -2435,23 +2709,13 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { Object actual = invokeScript(evalScript(decoderStr), msgStr); assertInstanceOf(Boolean.class, actual); assertTrue((Boolean) actual); - decoderStr = """ - var array = new int[3]; - array[0] = 1; - array[1] = 2; - array[2] = 3; - return isList(array); - """; - actual = invokeScript(evalScript(decoderStr), msgStr); - assertInstanceOf(Boolean.class, actual); - assertFalse((Boolean) actual); } @Test public void isInsidePolygon_Test() throws ExecutionException, InterruptedException { msgStr = "{}"; decoderStr = """ - String perimeter = "[[[37.7810,-122.4210],[37.7890,-122.3900],[37.7700,-122.3800],[37.7600,-122.4000],[37.7700,-122.4250],[37.7810,-122.4210]],[[37.7730,-122.4050],[37.7700,-122.3950],[37.7670,-122.3980],[37.7690,-122.4100],[37.7730,-122.4050]]]"; + var perimeter = "[[[37.7810,-122.4210],[37.7890,-122.3900],[37.7700,-122.3800],[37.7600,-122.4000],[37.7700,-122.4250],[37.7810,-122.4210]],[[37.7730,-122.4050],[37.7700,-122.3950],[37.7670,-122.3980],[37.7690,-122.4100],[37.7730,-122.4050]]]"; return{ outsidePolygon: isInsidePolygon(37.8000, -122.4300, perimeter), insidePolygon: isInsidePolygon(37.7725, -122.4010, perimeter), @@ -2470,7 +2734,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { public void isInsideCircle_Test() throws ExecutionException, InterruptedException { msgStr = "{}"; decoderStr = """ - String perimeter = "{\\"latitude\\":37.7749,\\"longitude\\":-122.4194,\\"radius\\":3000,\\"radiusUnit\\":\\"METER\\"}"; + var perimeter = "{\\"latitude\\":37.7749,\\"longitude\\":-122.4194,\\"radius\\":3000,\\"radiusUnit\\":\\"METER\\"}"; return{ outsideCircle: isInsideCircle(37.8044, -122.2712, perimeter), insideCircle: isInsideCircle(37.7599, -122.4148, perimeter) diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java index 732f31f044..fc66f806d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java @@ -20,10 +20,12 @@ import com.github.benmanes.caffeine.cache.Cache; import org.junit.Assert; import org.junit.Ignore; import org.junit.jupiter.api.Test; +import org.mvel2.CompileException; import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.TbScriptException; import org.thingsboard.script.api.tbel.TbelScript; import java.io.Serializable; @@ -37,6 +39,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; @TestPropertySource(properties = { "tbel.max_script_body_size=100", @@ -50,6 +53,25 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest { @Value("${tbel.max_errors}") private int maxJsErrors; + @Test + void givenUncompilableScript_whenEvaluating_thenThrowsErrorWithCompilationErrorCode() { + // GIVEN + var uncompilableScript = "return msg.property !== undefined;"; + + // WHEN-THEN + assertThatThrownBy(() -> evalScript(uncompilableScript)) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getScriptId()).isNotNull(); + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.COMPILATION); + assertThat(ex.getBody()).isEqualTo(uncompilableScript); + assertThat(ex.getCause()).isInstanceOf(CompileException.class); + }); + } + @Test void givenSimpleScriptTestPerformance() throws ExecutionException, InterruptedException { int iterations = 100000; 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 26e913eacc..5bf87137ff 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 @@ -16,6 +16,9 @@ package org.thingsboard.server.service.state; import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +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; @@ -31,13 +34,13 @@ import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.DeviceIdInfo; 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.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger; -import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; @@ -50,20 +53,22 @@ import org.thingsboard.server.dao.sql.query.EntityQueryRepository; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.discovery.PartitionService; -import org.thingsboard.server.queue.discovery.QueueKey; -import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.usagestats.DefaultTbApiUsageReportClient; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import java.time.Duration; import java.util.Collections; +import java.util.HashSet; import java.util.List; -import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -77,8 +82,8 @@ import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.willReturn; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -90,7 +95,10 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME; @ExtendWith(MockitoExtension.class) -public class DefaultDeviceStateServiceTest { +class DefaultDeviceStateServiceTest { + + ListeningExecutorService deviceStateExecutor; + ListeningExecutorService deviceStateCallbackExecutor; @Mock DeviceService deviceService; @@ -113,25 +121,48 @@ public class DefaultDeviceStateServiceTest { @Mock DefaultTbApiUsageReportClient defaultTbApiUsageReportClient; - TenantId tenantId = new TenantId(UUID.fromString("00797a3b-7aeb-4b5b-b57a-c2a810d0f112")); - DeviceId deviceId = DeviceId.fromString("00797a3b-7aeb-4b5b-b57a-c2a810d0f112"); - TopicPartitionInfo tpi; + long defaultInactivityTimeoutMs = Duration.ofMinutes(10L).toMillis(); + + TenantId tenantId = TenantId.fromUUID(UUID.fromString("00797a3b-7aeb-4b5b-b57a-c2a810d0f112")); + DeviceId deviceId = DeviceId.fromString("c209f718-42e5-11f0-9fe2-0242ac120002"); + TopicPartitionInfo tpi = TopicPartitionInfo.builder() + .topic("tb_core") + .partition(0) + .myPartition(true) + .build(); DefaultDeviceStateService service; @BeforeEach - public void setUp() { + void setUp() { service = spy(new DefaultDeviceStateService(deviceService, attributesService, tsService, clusterService, partitionService, entityQueryRepository, null, defaultTbApiUsageReportClient, notificationRuleProcessor)); ReflectionTestUtils.setField(service, "tsSubService", telemetrySubscriptionService); + ReflectionTestUtils.setField(service, "defaultInactivityTimeoutMs", defaultInactivityTimeoutMs); ReflectionTestUtils.setField(service, "defaultStateCheckIntervalInSec", 60); ReflectionTestUtils.setField(service, "defaultActivityStatsIntervalInSec", 60); - ReflectionTestUtils.setField(service, "initFetchPackSize", 10); + ReflectionTestUtils.setField(service, "initFetchPackSize", 50000); + + deviceStateExecutor = MoreExecutors.newDirectExecutorService(); + ReflectionTestUtils.setField(service, "deviceStateExecutor", deviceStateExecutor); + + deviceStateCallbackExecutor = MoreExecutors.newDirectExecutorService(); + ReflectionTestUtils.setField(service, "deviceStateCallbackExecutor", deviceStateCallbackExecutor); + + lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId)).thenReturn(tpi); + + ConcurrentMap> partitionedEntities = new ConcurrentHashMap<>(); + partitionedEntities.put(tpi, new HashSet<>()); + ReflectionTestUtils.setField(service, "partitionedEntities", partitionedEntities); + } - tpi = TopicPartitionInfo.builder().myPartition(true).build(); + @AfterEach + void cleanup() { + deviceStateExecutor.shutdownNow(); + deviceStateCallbackExecutor.shutdownNow(); } @Test - public void givenDeviceBelongsToExternalPartition_whenOnDeviceConnect_thenCleansStateAndDoesNotReportConnect() { + void givenDeviceBelongsToExternalPartition_whenOnDeviceConnect_thenCleansStateAndDoesNotReportConnect() { // GIVEN doReturn(true).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -149,7 +180,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @ValueSource(longs = {Long.MIN_VALUE, -100, -1}) - public void givenNegativeLastConnectTime_whenOnDeviceConnect_thenSkipsThisEvent(long negativeLastConnectTime) { + void givenNegativeLastConnectTime_whenOnDeviceConnect_thenSkipsThisEvent(long negativeLastConnectTime) { // GIVEN doReturn(false).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -166,7 +197,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @MethodSource("provideOutdatedTimestamps") - public void givenOutdatedLastConnectTime_whenOnDeviceDisconnect_thenSkipsThisEvent(long outdatedLastConnectTime, long currentLastConnectTime) { + void givenOutdatedLastConnectTime_whenOnDeviceDisconnect_thenSkipsThisEvent(long outdatedLastConnectTime, long currentLastConnectTime) { // GIVEN doReturn(false).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -188,7 +219,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceBelongsToMyPartition_whenOnDeviceConnect_thenReportsConnect() { + void givenDeviceBelongsToMyPartition_whenOnDeviceConnect_thenReportsConnect() { // GIVEN var deviceStateData = DeviceStateData.builder() .tenantId(tenantId) @@ -202,11 +233,13 @@ public class DefaultDeviceStateServiceTest { service.deviceStates.put(deviceId, deviceStateData); long lastConnectTime = System.currentTimeMillis(); + mockSuccessfulSaveAttributes(); + // WHEN service.onDeviceConnect(tenantId, deviceId, lastConnectTime); // THEN - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(LAST_CONNECT_TIME) && @@ -221,7 +254,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceBelongsToExternalPartition_whenOnDeviceDisconnect_thenCleansStateAndDoesNotReportDisconnect() { + void givenDeviceBelongsToExternalPartition_whenOnDeviceDisconnect_thenCleansStateAndDoesNotReportDisconnect() { // GIVEN doReturn(true).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -238,7 +271,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @ValueSource(longs = {Long.MIN_VALUE, -100, -1}) - public void givenNegativeLastDisconnectTime_whenOnDeviceDisconnect_thenSkipsThisEvent(long negativeLastDisconnectTime) { + void givenNegativeLastDisconnectTime_whenOnDeviceDisconnect_thenSkipsThisEvent(long negativeLastDisconnectTime) { // GIVEN doReturn(false).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -254,7 +287,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @MethodSource("provideOutdatedTimestamps") - public void givenOutdatedLastDisconnectTime_whenOnDeviceDisconnect_thenSkipsThisEvent(long outdatedLastDisconnectTime, long currentLastDisconnectTime) { + void givenOutdatedLastDisconnectTime_whenOnDeviceDisconnect_thenSkipsThisEvent(long outdatedLastDisconnectTime, long currentLastDisconnectTime) { // GIVEN doReturn(false).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -275,7 +308,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceBelongsToMyPartition_whenOnDeviceDisconnect_thenReportsDisconnect() { + void givenDeviceBelongsToMyPartition_whenOnDeviceDisconnect_thenReportsDisconnect() { // GIVEN var deviceStateData = DeviceStateData.builder() .tenantId(tenantId) @@ -289,11 +322,13 @@ public class DefaultDeviceStateServiceTest { service.deviceStates.put(deviceId, deviceStateData); long lastDisconnectTime = System.currentTimeMillis(); + mockSuccessfulSaveAttributes(); + // WHEN service.onDeviceDisconnect(tenantId, deviceId, lastDisconnectTime); // THEN - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(LAST_DISCONNECT_TIME) && @@ -308,7 +343,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceBelongsToExternalPartition_whenOnDeviceInactivity_thenCleansStateAndDoesNotReportInactivity() { + void givenDeviceBelongsToExternalPartition_whenOnDeviceInactivity_thenCleansStateAndDoesNotReportInactivity() { // GIVEN doReturn(true).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -325,7 +360,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @ValueSource(longs = {Long.MIN_VALUE, -100, -1}) - public void givenNegativeLastInactivityTime_whenOnDeviceInactivity_thenSkipsThisEvent(long negativeLastInactivityTime) { + void givenNegativeLastInactivityTime_whenOnDeviceInactivity_thenSkipsThisEvent(long negativeLastInactivityTime) { // GIVEN doReturn(false).when(service).cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId); @@ -341,7 +376,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @MethodSource("provideOutdatedTimestamps") - public void givenReceivedInactivityTimeIsLessThanOrEqualToCurrentInactivityTime_whenOnDeviceInactivity_thenSkipsThisEvent( + void givenReceivedInactivityTimeIsLessThanOrEqualToCurrentInactivityTime_whenOnDeviceInactivity_thenSkipsThisEvent( long outdatedLastInactivityTime, long currentLastInactivityTime ) { // GIVEN @@ -365,7 +400,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @MethodSource("provideOutdatedTimestamps") - public void givenReceivedInactivityTimeIsLessThanOrEqualToCurrentActivityTime_whenOnDeviceInactivity_thenSkipsThisEvent( + void givenReceivedInactivityTimeIsLessThanOrEqualToCurrentActivityTime_whenOnDeviceInactivity_thenSkipsThisEvent( long outdatedLastInactivityTime, long currentLastActivityTime ) { // GIVEN @@ -398,7 +433,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceBelongsToMyPartition_whenOnDeviceInactivity_thenReportsInactivity() { + void givenDeviceBelongsToMyPartition_whenOnDeviceInactivity_thenReportsInactivity() { // GIVEN var deviceStateData = DeviceStateData.builder() .tenantId(tenantId) @@ -412,17 +447,19 @@ public class DefaultDeviceStateServiceTest { service.deviceStates.put(deviceId, deviceStateData); long lastInactivityTime = System.currentTimeMillis(); + mockSuccessfulSaveAttributes(); + // WHEN service.onDeviceInactivity(tenantId, deviceId, lastInactivityTime); // THEN - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(INACTIVITY_ALARM_TIME) && request.getEntries().get(0).getValue().equals(lastInactivityTime) )); - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && @@ -445,7 +482,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenInactivityTimeoutReached_whenUpdateInactivityStateIfExpired_thenReportsInactivity() { + void givenInactivityTimeoutReached_whenUpdateInactivityStateIfExpired_thenReportsInactivity() { // GIVEN var deviceStateData = DeviceStateData.builder() .tenantId(tenantId) @@ -456,16 +493,18 @@ public class DefaultDeviceStateServiceTest { given(partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId)).willReturn(tpi); + mockSuccessfulSaveAttributes(); + // WHEN service.updateInactivityStateIfExpired(System.currentTimeMillis(), deviceId, deviceStateData); // THEN - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(INACTIVITY_ALARM_TIME) )); - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && @@ -488,7 +527,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceIdFromDeviceStatesMap_whenGetOrFetchDeviceStateData_thenNoStackOverflow() { + void givenDeviceIdFromDeviceStatesMap_whenGetOrFetchDeviceStateData_thenNoStackOverflow() { service.deviceStates.put(deviceId, deviceStateDataMock); DeviceStateData deviceStateData = service.getOrFetchDeviceStateData(deviceId); assertThat(deviceStateData).isEqualTo(deviceStateDataMock); @@ -496,7 +535,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceIdWithoutDeviceStateInMap_whenGetOrFetchDeviceStateData_thenFetchDeviceStateData() { + void givenDeviceIdWithoutDeviceStateInMap_whenGetOrFetchDeviceStateData_thenFetchDeviceStateData() { service.deviceStates.clear(); willReturn(deviceStateDataMock).given(service).fetchDeviceStateDataUsingSeparateRequests(deviceId); DeviceStateData deviceStateData = service.getOrFetchDeviceStateData(deviceId); @@ -504,172 +543,18 @@ public class DefaultDeviceStateServiceTest { verify(service).fetchDeviceStateDataUsingSeparateRequests(deviceId); } - private void initStateService(long timeout) throws InterruptedException { - service.stop(); - reset(service, telemetrySubscriptionService); - service.setDefaultInactivityTimeoutMs(timeout); - service.init(); - when(partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId)).thenReturn(tpi); - when(entityQueryRepository.findEntityDataByQueryInternal(any())).thenReturn(new PageData<>()); - var deviceIdInfo = new DeviceIdInfo(tenantId.getId(), null, deviceId.getId()); - when(deviceService.findDeviceIdInfos(any())) - .thenReturn(new PageData<>(List.of(deviceIdInfo), 0, 1, false)); - PartitionChangeEvent event = new PartitionChangeEvent(this, ServiceType.TB_CORE, Map.of( - new QueueKey(ServiceType.TB_CORE), Collections.singleton(tpi) - ), Collections.emptyMap()); - service.onApplicationEvent(event); - Thread.sleep(100); - } - - @Test - public void increaseInactivityForInactiveDeviceTest() throws Exception { - final long defaultTimeout = 1; - initStateService(defaultTimeout); - DeviceState deviceState = DeviceState.builder().build(); - DeviceStateData deviceStateData = DeviceStateData.builder() - .tenantId(tenantId) - .deviceId(deviceId) - .state(deviceState) - .metaData(new TbMsgMetaData()) - .build(); - - service.deviceStates.put(deviceId, deviceStateData); - service.getPartitionedEntities(tpi).add(deviceId); - - service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); - activityVerify(true); - Thread.sleep(defaultTimeout); - service.checkStates(); - activityVerify(false); - - reset(telemetrySubscriptionService); - - long increase = 100; - long newTimeout = System.currentTimeMillis() - deviceState.getLastActivityTime() + increase; - - service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); - activityVerify(true); - Thread.sleep(increase); - service.checkStates(); - activityVerify(false); - - reset(telemetrySubscriptionService); - - service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); - activityVerify(true); - Thread.sleep(newTimeout + 5); - service.checkStates(); - activityVerify(false); - } - - @Test - public void increaseInactivityForActiveDeviceTest() throws Exception { - final long defaultTimeout = 1000; - initStateService(defaultTimeout); - DeviceState deviceState = DeviceState.builder().build(); - DeviceStateData deviceStateData = DeviceStateData.builder() - .tenantId(tenantId) - .deviceId(deviceId) - .state(deviceState) - .metaData(new TbMsgMetaData()) - .build(); - - service.deviceStates.put(deviceId, deviceStateData); - service.getPartitionedEntities(tpi).add(deviceId); - - service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); - activityVerify(true); - - reset(telemetrySubscriptionService); - - long increase = 100; - long newTimeout = System.currentTimeMillis() - deviceState.getLastActivityTime() + increase; - - service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); - verify(telemetrySubscriptionService, never()).saveAttributes(argThat(request -> - request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) - )); - Thread.sleep(defaultTimeout + increase); - service.checkStates(); - activityVerify(false); - - reset(telemetrySubscriptionService); - - service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); - activityVerify(true); - Thread.sleep(newTimeout); - service.checkStates(); - activityVerify(false); - } - - @Test - public void increaseSmallInactivityForInactiveDeviceTest() throws Exception { - final long defaultTimeout = 1; - initStateService(defaultTimeout); - DeviceState deviceState = DeviceState.builder().build(); - DeviceStateData deviceStateData = DeviceStateData.builder() - .tenantId(tenantId) - .deviceId(deviceId) - .state(deviceState) - .metaData(new TbMsgMetaData()) - .build(); - - service.deviceStates.put(deviceId, deviceStateData); - service.getPartitionedEntities(tpi).add(deviceId); - - service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); - activityVerify(true); - Thread.sleep(defaultTimeout); - service.checkStates(); - activityVerify(false); - - reset(telemetrySubscriptionService); - - long newTimeout = 1; - Thread.sleep(newTimeout); - verify(telemetrySubscriptionService, never()).saveAttributes(argThat(request -> - request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) - )); - } + @MethodSource + @ParameterizedTest + void testOnDeviceInactivityTimeoutUpdate(boolean initialActivityStatus, long newInactivityTimeout, boolean expectedActivityStatus) { + // GIVEN + doReturn(200L).when(service).getCurrentTimeMillis(); - @Test - public void decreaseInactivityForActiveDeviceTest() throws Exception { - final long defaultTimeout = 1000; - initStateService(defaultTimeout); - DeviceState deviceState = DeviceState.builder().build(); - DeviceStateData deviceStateData = DeviceStateData.builder() - .tenantId(tenantId) - .deviceId(deviceId) - .state(deviceState) - .metaData(new TbMsgMetaData()) + var deviceState = DeviceState.builder() + .active(initialActivityStatus) + .lastActivityTime(100L) .build(); - service.deviceStates.put(deviceId, deviceStateData); - service.getPartitionedEntities(tpi).add(deviceId); - - service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); - activityVerify(true); - - long newTimeout = 1; - Thread.sleep(newTimeout); - - service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); - activityVerify(false); - reset(telemetrySubscriptionService); - - service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, defaultTimeout); - activityVerify(true); - Thread.sleep(defaultTimeout); - service.checkStates(); - activityVerify(false); - } - - @Test - public void decreaseInactivityForInactiveDeviceTest() throws Exception { - final long defaultTimeout = 1000; - initStateService(defaultTimeout); - DeviceState deviceState = DeviceState.builder().build(); - DeviceStateData deviceStateData = DeviceStateData.builder() + var deviceStateData = DeviceStateData.builder() .tenantId(tenantId) .deviceId(deviceId) .state(deviceState) @@ -679,31 +564,44 @@ public class DefaultDeviceStateServiceTest { service.deviceStates.put(deviceId, deviceStateData); service.getPartitionedEntities(tpi).add(deviceId); - service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); - activityVerify(true); - Thread.sleep(defaultTimeout); - service.checkStates(); - activityVerify(false); - reset(telemetrySubscriptionService); + mockSuccessfulSaveAttributes(); - long newTimeout = 1; + // WHEN + service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newInactivityTimeout); - service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); - verify(telemetrySubscriptionService, never()).saveAttributes(argThat(request -> - request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) - )); + // THEN + long expectedInactivityTimeout = newInactivityTimeout != 0 ? newInactivityTimeout : defaultInactivityTimeoutMs; + assertThat(deviceState.getInactivityTimeout()).isEqualTo(expectedInactivityTimeout); + + assertThat(deviceState.isActive()).isEqualTo(expectedActivityStatus); + if (initialActivityStatus != expectedActivityStatus) { + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> { + AttributeKvEntry entry = request.getEntries().get(0); + return request.getEntityId().equals(deviceId) && entry.getKey().equals(ACTIVITY_STATE) && entry.getValue().equals(expectedActivityStatus); + })); + } } - private void activityVerify(boolean isActive) { - verify(telemetrySubscriptionService).saveAttributes(argThat(request -> - request.getEntityId().equals(deviceId) && - request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && - request.getEntries().get(0).getValue().equals(isActive) - )); + // to simplify test, these arguments assume that the current time is 200 and the last activity time is 100 + private static Stream testOnDeviceInactivityTimeoutUpdate() { + return Stream.of( + Arguments.of(true, 1L, false), + Arguments.of(true, 50L, false), + Arguments.of(true, 99L, false), + Arguments.of(true, 100L, false), + Arguments.of(true, 101L, true), + Arguments.of(true, 0L, true), // should use default inactivity timeout of 10 minutes + Arguments.of(false, 1L, false), + Arguments.of(false, 50L, false), + Arguments.of(false, 99L, false), + Arguments.of(false, 100L, false), + Arguments.of(false, 101L, true), + Arguments.of(false, 0L, true) // should use default inactivity timeout of 10 minutes + ); } @Test - public void givenStateDataIsNull_whenUpdateActivityState_thenShouldCleanupDevice() { + void givenStateDataIsNull_whenUpdateActivityState_thenShouldCleanupDevice() { // GIVEN service.deviceStates.put(deviceId, deviceStateDataMock); @@ -719,7 +617,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @MethodSource("provideParametersForUpdateActivityState") - public void givenTestParameters_whenUpdateActivityState_thenShouldBeInTheExpectedStateAndPerformExpectedActions( + void givenTestParameters_whenUpdateActivityState_thenShouldBeInTheExpectedStateAndPerformExpectedActions( boolean activityState, long previousActivityTime, long lastReportedActivity, long inactivityAlarmTime, long expectedInactivityAlarmTime, boolean shouldSetInactivityAlarmTimeToZero, boolean shouldUpdateActivityStateToActive @@ -739,13 +637,15 @@ public class DefaultDeviceStateServiceTest { .metaData(new TbMsgMetaData()) .build(); + mockSuccessfulSaveAttributes(); + // WHEN service.updateActivityState(deviceId, deviceStateData, lastReportedActivity); // THEN assertThat(deviceState.isActive()).isEqualTo(true); assertThat(deviceState.getLastActivityTime()).isEqualTo(lastReportedActivity); - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(LAST_ACTIVITY_TIME) && request.getEntries().get(0).getValue().equals(lastReportedActivity) @@ -753,7 +653,7 @@ public class DefaultDeviceStateServiceTest { assertThat(deviceState.getLastInactivityAlarmTime()).isEqualTo(expectedInactivityAlarmTime); if (shouldSetInactivityAlarmTimeToZero) { - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(INACTIVITY_ALARM_TIME) && request.getEntries().get(0).getValue().equals(0L) @@ -761,7 +661,7 @@ public class DefaultDeviceStateServiceTest { } if (shouldUpdateActivityStateToActive) { - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && request.getEntries().get(0).getValue().equals(true) @@ -809,59 +709,8 @@ public class DefaultDeviceStateServiceTest { ); } - @ParameterizedTest - @MethodSource("provideParametersForDecreaseInactivityTimeout") - public void givenTestParameters_whenOnDeviceInactivityTimeout_thenShouldBeInTheExpectedStateAndPerformExpectedActions( - boolean activityState, long newInactivityTimeout, long timeIncrement, boolean expectedActivityState - ) throws Exception { - // GIVEN - long defaultInactivityTimeout = 10000; - initStateService(defaultInactivityTimeout); - - var currentTime = new AtomicLong(System.currentTimeMillis()); - - DeviceState deviceState = DeviceState.builder() - .active(activityState) - .lastActivityTime(currentTime.get()) - .inactivityTimeout(defaultInactivityTimeout) - .build(); - - DeviceStateData deviceStateData = DeviceStateData.builder() - .tenantId(tenantId) - .deviceId(deviceId) - .state(deviceState) - .metaData(new TbMsgMetaData()) - .build(); - - service.deviceStates.put(deviceId, deviceStateData); - service.getPartitionedEntities(tpi).add(deviceId); - - given(service.getCurrentTimeMillis()).willReturn(currentTime.addAndGet(timeIncrement)); - - // WHEN - service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newInactivityTimeout); - - // THEN - assertThat(deviceState.getInactivityTimeout()).isEqualTo(newInactivityTimeout); - assertThat(deviceState.isActive()).isEqualTo(expectedActivityState); - if (activityState && !expectedActivityState) { - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && - request.getEntries().get(0).getValue().equals(false) - )); - } - } - - private static Stream provideParametersForDecreaseInactivityTimeout() { - return Stream.of( - Arguments.of(true, 1, 0, true), - - Arguments.of(true, 1, 1, false) - ); - } - @Test - public void givenStateDataIsNull_whenUpdateInactivityTimeoutIfExpired_thenShouldCleanupDevice() { + void givenStateDataIsNull_whenUpdateInactivityTimeoutIfExpired_thenShouldCleanupDevice() { // GIVEN service.deviceStates.put(deviceId, deviceStateDataMock); @@ -875,7 +724,7 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenNotMyPartition_whenUpdateInactivityTimeoutIfExpired_thenShouldCleanupDevice() { + void givenNotMyPartition_whenUpdateInactivityTimeoutIfExpired_thenShouldCleanupDevice() { // GIVEN long currentTime = System.currentTimeMillis(); @@ -911,7 +760,7 @@ public class DefaultDeviceStateServiceTest { @ParameterizedTest @MethodSource("provideParametersForUpdateInactivityStateIfExpired") - public void givenTestParameters_whenUpdateInactivityStateIfExpired_thenShouldBeInTheExpectedStateAndPerformExpectedActions( + void givenTestParameters_whenUpdateInactivityStateIfExpired_thenShouldBeInTheExpectedStateAndPerformExpectedActions( boolean activityState, long ts, long lastActivityTime, long lastInactivityAlarmTime, long inactivityTimeout, long deviceCreationTime, boolean expectedActivityState, long expectedLastInactivityAlarmTime, boolean shouldUpdateActivityStateToInactive ) { @@ -933,6 +782,7 @@ public class DefaultDeviceStateServiceTest { if (shouldUpdateActivityStateToInactive) { given(partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId)).willReturn(tpi); + mockSuccessfulSaveAttributes(); } // WHEN @@ -943,7 +793,7 @@ public class DefaultDeviceStateServiceTest { assertThat(state.getLastInactivityAlarmTime()).isEqualTo(expectedLastInactivityAlarmTime); if (shouldUpdateActivityStateToInactive) { - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && request.getEntries().get(0).getValue().equals(false) )); @@ -961,7 +811,7 @@ public class DefaultDeviceStateServiceTest { assertThat(actualNotification.getDeviceId()).isEqualTo(deviceId); assertThat(actualNotification.isActive()).isFalse(); - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(INACTIVITY_ALARM_TIME) && @@ -1033,7 +883,79 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenConcurrentAccess_whenGetOrFetchDeviceStateData_thenFetchDeviceStateDataInvokedOnce() { + void givenInactiveDevice_whenActivityStatusChangesToActiveButFailedToSaveUpdatedActivityStatus_thenShouldNotUpdateCache2() { + // GIVEN + var deviceState = DeviceState.builder() + .active(false) + .lastActivityTime(100L) + .inactivityTimeout(50L) + .build(); + + var deviceStateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .state(deviceState) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + service.deviceStates.put(deviceId, deviceStateData); + service.getPartitionedEntities(tpi).add(deviceId); + + // WHEN-THEN + + // simulating short DB outage + given(telemetrySubscriptionService.saveAttributesInternal(any())).willReturn(Futures.immediateFailedFuture(new RuntimeException("failed to save"))); + doReturn(200L).when(service).getCurrentTimeMillis(); + service.onDeviceActivity(tenantId, deviceId, 180L); + assertThat(deviceState.isActive()).isFalse(); // still inactive + + // 10 millis pass... and new activity message it received + + // this time DB save is successful + when(telemetrySubscriptionService.saveAttributesInternal(any())).thenReturn(Futures.immediateFuture(AttributesSaveResult.of(generateRandomVersions(1)))); + doReturn(210L).when(service).getCurrentTimeMillis(); + service.onDeviceActivity(tenantId, deviceId, 190L); + assertThat(deviceState.isActive()).isTrue(); + } + + @Test + void givenActiveDevice_whenActivityStatusChangesToInactiveButFailedToSaveUpdatedActivityStatus_thenShouldNotUpdateCache() { + // GIVEN + var deviceState = DeviceState.builder() + .active(true) + .lastActivityTime(100L) + .inactivityTimeout(50L) + .build(); + + var deviceStateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .state(deviceState) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + service.deviceStates.put(deviceId, deviceStateData); + service.getPartitionedEntities(tpi).add(deviceId); + + // WHEN-THEN (assuming periodic activity states check is done every 100 millis) + + // simulating short DB outage + given(telemetrySubscriptionService.saveAttributesInternal(any())).willReturn(Futures.immediateFailedFuture(new RuntimeException("failed to save"))); + doReturn(200L).when(service).getCurrentTimeMillis(); + service.checkStates(); + assertThat(deviceState.isActive()).isTrue(); // still active + + // waiting 100 millis... periodic activity states check is triggered again + + // this time DB save is successful + when(telemetrySubscriptionService.saveAttributesInternal(any())).thenReturn(Futures.immediateFuture(AttributesSaveResult.of(generateRandomVersions(1)))); + doReturn(300L).when(service).getCurrentTimeMillis(); + service.checkStates(); + assertThat(deviceState.isActive()).isFalse(); + } + + @Test + void givenConcurrentAccess_whenGetOrFetchDeviceStateData_thenFetchDeviceStateDataInvokedOnce() { doAnswer(invocation -> { Thread.sleep(100); return deviceStateDataMock; @@ -1069,10 +991,8 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceAdded_whenOnQueueMsg_thenShouldCacheAndSaveActivityToFalse() throws InterruptedException { + void givenDeviceAdded_whenOnQueueMsg_thenShouldCacheAndSaveActivityToFalse() { // GIVEN - final long defaultTimeout = 1000; - initStateService(defaultTimeout); given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyCollection())).willReturn(Futures.immediateFuture(Collections.emptyList())); @@ -1086,13 +1006,15 @@ public class DefaultDeviceStateServiceTest { .setDeleted(false) .build(); + mockSuccessfulSaveAttributes(); + // WHEN service.onQueueMsg(proto, TbCallback.EMPTY); // THEN await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(false); - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && request.getEntries().get(0).getValue().equals(false) )); @@ -1100,14 +1022,12 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceActivityEventHappenedAfterAdded_whenOnDeviceActivity_thenShouldCacheAndSaveActivityToTrue() throws InterruptedException { + void givenDeviceActivityEventHappenedAfterAdded_whenOnDeviceActivity_thenShouldCacheAndSaveActivityToTrue() { // GIVEN - final long defaultTimeout = 1000; - initStateService(defaultTimeout); long currentTime = System.currentTimeMillis(); DeviceState deviceState = DeviceState.builder() .active(false) - .inactivityTimeout(service.getDefaultInactivityTimeoutInSec()) + .inactivityTimeout(defaultInactivityTimeoutMs) .build(); DeviceStateData stateData = DeviceStateData.builder() .tenantId(tenantId) @@ -1118,12 +1038,14 @@ public class DefaultDeviceStateServiceTest { .build(); service.deviceStates.put(deviceId, stateData); + mockSuccessfulSaveAttributes(); + // WHEN service.onDeviceActivity(tenantId, deviceId, currentTime); // THEN ArgumentCaptor attributeRequestCaptor = ArgumentCaptor.forClass(AttributesSaveRequest.class); - then(telemetrySubscriptionService).should(times(2)).saveAttributes(attributeRequestCaptor.capture()); + then(telemetrySubscriptionService).should(times(2)).saveAttributesInternal(attributeRequestCaptor.capture()); await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(true); @@ -1151,15 +1073,14 @@ public class DefaultDeviceStateServiceTest { } @Test - public void givenDeviceActivityEventHappenedBeforeAdded_whenOnQueueMsg_thenShouldSaveActivityStateUsingValueFromCache() throws InterruptedException { + void givenDeviceActivityEventHappenedBeforeAdded_whenOnQueueMsg_thenShouldSaveActivityStateUsingValueFromCache() { // GIVEN - final long defaultTimeout = 1000; - initStateService(defaultTimeout); given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyCollection())).willReturn(Futures.immediateFuture(Collections.emptyList())); long currentTime = System.currentTimeMillis(); - DeviceState deviceState = DeviceState.builder() + + var deviceState = DeviceState.builder() .active(true) .lastConnectTime(currentTime - 8000) .lastActivityTime(currentTime - 4000) @@ -1167,16 +1088,20 @@ public class DefaultDeviceStateServiceTest { .lastInactivityAlarmTime(0) .inactivityTimeout(3000) .build(); - DeviceStateData stateData = DeviceStateData.builder() + + var stateData = DeviceStateData.builder() .tenantId(tenantId) .deviceId(deviceId) .deviceCreationTime(currentTime - 10000) .state(deviceState) .build(); + service.deviceStates.put(deviceId, stateData); + mockSuccessfulSaveAttributes(); + // WHEN - TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() + var proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) @@ -1190,11 +1115,25 @@ public class DefaultDeviceStateServiceTest { // THEN await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(true); - then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> + then(telemetrySubscriptionService).should().saveAttributesInternal(argThat(request -> request.getEntityId().equals(deviceId) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && request.getEntries().get(0).getValue().equals(true) )); }); } + private void mockSuccessfulSaveAttributes() { + lenient().when(telemetrySubscriptionService.saveAttributesInternal(any())).thenAnswer(invocation -> { + AttributesSaveRequest request = invocation.getArgument(0); + return Futures.immediateFuture(generateRandomVersions(request.getEntries().size())); + }); + } + + private static List generateRandomVersions(int n) { + return ThreadLocalRandom.current() + .longs(n) + .boxed() + .toList(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java index 2630f95f9b..3bb470830b 100644 --- a/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java @@ -32,11 +32,16 @@ import org.thingsboard.server.cache.limits.RateLimitService; 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.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.service.ws.WebSocketSessionRef; +import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; import java.util.ArrayList; import java.util.HashMap; @@ -44,8 +49,13 @@ import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -54,11 +64,17 @@ import static org.mockito.Mockito.when; public class DefaultTbLocalSubscriptionServiceTest { - ListAppender testLogAppender; - TbLocalSubscriptionService subscriptionService; + private final TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + private final DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + private ListAppender testLogAppender; + private TbLocalSubscriptionService subscriptionService; + private ListeningExecutorService executorService; @BeforeEach public void setUp() throws Exception { + this.executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); + Logger logger = (Logger) LoggerFactory.getLogger(DefaultTbLocalSubscriptionService.class); testLogAppender = new ListAppender<>(); testLogAppender.start(); @@ -69,7 +85,9 @@ public class DefaultTbLocalSubscriptionServiceTest { PartitionService partitionService = mock(); when(partitionService.resolve(any(), any(), any())).thenReturn(TopicPartitionInfo.builder().build()); subscriptionService = new DefaultTbLocalSubscriptionService(mock(), mock(), mock(), partitionService, mock(), mock(), mock(), rateLimitService); + ReflectionTestUtils.setField(subscriptionService, "serviceId", "serviceId"); + ReflectionTestUtils.setField(subscriptionService, "subscriptionUpdateExecutor", executorService); } @AfterEach @@ -79,27 +97,22 @@ public class DefaultTbLocalSubscriptionServiceTest { Logger logger = (Logger) LoggerFactory.getLogger(DefaultTbLocalSubscriptionService.class); logger.detachAppender(testLogAppender); } + if (executorService != null) { + executorService.shutdownNow(); + } } @Test public void addSubscriptionConcurrentModificationTest() throws Exception { - ListeningExecutorService executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); - TenantId tenantId = new TenantId(UUID.randomUUID()); - DeviceId deviceId = new DeviceId(UUID.randomUUID()); WebSocketSessionRef sessionRef = mock(); - ReflectionTestUtils.setField(subscriptionService, "subscriptionUpdateExecutor", executorService); List> futures = new ArrayList<>(); - try { - subscriptionService.onCoreStartupMsg(TransportProtos.CoreStartupMsg.newBuilder().addAllPartitions(List.of(0)).getDefaultInstanceForType()); - for (int i = 0; i < 50; i++) { - futures.add(executorService.submit(() -> subscriptionService.addSubscription(createSubscription(tenantId, deviceId), sessionRef))); - } - Futures.allAsList(futures).get(); - } finally { - executorService.shutdownNow(); + subscriptionService.onCoreStartupMsg(TransportProtos.CoreStartupMsg.newBuilder().addAllPartitions(List.of(0)).getDefaultInstanceForType()); + for (int i = 0; i < 50; i++) { + futures.add(executorService.submit(() -> subscriptionService.addSubscription(createSubscription(tenantId, deviceId), sessionRef))); } + Futures.allAsList(futures).get(); List logs = testLogAppender.list; boolean exceptionLogged = logs.stream() @@ -123,4 +136,42 @@ public class DefaultTbLocalSubscriptionServiceTest { .keyStates(keys) .build(); } + + @Test + public void updateOldTelemetryTest() { + WebSocketSessionRef sessionRef = mock(); + + String key = "temperature"; + long ts = 1237465456L; + + Map keyStates = new HashMap<>(); + keyStates.put(key, ts); + + AtomicReference capturedUpdate = new AtomicReference<>(); + TbTimeSeriesSubscription tsSubscription = TbTimeSeriesSubscription.builder() + .tenantId(tenantId) + .entityId(deviceId) + .subscriptionId(2) + .sessionId(RandomStringUtils.randomAlphanumeric(5)) + .keyStates(keyStates) + .allKeys(true) + .latestValues(true) + .updateProcessor((subscription, update) -> capturedUpdate.set(update)) + .build(); + + subscriptionService.addSubscription(tsSubscription, sessionRef); + + // Send telemetry with ts == stateTs + TsKvEntry kv = new BasicTsKvEntry(ts, new StringDataEntry(key, "42")); + subscriptionService.onTimeSeriesUpdate(deviceId, List.of(kv), mock(TbCallback.class)); + + await().atMost(5, TimeUnit.SECONDS) + .pollInterval(5, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + TelemetrySubscriptionUpdate update = capturedUpdate.get(); + assertNotNull(update); + assertTrue(update.getLatestValues().containsKey(key)); + }); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtxTest.java b/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtxTest.java new file mode 100644 index 0000000000..1f0da1a30c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtxTest.java @@ -0,0 +1,130 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.subscription; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomUtils; +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.LongDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.service.ws.WebSocketService; +import org.thingsboard.server.service.ws.WebSocketSessionRef; +import org.thingsboard.server.service.ws.telemetry.cmd.v2.CmdUpdate; +import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; +import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class TbEntityDataSubCtxTest { + + private final DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + private final Integer cmdId = RandomUtils.nextInt(); + private final Integer subscriptionId = RandomUtils.nextInt(); + private final String serviceId = RandomStringUtils.randomAlphanumeric(10); + private final String sessionId = RandomStringUtils.randomAlphanumeric(10); + + private final int maxEntitiesPerDataSubscription = 100; + + private TbEntityDataSubCtx subCtx; + @Mock + private WebSocketService webSocketService; + @Mock + private WebSocketSessionRef webSocketSessionRef; + + @BeforeEach + public void setUp() { + when(webSocketSessionRef.getSessionId()).thenReturn(sessionId); + subCtx = new TbEntityDataSubCtx(serviceId, webSocketService, mock(), mock(), mock(), mock(), webSocketSessionRef, cmdId, maxEntitiesPerDataSubscription); + + Map subToEntityIdMap = new HashMap<>(); + subToEntityIdMap.put(subscriptionId, deviceId); + ReflectionTestUtils.setField(subCtx, "subToEntityIdMap", subToEntityIdMap); + + long now = System.currentTimeMillis(); + long oldTs = now - 1_000_000; + + Map latestCtxValues = new HashMap<>(); + latestCtxValues.put("key", new TsValue(oldTs, "15")); + Map> latest = new HashMap<>(); + latest.put(EntityKeyType.TIME_SERIES, latestCtxValues); + + EntityData entityData = new EntityData(); + entityData.setEntityId(deviceId); + entityData.setLatest(latest); + + PageData data = new PageData<>(List.of(entityData), 1, 1, true); + ReflectionTestUtils.setField(subCtx, "data", data); + } + + @Test + public void testSendLatestWsMsg() { + long ts = System.currentTimeMillis(); + List telemetry = List.of( + new BasicTsKvEntry(ts - 50000, new LongDataEntry("key", 42L), 34L), + new BasicTsKvEntry(ts - 20000, new LongDataEntry("key", 17L), 78L) + ); + + TelemetrySubscriptionUpdate subUpdate = new TelemetrySubscriptionUpdate(subscriptionId, telemetry); + + subCtx.sendWsMsg(sessionId, subUpdate, EntityKeyType.TIME_SERIES, true); + + Map> expectedLatest = new HashMap<>(); + Map expectedLatestCtxValues = new HashMap<>(); + expectedLatestCtxValues.put("key", new TsValue(ts - 20000, "17")); // use latest telemetry + expectedLatest.put(EntityKeyType.TIME_SERIES, expectedLatestCtxValues); + + EntityData expectedEntityData = new EntityData(); + expectedEntityData.setEntityId(deviceId); + expectedEntityData.setLatest(expectedLatest); + + List expected = List.of(expectedEntityData); + + ArgumentCaptor cmdUpdateCaptor = ArgumentCaptor.forClass(CmdUpdate.class); + then(webSocketService).should().sendUpdate(eq(sessionId), cmdUpdateCaptor.capture()); + CmdUpdate cmdUpdate = cmdUpdateCaptor.getValue(); + assertThat(cmdUpdate).isInstanceOf(EntityDataUpdate.class); + EntityDataUpdate entityDataUpdate = (EntityDataUpdate) cmdUpdate; + assertThat(entityDataUpdate.getCmdId()).isEqualTo(cmdId); + assertThat(entityDataUpdate.getData()).isNull(); + assertThat(entityDataUpdate.getUpdate()).isEqualTo(expected); + assertThat(entityDataUpdate.getAllowedEntities()).isEqualTo(maxEntitiesPerDataSubscription); + } + +} 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 da3b214afb..e70a0cd37c 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 @@ -66,6 +66,7 @@ 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.EntityViewId; +import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; @@ -203,11 +204,12 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { AssetProfile assetProfile = createAssetProfile(tenantId1, ruleChain.getId(), dashboard.getId(), "Asset profile 1"); Asset asset = createAsset(tenantId1, null, assetProfile.getId(), "Asset 1"); DeviceProfile deviceProfile = createDeviceProfile(tenantId1, ruleChain.getId(), dashboard.getId(), "Device profile 1"); - Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device 1"); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device 1", firmware.getId(), null); CalculatedField calculatedField = createCalculatedField(tenantId1, device.getId(), asset.getId()); Map entitiesExportData = Stream.of(customer.getId(), asset.getId(), device.getId(), - ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId()) + ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId(), firmware.getId()) .map(entityId -> { try { return exportEntity(tenantAdmin1, entityId, EntityExportSettings.builder() @@ -275,12 +277,17 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { verify(tbClusterService).sendNotificationMsgToEdge(any(), any(), eq(importedDeviceProfile.getId()), any(), any(), eq(EdgeEventActionType.ADDED), any()); verify(otaPackageStateService).update(eq(importedDeviceProfile), eq(false), eq(false)); + OtaPackage importedFirmware = (OtaPackage) importEntity(tenantAdmin2, getAndClone(entitiesExportData, EntityType.OTA_PACKAGE)).getSavedEntity(); + verify(entityActionService).logEntityAction(any(), eq(importedFirmware.getId()), eq(importedFirmware), + any(), eq(ActionType.ADDED), isNull()); + Device importedDevice = (Device) importEntity(tenantAdmin2, getAndClone(entitiesExportData, EntityType.DEVICE)).getSavedEntity(); verify(entityActionService).logEntityAction(any(), eq(importedDevice.getId()), eq(importedDevice), any(), eq(ActionType.ADDED), isNull()); verify(tbClusterService).onDeviceUpdated(eq(importedDevice), isNull()); importEntity(tenantAdmin2, getAndClone(entitiesExportData, EntityType.DEVICE)); verify(tbClusterService, Mockito.never()).onDeviceUpdated(eq(importedDevice), eq(importedDevice)); + assertThat(importedDevice.getFirmwareId()).isEqualTo(importedFirmware.getId()); // calculated field of imported device: List calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(tenantId2, importedDevice.getId()); @@ -318,14 +325,15 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { assetProfile = assetProfileService.saveAssetProfile(assetProfile); DeviceProfile deviceProfile = createDeviceProfile(tenantId1, ruleChain.getId(), dashboard.getId(), "Device profile 1"); - Device device = createDevice(tenantId1, customer.getId(), deviceProfile.getId(), "Device 1"); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + Device device = createDevice(tenantId1, customer.getId(), deviceProfile.getId(), "Device 1", firmware.getId(), null); EntityView entityView = createEntityView(tenantId1, customer.getId(), device.getId(), "Entity view 1"); CalculatedField calculatedField = createCalculatedField(tenantId1, device.getId(), device.getId()); Map ids = new HashMap<>(); for (EntityId entityId : List.of(customer.getId(), ruleChain.getId(), dashboard.getId(), assetProfile.getId(), asset.getId(), - deviceProfile.getId(), device.getId(), entityView.getId(), ruleChain.getId(), dashboard.getId())) { + deviceProfile.getId(), firmware.getId(), device.getId(), entityView.getId(), ruleChain.getId(), dashboard.getId())) { EntityExportData exportData = exportEntity(getSecurityUser(tenantAdmin1), entityId); EntityImportResult importResult = importEntity(getSecurityUser(tenantAdmin2), exportData, EntityImportSettings.builder() .saveCredentials(false) @@ -359,12 +367,17 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { assertThat(exportedDeviceProfile.getDefaultRuleChainId()).isEqualTo(ruleChain.getId()); assertThat(exportedDeviceProfile.getDefaultDashboardId()).isEqualTo(dashboard.getId()); - EntityExportData entityExportData = exportEntity(tenantAdmin2, (DeviceId) ids.get(device.getId())); + OtaPackage exportedFirmware = (OtaPackage) exportEntity(tenantAdmin2, (OtaPackageId) ids.get(firmware.getId())).getEntity(); + assertThat(exportedFirmware.getDeviceProfileId()).isEqualTo(exportedDeviceProfile.getId()); + assertThat(exportedFirmware.getId()).isEqualTo(firmware.getId()); + + EntityExportData entityExportData = exportEntity(tenantAdmin2, (DeviceId) ids.get(device.getId())); Device exportedDevice = entityExportData.getEntity(); assertThat(exportedDevice.getCustomerId()).isEqualTo(customer.getId()); assertThat(exportedDevice.getDeviceProfileId()).isEqualTo(deviceProfile.getId()); + assertThat(exportedDevice.getFirmwareId()).isEqualTo(firmware.getId()); - List calculatedFields = ((DeviceExportData) entityExportData).getCalculatedFields(); + List calculatedFields = entityExportData.getCalculatedFields(); assertThat(calculatedFields.size()).isOne(); CalculatedField field = calculatedFields.get(0); assertThat(field.getName()).isEqualTo(calculatedField.getName()); @@ -380,13 +393,15 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { deviceProfileService.saveDeviceProfile(importedDeviceProfile); } - protected Device createDevice(TenantId tenantId, CustomerId customerId, DeviceProfileId deviceProfileId, String name) { + protected Device createDevice(TenantId tenantId, CustomerId customerId, DeviceProfileId deviceProfileId, String name, OtaPackageId firmwareId, OtaPackageId softwareId) { Device device = new Device(); device.setTenantId(tenantId); device.setCustomerId(customerId); device.setName(name); device.setLabel("lbl"); device.setDeviceProfileId(deviceProfileId); + device.setFirmwareId(firmwareId); + device.setSoftwareId(softwareId); DeviceData deviceData = new DeviceData(); deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); device.setDeviceData(deviceData); 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 index c7d4068210..461ca5a2ec 100644 --- 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 @@ -116,8 +116,8 @@ 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; -import static org.thingsboard.server.controller.TbResourceControllerTest.TEST_DATA; import static org.thingsboard.server.controller.TbResourceControllerTest.JS_TEST_FILE_NAME; +import static org.thingsboard.server.controller.TbResourceControllerTest.TEST_DATA; @DaoSqlTest public class VersionControlTest extends AbstractControllerTest { @@ -262,19 +262,24 @@ public class VersionControlTest extends AbstractControllerTest { } @Test - public void testDeviceVc_withProfile_betweenTenants() throws Exception { + public void testDeviceVc_withProfileAndOtaPackage_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); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); + Device device = createDevice(null, deviceProfile.getId(), "Device of tenant 1", "test1", newDevice -> { + newDevice.setFirmwareId(firmware.getId()); + newDevice.setSoftwareId(software.getId()); + }); + String versionId = createVersion("devices with ota", EntityType.DEVICE, EntityType.OTA_PACKAGE); 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"); + assertThat(listVersions()).extracting(EntityVersion::getName).containsExactly("devices with ota", "profiles"); loginTenant2(); - Map result = loadVersion(versionId, EntityType.DEVICE, EntityType.DEVICE_PROFILE); + Map result = loadVersion(versionId, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE); assertThat(result.get(EntityType.DEVICE).getCreated()).isEqualTo(1); assertThat(result.get(EntityType.DEVICE_PROFILE).getCreated()).isEqualTo(1); @@ -293,6 +298,13 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedCredentials.getCredentialsId()).isEqualTo(deviceCredentials.getCredentialsId()); assertThat(importedCredentials.getCredentialsValue()).isEqualTo(deviceCredentials.getCredentialsValue()); assertThat(importedCredentials.getCredentialsType()).isEqualTo(deviceCredentials.getCredentialsType()); + + OtaPackage importedFirmwareOta = findOtaPackage(firmware.getTitle()); + OtaPackage importedSoftwareOta = findOtaPackage(software.getTitle()); + checkImportedEntity(tenantId1, firmware, tenantId2, importedFirmwareOta); + checkImportedOtaPackageData(firmware, importedFirmwareOta); + checkImportedEntity(tenantId1, software, tenantId2, importedSoftwareOta); + checkImportedOtaPackageData(software, importedSoftwareOta); } @Test @@ -653,6 +665,57 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedCalculatedField.getType()).isEqualTo(calculatedField.getType()); } + @Test + public void testOtaPackageVc_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); + String versionId = createVersion("ota packages", EntityType.OTA_PACKAGE); + + OtaPackage firmwareOta = findOtaPackage(firmware.getTitle()); + OtaPackage softwareOta = findOtaPackage(software.getTitle()); + + loadVersion(versionId, EntityType.OTA_PACKAGE); + OtaPackage importedFirmwareOta = findOtaPackage(firmwareOta.getTitle()); + OtaPackage importedSoftwareOta = findOtaPackage(softwareOta.getTitle()); + checkImportedEntity(tenantId1, firmwareOta, tenantId1, importedFirmwareOta); + checkImportedOtaPackageData(firmwareOta, importedFirmwareOta); + checkImportedEntity(tenantId1, softwareOta, tenantId1, importedSoftwareOta); + checkImportedOtaPackageData(softwareOta, importedSoftwareOta); + } + + @Test + public void testOtaPackageVcWithProfile_betweenTenants() 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); + deviceProfile.setFirmwareId(firmware.getId()); + deviceProfile.setSoftwareId(software.getId()); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + String versionId = createVersion("ota packages", EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE); + + loginTenant2(); + loadVersion(versionId, EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE); + DeviceProfile importedProfile = findDeviceProfile(deviceProfile.getName()); + OtaPackage importedFirmwareOta = findOtaPackage(firmware.getTitle()); + OtaPackage importedSoftwareOta = findOtaPackage(software.getTitle()); + checkImportedEntity(tenantId1, deviceProfile, tenantId2, importedProfile); + checkImportedDeviceProfileData(deviceProfile, importedProfile); + checkImportedEntity(tenantId1, firmware, tenantId2, importedFirmwareOta); + checkImportedOtaPackageData(firmware, importedFirmwareOta); + checkImportedEntity(tenantId1, software, tenantId2, importedSoftwareOta); + checkImportedOtaPackageData(software, importedSoftwareOta); + assertThat(importedProfile.getFirmwareId()).isEqualTo(importedFirmwareOta.getId()); + assertThat(importedProfile.getSoftwareId()).isEqualTo(importedSoftwareOta.getId()); + } + + protected void checkImportedOtaPackageData(OtaPackage otaPackage, OtaPackage importedOtaPackage) { + assertThat(importedOtaPackage.getName()).isEqualTo(otaPackage.getName()); + assertThat(importedOtaPackage.getTag()).isEqualTo(otaPackage.getTag()); + assertThat(importedOtaPackage.getType()).isEqualTo(otaPackage.getType()); + assertThat(importedOtaPackage.getFileName()).isEqualTo(otaPackage.getFileName()); + } + @Test public void testResourceVc_sameTenant() throws Exception { TbResourceInfo resourceInfo = createResource("Test resource"); @@ -923,6 +986,7 @@ public class VersionControlTest extends AbstractControllerTest { otaPackage.setDeviceProfileId(deviceProfileId); otaPackage.setType(type); otaPackage.setTitle("My " + type); + otaPackage.setTag("My " + type); otaPackage.setVersion("v1.0"); otaPackage.setFileName("filename.txt"); otaPackage.setContentType("text/plain"); @@ -933,6 +997,10 @@ public class VersionControlTest extends AbstractControllerTest { return otaPackageService.saveOtaPackage(otaPackage); } + private OtaPackage findOtaPackage(String title) throws Exception { + return doGetTypedWithPageLink("/api/otaPackages?", new TypeReference>() {}, new PageLink(100, 0, title)).getData().get(0); + } + protected Dashboard createDashboard(CustomerId customerId, String name) { Dashboard dashboard = new Dashboard(); dashboard.setTitle(name); diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index 2b4d9f38e5..153228a865 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; @@ -472,7 +473,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(saveAttributes, sendWsUpdate, processCalculatedFields)) .build(); - lenient().when(attrService.save(tenantId, entityId, request.getScope(), request.getEntries())).thenReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + lenient().when(attrService.save(tenantId, entityId, request.getScope(), request.getEntries())) + .thenReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(request.getEntries().size())))); // WHEN telemetryService.saveAttributes(request); @@ -547,7 +549,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), entries)) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(entries.size())))); // WHEN telemetryService.saveAttributes(request); @@ -581,7 +584,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, nonDeviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + given(attrService.save(tenantId, nonDeviceId, request.getScope(), entries)) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(entries.size())))); // WHEN telemetryService.saveAttributes(request); @@ -613,7 +617,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), entries)) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(entries.size())))); // WHEN telemetryService.saveAttributes(request); @@ -640,7 +645,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), entries)) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(entries.size())))); // WHEN telemetryService.saveAttributes(request); @@ -715,7 +721,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(request.getEntries().size())))); // WHEN telemetryService.saveAttributes(request); @@ -764,7 +771,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, nonDeviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + given(attrService.save(tenantId, nonDeviceId, request.getScope(), request.getEntries())) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(request.getEntries().size())))); // WHEN telemetryService.saveAttributes(request); @@ -792,7 +800,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(request.getEntries().size())))); // WHEN telemetryService.saveAttributes(request); @@ -815,7 +824,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(request.getEntries().size())))); // WHEN telemetryService.saveAttributes(request); @@ -843,7 +853,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(request.getEntries().size())))); // WHEN telemetryService.saveAttributes(request); @@ -870,7 +881,8 @@ class DefaultTelemetrySubscriptionServiceTest { .strategy(new AttributesSaveRequest.Strategy(true, false, false)) .build(); - given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())) + .willReturn(immediateFuture(AttributesSaveResult.of(listOfNNumbers(request.getEntries().size())))); // WHEN telemetryService.saveAttributes(request); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java index fb430c8963..e053ad9c9e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java @@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.device.profile.DefaultCoapDeviceTypeCo import org.thingsboard.server.common.data.device.profile.DeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.SingleEntityFilter; @@ -170,7 +171,7 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap protected void processJsonTestRequestAttributesValuesFromTheServer() throws Exception { client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); @@ -200,7 +201,7 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap protected void processProtoTestRequestAttributesValuesFromTheServer() throws Exception { client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java index 4943d1b5c3..3845cc0c3c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java @@ -29,6 +29,7 @@ import org.junit.Before; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.SingleEntityFilter; @@ -161,7 +162,7 @@ public class CoapClientIntegrationTest extends AbstractCoapIntegrationTest { protected void processTestRequestAttributesValuesFromTheServer(boolean confirmable) throws Exception { client = createClientForFeatureWithConfirmableParameter(FeatureType.ATTRIBUTES, confirmable); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); List csKeys = getEntityKeys(CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(SHARED_ATTRIBUTE); List keys = new ArrayList<>(); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java index 587f9ade1c..ddf8bca43f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java @@ -21,8 +21,14 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; +import org.eclipse.leshan.client.LeshanClient; import org.eclipse.leshan.client.object.Security; +import org.eclipse.leshan.client.servers.LwM2mServer; import org.eclipse.leshan.core.ResponseCode; +import org.eclipse.leshan.core.request.ContentFormat; +import org.eclipse.leshan.core.response.ErrorCallback; +import org.eclipse.leshan.core.response.ResponseCallback; +import org.eclipse.leshan.core.response.SendResponse; import org.eclipse.leshan.server.registration.Registration; import org.junit.After; import org.junit.Assert; @@ -57,6 +63,7 @@ import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.Abstrac import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MBootstrapServerCredential; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.NoSecLwM2MBootstrapServerCredential; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -73,6 +80,7 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd; import org.thingsboard.server.transport.AbstractTransportIntegrationTest; import org.thingsboard.server.transport.lwm2m.client.LwM2MTestClient; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientContext; +import org.thingsboard.server.transport.lwm2m.server.client.ResourceUpdateResult; import org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; @@ -82,6 +90,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -93,6 +102,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -340,7 +350,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); SingleEntityFilter sef = new SingleEntityFilter(); - sef.setSingleEntity(device.getId()); + sef.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); LatestValueCmd latestCmd = new LatestValueCmd(); latestCmd.setKeys(Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "batteryLevel"))); EntityDataQuery edq = new EntityDataQuery(sef, new EntityDataPageLink(1, 0, null, null), @@ -351,7 +361,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte getWsClient().waitForReply(); getWsClient().registerWaitForUpdate(); - this.createNewClient(security, null, false, endpoint, null, queueMode, device.getId().getId().toString()); + this.createNewClient(security, null, false, endpoint, null, queueMode, device.getId().getId().toString(), null); awaitObserveReadAll(1, lwM2MTestClient.getDeviceIdStr()); String msg = getWsClient().waitForUpdate(); @@ -407,7 +417,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); SingleEntityFilter sef = new SingleEntityFilter(); - sef.setSingleEntity(device.getId()); + sef.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); LatestValueCmd latestCmd = new LatestValueCmd(); String key1 = "pkgname"; String key2 = "pkgversion"; @@ -422,7 +432,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte getWsClient().waitForReply(); getWsClient().registerWaitForUpdate(); - this.createNewClient(security, null, false, endpoint, null, true, device.getId().getId().toString()); + this.createNewClient(security, null, false, endpoint, null, true, device.getId().getId().toString(), null); awaitObserveReadAll(cntObserve, lwM2MTestClient.getDeviceIdStr()); String msg = getWsClient().waitForUpdate(); @@ -543,16 +553,17 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte public void createNewClient(Security security, Security securityBs, boolean isRpc, String endpoint, String deviceIdStr) throws Exception { - this.createNewClient(security, securityBs, isRpc, endpoint, null, false, deviceIdStr); + this.createNewClient(security, securityBs, isRpc, endpoint, null, false, deviceIdStr, null); } public void createNewClient(Security security, Security securityBs, boolean isRpc, String endpoint, Integer clientDtlsCidLength, String deviceIdStr) throws Exception { - this.createNewClient(security, securityBs, isRpc, endpoint, clientDtlsCidLength, false, deviceIdStr); + this.createNewClient(security, securityBs, isRpc, endpoint, clientDtlsCidLength, false, deviceIdStr, null); } public void createNewClient(Security security, Security securityBs, boolean isRpc, - String endpoint, Integer clientDtlsCidLength, boolean queueMode, String deviceIdStr) throws Exception { + String endpoint, Integer clientDtlsCidLength, boolean queueMode, + String deviceIdStr, Integer value3_0_9) throws Exception { this.clientDestroy(false); lwM2MTestClient = new LwM2MTestClient(this.executor, endpoint, resources); @@ -560,11 +571,86 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte int clientPort = socket.getLocalPort(); lwM2MTestClient.init(security, securityBs, clientPort, isRpc, this.defaultLwM2mUplinkMsgHandlerTest, this.clientContextTest, - clientDtlsCidLength, queueMode, supportFormatOnly_SenMLJSON_SenMLCBOR); + clientDtlsCidLength, queueMode, supportFormatOnly_SenMLJSON_SenMLCBOR, value3_0_9); } lwM2MTestClient.setDeviceIdStr(deviceIdStr); } + /** + * Test: "/3/0/9" value = 44 (constant); count = 10; send from client to telemetry without observe + * @param security + * @param deviceCredentials + * @param endpoint + * @param queueMode + * @throws Exception + */ + public void testConnectionWithoutObserveWithDataReceivedSingleTelemetry(Security security, + LwM2MDeviceCredentials deviceCredentials, + String endpoint, + boolean queueMode) throws Exception { + Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsNoSec(NONE)); + DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + endpoint, transportConfiguration); + Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); + + + + SingleEntityFilter sef = new SingleEntityFilter(); + sef.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); + LatestValueCmd latestCmd = new LatestValueCmd(); + latestCmd.setKeys(Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "batteryLevel"))); + EntityDataQuery edq = new EntityDataQuery(sef, new EntityDataPageLink(1, 0, null, null), + Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + + EntityDataCmd cmd = new EntityDataCmd(1, edq, null, latestCmd, null); + getWsClient().send(cmd); + getWsClient().waitForReply(); + + getWsClient().registerWaitForUpdate(); + + this.createNewClient(security, null, false, endpoint, null, queueMode, device.getId().getId().toString(), 44); + awaitObserveReadAll(1, lwM2MTestClient.getDeviceIdStr()); + + LeshanClient leshanClient = lwM2MTestClient.getLeshanClient(); + Map registeredServers = leshanClient.getRegisteredServers(); + List paths = List.of("/3/0/9"); + int cntUpdate = 10; + int cntLast = cntUpdate; + for (final LwM2mServer server : registeredServers.values()) { + log.info("Sending Data to {} using {}.", server, ContentFormat.SENML_CBOR); + ResponseCallback responseCallback = (response) -> { + if (response.isSuccess()) + log.warn("Data sent successfully to {} [{}].", server, response.getCode()); + else + log.warn("Send data to {} failed [{}] : {}.", server, response.getCode(), + response.getErrorMessage() == null ? "" : response.getErrorMessage()); + }; + ErrorCallback errorCallback = (e) -> log.warn("Unable to send data to {}.", server, e); + while(cntLast > 0) { + leshanClient.getSendService().sendData(server, ContentFormat.SENML_CBOR, paths, + 2000, responseCallback, errorCallback); + cntLast-- ; + } + } + + + verify(defaultUplinkMsgHandlerTest, timeout(10000).atLeast(cntUpdate)) + .updateAttrTelemetry(Mockito.any(ResourceUpdateResult.class), eq(null)); + + String msg = getWsClient().waitForUpdate(); + EntityDataUpdate update = JacksonUtil.fromString(msg, EntityDataUpdate.class); + Assert.assertEquals(1, update.getCmdId()); + List eData = update.getUpdate(); + Assert.assertNotNull(eData); + Assert.assertEquals(1, eData.size()); + Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); + Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); + var tsValue = eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("batteryLevel"); + assertThat(Long.parseLong(tsValue.getValue()), instanceOf(Long.class)); + int expected = 44; + assertEquals(expected, Long.parseLong(tsValue.getValue())); + } + + private void clientDestroy(boolean isAfter) { try { if (lwM2MTestClient != null && lwM2MTestClient.getLeshanClient() != null) { diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java index 06e41fe29f..2ae1432cac 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java @@ -144,7 +144,7 @@ public class LwM2MTestClient { public void init(Security security, Security securityBs, int port, boolean isRpc, LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandler, LwM2mClientContext clientContext, Integer cIdLength, boolean queueMode, - boolean supportFormatOnly_SenMLJSON_SenMLCBOR) throws InvalidDDFFileException, IOException { + boolean supportFormatOnly_SenMLJSON_SenMLCBOR, Integer value3_0_9) throws InvalidDDFFileException, IOException { Assert.assertNull("client already initialized", leshanClient); this.defaultLwM2mUplinkMsgHandlerTest = defaultLwM2mUplinkMsgHandler; this.clientContext = clientContext; @@ -197,7 +197,7 @@ public class LwM2MTestClient { initializer.setInstancesForObject(SERVER, lwm2mServer); } - initializer.setInstancesForObject(DEVICE, lwM2MDevice = new SimpleLwM2MDevice(executor)); + initializer.setInstancesForObject(DEVICE, lwM2MDevice = new SimpleLwM2MDevice(executor, value3_0_9)); initializer.setInstancesForObject(FIRMWARE, fwLwM2MDevice = new FwLwM2MDevice()); initializer.setInstancesForObject(SOFTWARE_MANAGEMENT, swLwM2MDevice = new SwLwM2MDevice()); initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java index 447cc051fc..5157d5597d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java @@ -85,18 +85,22 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl */ private static Map errorCode = Map.of(0, 0L); // 0-32 + private Integer value3_0_9; public SimpleLwM2MDevice() { } - public SimpleLwM2MDevice(ScheduledExecutorService executorService) { + public SimpleLwM2MDevice(ScheduledExecutorService executorService, Integer value3_0_9) { + this.value3_0_9 = value3_0_9; try { - executorService.scheduleWithFixedDelay(() -> { - fireResourceChange(9); - fireResourceChange(20); - } - , 1, 1, TimeUnit.SECONDS); // 2 sec + if ( this.value3_0_9 == null) { + executorService.scheduleWithFixedDelay(() -> { + fireResourceChange(9); + fireResourceChange(20); + } + , 1, 1, TimeUnit.SECONDS); // 2 sec // , 1800000, 1800000, TimeUnit.MILLISECONDS); // 30 MIN + } } catch (Throwable e) { log.error("[{}]Throwable", e.toString()); e.printStackTrace(); @@ -211,8 +215,14 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl } private int getBatteryLevel() { - int valBattery = randomIterator.nextInt(); - log.trace("Send from client [3/0/9] val: [{}]", valBattery); + int valBattery; + if (this.value3_0_9 == null) { + valBattery = randomIterator.nextInt(); + log.trace("Send from client [3/0/9] val: [{}]", valBattery); + } else { + valBattery = this.value3_0_9; + log.warn("Send from client [3/0/9] constant value: [{}]", valBattery); + } return valBattery; } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java new file mode 100644 index 0000000000..8cb5e06248 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.rpc.sql; + +import org.junit.Test; +import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MDeviceCredentials; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; + +public class RpcLwm2mIntegrationDataReceivedFromClientTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessAndObserveTelemetry() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC; + LwM2MDeviceCredentials clientCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); + super.testConnectionWithoutObserveWithDataReceivedSingleTelemetry(SECURITY_NO_SEC, clientCredentials, clientEndpoint, false); + } + +} 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 40afe76e01..5c70f05b94 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 @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityKey; @@ -339,7 +340,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt MqttTestClient client = new MqttTestClient(); client.connectAndWait(accessToken); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); @@ -426,7 +427,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt }); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(device.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); List sharedKeysList = List.of(sharedKeysStr.split(",")); @@ -481,7 +482,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt assertNotNull(device); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(device.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java index 05a5843626..13e9dcaf0e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java @@ -29,6 +29,7 @@ import org.eclipse.paho.mqttv5.common.packet.MqttReturnCode; import org.eclipse.paho.mqttv5.common.packet.MqttWireMessage; import org.junit.Assert; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; @@ -67,7 +68,7 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataTyp import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt64; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt8; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.createMetric; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; /** * Created by nickAS21 on 12.01.23 @@ -112,31 +113,44 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte long value = bdSeq = 0; clientWithCorrectNodeAccessTokenWithNDEATH(ts, value); } + public void clientWithCorrectNodeAccessTokenWithNDEATH(Long alias) throws Exception { + long ts = calendar.getTimeInMillis(); + long value = bdSeq = 0; + clientMqttV5ConnectWithNDEATH(ts, value,alias); + } public void clientWithCorrectNodeAccessTokenWithNDEATH(long ts, long value) throws Exception { - IMqttToken connectionResult = clientConnectWithNDEATH(ts, value); + IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(ts, value, -1L); MqttWireMessage response = connectionResult.getResponse(); Assert.assertEquals(MESSAGE_TYPE_CONNACK, response.getType()); MqttConnAck connAckMsg = (MqttConnAck) response; Assert.assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode()); } - public IMqttToken clientConnectWithNDEATH(long ts, long value, String... nameSpaceBad) throws Exception { - String key = keysBdSeq; + public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, Long alias, String... nameSpaceBad) throws Exception { + return clientMqttV5ConnectWithNDEATH(ts, value, null, alias, nameSpaceBad); + } + public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, String metricName, Long alias, String... nameSpaceBad) throws Exception { + String key = metricName == null ? keysBdSeq : metricName; MetricDataType metricDataType = Int64; SparkplugBProto.Payload.Builder deathPayload = SparkplugBProto.Payload.newBuilder() .setTimestamp(calendar.getTimeInMillis()); - deathPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + deathPayload.addMetrics(createMetric(value, ts, key, metricDataType, alias)); byte[] deathBytes = deathPayload.build().toByteArray(); this.client = new MqttV5TestClient(); this.mqttCallback = new SparkplugMqttCallback(); this.client.setCallback(this.mqttCallback); MqttConnectionOptions options = new MqttConnectionOptions(); + // If the MQTT client is using MQTT v5.0, the Edge Node’s MQTT CONNECT packet MUST set the Clean Start flag to true and the Session Expiry Interval to 0 + options.setCleanStart(true); + options.setSessionExpiryInterval(0L); options.setUserName(gatewayAccessToken); - String nameSpace = nameSpaceBad.length == 0 ? NAMESPACE : nameSpaceBad[0]; + String nameSpace = nameSpaceBad.length == 0 ? TOPIC_ROOT_SPB_V_1_0 : nameSpaceBad[0]; String topic = nameSpace + "/" + groupId + "/" + SparkplugMessageType.NDEATH.name() + "/" + edgeNode; + // The NDEATH message MUST set the MQTT Will QoS to 1 and Retained flag to false MqttMessage msg = new MqttMessage(); msg.setId(0); + msg.setQos(1); msg.setPayload(deathBytes); options.setWill(topic, msg); return client.connect(options); @@ -148,19 +162,19 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte MetricDataType metricDataType = Int32; String key = "Node Metric int32"; int valueDeviceInt32 = 1024; - SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType); + SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L); SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getBdSeqNum()); payloadBirthNode.addMetrics(metric); payloadBirthNode.setTimestamp(ts); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, payloadBirthNode.build().toByteArray(), 0, false); } valueDeviceInt32 = 4024; - metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32); + metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); for (int i = 0; i < cntDevices; i++) { SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) @@ -169,7 +183,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte payloadBirthDevice.addMetrics(metric); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, payloadBirthDevice.build().toByteArray(), 0, false); AtomicReference device = new AtomicReference<>(); await(alias + "find device [" + deviceName + "] after created") @@ -187,6 +201,49 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte return devices; } + protected List connectClientWithCorrectAccessTokenWithNDEATHWithAliasCreatedDevices(long ts) throws Exception { + List devices = new ArrayList<>(); + Long alias = 0L; + clientWithCorrectNodeAccessTokenWithNDEATH(alias++); + MetricDataType metricDataType = Int32; + String key = "Node Metric int32"; + int valueDeviceInt32 = 1024; + SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, alias++); + SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getBdSeqNum()); + payloadBirthNode.addMetrics(metric); + payloadBirthNode.setTimestamp(ts); + if (client.isConnected()) { + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + payloadBirthNode.build().toByteArray(), 0, false); + } + + valueDeviceInt32 = 4024; + metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, alias++); + SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getSeqNum()); + String deviceName = deviceId + "_" + 1; + + payloadBirthDevice.addMetrics(metric); + if (client.isConnected()) { + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, + payloadBirthDevice.build().toByteArray(), 0, false); + AtomicReference device = new AtomicReference<>(); + await(alias + "find device [" + deviceName + "] after created") + .atMost(200, TimeUnit.SECONDS) + .until(() -> { + device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); + return device.get() != null; + }); + devices.add(device.get()); + } + + Assert.assertEquals(1, devices.size()); + return devices; + } + protected long getBdSeqNum() throws Exception { if (bdSeq == 256) { bdSeq = 0; @@ -207,16 +264,20 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte .setTimestamp(calendar.getTimeInMillis()); long ts = calendar.getTimeInMillis() - PUBLISH_TS_DELTA_MS; long valueBdSec = getBdSeqNum(); - payloadBirthNode.addMetrics(createMetric(valueBdSec, ts, keysBdSeq, Int64)); + payloadBirthNode.addMetrics(createMetric(valueBdSec, ts, keysBdSeq, Int64, -1L)); listKeys.add(SparkplugMessageType.NBIRTH.name() + " " + keysBdSeq); - payloadBirthNode.addMetrics(createMetric(false, ts, keyNodeRebirth, MetricDataType.Boolean)); + payloadBirthNode.addMetrics(createMetric(false, ts, keyNodeRebirth, MetricDataType.Boolean, -1L)); listKeys.add(keyNodeRebirth); - payloadBirthNode.addMetrics(createMetric(metricValue, ts, metricKey, metricDataType)); + if (StringUtils.isNotBlank(metricKey)) { + payloadBirthNode.addMetrics(createMetric(metricValue, ts, metricKey, metricDataType, -1L)); + } else { + payloadBirthNode.addMetrics(createMetric(metricValue, ts, metricKey, metricDataType, 4L)); + } listKeys.add(metricKey); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, payloadBirthNode.build().toByteArray(), 0, false); } return listKeys; @@ -297,7 +358,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte private TsKvEntry createdAddMetricTsKvLong(SparkplugBProto.Payload.Builder dataPayload, String key, Object value, long ts, MetricDataType metricDataType) throws ThingsboardException { TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new LongDataEntry(key, Long.valueOf(String.valueOf(value)))); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } @@ -305,7 +366,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte long ts, MetricDataType metricDataType) throws ThingsboardException { Double dd = Double.parseDouble(Float.toString(value)); TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new DoubleDataEntry(key, dd)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } @@ -313,21 +374,21 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte long ts, MetricDataType metricDataType) throws ThingsboardException { Long l = Double.valueOf(value).longValue(); TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new LongDataEntry(key, l)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } private TsKvEntry createdAddMetricTsKvBoolean(SparkplugBProto.Payload.Builder dataPayload, String key, boolean value, long ts, MetricDataType metricDataType) throws ThingsboardException { TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new BooleanDataEntry(key, value)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } private TsKvEntry createdAddMetricTsKvString(SparkplugBProto.Payload.Builder dataPayload, String key, String value, long ts, MetricDataType metricDataType) throws ThingsboardException { TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new StringDataEntry(key, value)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } @@ -348,7 +409,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte if (nodeArray.size() > 0) { Optional tsKvEntryOptional = Optional.of(new BasicTsKvEntry(ts, new JsonDataEntry(key, nodeArray.toString()))); if (tsKvEntryOptional.isPresent()) { - dataPayload.addMetrics(createMetric(values, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(values, ts, key, metricDataType, -1L)); listTsKvEntry.add(tsKvEntryOptional.get()); listKeys.add(key); } @@ -416,7 +477,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte return java.util.UUID.randomUUID().toString(); } - public class SparkplugMqttCallback implements MqttCallback { + public class SparkplugMqttCallback implements MqttCallback { private final List messageArrivedMetrics = new ArrayList<>(); @Override diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java index 4ca7ea5661..e4e76e6761 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java @@ -16,7 +16,6 @@ package org.thingsboard.server.transport.mqtt.sparkplug.attributes; import com.fasterxml.jackson.core.type.TypeReference; -import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.thingsboard.server.common.data.Device; @@ -33,8 +32,10 @@ import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt32; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DCMD; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DDATA; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NCMD; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NDATA; /** * Created by nickAS21 on 12.01.23 @@ -50,7 +51,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Assert.assertTrue(listKeys.contains(keyNodeRebirth)); String SHARED_ATTRIBUTES_PAYLOAD = "{\"" + keyNodeRebirth + "\":" + value + "}"; Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); doPostAsync("/api/plugins/telemetry/DEVICE/" + savedGateway.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); await(alias + SparkplugMessageType.NBIRTH.name()) @@ -76,7 +76,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextBoolean(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Boolean <-> String @@ -140,7 +139,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextUInt32(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Long <-> String @@ -192,7 +190,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextFloat(30, 400); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Float <-> String @@ -237,6 +234,67 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Assert.assertTrue(expectedValue == mqttCallback.getMessageArrivedMetrics().get(0).getFloatValue()); } + protected void processClientWithCorrectAccessTokenPublishMetricDataTypeFromJson_SendValueOk() throws Exception { + long ts = calendar.getTimeInMillis(); + List devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(1, ts); + awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); + awaitForDeviceActorToReceiveSubscription(devices.get(0).getId(), FeatureType.ATTRIBUTES, 1); + + // Node Edge + SparkplugMessageType messageType = NCMD; + String keyBirthNameNode = "Node Metric int32"; + int valueBirthNameNode = 1024; + String SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameNode + "\":" + valueBirthNameNode + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + savedGateway.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + NCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameNode, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameNode == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + + messageType = NDATA; + SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameNode + "\":" + valueBirthNameNode + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + savedGateway.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + NCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameNode, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameNode == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + + // Device + messageType = DCMD; + String keyBirthNameDevice = metricBirthName_Int32; + int valueBirthNameDevice = 123456; + SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameDevice + "\":" + valueBirthNameDevice + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + devices.get(0).getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + DCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameDevice, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameDevice == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + + messageType = DDATA; + SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameDevice + "\":" + valueBirthNameDevice + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + devices.get(0).getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + DCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameDevice, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameDevice == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + } + protected void processClientWithCorrectAccessTokenPublishNCMD_DoubleType_IfMetricFailedTypeCheck_SendValueOk() throws Exception { clientWithCorrectNodeAccessTokenWithNDEATH(); MetricDataType metricDataType = MetricDataType.Double; @@ -244,7 +302,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextDouble(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Double <-> String @@ -296,7 +353,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextString(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // String <-> Long @@ -356,7 +412,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra return mqttCallback.getMessageArrivedMetrics().size() == 1; }); Assert.assertEquals(metricBirthName_Int32, mqttCallback.getMessageArrivedMetrics().get(0).getName()); - Assert.assertEquals(metricBirthName_Int32, mqttCallback.getMessageArrivedMetrics().get(0).getName()); Assert.assertEquals(expectedValueInt, mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java index a8d67360be..cecad77c7a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java @@ -57,6 +57,10 @@ public class MqttV5ClientSparkplugBAttributesTest extends AbstractMqttV5ClientSp public void testClientWithCorrectAccessTokenPublishNCMD_FloatType_IfMetricFailedTypeCheck_SendValueOk() throws Exception { processClientWithCorrectAccessTokenPublishNCMD_FloatType_IfMetricFailedTypeCheck_SendValueOk(); } + @Test + public void testClientWithCorrectAccessTokenPublishMetricDataTypeFromJson_SendValueOk() throws Exception { + processClientWithCorrectAccessTokenPublishMetricDataTypeFromJson_SendValueOk(); + } @Test public void testClientWithCorrectAccessTokenPublishNCMD_DoubleType_IfMetricFailedTypeCheck_SendValueOk() throws Exception { 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 459596e38f..9b22bd52d9 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 @@ -40,7 +40,7 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConn import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; /** * Created by nickAS21 on 12.01.23 @@ -80,7 +80,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra protected void processClientWithCorrectNodeAccessTokenNameSpaceInvalid_Test() throws Exception { long ts = calendar.getTimeInMillis() - PUBLISH_TS_DELTA_MS; long value = bdSeq = 0; - MqttException actualException = Assert.assertThrows(MqttException.class, () -> clientConnectWithNDEATH(ts, value, "spBv1.2")); + MqttException actualException = Assert.assertThrows(MqttException.class, () -> clientMqttV5ConnectWithNDEATH(ts, value, -1L,"spBv1.2")); String expectedMessage = "Server unavailable."; int expectedReasonCode = 136; Assert.assertEquals(expectedMessage, actualException.getMessage()); @@ -135,7 +135,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra if (client.isConnected()) { List devicesList = new ArrayList<>(devices); Device device = devicesList.get(indexDeviceDisconnect); - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.DDEATH.name() + "/" + edgeNode + "/" + device.getName(), + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DDEATH.name() + "/" + edgeNode + "/" + device.getName(), payloadDeathDevice.build().toByteArray(), 0, false); await(alias + messageName(STATE) + ", device: " + device.getName()) .atMost(40, TimeUnit.SECONDS) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java index 8d351bbd9d..96fc9c0a4c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java @@ -15,8 +15,8 @@ */ package org.thingsboard.server.transport.mqtt.sparkplug.rpc; -import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; +import org.eclipse.paho.mqttv5.common.MqttException; import org.junit.Assert; import org.junit.Test; import org.thingsboard.server.common.data.Device; @@ -32,7 +32,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.thingsboard.server.common.data.exception.ThingsboardErrorCode.INVALID_ARGUMENTS; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DCMD; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NCMD; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; @Slf4j public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5ClientSparkplugTest { @@ -45,7 +44,6 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie clientWithCorrectNodeAccessTokenWithNDEATH(); connectionWithNBirth(metricBirthDataType_Int32, metricBirthName_Int32, nextInt32()); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.RPC, 1); String expected = "{\"result\":\"Success: " + SparkplugMessageType.NCMD.name() + "\"}"; String actual = sendRPCSparkplug(NCMD.name(), sparkplugRpcRequest, savedGateway); @@ -63,9 +61,10 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie public void processClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success() throws Exception { long ts = calendar.getTimeInMillis(); List devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(1, ts); + awaitForDeviceActorToReceiveSubscription(devices.get(0).getId(), FeatureType.RPC, 1); String expected = "{\"result\":\"Success: " + DCMD.name() + "\"}"; String actual = sendRPCSparkplug(DCMD.name() , sparkplugRpcRequest, devices.get(0)); - await(alias + NCMD.name()) + await(alias + DCMD.name()) .atMost(40, TimeUnit.SECONDS) .until(() -> { return mqttCallback.getMessageArrivedMetrics().size() == 1; @@ -75,12 +74,41 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie Assert.assertTrue(metricBirthValue_Int32 == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); } + @Test + public void processClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success() throws Exception { + long ts = calendar.getTimeInMillis(); + List devices = connectClientWithCorrectAccessTokenWithNDEATHWithAliasCreatedDevices(ts); + awaitForDeviceActorToReceiveSubscription(devices.get(0).getId(), FeatureType.RPC, 1); + String expected = "{\"result\":\"Success: " + DCMD.name() + "\"}"; + String actual = sendRPCSparkplug(DCMD.name() , sparkplugRpcRequest, devices.get(0)); + await(alias + DCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(expected, actual); + Assert.assertFalse(mqttCallback.getMessageArrivedMetrics().get(0).hasName()); + Assert.assertTrue(mqttCallback.getMessageArrivedMetrics().get(0).hasAlias()); + Assert.assertTrue(2L == mqttCallback.getMessageArrivedMetrics().get(0).getAlias()); + Assert.assertTrue(metricBirthValue_Int32 == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + } + + @Test + public void processClientNodeWithCorrectAccessTokenPublishWithAliasWithoutMetricName_TwoWayRpc_BAD_REQUEST_PARAMS() throws Exception { + long ts = calendar.getTimeInMillis() - PUBLISH_TS_DELTA_MS; + long value = bdSeq = 0; + MqttException actualException = Assert.assertThrows(MqttException.class, () -> clientMqttV5ConnectWithNDEATH(ts, value, "",4L)); + String expectedMessage = "Server unavailable."; + int expectedReasonCode = 136; + Assert.assertEquals(expectedMessage, actualException.getMessage()); + Assert.assertEquals(expectedReasonCode, actualException.getReasonCode()); + } + @Test public void processClientNodeWithCorrectAccessTokenPublish_TwoWayRpc_InvalidTypeMessage_INVALID_ARGUMENTS() throws Exception { clientWithCorrectNodeAccessTokenWithNDEATH(); connectionWithNBirth(metricBirthDataType_Int32, metricBirthName_Int32, nextInt32()); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.RPC, 1); String invalidateTypeMessageName = "RCMD"; String expected = "{\"result\":\"" + INVALID_ARGUMENTS + "\",\"error\":\"Failed to convert device RPC command to MQTT msg: " + @@ -94,7 +122,6 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie clientWithCorrectNodeAccessTokenWithNDEATH(); connectionWithNBirth(metricBirthDataType_Int32, metricBirthName_Int32, nextInt32()); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.RPC, 1); String metricNameBad = metricBirthName_Int32 + "_Bad"; String sparkplugRpcRequestBad = "{\"metricName\":\"" + metricNameBad + "\",\"value\":" + metricBirthValue_Int32 + "}"; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java index 10ef53260c..eb6c613940 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java @@ -47,6 +47,15 @@ public class MqttV5RpcSparkplugTest extends AbstractMqttV5RpcSparkplugTest { public void testClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success() throws Exception { processClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success(); } + @Test + public void testClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success() throws Exception { + processClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success(); + } + + @Test + public void testClientNodeWithCorrectAccessTokenPublishWithAliasWithoutMetricName_TwoWayRpc_BAD_REQUEST_PARAMS() throws Exception { + processClientNodeWithCorrectAccessTokenPublishWithAliasWithoutMetricName_TwoWayRpc_BAD_REQUEST_PARAMS(); + } @Test public void testClientNodeWithCorrectAccessTokenPublish_TwoWayRpc_InvalidTypeMessage_INVALID_ARGUMENTS() throws Exception { 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 c5d0dec98b..8368db546b 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 @@ -29,7 +29,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static org.awaitility.Awaitility.await; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; /** * Created by nickAS21 on 12.01.23 @@ -67,7 +67,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac createdAddMetricValuePrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, ndataPayload.build().toByteArray(), 0, false); } @@ -96,7 +96,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac createdAddMetricValueArraysPrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, ndataPayload.build().toByteArray(), 0, false); } diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 8ce6cf3761..5a7c3fd665 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java b/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java index 4b822e0030..c20726d765 100644 --- a/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java +++ b/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java @@ -19,8 +19,8 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.common.util.RecoveryAware; import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.TbActorError; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbActorStopReason; @@ -35,6 +35,7 @@ import java.util.function.Supplier; @Getter @RequiredArgsConstructor public final class TbActorMailbox implements TbActorCtx { + private static final boolean HIGH_PRIORITY = true; private static final boolean NORMAL_PRIORITY = false; @@ -100,7 +101,7 @@ public final class TbActorMailbox implements TbActorCtx { if (t instanceof TbActorException && t.getCause() != null) { t = t.getCause(); } - return t instanceof TbActorError && ((TbActorError) t).isUnrecoverable(); + return t instanceof RecoveryAware recoveryAware && recoveryAware.isUnrecoverable(); } private void enqueue(TbActorMsg msg, boolean highPriority) { @@ -237,8 +238,14 @@ public final class TbActorMailbox implements TbActorCtx { try { ready.set(NOT_READY); actor.destroy(stopReason, cause); - highPriorityMsgs.forEach(msg -> msg.onTbActorStopped(stopReason)); - normalPriorityMsgs.forEach(msg -> msg.onTbActorStopped(stopReason)); + highPriorityMsgs.removeIf(msg -> { + msg.onTbActorStopped(stopReason); + return true; + }); + normalPriorityMsgs.removeIf(msg -> { + msg.onTbActorStopped(stopReason); + return true; + }); } catch (Throwable t) { log.warn("[{}] Failed to destroy actor: ", selfId, t); } diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 5ee70e2902..3094e1601f 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/resourceInfo/ResourceInfoCacheKey.java b/common/cache/src/main/java/org/thingsboard/server/cache/resourceInfo/ResourceInfoCacheKey.java index cb19b36059..e1877ebe4e 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/resourceInfo/ResourceInfoCacheKey.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/resourceInfo/ResourceInfoCacheKey.java @@ -20,7 +20,6 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.thingsboard.server.common.data.id.TbResourceId; -import org.thingsboard.server.common.data.id.TenantId; import java.io.Serial; import java.io.Serializable; @@ -34,12 +33,11 @@ public class ResourceInfoCacheKey implements Serializable { @Serial private static final long serialVersionUID = 2100510964692846992L; - private final TenantId tenantId; private final TbResourceId tbResourceId; @Override public String toString() { - return tenantId + "_" + tbResourceId; + return tbResourceId.toString(); } } diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index d2ad3b48a8..a8a419aba3 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common 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 aed6eb4cf5..6da6fcf8a8 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 @@ -87,6 +87,8 @@ public interface TbClusterService extends TbQueueClusterService { void broadcastEntityStateChangeEvent(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent state); + void broadcast(ComponentLifecycleMsg componentLifecycleMsg); + void onDeviceProfileChange(DeviceProfile deviceProfile, DeviceProfile oldDeviceProfile, TbQueueCallback callback); void onDeviceProfileDelete(DeviceProfile deviceProfile, TbQueueCallback callback); diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java index e15d9c8ace..99523bf21f 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.queue; - public interface TbQueueCallback { TbQueueCallback EMPTY = new TbQueueCallback() { @@ -34,4 +33,5 @@ public interface TbQueueCallback { void onSuccess(TbQueueMsgMetadata metadata); void onFailure(Throwable t); + } diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java index bb5bc1d668..523a97e13c 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java @@ -17,11 +17,12 @@ package org.thingsboard.server.queue; import com.google.common.util.concurrent.ListenableFuture; -/** - * Created by ashvayka on 05.10.18. - */ public interface TbQueueHandler { ListenableFuture handle(Request request); + default Response constructErrorResponseMsg(Request request, Throwable cause) { + return null; + } + } diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueProducer.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueProducer.java index 51fd735a9d..a374928b16 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueProducer.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueProducer.java @@ -19,11 +19,10 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; public interface TbQueueProducer { - void init(); - String getDefaultTopic(); void send(TopicPartitionInfo tpi, T msg, TbQueueCallback callback); void stop(); + } diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index fe6ae3c64c..64867e9020 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common @@ -46,6 +46,10 @@ org.thingsboard.common data + + org.thingsboard.common + discovery-api + org.thingsboard.common.transport transport-api diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java index 98df973c65..5093a5025e 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.coapserver; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${coap.server.enabled}'=='true')") -public @interface TbCoapServerComponent { -} +public @interface TbCoapServerComponent {} diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java index 558ccf16ef..a68810f10c 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java @@ -17,11 +17,12 @@ package org.thingsboard.server.coapserver; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || " + "('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${coap.server.enabled}'=='true' && '${transport.coap.enabled}'=='true')") -public @interface TbCoapTransportComponent { -} +public @interface TbCoapTransportComponent {} diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index a4b4d807dd..7f95023057 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java new file mode 100644 index 0000000000..3ad12048cf --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +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.EntityDaoService; + +import java.util.Optional; + +public interface AiModelService extends EntityDaoService { + + AiModel save(AiModel model); + + Optional findAiModelById(TenantId tenantId, AiModelId modelId); + + PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink); + + Optional findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId); + + FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId); + + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java index 7be61d15d5..e26955d465 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.alarm; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.alarm.Alarm; @@ -105,6 +106,8 @@ public interface AlarmService extends EntityDaoService { Alarm findLatestActiveByOriginatorAndType(TenantId tenantId, EntityId originator, String type); + FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); + PageData findAlarmDataByQueryForEntities(TenantId tenantId, AlarmDataQuery query, Collection orderedEntityIds); 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 718c574c3c..0d5d3dcd13 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 @@ -21,6 +21,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.kv.AttributesSaveResult; import java.util.Collection; import java.util.List; @@ -37,9 +38,9 @@ public interface AttributesService { ListenableFuture> findAll(TenantId tenantId, EntityId entityId, AttributeScope scope); - ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes); + 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); ListenableFuture> removeAll(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributeKeys); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java index 5101d6d57e..85cd8d24fd 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java @@ -31,8 +31,12 @@ public interface CalculatedFieldService extends EntityDaoService { CalculatedField save(CalculatedField calculatedField); + CalculatedField save(CalculatedField calculatedField, boolean doValidate); + CalculatedField findById(TenantId tenantId, CalculatedFieldId calculatedFieldId); + CalculatedField findByEntityIdAndName(EntityId entityId, String name); + List findCalculatedFieldIdsByEntityId(TenantId tenantId, EntityId entityId); List findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java index 65db0d5a76..9adf703e0b 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.entity; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -25,7 +26,9 @@ import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; +import java.util.Map; import java.util.Optional; +import java.util.Set; public interface EntityService { @@ -37,6 +40,8 @@ public interface EntityService { Optional> fetchEntity(TenantId tenantId, EntityId entityId); + Map fetchEntityInfos(TenantId tenantId, CustomerId customerId, Set entityIds); + Optional fetchNameLabelAndCustomerDetails(TenantId tenantId, EntityId entityId); long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/job/JobService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/job/JobService.java new file mode 100644 index 0000000000..4f3f9548d8 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/job/JobService.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobFilter; +import org.thingsboard.server.common.data.job.JobStats; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entity.EntityDaoService; + +public interface JobService extends EntityDaoService { + + Job saveJob(TenantId tenantId, Job job); + + Job findJobById(TenantId tenantId, JobId jobId); + + void cancelJob(TenantId tenantId, JobId jobId); + + void markAsFailed(TenantId tenantId, JobId jobId, String error); + + void processStats(TenantId tenantId, JobId jobId, JobStats jobStats); + + PageData findJobsByFilter(TenantId tenantId, JobFilter filter, PageLink pageLink); + + Job findLatestJobByKey(TenantId tenantId, String key); + + void deleteJob(TenantId tenantId, JobId jobId); + + int deleteJobsByEntityId(TenantId tenantId, EntityId entityId); + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java index 12c8e5053b..b0cdb4d384 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java @@ -41,6 +41,8 @@ public interface OtaPackageService extends EntityDaoService { OtaPackageInfo findOtaPackageInfoById(TenantId tenantId, OtaPackageId otaPackageId); + OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version); + ListenableFuture findOtaPackageInfoByIdAsync(TenantId tenantId, OtaPackageId otaPackageId); PageData findTenantOtaPackagesByTenantId(TenantId tenantId, PageLink pageLink); @@ -52,4 +54,5 @@ public interface OtaPackageService extends EntityDaoService { void deleteOtaPackagesByTenantId(TenantId tenantId); long sumDataSizeByTenantId(TenantId tenantId); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java index b803700582..5223a2a9c9 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java @@ -18,8 +18,9 @@ package org.thingsboard.server.dao.settings; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.id.AdminSettingsId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.entity.EntityDaoService; -public interface AdminSettingsService { +public interface AdminSettingsService extends EntityDaoService { AdminSettings findAdminSettingsById(TenantId tenantId, AdminSettingsId adminSettingsId); @@ -31,6 +32,4 @@ public interface AdminSettingsService { boolean deleteAdminSettingsByTenantIdAndKey(TenantId tenantId, String key); - void deleteAdminSettingsByTenantId(TenantId tenantId); - } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java index a7a774b83a..d70bcef209 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java @@ -49,7 +49,10 @@ public interface TenantService extends EntityDaoService { List findTenantIdsByTenantProfileId(TenantProfileId tenantProfileId); + Tenant findTenantByName(String name); + void deleteTenants(); PageData findTenantsIds(PageLink pageLink); + } diff --git a/common/data/pom.xml b/common/data/pom.xml index 648f3d1086..f582c32023 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common @@ -112,6 +112,10 @@ leshan-core compile + + dev.langchain4j + langchain4j-core + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/AdminSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/AdminSettings.java index ea3297e621..6092e65648 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/AdminSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/AdminSettings.java @@ -32,8 +32,8 @@ public class AdminSettings extends BaseData implements HasTenan @NoXss @Length(fieldName = "key") private String key; - private transient JsonNode jsonValue; - + private JsonNode jsonValue; + public AdminSettings() { super(); } @@ -41,7 +41,7 @@ public class AdminSettings extends BaseData implements HasTenan public AdminSettings(AdminSettingsId id) { super(id); } - + public AdminSettings(AdminSettings adminSettings) { super(adminSettings); this.tenantId = adminSettings.getTenantId(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java b/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java index 10ea83397b..614cf67054 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.id.IdBased; import org.thingsboard.server.common.data.id.UUIDBased; @@ -25,9 +26,9 @@ public abstract class BaseData extends IdBased implement private static final long serialVersionUID = 5422817607129962637L; public static final ObjectMapper mapper = new ObjectMapper(); - + protected long createdTime; - + public BaseData() { super(); } @@ -35,12 +36,17 @@ public abstract class BaseData extends IdBased implement public BaseData(I id) { super(id); } - + public BaseData(BaseData data) { super(data.getId()); this.createdTime = data.getCreatedTime(); } + @Schema( + description = "Entity creation timestamp in milliseconds since Unix epoch", + example = "1746028547220", + accessMode = Schema.AccessMode.READ_ONLY + ) public long getCreatedTime() { return createdTime; } @@ -53,7 +59,7 @@ public abstract class BaseData extends IdBased implement public int hashCode() { final int prime = 31; int result = super.hashCode(); - result = prime * result + (int) (createdTime ^ (createdTime >>> 32)); + result = prime * result + Long.hashCode(createdTime); return result; } 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 5b167c88a2..b55453f393 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 @@ -15,7 +15,10 @@ */ package org.thingsboard.server.common.data; -public class CacheConstants { +public final class CacheConstants { + + private CacheConstants() {} + public static final String DEVICE_CREDENTIALS_CACHE = "deviceCredentials"; public static final String RELATIONS_CACHE = "relations"; public static final String DEVICE_CACHE = "devices"; @@ -36,6 +39,7 @@ public class CacheConstants { public static final String NOTIFICATION_SETTINGS_CACHE = "notificationSettings"; public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; + public static final String AI_MODEL_CACHE = "aiModel"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; @@ -54,4 +58,5 @@ public class CacheConstants { public static final String ALARM_TYPES_CACHE = "alarmTypes"; public static final String QR_CODE_SETTINGS_CACHE = "qrCodeSettings"; public static final String MOBILE_SECRET_KEY_CACHE = "mobileSecretKey"; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java b/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java index 1deb322d39..b5ec74c2d3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java @@ -68,8 +68,8 @@ public class Dashboard extends DashboardInfo implements ExportableEntity implements HasName, HasTenantId, HasTitle { +public class OtaPackageInfo extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, HasTitle, ExportableEntity { + @Serial private static final long serialVersionUID = 3168391583570815419L; @Schema(description = "JSON object with Tenant Id. Tenant Id of the ota package can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) @@ -77,6 +81,8 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp @Schema(description = "OTA Package data size.", example = "8", accessMode = Schema.AccessMode.READ_ONLY) private Long dataSize; + private OtaPackageId externalId; + public OtaPackageInfo() { super(); } @@ -100,6 +106,7 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp this.checksumAlgorithm = otaPackageInfo.getChecksumAlgorithm(); this.checksum = otaPackageInfo.getChecksum(); this.dataSize = otaPackageInfo.getDataSize(); + this.externalId = otaPackageInfo.getExternalId(); } @Schema(description = "JSON object with the ota package Id. " + @@ -118,7 +125,7 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp } @Override - @JsonIgnore + @JsonProperty(access = JsonProperty.Access.READ_ONLY) public String getName() { return title; } @@ -133,4 +140,5 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfoData.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfoData.java index c979fbffd1..afbad6e3a2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfoData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfoData.java @@ -20,6 +20,7 @@ import lombok.Data; @Data public class SystemInfoData { + @Schema(description = "Service Id.") private String serviceId; @Schema(description = "Service type.") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index 498fa5be3e..ba37067106 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -23,6 +23,7 @@ import lombok.EqualsAndHashCode; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.TbResourceId; +import java.io.Serial; import java.util.Base64; import java.util.Optional; @@ -31,6 +32,7 @@ import java.util.Optional; @EqualsAndHashCode(callSuper = true) public class TbResource extends TbResourceInfo { + @Serial private static final long serialVersionUID = 7379609705527272306L; private byte[] data; @@ -88,4 +90,5 @@ public class TbResource extends TbResourceInfo { public String toString() { return super.toString(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java new file mode 100644 index 0000000000..4d7bb21930 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java @@ -0,0 +1,102 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoNullChar; + +import java.io.Serial; + +@Data +@Builder +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public final class AiModel extends BaseData implements HasTenantId, HasVersion, ExportableEntity { + + @Serial + private static final long serialVersionUID = 9017108678716011604L; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, + description = "JSON object representing the ID of the tenant associated with this AI model", + example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" + ) + private TenantId tenantId; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, + description = "Version of the AI model record; increments automatically whenever the record is changed", + example = "7", + defaultValue = "1" + ) + private Long version; + + @NotBlank + @NoNullChar + @Length(min = 1, max = 255) + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Display name for this AI model configuration; not the technical model identifier", + example = "Fast and cost-efficient model" + ) + private String name; + + @NotNull + @Valid + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Configuration of the AI model" + ) + private AiModelConfig configuration; + + private AiModelId externalId; + + public AiModel() {} + + public AiModel(AiModelId id) { + super(id); + } + + public AiModel(AiModel model) { + super(model.getId()); + createdTime = model.getCreatedTime(); + tenantId = model.getTenantId(); + version = model.getVersion(); + name = model.getName(); + configuration = model.getConfiguration(); + externalId = model.getExternalId() == null ? null : new AiModelId(model.getExternalId().getId()); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java new file mode 100644 index 0000000000..7e43520b79 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.dto; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; + +import java.util.ArrayList; +import java.util.List; + +public record TbChatRequest( + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "A system-level instruction that frames the user's input, setting the persona, tone, and constraints for the generated response", + example = "You are a helpful assistant. Only output valid JSON." + ) + String systemMessage, + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "The actual user prompt that will be answered by the AI model" + ) + @NotNull @Valid + TbUserMessage userMessage, + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Configuration of the AI chat model that should execute the request" + ) + @NotNull @Valid + AiChatModelConfig chatModelConfig +) { + + public ChatRequest toLangChainChatRequest() { + return ChatRequest.builder() + .messages(getLangChainMessages()) + .build(); + } + + private List getLangChainMessages() { + List messages = new ArrayList<>(2); + + if (systemMessage != null) { + messages.add(SystemMessage.from(systemMessage)); + } + + List langChainContents = userMessage.contents().stream() + .map(TbContent::toLangChainContent) + .toList(); + + messages.add(UserMessage.from(langChainContents)); + + return messages; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java new file mode 100644 index 0000000000..2cc17e4553 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "status", + include = JsonTypeInfo.As.PROPERTY, + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TbChatResponse.Success.class, name = "SUCCESS"), + @JsonSubTypes.Type(value = TbChatResponse.Failure.class, name = "FAILURE") +}) +public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatResponse.Failure { + + @Schema( + description = "Indicates whether the request was successful or not", + example = "SUCCESS" + ) + String getStatus(); + + record Success( + @Schema(description = "The text content generated by the model") + String generatedContent + ) implements TbChatResponse { + + @Override + @Schema(example = "SUCCESS") + public String getStatus() { + return "SUCCESS"; + } + + } + + record Failure( + @Schema( + description = "A string containing details about the failure" + ) + String errorDetails + ) implements TbChatResponse { + + @Override + @Schema(example = "FAILURE") + public String getStatus() { + return "FAILURE"; + } + + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java new file mode 100644 index 0000000000..23543121a4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.TextContent; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +import static org.thingsboard.server.common.data.ai.dto.TbContent.TbTextContent; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "contentType", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TbTextContent.class, name = "TEXT") +}) +public sealed interface TbContent permits TbTextContent { + + TbContentType contentType(); + + Content toLangChainContent(); + + enum TbContentType { + + TEXT + + } + + @Schema( + description = "Text-based content part of a user's prompt" + ) + record TbTextContent( + @NotBlank + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "The text content", + example = "What is the weather like in Kyiv today?" + ) + String text + ) implements TbContent { + + @Override + public TbContentType contentType() { + return TbContentType.TEXT; + } + + @Override + public Content toLangChainContent() { + return TextContent.from(text); + } + + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java new file mode 100644 index 0000000000..fdd7d8dc63 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record TbUserMessage( + @NotEmpty + @Valid + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "A list of content parts that make up the complete user prompt" + ) + List contents +) {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java new file mode 100644 index 0000000000..0a2b41a91f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "provider", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = AzureOpenAiChatModelConfig.class, name = "AZURE_OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModelConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = GoogleVertexAiGeminiChatModelConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), + @JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"), + @JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"), + @JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"), + @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS") +}) +public interface AiModelConfig { + + AiProvider provider(); + + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXTERNAL_PROPERTY, + property = "provider" + ) + @JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), + @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"), + @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"), + @JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"), + @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS") + }) + AiProviderConfig providerConfig(); + + AiModelType modelType(); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/TenantRateLimitException.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java similarity index 86% rename from dao/src/main/java/org/thingsboard/server/dao/util/TenantRateLimitException.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java index 3d79af980d..d6299cf5e6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/TenantRateLimitException.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.util; +package org.thingsboard.server.common.data.ai.model; + +public enum AiModelType { + + CHAT -public class TenantRateLimitException extends Exception { } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java new file mode 100644 index 0000000000..2bc28cfce0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.AiModelType; + +public sealed interface AiChatModelConfig> extends AiModelConfig + permits + OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig, + GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig, + AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig { + + ChatModel configure(Langchain4jChatModelConfigurer configurer); + + @Override + @JsonProperty(value = "modelType", access = JsonProperty.Access.READ_ONLY) + default AiModelType modelType() { + return AiModelType.CHAT; + } + + Integer timeoutSeconds(); + + Integer maxRetries(); + + C withTimeoutSeconds(Integer timeoutSeconds); + + C withMaxRetries(Integer maxRetries); + + boolean supportsJsonMode(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java new file mode 100644 index 0000000000..2bb4de5aa8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; + +@Builder +public record AmazonBedrockChatModelConfig( + @NotNull @Valid AmazonBedrockProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.AMAZON_BEDROCK; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return false; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java new file mode 100644 index 0000000000..69b5578fb3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; + +@Builder +public record AnthropicChatModelConfig( + @NotNull @Valid AnthropicProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.ANTHROPIC; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return false; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java new file mode 100644 index 0000000000..47e7e96c37 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; + +@Builder +public record AzureOpenAiChatModelConfig( + @NotNull @Valid AzureOpenAiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.AZURE_OPENAI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java new file mode 100644 index 0000000000..b509254f77 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; + +@Builder +public record GitHubModelsChatModelConfig( + @NotNull @Valid GitHubModelsProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.GITHUB_MODELS; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return false; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java new file mode 100644 index 0000000000..fe11a11460 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; + +@Builder +public record GoogleAiGeminiChatModelConfig( + @NotNull @Valid GoogleAiGeminiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.GOOGLE_AI_GEMINI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java new file mode 100644 index 0000000000..609e14f86e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; + +@Builder +public record GoogleVertexAiGeminiChatModelConfig( + @NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.GOOGLE_VERTEX_AI_GEMINI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java new file mode 100644 index 0000000000..c9c1bc3173 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; + +public interface Langchain4jChatModelConfigurer { + + ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig); + + ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig); + + ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java new file mode 100644 index 0000000000..f603e99c53 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; + +@Builder +public record MistralAiChatModelConfig( + @NotNull @Valid MistralAiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.MISTRAL_AI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java new file mode 100644 index 0000000000..00b5115d7d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +@Builder +public record OpenAiChatModelConfig( + @NotNull @Valid OpenAiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.OPENAI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java new file mode 100644 index 0000000000..d0a5bd0510 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +public enum AiProvider { + + OPENAI, + AZURE_OPENAI, + GOOGLE_AI_GEMINI, + GOOGLE_VERTEX_AI_GEMINI, + MISTRAL_AI, + ANTHROPIC, + AMAZON_BEDROCK, + GITHUB_MODELS + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java new file mode 100644 index 0000000000..bd32c88efb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +public sealed interface AiProviderConfig + permits + OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, + GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig, + AmazonBedrockProviderConfig, GitHubModelsProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java new file mode 100644 index 0000000000..e705b545c2 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record AmazonBedrockProviderConfig( + @NotNull String region, + @NotNull String accessKeyId, + @NotNull String secretAccessKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java new file mode 100644 index 0000000000..d6db07941b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record AnthropicProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java new file mode 100644 index 0000000000..ea7ffebe3a --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record AzureOpenAiProviderConfig( + @NotNull String endpoint, + String serviceVersion, + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java new file mode 100644 index 0000000000..f5240fe836 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record GitHubModelsProviderConfig( + @NotNull String personalAccessToken +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java new file mode 100644 index 0000000000..bfa729e66d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record GoogleAiGeminiProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java new file mode 100644 index 0000000000..b0efac764c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record GoogleVertexAiGeminiProviderConfig( + @NotBlank String fileName, // not used on BE, but needed for UI + @NotNull String projectId, + @NotNull String location, + @NotNull String serviceAccountKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java new file mode 100644 index 0000000000..eb62557a15 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record MistralAiProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java new file mode 100644 index 0000000000..09ffda837b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record OpenAiProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java index c53f1fe5f1..ad3d4373ad 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; @@ -36,6 +37,7 @@ import java.util.Map; @JsonSubTypes.Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"), @JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT") }) +@JsonIgnoreProperties(ignoreUnknown = true) public interface CalculatedFieldConfiguration { @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java index af3cb4d5cd..86c7b9e9b6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; @EqualsAndHashCode(callSuper = true) public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { - private boolean preserveMsgTs; + private boolean useLatestTs; @Override public CalculatedFieldType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java index 6f1ae8150f..0d5c3f34ad 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java @@ -41,12 +41,13 @@ public enum EdgeEventType { ADMIN_SETTINGS(true, null), OTA_PACKAGE(true, EntityType.OTA_PACKAGE), QUEUE(true, EntityType.QUEUE), - NOTIFICATION_RULE (true, EntityType.NOTIFICATION_RULE), - NOTIFICATION_TARGET (true, EntityType.NOTIFICATION_TARGET), - NOTIFICATION_TEMPLATE (true, EntityType.NOTIFICATION_TEMPLATE), + NOTIFICATION_RULE(true, EntityType.NOTIFICATION_RULE), + NOTIFICATION_TARGET(true, EntityType.NOTIFICATION_TARGET), + NOTIFICATION_TEMPLATE(true, EntityType.NOTIFICATION_TEMPLATE), TB_RESOURCE(true, EntityType.TB_RESOURCE), OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT), - DOMAIN(true, EntityType.DOMAIN); + DOMAIN(true, EntityType.DOMAIN), + CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD); private final boolean allEdgesRelated; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/AttributeKv.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/AttributeKv.java index c162365257..3ffd6ad424 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/AttributeKv.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/AttributeKv.java @@ -25,6 +25,8 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; +import java.util.UUID; + @Data @AllArgsConstructor @NoArgsConstructor @@ -58,7 +60,7 @@ public class AttributeKv implements EdqsObject { } @Override - public String key() { + public String stringKey() { return "a_" + entityId + "_" + scope + "_" + key; } @@ -72,4 +74,6 @@ public class AttributeKv implements EdqsObject { return ObjectType.ATTRIBUTE_KV; } + public record Key(UUID entityId, AttributeScope scope, int key) implements EdqsObjectKey {} + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObject.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObject.java index a74c90208a..f4685f896a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObject.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObject.java @@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.ObjectType; public interface EdqsObject { @JsonIgnore - String key(); + String stringKey(); @JsonIgnore Long version(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObjectKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObjectKey.java new file mode 100644 index 0000000000..8d48bbd383 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObjectKey.java @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edqs; + +public interface EdqsObjectKey {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsState.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsState.java new file mode 100644 index 0000000000..1e890da961 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsState.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edqs; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import static org.apache.commons.lang3.BooleanUtils.toBooleanDefaultIfNull; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class EdqsState { + + private Boolean edqsReady; + @Setter + private EdqsSyncStatus syncStatus; + @Setter + private EdqsApiMode apiMode; + + public boolean updateEdqsReady(boolean ready) { + boolean changed = toBooleanDefaultIfNull(this.edqsReady, false) != ready; + this.edqsReady = ready; + return changed; + } + + @JsonIgnore + public boolean isApiReady() { + return toBooleanDefaultIfNull(edqsReady, false) && syncStatus == EdqsSyncStatus.FINISHED; + } + + @JsonIgnore + public boolean isApiEnabled() { + return apiMode != null && (apiMode == EdqsApiMode.ENABLED || apiMode == EdqsApiMode.AUTO_ENABLED); + } + + @Override + public String toString() { + return '[' + + "EDQS ready: " + edqsReady + + ", sync status: " + syncStatus + + ", API mode: " + apiMode + + ']'; + } + + public enum EdqsSyncStatus { + REQUESTED, + STARTED, + FINISHED, + FAILED + } + + public enum EdqsApiMode { + ENABLED, + AUTO_ENABLED, + DISABLED, + AUTO_DISABLED + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/Entity.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/Entity.java index c22ef147e3..96a5b26aee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/Entity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/Entity.java @@ -51,7 +51,7 @@ public class Entity implements EdqsObject { } @Override - public String key() { + public String stringKey() { return "e_" + fields.getId().toString(); } @@ -65,4 +65,6 @@ public class Entity implements EdqsObject { return ObjectType.fromEntityType(type); } + public record Key(UUID id) implements EdqsObjectKey {} + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/LatestTsKv.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/LatestTsKv.java index 8bd69c41a4..12695de423 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/LatestTsKv.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/LatestTsKv.java @@ -24,6 +24,8 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import java.util.UUID; + @Data @AllArgsConstructor @NoArgsConstructor @@ -53,7 +55,8 @@ public class LatestTsKv implements EdqsObject { this.version = version != null ? version : 0L; } - public String key() { + @Override + public String stringKey() { return "l_" + entityId + "_" + key; } @@ -67,4 +70,6 @@ public class LatestTsKv implements EdqsObject { return ObjectType.LATEST_TS_KV; } + public record Key(UUID entityId, int key) implements EdqsObjectKey {} + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsMsg.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsMsg.java index 78bebba20a..d581a5d17c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsMsg.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsMsg.java @@ -19,6 +19,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.edqs.EdqsState.EdqsSyncStatus; @Data @AllArgsConstructor @@ -29,4 +30,7 @@ public class ToCoreEdqsMsg { private EdqsSyncRequest syncRequest; private Boolean apiEnabled; + private EdqsSyncStatus syncStatus; + private Boolean edqsReady; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsRequest.java index c4f262fbf0..44bbfa2cc9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsRequest.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.edqs; -import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -30,9 +29,4 @@ public class ToCoreEdqsRequest { private EdqsSyncRequest syncRequest; private Boolean apiEnabled; - @JsonIgnore - public ToCoreEdqsMsg toInternalMsg() { - return new ToCoreEdqsMsg(syncRequest, apiEnabled); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AbstractEntityFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AbstractEntityFields.java index 4aad5eb4dd..e6dad28193 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AbstractEntityFields.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AbstractEntityFields.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.edqs.fields; import lombok.Data; import lombok.experimental.SuperBuilder; -import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; import java.util.UUID; @@ -36,7 +36,7 @@ public class AbstractEntityFields implements EntityFields { this.id = id; this.createdTime = createdTime; this.tenantId = tenantId; - this.customerId = (customerId != null && customerId != CustomerId.NULL_UUID) ? customerId : null; + this.customerId = checkId(customerId); this.name = name; this.version = version; } @@ -62,4 +62,13 @@ public class AbstractEntityFields implements EntityFields { this(id, createdTime, tenantId, null, null, null); } + protected UUID checkId(UUID id) { + return id == null || id.equals(EntityId.NULL_UUID) ? null : id; + } + + @Override + public UUID getCustomerId() { + return checkId(customerId); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityFields.java index 1b0975542c..2eae806f43 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityFields.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityFields.java @@ -29,9 +29,7 @@ public interface EntityFields { Logger log = LoggerFactory.getLogger(EntityFields.class); - default UUID getId() { - return null; - } + UUID getId(); default UUID getTenantId() { return null; @@ -147,6 +145,7 @@ public interface EntityFields { default String getAsString(String key) { return switch (key) { + case "id" -> getId().toString(); case "createdTime" -> Long.toString(getCreatedTime()); case "title" -> getName(); case "type" -> getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java index ed02d22a74..2d88cef037 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java @@ -85,6 +85,10 @@ public class HousekeeperTask implements Serializable { return new HousekeeperTask(tenantId, entityId, HousekeeperTaskType.DELETE_CALCULATED_FIELDS); } + public static HousekeeperTask deleteJobs(TenantId tenantId, EntityId entityId) { + return new HousekeeperTask(tenantId, entityId, HousekeeperTaskType.DELETE_JOBS); + } + @JsonIgnore public String getDescription() { return taskType.getDescription() + " for " + entityId.getEntityType().getNormalName().toLowerCase() + " " + entityId.getId(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java index ef217debc3..e84642b599 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java @@ -31,7 +31,8 @@ public enum HousekeeperTaskType { UNASSIGN_ALARMS("alarms unassigning"), DELETE_TENANT_ENTITIES("tenant entities deletion"), DELETE_ENTITIES("entities deletion"), - DELETE_CALCULATED_FIELDS("calculated fields deletion"); + DELETE_CALCULATED_FIELDS("calculated fields deletion"), + DELETE_JOBS("jobs deletion"); private final String description; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java index 8f2104b5fd..05194b8df5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java @@ -17,14 +17,26 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; -public class AdminSettingsId extends UUIDBased { +public class AdminSettingsId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = -4208011957475806567L; @JsonCreator - public AdminSettingsId(@JsonProperty("id") UUID id){ + public AdminSettingsId(@JsonProperty("id") UUID id) { super(id); } - + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ADMIN_SETTINGS", allowableValues = "ADMIN_SETTINGS") + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java new file mode 100644 index 0000000000..cac9e8200c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.io.Serial; +import java.util.UUID; + +public final class AiModelId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = 3021036138554389754L; + + @JsonCreator + public AiModelId(@JsonProperty("id") UUID id) { + super(id); + } + + @Override + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "Entity type of the AI model", + example = "AI_MODEL", + allowableValues = "AI_MODEL" + ) + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + + public static AiModelId fromString(String uuid) { + return new AiModelId(UUID.fromString(uuid)); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java index 9a20be4eb9..4ccabd39b8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java @@ -23,10 +23,12 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public class EdgeId extends UUIDBased implements EntityId { + @Serial private static final long serialVersionUID = 1L; @JsonIgnore @@ -51,4 +53,5 @@ public class EdgeId extends UUIDBased implements EntityId { public static EdgeId fromUUID(@JsonProperty("id") UUID id) { return edges.computeIfAbsent(id, EdgeId::new); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java index 24196e28b5..ddfcd85dc8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java @@ -24,10 +24,6 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serializable; import java.util.UUID; -/** - * @author Andrew Shvayka - */ - @JsonDeserialize(using = EntityIdDeserializer.class) @JsonSerialize(using = EntityIdSerializer.class) @Schema diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index f5dd4b12a0..fc1c1cd1a9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -20,9 +20,6 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import java.util.UUID; -/** - * Created by ashvayka on 25.04.17. - */ public class EntityIdFactory { public static EntityId getByTypeAndUuid(int type, String uuid) { @@ -50,127 +47,74 @@ public class EntityIdFactory { } public static EntityId getByTypeAndUuid(EntityType type, UUID uuid) { - switch (type) { - case TENANT: - return TenantId.fromUUID(uuid); - case CUSTOMER: - return new CustomerId(uuid); - case USER: - return new UserId(uuid); - case DASHBOARD: - return new DashboardId(uuid); - case DEVICE: - return new DeviceId(uuid); - case ASSET: - return new AssetId(uuid); - case ALARM: - return new AlarmId(uuid); - case RULE_CHAIN: - return new RuleChainId(uuid); - case RULE_NODE: - return new RuleNodeId(uuid); - case ENTITY_VIEW: - return new EntityViewId(uuid); - case WIDGETS_BUNDLE: - return new WidgetsBundleId(uuid); - case WIDGET_TYPE: - return new WidgetTypeId(uuid); - case DEVICE_PROFILE: - return new DeviceProfileId(uuid); - case ASSET_PROFILE: - return new AssetProfileId(uuid); - case TENANT_PROFILE: - return new TenantProfileId(uuid); - case API_USAGE_STATE: - return new ApiUsageStateId(uuid); - case TB_RESOURCE: - return new TbResourceId(uuid); - case OTA_PACKAGE: - return new OtaPackageId(uuid); - case EDGE: - return new EdgeId(uuid); - case RPC: - return new RpcId(uuid); - case QUEUE: - return new QueueId(uuid); - case NOTIFICATION_TARGET: - return new NotificationTargetId(uuid); - case NOTIFICATION_REQUEST: - return new NotificationRequestId(uuid); - case NOTIFICATION_RULE: - return new NotificationRuleId(uuid); - case NOTIFICATION_TEMPLATE: - return new NotificationTemplateId(uuid); - case NOTIFICATION: - return new NotificationId(uuid); - case QUEUE_STATS: - return new QueueStatsId(uuid); - case OAUTH2_CLIENT: - return new OAuth2ClientId(uuid); - case MOBILE_APP: - return new MobileAppId(uuid); - case DOMAIN: - return new DomainId(uuid); - case MOBILE_APP_BUNDLE: - return new MobileAppBundleId(uuid); - case CALCULATED_FIELD: - return new CalculatedFieldId(uuid); - case CALCULATED_FIELD_LINK: - return new CalculatedFieldLinkId(uuid); - } - throw new IllegalArgumentException("EntityType " + type + " is not supported!"); + return switch (type) { + case TENANT -> TenantId.fromUUID(uuid); + case CUSTOMER -> new CustomerId(uuid); + case USER -> new UserId(uuid); + case DASHBOARD -> new DashboardId(uuid); + case DEVICE -> new DeviceId(uuid); + case ASSET -> new AssetId(uuid); + case ALARM -> new AlarmId(uuid); + case RULE_CHAIN -> new RuleChainId(uuid); + case RULE_NODE -> new RuleNodeId(uuid); + case ENTITY_VIEW -> new EntityViewId(uuid); + case WIDGETS_BUNDLE -> new WidgetsBundleId(uuid); + case WIDGET_TYPE -> new WidgetTypeId(uuid); + case DEVICE_PROFILE -> new DeviceProfileId(uuid); + case ASSET_PROFILE -> new AssetProfileId(uuid); + case TENANT_PROFILE -> new TenantProfileId(uuid); + case API_USAGE_STATE -> new ApiUsageStateId(uuid); + case TB_RESOURCE -> new TbResourceId(uuid); + case OTA_PACKAGE -> new OtaPackageId(uuid); + case EDGE -> new EdgeId(uuid); + case RPC -> new RpcId(uuid); + case QUEUE -> new QueueId(uuid); + case NOTIFICATION_TARGET -> new NotificationTargetId(uuid); + case NOTIFICATION_REQUEST -> new NotificationRequestId(uuid); + case NOTIFICATION_RULE -> new NotificationRuleId(uuid); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateId(uuid); + case NOTIFICATION -> new NotificationId(uuid); + case QUEUE_STATS -> new QueueStatsId(uuid); + case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); + case MOBILE_APP -> new MobileAppId(uuid); + case DOMAIN -> new DomainId(uuid); + case MOBILE_APP_BUNDLE -> new MobileAppBundleId(uuid); + case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid); + case JOB -> new JobId(uuid); + case ADMIN_SETTINGS -> new AdminSettingsId(uuid); + case AI_MODEL -> new AiModelId(uuid); + }; } public static EntityId getByEdgeEventTypeAndUuid(EdgeEventType edgeEventType, UUID uuid) { - switch (edgeEventType) { - case TENANT: - return TenantId.fromUUID(uuid); - case CUSTOMER: - return new CustomerId(uuid); - case USER: - return new UserId(uuid); - case DASHBOARD: - return new DashboardId(uuid); - case DEVICE: - return new DeviceId(uuid); - case ASSET: - return new AssetId(uuid); - case ALARM: - return new AlarmId(uuid); - case RULE_CHAIN: - return new RuleChainId(uuid); - case ENTITY_VIEW: - return new EntityViewId(uuid); - case WIDGETS_BUNDLE: - return new WidgetsBundleId(uuid); - case WIDGET_TYPE: - return new WidgetTypeId(uuid); - case DEVICE_PROFILE: - return new DeviceProfileId(uuid); - case ASSET_PROFILE: - return new AssetProfileId(uuid); - case TENANT_PROFILE: - return new TenantProfileId(uuid); - case OTA_PACKAGE: - return new OtaPackageId(uuid); - case EDGE: - return new EdgeId(uuid); - case QUEUE: - return new QueueId(uuid); - case TB_RESOURCE: - return new TbResourceId(uuid); - case NOTIFICATION_RULE: - return new NotificationRuleId(uuid); - case NOTIFICATION_TARGET: - return new NotificationTargetId(uuid); - case NOTIFICATION_TEMPLATE: - return new NotificationTemplateId(uuid); - case OAUTH2_CLIENT: - return new OAuth2ClientId(uuid); - case DOMAIN: - return new DomainId(uuid); - } - throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); + return switch (edgeEventType) { + case TENANT -> TenantId.fromUUID(uuid); + case CUSTOMER -> new CustomerId(uuid); + case USER -> new UserId(uuid); + case DASHBOARD -> new DashboardId(uuid); + case DEVICE -> new DeviceId(uuid); + case ASSET -> new AssetId(uuid); + case ALARM -> new AlarmId(uuid); + case RULE_CHAIN -> new RuleChainId(uuid); + case ENTITY_VIEW -> new EntityViewId(uuid); + case WIDGETS_BUNDLE -> new WidgetsBundleId(uuid); + case WIDGET_TYPE -> new WidgetTypeId(uuid); + case DEVICE_PROFILE -> new DeviceProfileId(uuid); + case ASSET_PROFILE -> new AssetProfileId(uuid); + case TENANT_PROFILE -> new TenantProfileId(uuid); + case OTA_PACKAGE -> new OtaPackageId(uuid); + case EDGE -> EdgeId.fromUUID(uuid); + case QUEUE -> new QueueId(uuid); + case TB_RESOURCE -> new TbResourceId(uuid); + case NOTIFICATION_RULE -> new NotificationRuleId(uuid); + case NOTIFICATION_TARGET -> new NotificationTargetId(uuid); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateId(uuid); + case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); + case DOMAIN -> new DomainId(uuid); + case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); + }; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java new file mode 100644 index 0000000000..372452825f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.io.Serial; +import java.util.UUID; + +public class JobId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = -2225072123132918395L; + + @JsonCreator + public JobId(@JsonProperty("id") UUID id) { + super(id); + } + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "JOB", allowableValues = "JOB") + @Override + public EntityType getEntityType() { + return EntityType.JOB; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java index 2a8efc5bfb..a246ab5f94 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java @@ -20,10 +20,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public class OtaPackageId extends UUIDBased implements EntityId { + @Serial private static final long serialVersionUID = 1L; @JsonCreator diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java index 57561fb7ee..7cad1698d7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java @@ -23,6 +23,7 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public final class TenantId extends UUIDBased implements EntityId { @@ -33,6 +34,7 @@ public final class TenantId extends UUIDBased implements EntityId { @JsonIgnore public static final TenantId SYS_TENANT_ID = TenantId.fromUUID(EntityId.NULL_UUID); + @Serial private static final long serialVersionUID = 1L; @JsonCreator diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java new file mode 100644 index 0000000000..decdc71177 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString(callSuper = true) +public class DummyJobConfiguration extends JobConfiguration { + + private long taskProcessingTimeMs; + private int successfulTasksCount; + private int failedTasksCount; + private int permanentlyFailedTasksCount; + private List errors; + private int retries; + private long taskProcessingTimeoutMs; + + private String generalError; + private int submittedTasksBeforeGeneralError; + + @Override + public JobType getType() { + return JobType.DUMMY; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobResult.java new file mode 100644 index 0000000000..031a733d51 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobResult.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +public class DummyJobResult extends JobResult { + + @Override + public JobType getJobType() { + return JobType.DUMMY; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java new file mode 100644 index 0000000000..4af60bbd5e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.Set; +import java.util.UUID; + +@Data +@NoArgsConstructor +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +public class Job extends BaseData implements HasTenantId { + + @NotNull + private TenantId tenantId; + @NotNull + private JobType type; + @NotBlank + private String key; + @NotNull + private EntityId entityId; + private String entityName; // read-only + @NotNull + private JobStatus status; + @NotNull + @Valid + private JobConfiguration configuration; + @NotNull + private JobResult result; + + public static final Set SUPPORTED_ENTITY_TYPES = Set.of( + EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE + ); + + @Builder(toBuilder = true) + public Job(TenantId tenantId, JobType type, String key, EntityId entityId, JobConfiguration configuration) { + this.tenantId = tenantId; + this.type = type; + this.key = key; + this.entityId = entityId; + this.configuration = configuration; + this.configuration.setTasksKey(UUID.randomUUID().toString()); + presetResult(); + } + + public void presetResult() { + this.result = switch (type) { + case DUMMY -> new DummyJobResult(); + }; + } + + @SuppressWarnings("unchecked") + public C getConfiguration() { + return (C) configuration; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java new file mode 100644 index 0000000000..54d8166a5d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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.NotBlank; +import lombok.Data; +import org.thingsboard.server.common.data.job.task.TaskResult; + +import java.io.Serializable; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @Type(name = "DUMMY", value = DummyJobConfiguration.class), +}) +@Data +public abstract class JobConfiguration implements Serializable { + + @NotBlank + private String tasksKey; // internal + private List toReprocess; // internal + + @JsonIgnore + public abstract JobType getType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobFilter.java new file mode 100644 index 0000000000..1a678ba34b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobFilter.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.UUID; + +@Data +@Builder +public class JobFilter { + + private final List types; + private final List statuses; + private final List entities; + private final Long startTime; + private final Long endTime; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java new file mode 100644 index 0000000000..bd2c73d0d8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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 lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.job.task.TaskResult; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "jobType") +@JsonSubTypes({ + @Type(name = "DUMMY", value = DummyJobResult.class) +}) +@Data +@NoArgsConstructor +public abstract class JobResult implements Serializable { + + private int successfulCount; + private int failedCount; + private int discardedCount; + private Integer totalCount = null; // set when all tasks are submitted + private List results = new ArrayList<>(); + private String generalError; + + private long startTs; + private long finishTs; + private long cancellationTs; + + @JsonIgnore + public int getCompletedCount() { + return successfulCount + failedCount + discardedCount; + } + + public void processTaskResult(TaskResult taskResult) { + if (taskResult.isSuccess()) { + successfulCount++; + } else if (taskResult.isDiscarded()) { + discardedCount++; + } else { + failedCount++; + if (results.size() < 100) { // preserving only first 100 errors, not reprocessing if there are more failures + results.add(taskResult); + } + } + } + + @JsonIgnore + public abstract JobType getJobType(); + +} diff --git a/edqs/src/main/java/org/thingsboard/server/edqs/DummyTenantRoutingInfoService.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobStats.java similarity index 60% rename from edqs/src/main/java/org/thingsboard/server/edqs/DummyTenantRoutingInfoService.java rename to common/data/src/main/java/org/thingsboard/server/common/data/job/JobStats.java index 4e16e5e16a..50a3b1d759 100644 --- a/edqs/src/main/java/org/thingsboard/server/edqs/DummyTenantRoutingInfoService.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobStats.java @@ -13,18 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.edqs; +package org.thingsboard.server.common.data.job; -import org.springframework.stereotype.Service; +import lombok.Data; +import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.queue.discovery.TenantRoutingInfo; -import org.thingsboard.server.queue.discovery.TenantRoutingInfoService; +import org.thingsboard.server.common.data.job.task.TaskResult; -@Service -public class DummyTenantRoutingInfoService implements TenantRoutingInfoService { - @Override - public TenantRoutingInfo getRoutingInfo(TenantId tenantId) { - return null; - } +import java.util.ArrayList; +import java.util.List; + +@Data +public class JobStats { + + private final TenantId tenantId; + private final JobId jobId; + private final List taskResults = new ArrayList<>(); + private Integer totalTasksCount; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobStatus.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobStatus.java new file mode 100644 index 0000000000..5a4a9e35ee --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobStatus.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +public enum JobStatus { + + QUEUED, + PENDING, + RUNNING, + COMPLETED, + FAILED, + CANCELLED; + + public boolean isOneOf(JobStatus... statuses) { + if (statuses == null) { + return false; + } + for (JobStatus status : statuses) { + if (this == status) { + return true; + } + } + return false; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobType.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobType.java new file mode 100644 index 0000000000..c2c461d12b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobType.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum JobType { + + DUMMY("Dummy job"); + + private final String title; + + public String getTasksTopic() { + return "tasks." + name().toLowerCase(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTask.java new file mode 100644 index 0000000000..66f0fc9ff9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTask.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job.task; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.job.JobType; + +import java.util.List; +import java.util.UUID; + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@ToString(callSuper = true) +public class DummyTask extends Task { + + private int number; + private long processingTimeMs; + private long processingTimeoutMs; + private List errors; // errors for each attempt + private boolean failAlways; + + @Override + public DummyTaskResult toFailed(Throwable error) { + return DummyTaskResult.failed(this, error); + } + + @Override + public DummyTaskResult toDiscarded() { + return DummyTaskResult.discarded(this); + } + + @Override + public EntityId getEntityId() { + return new DeviceId(UUID.randomUUID()); + } + + @Override + public JobType getJobType() { + return JobType.DUMMY; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java new file mode 100644 index 0000000000..5b913af3e5 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job.task; + +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.thingsboard.server.common.data.job.JobType; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@ToString(callSuper = true) +public class DummyTaskResult extends TaskResult { + + private DummyTaskFailure failure; + + @Builder + private DummyTaskResult(boolean success, boolean discarded, DummyTaskFailure failure) { + super(success, discarded); + this.failure = failure; + } + + public static DummyTaskResult success(DummyTask task) { + return DummyTaskResult.builder() + .success(true) + .build(); + } + + public static DummyTaskResult failed(DummyTask task, Throwable error) { + return DummyTaskResult.builder() + .failure(DummyTaskFailure.builder() + .error(error.getMessage()) + .number(task.getNumber()) + .failAlways(task.isFailAlways()) + .build()) + .build(); + } + + public static DummyTaskResult discarded(DummyTask task) { + return DummyTaskResult.builder() + .discarded(true) + .build(); + } + + @Override + public JobType getJobType() { + return JobType.DUMMY; + } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + @SuperBuilder + public static class DummyTaskFailure extends TaskFailure { + + private int number; + private boolean failAlways; + + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/Task.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/Task.java new file mode 100644 index 0000000000..fb373d9850 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/Task.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job.task; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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 lombok.AllArgsConstructor; +import lombok.Data; +import lombok.experimental.SuperBuilder; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.JobType; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "jobType") +@JsonSubTypes({ + @Type(name = "DUMMY", value = DummyTask.class) +}) +@SuperBuilder +@AllArgsConstructor +public abstract class Task { + + private TenantId tenantId; + private JobId jobId; + private String key; + private int retries; + + public Task() { + } + + private int attempt = 0; + + public abstract R toFailed(Throwable error); + + public abstract R toDiscarded(); + + @JsonIgnore + public abstract EntityId getEntityId(); + + @JsonIgnore + public abstract JobType getJobType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskFailure.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskFailure.java new file mode 100644 index 0000000000..ce22a08269 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskFailure.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job.task; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@SuperBuilder +public abstract class TaskFailure { + + private String error; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java new file mode 100644 index 0000000000..da3c8252eb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job.task; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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 lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.job.JobType; + +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "jobType") +@JsonSubTypes({ + @Type(name = "DUMMY", value = DummyTaskResult.class) +}) +public abstract class TaskResult { + + private String key; + private boolean success; + private boolean discarded; + private long finishTs; + + protected TaskResult(boolean success, boolean discarded) { + this.success = success; + this.discarded = discarded; + } + + @JsonIgnore + public abstract JobType getJobType(); + +} diff --git a/edqs/src/main/java/org/thingsboard/server/edqs/DummyQueueRoutingInfoService.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributesSaveResult.java similarity index 61% rename from edqs/src/main/java/org/thingsboard/server/edqs/DummyQueueRoutingInfoService.java rename to common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributesSaveResult.java index 1f1152af68..711a3e06a4 100644 --- a/edqs/src/main/java/org/thingsboard/server/edqs/DummyQueueRoutingInfoService.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributesSaveResult.java @@ -13,21 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.edqs; - -import org.springframework.stereotype.Service; -import org.thingsboard.server.queue.discovery.QueueRoutingInfo; -import org.thingsboard.server.queue.discovery.QueueRoutingInfoService; +package org.thingsboard.server.common.data.kv; import java.util.Collections; import java.util.List; -@Service -public class DummyQueueRoutingInfoService implements QueueRoutingInfoService { +public record AttributesSaveResult(List versions) { + + public static final AttributesSaveResult EMPTY = new AttributesSaveResult(Collections.emptyList()); - @Override - public List getAllQueuesRoutingInfo() { - return Collections.emptyList(); + public static AttributesSaveResult of(List versions) { + if (versions == null) { + return EMPTY; + } + return new AttributesSaveResult(versions); } } 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 db7f14171b..3dc063ccca 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 @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileCon import java.util.Optional; import java.util.function.Function; +@Getter public enum LimitedApi { ENTITY_EXPORT(DefaultTenantProfileConfiguration::getTenantEntityExportRateLimit, "entity version creation", true), @@ -30,7 +31,19 @@ public enum LimitedApi { REST_REQUESTS_PER_TENANT(DefaultTenantProfileConfiguration::getTenantServerRestLimitsConfiguration, "REST API requests", true), REST_REQUESTS_PER_CUSTOMER(DefaultTenantProfileConfiguration::getCustomerServerRestLimitsConfiguration, "REST API requests per customer", false), WS_UPDATES_PER_SESSION(DefaultTenantProfileConfiguration::getWsUpdatesPerSessionRateLimit, "WS updates per session", true), - CASSANDRA_QUERIES(DefaultTenantProfileConfiguration::getCassandraQueryTenantRateLimitsConfiguration, "Cassandra queries", true), + CASSANDRA_WRITE_QUERIES_CORE(DefaultTenantProfileConfiguration::getCassandraWriteQueryTenantCoreRateLimits, "Rest API Cassandra write queries", true), + CASSANDRA_READ_QUERIES_CORE(DefaultTenantProfileConfiguration::getCassandraReadQueryTenantCoreRateLimits, "Rest API and WS telemetry Cassandra read queries", true), + CASSANDRA_WRITE_QUERIES_RULE_ENGINE(DefaultTenantProfileConfiguration::getCassandraWriteQueryTenantRuleEngineRateLimits, "Rule Engine telemetry Cassandra write queries", true), + CASSANDRA_READ_QUERIES_RULE_ENGINE(DefaultTenantProfileConfiguration::getCassandraReadQueryTenantRuleEngineRateLimits, "Rule Engine telemetry Cassandra read queries", true), + CASSANDRA_READ_QUERIES_MONOLITH( + RateLimitUtil.merge( + DefaultTenantProfileConfiguration::getCassandraReadQueryTenantCoreRateLimits, + DefaultTenantProfileConfiguration::getCassandraReadQueryTenantRuleEngineRateLimits), "Monolith telemetry Cassandra read queries", true), + CASSANDRA_WRITE_QUERIES_MONOLITH( + RateLimitUtil.merge( + DefaultTenantProfileConfiguration::getCassandraWriteQueryTenantCoreRateLimits, + DefaultTenantProfileConfiguration::getCassandraWriteQueryTenantRuleEngineRateLimits), "Monolith telemetry Cassandra write queries", true), + CASSANDRA_QUERIES(null, true), // left for backward compatibility with RateLimitsNotificationInfo EDGE_EVENTS(DefaultTenantProfileConfiguration::getEdgeEventRateLimits, "Edge events", true), EDGE_EVENTS_PER_EDGE(DefaultTenantProfileConfiguration::getEdgeEventRateLimitsPerEdge, "Edge events per edge", false), EDGE_UPLINK_MESSAGES(DefaultTenantProfileConfiguration::getEdgeUplinkMessagesRateLimits, "Edge uplink messages", true), @@ -46,28 +59,28 @@ public enum LimitedApi { WS_SUBSCRIPTIONS("WS subscriptions", false), CALCULATED_FIELD_DEBUG_EVENTS("calculated field debug events", true); - private Function configExtractor; - @Getter + private final Function configExtractor; private final boolean perTenant; - @Getter - private boolean refillRateLimitIntervally; - @Getter - private String label; + private final boolean refillRateLimitIntervally; + private final String label; LimitedApi(Function configExtractor, String label, boolean perTenant) { - this.configExtractor = configExtractor; - this.label = label; - this.perTenant = perTenant; + this(configExtractor, label, perTenant, false); } LimitedApi(boolean perTenant, boolean refillRateLimitIntervally) { - this.perTenant = perTenant; - this.refillRateLimitIntervally = refillRateLimitIntervally; + this(null, null, perTenant, refillRateLimitIntervally); } LimitedApi(String label, boolean perTenant) { + this(null, label, perTenant, false); + } + + LimitedApi(Function configExtractor, String label, boolean perTenant, boolean refillRateLimitIntervally) { + this.configExtractor = configExtractor; this.label = label; this.perTenant = perTenant; + this.refillRateLimitIntervally = refillRateLimitIntervally; } public String getLimitConfig(DefaultTenantProfileConfiguration profileConfiguration) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitEntry.java new file mode 100644 index 0000000000..566ff96c75 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitEntry.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.limit; + +public record RateLimitEntry(long capacity, long durationSeconds) { + + public static RateLimitEntry parse(String s) { + String[] parts = s.split(":"); + return new RateLimitEntry(Long.parseLong(parts[0]), Long.parseLong(parts[1])); + } + + @Override + public String toString() { + return capacity + ":" + durationSeconds; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitUtil.java new file mode 100644 index 0000000000..f6e9085489 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitUtil.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.limit; + +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class RateLimitUtil { + + public static List parseConfig(String config) { + if (config == null || config.isEmpty()) { + return Collections.emptyList(); + } + return Arrays.stream(config.split(",")) + .map(RateLimitEntry::parse) + .toList(); + } + + public static Function merge( + Function configExtractor1, + Function configExtractor2) { + return config -> { + String config1 = configExtractor1.apply(config); + String config2 = configExtractor2.apply(config); + return RateLimitUtil.mergeStrConfigs(config1, config2); // merges the configs + }; + } + + private static String mergeStrConfigs(String firstConfig, String secondConfig) { + List all = new ArrayList<>(); + all.addAll(parseConfig(firstConfig)); + all.addAll(parseConfig(secondConfig)); + + Map merged = new HashMap<>(); + + for (RateLimitEntry entry : all) { + merged.merge(entry.durationSeconds(), entry.capacity(), Long::sum); + } + + return merged.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) // optional: sort by duration + .map(e -> e.getValue() + ":" + e.getKey()) + .collect(Collectors.joining(",")); + } + + public static boolean isValid(String configStr) { + List limitedApiEntries = parseConfig(configStr); + Set distinctDurations = new HashSet<>(); + for (RateLimitEntry entry : limitedApiEntries) { + if (!distinctDurations.add(entry.durationSeconds())) { + return false; + } + } + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java index e5fefd6072..ef1580c0e3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java @@ -43,6 +43,9 @@ public class MobileApp extends BaseData implements HasTenantId, Has @NotBlank @Length(fieldName = "pkgName") private String pkgName; + @Schema(description = "Application title") + @Length(fieldName = "title") + private String title; @Schema(description = "Application secret. The length must be at least 16 characters", requiredMode = Schema.RequiredMode.REQUIRED) @NotEmpty @Length(fieldName = "appSecret", min = 16, max = 2048, message = "must be at least 16 and max 2048 characters") @@ -72,6 +75,7 @@ public class MobileApp extends BaseData implements HasTenantId, Has super(mobile); this.tenantId = mobile.tenantId; this.pkgName = mobile.pkgName; + this.title = mobile.title; this.appSecret = mobile.appSecret; this.platformType = mobile.platformType; this.status = mobile.status; 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 76ad4ac4dc..d4d3d11e8b 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 @@ -37,7 +37,8 @@ public enum NotificationType { RATE_LIMITS, EDGE_CONNECTION, EDGE_COMMUNICATION_FAILURE, - TASK_PROCESSING_FAILURE; + TASK_PROCESSING_FAILURE, + RESOURCES_SHORTAGE; @Getter private boolean system; // for future use and compatibility with PE diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/ResourcesShortageNotificationInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/ResourcesShortageNotificationInfo.java new file mode 100644 index 0000000000..c05a10ef8f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/ResourcesShortageNotificationInfo.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.notification.info; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ResourcesShortageNotificationInfo implements RuleOriginatedNotificationInfo { + + private String resource; + private Long usage; + private String serviceId; + private String serviceType; + + @Override + public Map getTemplateData() { + return Map.of( + "resource", resource, + "usage", String.valueOf(usage), + "serviceId", serviceId, + "serviceType", serviceType + ); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeCommunicationFailureTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeCommunicationFailureTrigger.java index 4124eb04f8..5672b2c98c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeCommunicationFailureTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeCommunicationFailureTrigger.java @@ -23,12 +23,16 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.io.Serial; import java.util.concurrent.TimeUnit; @Data @Builder public class EdgeCommunicationFailureTrigger implements NotificationRuleTrigger { + @Serial + private static final long serialVersionUID = 2918443863787603524L; + private final TenantId tenantId; private final CustomerId customerId; private final EdgeId edgeId; @@ -37,8 +41,8 @@ public class EdgeCommunicationFailureTrigger implements NotificationRuleTrigger private final String error; @Override - public boolean deduplicate() { - return true; + public DeduplicationStrategy getDeduplicationStrategy() { + return DeduplicationStrategy.ALL; } @Override @@ -60,4 +64,5 @@ public class EdgeCommunicationFailureTrigger implements NotificationRuleTrigger public EntityId getOriginatorEntityId() { return edgeId; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeConnectionTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeConnectionTrigger.java index fc3b69e697..0da465ec09 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeConnectionTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/EdgeConnectionTrigger.java @@ -23,12 +23,16 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.io.Serial; import java.util.concurrent.TimeUnit; @Data @Builder public class EdgeConnectionTrigger implements NotificationRuleTrigger { + @Serial + private static final long serialVersionUID = -261939829962721957L; + private final TenantId tenantId; private final CustomerId customerId; private final EdgeId edgeId; @@ -36,8 +40,8 @@ public class EdgeConnectionTrigger implements NotificationRuleTrigger { private final String edgeName; @Override - public boolean deduplicate() { - return true; + public DeduplicationStrategy getDeduplicationStrategy() { + return DeduplicationStrategy.ALL; } @Override @@ -59,4 +63,5 @@ public class EdgeConnectionTrigger implements NotificationRuleTrigger { public EntityId getOriginatorEntityId() { return edgeId; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionTrigger.java index 7a66d430e6..50ee0768d9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NewPlatformVersionTrigger.java @@ -22,10 +22,15 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.io.Serial; + @Data @Builder public class NewPlatformVersionTrigger implements NotificationRuleTrigger { + @Serial + private static final long serialVersionUID = 3298785969736390092L; + private final UpdateMessage updateInfo; @Override @@ -45,8 +50,8 @@ public class NewPlatformVersionTrigger implements NotificationRuleTrigger { @Override - public boolean deduplicate() { - return true; + public DeduplicationStrategy getDeduplicationStrategy() { + return DeduplicationStrategy.ALL; } @Override @@ -55,9 +60,4 @@ public class NewPlatformVersionTrigger implements NotificationRuleTrigger { updateInfo.getCurrentVersion(), updateInfo.getLatestVersion()); } - @Override - public long getDefaultDeduplicationDuration() { - return 0; - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NotificationRuleTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NotificationRuleTrigger.java index 31940d6ac8..f6cf398b44 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NotificationRuleTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/NotificationRuleTrigger.java @@ -29,9 +29,8 @@ public interface NotificationRuleTrigger extends Serializable { EntityId getOriginatorEntityId(); - - default boolean deduplicate() { - return false; + default DeduplicationStrategy getDeduplicationStrategy() { + return DeduplicationStrategy.NONE; } default String getDeduplicationKey() { @@ -43,4 +42,10 @@ public interface NotificationRuleTrigger extends Serializable { return 0; } + enum DeduplicationStrategy { + NONE, + ALL, + ONLY_MATCHING + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RateLimitsTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RateLimitsTrigger.java index 39d570a9e0..37e984c6f5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RateLimitsTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/RateLimitsTrigger.java @@ -22,12 +22,16 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.limit.LimitedApi; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.io.Serial; import java.util.concurrent.TimeUnit; @Data @Builder public class RateLimitsTrigger implements NotificationRuleTrigger { + @Serial + private static final long serialVersionUID = -4423112145409424886L; + private final TenantId tenantId; private final LimitedApi api; private final EntityId limitLevel; @@ -45,8 +49,8 @@ public class RateLimitsTrigger implements NotificationRuleTrigger { @Override - public boolean deduplicate() { - return true; + public DeduplicationStrategy getDeduplicationStrategy() { + return DeduplicationStrategy.ALL; } @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java new file mode 100644 index 0000000000..be2485c959 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.notification.rule.trigger; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; + +import java.io.Serial; +import java.util.concurrent.TimeUnit; + +@Data +@Builder +public class ResourcesShortageTrigger implements NotificationRuleTrigger { + + @Serial + private static final long serialVersionUID = 6024216015202949570L; + + private Resource resource; + private Long usage; + private String serviceId; + private String serviceType; + + @Override + public TenantId getTenantId() { + return TenantId.SYS_TENANT_ID; + } + + @Override + public EntityId getOriginatorEntityId() { + return TenantId.SYS_TENANT_ID; + } + + @Override + public DeduplicationStrategy getDeduplicationStrategy() { + return DeduplicationStrategy.ONLY_MATCHING; + } + + @Override + public String getDeduplicationKey() { + return String.join(":", resource.name(), serviceId, serviceType); + } + + @Override + public long getDefaultDeduplicationDuration() { + return TimeUnit.HOURS.toMillis(1); + } + + @Override + public NotificationRuleTriggerType getType() { + return NotificationRuleTriggerType.RESOURCES_SHORTAGE; + } + + public enum Resource { + CPU, RAM, STORAGE + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/TaskProcessingFailureTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/TaskProcessingFailureTrigger.java index 2e781e26f7..3677df13ab 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/TaskProcessingFailureTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/TaskProcessingFailureTrigger.java @@ -22,10 +22,15 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.io.Serial; + @Data @Builder public class TaskProcessingFailureTrigger implements NotificationRuleTrigger { + @Serial + private static final long serialVersionUID = 5606203770553105345L; + private final HousekeeperTask task; private final int attempt; private final Throwable error; @@ -45,9 +50,4 @@ public class TaskProcessingFailureTrigger implements NotificationRuleTrigger { return task.getEntityId(); } - @Override - public boolean deduplicate() { - return false; - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java index afdc891b86..8db874f812 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java @@ -38,7 +38,8 @@ import java.io.Serializable; @Type(value = RateLimitsNotificationRuleTriggerConfig.class, name = "RATE_LIMITS"), @Type(value = EdgeConnectionNotificationRuleTriggerConfig.class, name = "EDGE_CONNECTION"), @Type(value = EdgeCommunicationFailureNotificationRuleTriggerConfig.class, name = "EDGE_COMMUNICATION_FAILURE"), - @Type(value = TaskProcessingFailureNotificationRuleTriggerConfig.class, name = "TASK_PROCESSING_FAILURE") + @Type(value = TaskProcessingFailureNotificationRuleTriggerConfig.class, name = "TASK_PROCESSING_FAILURE"), + @Type(value = ResourcesShortageNotificationRuleTriggerConfig.class, name = "RESOURCES_SHORTAGE") }) public interface NotificationRuleTriggerConfig extends Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerType.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerType.java index 72cbb51b0c..aeabfa814d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerType.java @@ -32,7 +32,8 @@ public enum NotificationRuleTriggerType { ENTITIES_LIMIT(false), API_USAGE_LIMIT(false), RATE_LIMITS(false), - TASK_PROCESSING_FAILURE(false); + TASK_PROCESSING_FAILURE(false), + RESOURCES_SHORTAGE(false); private final boolean tenantLevel; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java new file mode 100644 index 0000000000..e8ea6c9be5 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.notification.rule.trigger.config; + +import jakarta.validation.constraints.Max; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ResourcesShortageNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + + @Serial + private static final long serialVersionUID = 339395299693241424L; + + @Max(1) + private float cpuThreshold; // in percents + @Max(1) + private float ramThreshold; // in percents + @Max(1) + private float storageThreshold; // in percents + + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RESOURCES_SHORTAGE; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index 71a8e8ef33..1ab7ef5b34 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java @@ -45,4 +45,8 @@ public class AlarmCountQuery extends EntityCountQuery { super(entityFilter); } + public AlarmCountQuery(EntityFilter entityFilter, List keyFilters) { + super(entityFilter, keyFilters); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java index 31547246c6..c63682b899 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.query; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.EqualsAndHashCode; import lombok.Getter; import org.thingsboard.server.common.data.alarm.Alarm; @@ -34,6 +36,12 @@ public class AlarmData extends AlarmInfo { @Getter private final Map> latest; + @JsonCreator + public AlarmData(@JsonProperty("entityId") EntityId entityId, @JsonProperty("latest") Map> latest) { + this.entityId = entityId; + this.latest = latest; + } + public AlarmData(AlarmInfo main, AlarmData prototype) { super(main); this.entityId = prototype.entityId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityId.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityId.java new file mode 100644 index 0000000000..51c3dcdd53 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityId.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.query; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; + +@JsonDeserialize(using = AliasEntityIdDeserializer.class) +@JsonSerialize(using = AliasEntityIdSerializer.class) +@Schema +public interface AliasEntityId extends EntityId { + + AliasEntityType getAliasEntityType(); + + EntityId defaultEntityId(); + + EntityId toEntityId(); + + @JsonIgnore + default boolean isAliasEntityId() { + return getAliasEntityType() != null; + } + + static AliasEntityId fromEntityId(EntityId entityId) { + if (entityId != null) { + return new AliasEntityIdImpl(entityId); + } else { + return null; + } + } + + static AliasEntityId resolveAliasEntityId(AliasEntityId aliasEntityId, TenantId tenantId, UserId userId, EntityId userOwnerId) { + if (aliasEntityId != null) { + if (aliasEntityId.isAliasEntityId()) { + AliasEntityType aliasEntityType = aliasEntityId.getAliasEntityType(); + switch (aliasEntityType) { + case CURRENT_CUSTOMER -> { + if (EntityType.CUSTOMER.equals(userOwnerId.getEntityType())) { + return fromEntityId(userOwnerId); + } else { + return fromEntityId(aliasEntityId.defaultEntityId()); + } + } + case CURRENT_TENANT -> { + return fromEntityId(tenantId); + } + case CURRENT_USER -> { + return fromEntityId(userId); + } + case CURRENT_USER_OWNER -> { + return fromEntityId(userOwnerId); + } + } + } else { + return aliasEntityId; + } + } + return null; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdDeserializer.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdDeserializer.java new file mode 100644 index 0000000000..488bd3eb3b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdDeserializer.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.query; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; + +import java.io.IOException; +import java.util.UUID; + +public class AliasEntityIdDeserializer extends JsonDeserializer { + + @Override + public AliasEntityId deserialize(JsonParser jsonParser, DeserializationContext ctx) throws IOException, JacksonException { + ObjectCodec oc = jsonParser.getCodec(); + ObjectNode node = oc.readTree(jsonParser); + if (node.has("entityType")) { + String entityType = node.get("entityType").asText(); + try { + EntityType.valueOf(entityType); + if (!node.has("id")) { + throw new IOException("Missing entityType or id!"); + } + EntityId entityId = EntityIdFactory.getByTypeAndId(node.get("entityType").asText(), node.get("id").asText()); + return new AliasEntityIdImpl(entityId); + } catch (IllegalArgumentException e) { + AliasEntityType aliasEntityType = AliasEntityType.valueOf(entityType); + UUID id = null; + if (node.has("id")) { + id = UUID.fromString(node.get("id").asText()); + } + return new AliasEntityIdImpl(aliasEntityType, id); + } + } else { + throw new IOException("Missing entityType!"); + } + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdImpl.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdImpl.java new file mode 100644 index 0000000000..6ab1e9875d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdImpl.java @@ -0,0 +1,104 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.query; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; + +import java.util.UUID; + +class AliasEntityIdImpl implements AliasEntityId { + + private UUID id; + private EntityType entityType; + private AliasEntityType aliasEntityType; + private EntityId defaultEntityId; + + protected AliasEntityIdImpl(EntityId entityId) { + this.id = entityId.getId(); + this.entityType = entityId.getEntityType(); + } + + protected AliasEntityIdImpl(AliasEntityType aliasEntityType, UUID id) { + this.aliasEntityType = aliasEntityType; + if (id != null) { + switch (this.aliasEntityType) { + case CURRENT_CUSTOMER: + this.defaultEntityId = new CustomerId(id); + break; + } + } + } + + @Override + public AliasEntityType getAliasEntityType() { + return aliasEntityType; + } + + @Override + public EntityId defaultEntityId() { + return defaultEntityId; + } + + @Override + public EntityId toEntityId() { + return EntityIdFactory.getByTypeAndUuid(entityType, id); + } + + @Override + public UUID getId() { + return id; + } + + @Override + public EntityType getEntityType() { + return entityType; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (!(obj instanceof EntityId otherEntityId)) + return false; + if (obj instanceof AliasEntityId otherAliasEntityId) { + if (otherAliasEntityId.isAliasEntityId()) { + if (!this.isAliasEntityId()) { + return false; + } + if (this.aliasEntityType != otherAliasEntityId.getAliasEntityType()) { + return false; + } + if (this.defaultEntityId != null && !this.defaultEntityId.equals(otherAliasEntityId.defaultEntityId())) { + return false; + } + if (this.defaultEntityId == null && otherAliasEntityId.defaultEntityId() != null) { + return false; + } + } + } + if (this.isAliasEntityId()) { + return false; + } + if (id == null) { + return otherEntityId.getId() == null; + } else return id.equals(otherEntityId.getId()); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdSerializer.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdSerializer.java new file mode 100644 index 0000000000..cd115f2ca6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdSerializer.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.query; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; +import java.util.UUID; + +public class AliasEntityIdSerializer extends JsonSerializer { + @Override + public void serialize(AliasEntityId value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + String entityType; + if (value.isAliasEntityId()) { + entityType = value.getAliasEntityType().name(); + } else { + entityType = value.getEntityType().name(); + } + gen.writeStringField("entityType", entityType); + UUID id = null; + if (value.getId() != null) { + id = value.getId(); + } else if (value.defaultEntityId() != null) { + id = value.defaultEntityId().getId(); + } + if (id != null) { + gen.writeStringField("id", id.toString()); + } + gen.writeEndObject(); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityType.java new file mode 100644 index 0000000000..ebcb41a2eb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityType.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.query; + +public enum AliasEntityType { + CURRENT_CUSTOMER, + CURRENT_TENANT, + CURRENT_USER, + CURRENT_USER_OWNER +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java index 5507f53f08..9c0696f97c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java @@ -19,6 +19,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; + +import static org.thingsboard.server.common.data.query.AliasEntityId.resolveAliasEntityId; @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( @@ -45,4 +50,17 @@ public interface EntityFilter { @JsonIgnore EntityFilterType getType(); + + static void resolveEntityFilter(EntityFilter filter, TenantId tenantId, UserId userId, EntityId userOwnerId) { + if (filter instanceof SingleEntityFilter singleEntityFilter) { + AliasEntityId resolved = resolveAliasEntityId(singleEntityFilter.getSingleEntity(), tenantId, userId, userOwnerId); + singleEntityFilter.setSingleEntity(resolved); + } else if (filter instanceof RelationsQueryFilter queryFilter) { + AliasEntityId resolved = resolveAliasEntityId(queryFilter.getRootEntity(), tenantId, userId, userOwnerId); + queryFilter.setRootEntity(resolved); + } else if (filter instanceof EntitySearchQueryFilter queryFilter) { + AliasEntityId resolved = resolveAliasEntityId(queryFilter.getRootEntity(), tenantId, userId, userOwnerId); + queryFilter.setRootEntity(resolved); + } + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java index babcc466bd..873aadbebc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java @@ -22,10 +22,12 @@ import org.thingsboard.server.common.data.relation.EntitySearchDirection; @Data public abstract class EntitySearchQueryFilter implements EntityFilter { - private EntityId rootEntity; + private AliasEntityId rootEntity; private String relationType; private EntitySearchDirection direction; private int maxLevel; private boolean fetchLastLevelOnly; + private boolean rootStateEntity; + private AliasEntityId defaultStateEntity; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java index 83aad4eaaf..fb19857428 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java @@ -32,7 +32,7 @@ public class RelationsQueryFilter implements EntityFilter { return EntityFilterType.RELATIONS_QUERY; } - private EntityId rootEntity; + private AliasEntityId rootEntity; private boolean isMultiRoot; private EntityType multiRootEntitiesType; private Set multiRootEntityIds; @@ -41,5 +41,7 @@ public class RelationsQueryFilter implements EntityFilter { private int maxLevel; private boolean fetchLastLevelOnly; private boolean negate; + private boolean rootStateEntity; + private AliasEntityId defaultStateEntity; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java index 2e538806b9..8aa7d4e63b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java @@ -25,6 +25,6 @@ public class SingleEntityFilter implements EntityFilter { return EntityFilterType.SINGLE_ENTITY; } - private EntityId singleEntity; + private AliasEntityId singleEntity; } 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 002d8c1b82..1830310222 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 @@ -27,10 +27,12 @@ import org.thingsboard.server.common.data.BaseDataWithAdditionalInfo; import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.EdqsObjectKey; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.validation.Length; import java.io.Serializable; +import java.util.UUID; @Slf4j @Schema @@ -119,8 +121,8 @@ public class EntityRelation implements HasVersion, Serializable, EdqsObject { BaseDataWithAdditionalInfo.setJson(addInfo, json -> this.additionalInfo = json, bytes -> this.additionalInfoBytes = bytes); } - @JsonIgnore - public String key() { + @Override + public String stringKey() { return "r_" + from + "_" + to + "_" + typeGroup + "_" + type; } @@ -134,4 +136,6 @@ public class EntityRelation implements HasVersion, Serializable, EdqsObject { return ObjectType.RELATION; } + public record Key(UUID from, UUID to, RelationTypeGroup typeGroup, String type) implements EdqsObjectKey {} + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java index d9eff103a7..24d5c20d45 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java @@ -62,9 +62,7 @@ public class RuleNode extends BaseDataWithAdditionalInfo implements @Schema(description = "Version of rule node configuration. ", example = "0") private int configurationVersion; @Schema(description = "JSON with the rule node configuration. Structure depends on the rule node implementation.", implementation = JsonNode.class) - private transient JsonNode configuration; - @JsonIgnore - private byte[] configurationBytes; + private JsonNode configuration; private RuleNodeId externalId; @@ -92,14 +90,6 @@ public class RuleNode extends BaseDataWithAdditionalInfo implements return name; } - public JsonNode getConfiguration() { - return BaseDataWithAdditionalInfo.getJson(() -> configuration, () -> configurationBytes); - } - - public void setConfiguration(JsonNode data) { - setJson(data, json -> this.configuration = json, bytes -> this.configurationBytes = bytes); - } - @Schema(description = "JSON object with the Rule Node Id. " + "Specify this field to update the Rule Node. " + "Referencing non-existing Rule Node Id will cause error. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettings.java index 5c9ef8fd1f..733d7fc041 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettings.java @@ -15,20 +15,15 @@ */ package org.thingsboard.server.common.data.settings; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import lombok.ToString; 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 java.io.Serializable; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.getJson; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.setJson; - @Schema @Data public class UserSettings implements Serializable { @@ -43,20 +38,9 @@ public class UserSettings implements Serializable { @Length(fieldName = "type", max = 50) private UserSettingsType type; - @Schema(description = "JSON object with user settings.",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "JSON object with user settings.", implementation = com.fasterxml.jackson.databind.JsonNode.class) @NoXss @Length(fieldName = "settings", max = 100000) - private transient JsonNode settings; - - @JsonIgnore - @ToString.Exclude - private byte[] settingsBytes; - - public JsonNode getSettings() { - return getJson(() -> settings, () -> settingsBytes); - } + private JsonNode settings; - public void setSettings(JsonNode settings) { - setJson(settings, json -> this.settings = json, bytes -> this.settingsBytes = bytes); - } } 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 c763daeb7f..79dfbe6be2 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 @@ -25,7 +25,9 @@ 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.EntityView; +import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.notification.rule.NotificationRule; @@ -58,8 +60,9 @@ import java.lang.annotation.Target; @Type(name = "NOTIFICATION_TEMPLATE", value = NotificationTemplate.class), @Type(name = "NOTIFICATION_TARGET", value = NotificationTarget.class), @Type(name = "NOTIFICATION_RULE", value = NotificationRule.class), - @Type(name = "TB_RESOURCE", value = TbResource.class) + @Type(name = "TB_RESOURCE", value = TbResource.class), + @Type(name = "OTA_PACKAGE", value = OtaPackage.class), + @Type(name = "AI_MODEL", value = AiModel.class) }) @JsonIgnoreProperties(value = {"tenantId", "createdTime", "version"}, ignoreUnknown = true) -public @interface JsonTbEntity { -} +public @interface JsonTbEntity {} 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 a1692aef04..5e1f98638a 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 @@ -41,7 +41,8 @@ import java.util.Map; @Type(name = "DEVICE", value = DeviceExportData.class), @Type(name = "RULE_CHAIN", value = RuleChainExportData.class), @Type(name = "WIDGET_TYPE", value = WidgetTypeExportData.class), - @Type(name = "WIDGETS_BUNDLE", value = WidgetsBundleExportData.class) + @Type(name = "WIDGETS_BUNDLE", value = WidgetsBundleExportData.class), + @Type(name = "OTA_PACKAGE", value = OtaPackageExportData.class) }) @JsonInclude(JsonInclude.Include.NON_NULL) @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java new file mode 100644 index 0000000000..44e2f7857c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.sync.ie; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.OtaPackage; + +@EqualsAndHashCode(callSuper = true) +public class OtaPackageExportData extends EntityExportData { + + /* + * OtaPackage is not a versioned entity; its 'version' field is part of the domain model (not used for optimistic locking) + * We override both methods to ensure 'version' is not ignored during (de)serialization. + */ + @JsonIgnoreProperties(value = {"tenantId", "createdTime"}, ignoreUnknown = true) + @Override + public OtaPackage getEntity() { + return super.getEntity(); + } + + @JsonIgnoreProperties(value = {"tenantId", "createdTime"}, ignoreUnknown = true) + @Override + public void setEntity(OtaPackage entity) { + super.setEntity(entity); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java index 8e61991b38..b5e2813134 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java @@ -18,10 +18,13 @@ package org.thingsboard.server.common.data.sync.vc.request.create; import lombok.Data; import lombok.EqualsAndHashCode; +import java.io.Serial; + @EqualsAndHashCode(callSuper = true) @Data public class AutoVersionCreateConfig extends VersionCreateConfig { + @Serial private static final long serialVersionUID = 8245450889383315551L; private String branch; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index f256b02d9a..246fe46791 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -24,6 +24,7 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.TenantProfileType; +import org.thingsboard.server.common.data.validation.RateLimit; import java.io.Serial; @@ -49,37 +50,53 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxResourceSize; @Schema(example = "1000:1,20000:60") + @RateLimit(fieldName = "Transport tenant messages") private String transportTenantMsgRateLimit; @Schema(example = "1000:1,20000:60") + @RateLimit(fieldName = "Transport tenant telemetry messages") private String transportTenantTelemetryMsgRateLimit; @Schema(example = "1000:1,20000:60") + @RateLimit(fieldName = "Transport tenant telemetry data points") private String transportTenantTelemetryDataPointsRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport device messages") private String transportDeviceMsgRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport device telemetry messages") private String transportDeviceTelemetryMsgRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport device telemetry data points") private String transportDeviceTelemetryDataPointsRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport gateway messages") private String transportGatewayMsgRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport gateway telemetry messages") private String transportGatewayTelemetryMsgRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport gateway telemetry data points") private String transportGatewayTelemetryDataPointsRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport gateway device messages") private String transportGatewayDeviceMsgRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport gateway device telemetry messages") private String transportGatewayDeviceTelemetryMsgRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Transport gateway device telemetry data points") private String transportGatewayDeviceTelemetryDataPointsRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Entity version creation") private String tenantEntityExportRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Entity version load") private String tenantEntityImportRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Notification requests") private String tenantNotificationRequestsRateLimit; @Schema(example = "20:1,600:60") + @RateLimit(fieldName = "Notification requests per notification rule") private String tenantNotificationRequestsPerRuleRateLimit; @Schema(example = "10000000") @@ -107,7 +124,9 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura @Schema(example = "1000") private long maxCreatedAlarms; + @RateLimit(fieldName = "REST requests for tenant") private String tenantServerRestLimitsConfiguration; + @RateLimit(fieldName = "REST requests for customer") private String customerServerRestLimitsConfiguration; private int maxWsSessionsPerTenant; @@ -119,13 +138,26 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxWsSubscriptionsPerCustomer; private long maxWsSubscriptionsPerRegularUser; private long maxWsSubscriptionsPerPublicUser; + @RateLimit(fieldName = "WS updates per session") private String wsUpdatesPerSessionRateLimit; - private String cassandraQueryTenantRateLimitsConfiguration; + @RateLimit(fieldName = "Rest API and WS telemetry Cassandra read queries") + private String cassandraReadQueryTenantCoreRateLimits; + @RateLimit(fieldName = "Rest API Cassandra write queries") + private String cassandraWriteQueryTenantCoreRateLimits; + @RateLimit(fieldName = "Rule Engine telemetry Cassandra read queries") + private String cassandraReadQueryTenantRuleEngineRateLimits; + @RateLimit(fieldName = "Rule Engine telemetry Cassandra write queries") + private String cassandraWriteQueryTenantRuleEngineRateLimits; + + @RateLimit(fieldName = "Edge events") private String edgeEventRateLimits; + @RateLimit(fieldName = "Edge events per edge") private String edgeEventRateLimitsPerEdge; + @RateLimit(fieldName = "Edge uplink messages") private String edgeUplinkMessagesRateLimits; + @RateLimit(fieldName = "Edge uplink messages per edge") private String edgeUplinkMessagesRateLimitsPerEdge; private int defaultStorageTtlDays; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java new file mode 100644 index 0000000000..51399a9634 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = {}) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NoNullChar { + + String message() default "should not contain 0x00 symbol"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/RateLimit.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/RateLimit.java new file mode 100644 index 0000000000..9afe71da2f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/RateLimit.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD}) +@Constraint(validatedBy = {}) +public @interface RateLimit { + + String message() default "rate limit has duplicate 'Per seconds' configuration."; + + String fieldName() default ""; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java new file mode 100644 index 0000000000..d37d7eb9e7 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = {}) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidJsonSchema { + + String message() default "must conform to the Draft 2020-12 meta-schema"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetType.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetType.java index bdac430ce1..5385a54f74 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetType.java @@ -21,15 +21,17 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.WidgetTypeId; import java.util.Optional; +@EqualsAndHashCode(callSuper = true) @Data public class WidgetType extends BaseWidgetType { @Schema(description = "Complex JSON object that describes the widget type", accessMode = Schema.AccessMode.READ_ONLY) - private transient JsonNode descriptor; + private JsonNode descriptor; public WidgetType() { super(); diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/limit/LimitedApiTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/limit/LimitedApiTest.java new file mode 100644 index 0000000000..6c5e62e456 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/limit/LimitedApiTest.java @@ -0,0 +1,101 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.limit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class LimitedApiTest { + + private DefaultTenantProfileConfiguration config; + + @BeforeEach + void setUp() { + config = mock(DefaultTenantProfileConfiguration.class); + } + + @Test + void testCorrectConfigExtractorsUsed() { + Map verifierMap = Map.ofEntries( + Map.entry(LimitedApi.ENTITY_EXPORT, () -> + verify(config).getTenantEntityExportRateLimit()), + Map.entry(LimitedApi.ENTITY_IMPORT, () -> + verify(config).getTenantEntityImportRateLimit()), + Map.entry(LimitedApi.NOTIFICATION_REQUESTS, () -> + verify(config).getTenantNotificationRequestsRateLimit()), + Map.entry(LimitedApi.NOTIFICATION_REQUESTS_PER_RULE, () -> + verify(config).getTenantNotificationRequestsPerRuleRateLimit()), + Map.entry(LimitedApi.REST_REQUESTS_PER_TENANT, () -> + verify(config).getTenantServerRestLimitsConfiguration()), + Map.entry(LimitedApi.REST_REQUESTS_PER_CUSTOMER, () -> + verify(config).getCustomerServerRestLimitsConfiguration()), + Map.entry(LimitedApi.WS_UPDATES_PER_SESSION, () -> + verify(config).getWsUpdatesPerSessionRateLimit()), + Map.entry(LimitedApi.CASSANDRA_WRITE_QUERIES_CORE, () -> + verify(config).getCassandraWriteQueryTenantCoreRateLimits()), + Map.entry(LimitedApi.CASSANDRA_READ_QUERIES_CORE, () -> + verify(config).getCassandraReadQueryTenantCoreRateLimits()), + Map.entry(LimitedApi.CASSANDRA_WRITE_QUERIES_RULE_ENGINE, () -> + verify(config).getCassandraWriteQueryTenantRuleEngineRateLimits()), + Map.entry(LimitedApi.CASSANDRA_READ_QUERIES_RULE_ENGINE, () -> + verify(config).getCassandraReadQueryTenantRuleEngineRateLimits()), + Map.entry(LimitedApi.CASSANDRA_READ_QUERIES_MONOLITH, () -> { + verify(config).getCassandraReadQueryTenantCoreRateLimits(); + verify(config).getCassandraReadQueryTenantRuleEngineRateLimits(); + }), + Map.entry(LimitedApi.CASSANDRA_WRITE_QUERIES_MONOLITH, () -> { + verify(config).getCassandraWriteQueryTenantCoreRateLimits(); + verify(config).getCassandraWriteQueryTenantRuleEngineRateLimits(); + }), + Map.entry(LimitedApi.EDGE_EVENTS, () -> + verify(config).getEdgeEventRateLimits()), + Map.entry(LimitedApi.EDGE_EVENTS_PER_EDGE, () -> + verify(config).getEdgeEventRateLimitsPerEdge()), + Map.entry(LimitedApi.EDGE_UPLINK_MESSAGES, () -> + verify(config).getEdgeUplinkMessagesRateLimits()), + Map.entry(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, () -> + verify(config).getEdgeUplinkMessagesRateLimitsPerEdge()) + ); + + Set expected = verifierMap.keySet(); + Set actual = Arrays.stream(LimitedApi.values()) + .filter(api -> api.getConfigExtractor() != null) + .collect(Collectors.toSet()); + + assertThat(expected) + .as("Verifier map should cover all LimitedApis with extractors") + .containsExactlyInAnyOrderElementsOf(actual); + + for (Map.Entry entry : verifierMap.entrySet()) { + LimitedApi api = entry.getKey(); + api.getLimitConfig(config); + entry.getValue().run(); + clearInvocations(config); + } + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/limit/RateLimitUtilTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/limit/RateLimitUtilTest.java new file mode 100644 index 0000000000..a410785126 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/limit/RateLimitUtilTest.java @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.limit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; + +import java.util.List; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +class RateLimitUtilTest { + + @Test + @DisplayName("LimitedApiUtil should parse single entry correctly") + void testParseSingleEntry() { + List entries = RateLimitUtil.parseConfig("100:60"); + + assertThat(entries).hasSize(1); + assertThat(entries.get(0).capacity()).isEqualTo(100); + assertThat(entries.get(0).durationSeconds()).isEqualTo(60); + } + + @Test + @DisplayName("LimitedApiUtil should parse multiple entries correctly") + void testParseMultipleEntries() { + List entries = RateLimitUtil.parseConfig("100:60,200:30"); + + assertThat(entries).hasSize(2); + assertThat(entries.get(0).capacity()).isEqualTo(100); + assertThat(entries.get(0).durationSeconds()).isEqualTo(60); + assertThat(entries.get(1).capacity()).isEqualTo(200); + assertThat(entries.get(1).durationSeconds()).isEqualTo(30); + } + + @Test + @DisplayName("LimitedApiUtil should return empty list for null or empty config") + void testParseEmptyConfig() { + assertThat(RateLimitUtil.parseConfig(null)).isEmpty(); + assertThat(RateLimitUtil.parseConfig("")).isEmpty(); + } + + @Test + @DisplayName("LimitedApiUtil should merge two configs by summing capacities with same durations") + void testMergeStrConfigs() { + Function extractor1 = cfg -> "100:60,50:30"; + Function extractor2 = cfg -> "200:60,25:10"; + + // Fake config instance (not used directly in lambda logic) + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + + String result = RateLimitUtil.merge(extractor1, extractor2).apply(config); + + // Should be: 300:60 (100+200), 50:30, 25:10 + assertThat(result).isEqualTo("25:10,50:30,300:60"); + } + + @Test + @DisplayName("LimitedApiUtil should merge configs when one is empty") + void testMergeWithEmptyOne() { + Function extractor1 = cfg -> "100:60"; + Function extractor2 = cfg -> ""; + + // Fake config instance (not used directly in lambda logic) + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + String result = RateLimitUtil.merge(extractor1, extractor2).apply(config); + + assertThat(result).isEqualTo("100:60"); + } + + @Test + @DisplayName("LimitedApiUtil should merge configs when both have distinct durations") + void testMergeWithDistinctDurations() { + Function extractor1 = cfg -> "100:60"; + Function extractor2 = cfg -> "200:10"; + + // Fake config instance (not used directly in lambda logic) + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + String result = RateLimitUtil.merge(extractor1, extractor2).apply(config); + + assertThat(result).isEqualTo("200:10,100:60"); + } + +} diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml new file mode 100644 index 0000000000..e66d86ff41 --- /dev/null +++ b/common/discovery-api/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + org.thingsboard + 4.2.0-SNAPSHOT + common + + org.thingsboard.common + discovery-api + jar + + Thingsboard Server Service Info Provider API + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + + + + + org.thingsboard.common + proto + + + org.thingsboard.common + message + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-deploy-plugin + + false + + + + + + diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TbServiceInfoProvider.java b/common/discovery-api/src/main/java/org/thingsboard/server/queue/discovery/TbServiceInfoProvider.java similarity index 94% rename from common/queue/src/main/java/org/thingsboard/server/queue/discovery/TbServiceInfoProvider.java rename to common/discovery-api/src/main/java/org/thingsboard/server/queue/discovery/TbServiceInfoProvider.java index 51a6d808dd..b01eac8d8b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TbServiceInfoProvider.java +++ b/common/discovery-api/src/main/java/org/thingsboard/server/queue/discovery/TbServiceInfoProvider.java @@ -29,10 +29,14 @@ public interface TbServiceInfoProvider { ServiceInfo getServiceInfo(); + boolean isMonolith(); + boolean isService(ServiceType serviceType); ServiceInfo generateNewServiceInfoWithCurrentSystemInfo(); Set getAssignedTenantProfiles(); + boolean setReady(boolean ready); + } diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index b3ec95983f..a153ee7a9f 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common 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 509b30feb4..0eff7ad053 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 @@ -136,7 +136,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { .setConnectRequestMsg(ConnectRequestMsg.newBuilder() .setEdgeRoutingKey(edgeKey) .setEdgeSecret(edgeSecret) - .setEdgeVersion(EdgeVersion.V_4_0_0) + .setEdgeVersion(EdgeVersion.V_4_2_0) .setMaxInboundMessageSize(maxInboundMessageSize) .build()) .build()); diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 023ac00634..dbda462a99 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -42,6 +42,8 @@ enum EdgeVersion { V_3_8_0 = 8; V_3_9_0 = 9; V_4_0_0 = 10; + V_4_1_0 = 11; + V_4_2_0 = 12; V_LATEST = 999; } @@ -124,6 +126,14 @@ enum UpdateMsgType { // use 6 as a next number } +message CalculatedFieldUpdateMsg{ + UpdateMsgType msgType = 1; + int64 idMSB = 2; + int64 idLSB = 3; + string entity = 4; +} + + message EntityDataProto { int64 entityIdMSB = 1; int64 entityIdLSB = 2; @@ -325,6 +335,12 @@ message RelationRequestMsg { string entityType = 3; } +message CalculatedFieldRequestMsg { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + string entityType = 3; +} + // DEPRECATED. FOR REMOVAL message UserCredentialsRequestMsg { option deprecated = true; @@ -423,6 +439,8 @@ message UplinkMsg { repeated AlarmCommentUpdateMsg alarmCommentUpdateMsg = 22; repeated RuleChainUpdateMsg ruleChainUpdateMsg = 23; repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24; + repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25; + repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26; } message UplinkResponseMsg { @@ -472,4 +490,5 @@ message DownlinkMsg { repeated NotificationTargetUpdateMsg notificationTargetUpdateMsg = 32; repeated NotificationTemplateUpdateMsg notificationTemplateUpdateMsg = 33; repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34; + repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 09181e796e..fa5012a180 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common @@ -68,6 +68,10 @@ org.thingsboard.common queue + + org.thingsboard.common + discovery-api + org.springframework.boot spring-boot-starter-web diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java index 72919c3fc0..13a54973e6 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java @@ -18,7 +18,6 @@ package org.thingsboard.server.edqs.processor; import com.google.common.collect.Sets; 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 lombok.Getter; @@ -29,7 +28,6 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.common.util.ExceptionUtil; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.edqs.EdqsEvent; @@ -47,31 +45,31 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.edqs.repo.EdqsRepository; import org.thingsboard.server.edqs.state.EdqsPartitionService; import org.thingsboard.server.edqs.state.EdqsStateService; -import org.thingsboard.server.edqs.util.EdqsConverter; +import org.thingsboard.server.edqs.util.EdqsMapper; import org.thingsboard.server.edqs.util.VersionsStore; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.EdqsEventMsg; import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.queue.TbQueueHandler; -import org.thingsboard.server.queue.TbQueueResponseTemplate; +import org.thingsboard.server.queue.common.PartitionedQueueResponseTemplate; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.DiscoveryService; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.edqs.EdqsComponent; import org.thingsboard.server.queue.edqs.EdqsConfig; import org.thingsboard.server.queue.edqs.EdqsConfig.EdqsPartitioningStrategy; +import org.thingsboard.server.queue.edqs.EdqsExecutors; import org.thingsboard.server.queue.edqs.EdqsQueueFactory; -import org.thingsboard.server.queue.util.AfterStartUp; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -85,24 +83,20 @@ import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTop public class EdqsProcessor implements TbQueueHandler, TbProtoQueueMsg> { private final EdqsQueueFactory queueFactory; - private final EdqsConverter converter; + private final EdqsMapper mapper; private final EdqsRepository repository; private final EdqsConfig config; + private final EdqsExecutors edqsExecutors; private final EdqsPartitionService partitionService; + private final DiscoveryService discoveryService; private final TopicService topicService; private final ConfigurableApplicationContext applicationContext; private final EdqsStateService stateService; private PartitionedQueueConsumerManager> eventConsumer; - private TbQueueResponseTemplate, TbProtoQueueMsg> responseTemplate; - - private ExecutorService consumersExecutor; - private ExecutorService taskExecutor; - private ScheduledExecutorService scheduler; + private PartitionedQueueResponseTemplate, TbProtoQueueMsg> responseTemplate; private ListeningExecutorService requestExecutor; - - private final VersionsStore versionsStore = new VersionsStore(); - + private VersionsStore versionsStore; private final AtomicInteger counter = new AtomicInteger(); @Getter @@ -110,24 +104,23 @@ public class EdqsProcessor implements TbQueueHandler, @PostConstruct private void init() { - consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("edqs-consumer")); - taskExecutor = ThingsBoardExecutors.newWorkStealingPool(4, "edqs-consumer-task-executor"); - scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edqs-scheduler"); - requestExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(12, "edqs-requests")); errorHandler = error -> { if (error instanceof OutOfMemoryError) { log.error("OOM detected, shutting down"); repository.clear(); + discoveryService.setReady(false); Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edqs-shutdown")) .execute(applicationContext::close); } }; + requestExecutor = edqsExecutors.getRequestExecutor(); + versionsStore = new VersionsStore(config.getVersionsCacheTtl()); eventConsumer = PartitionedQueueConsumerManager.>create() .queueKey(new QueueKey(ServiceType.EDQS, config.getEventsTopic())) .topic(topicService.buildTopicName(config.getEventsTopic())) .pollInterval(config.getPollInterval()) - .msgPackProcessor((msgs, consumer, config) -> { + .msgPackProcessor((msgs, consumer, consumerKey, config) -> { for (TbProtoQueueMsg queueMsg : msgs) { if (consumer.isStopped()) { return; @@ -143,19 +136,14 @@ public class EdqsProcessor implements TbQueueHandler, }) .consumerCreator((config, tpi) -> queueFactory.createEdqsEventsConsumer()) .queueAdmin(queueFactory.getEdqsQueueAdmin()) - .consumerExecutor(consumersExecutor) - .taskExecutor(taskExecutor) - .scheduler(scheduler) + .consumerExecutor(edqsExecutors.getConsumersExecutor()) + .taskExecutor(edqsExecutors.getConsumerTaskExecutor()) + .scheduler(edqsExecutors.getScheduler()) .uncaughtErrorHandler(errorHandler) .build(); - stateService.init(eventConsumer); + responseTemplate = queueFactory.createEdqsResponseTemplate(this); - responseTemplate = queueFactory.createEdqsResponseTemplate(); - } - - @AfterStartUp(order = 1) - public void start() { - responseTemplate.launch(this); + stateService.init(eventConsumer, List.of(responseTemplate.getRequestConsumer())); } @EventListener @@ -165,10 +153,8 @@ public class EdqsProcessor implements TbQueueHandler, } try { Set newPartitions = event.getNewPartitions().get(new QueueKey(ServiceType.EDQS)); - stateService.process(withTopic(newPartitions, topicService.buildTopicName(config.getStateTopic()))); - // eventsConsumer's partitions are updated by stateService - responseTemplate.subscribe(withTopic(newPartitions, topicService.buildTopicName(config.getRequestsTopic()))); // TODO: we subscribe to partitions before we are ready. implement consumer-per-partition version for request template + // partitions for event and request consumers are updated by stateService Set oldPartitions = event.getOldPartitions().get(new QueueKey(ServiceType.EDQS)); if (CollectionsUtil.isNotEmpty(oldPartitions)) { @@ -217,6 +203,25 @@ public class EdqsProcessor implements TbQueueHandler, }); } + @Override + public TbProtoQueueMsg constructErrorResponseMsg(TbProtoQueueMsg request, Throwable e) { + EdqsResponse response = new EdqsResponse(); + String errorMessage; + if (e instanceof org.apache.kafka.common.errors.RecordTooLargeException) { + errorMessage = "Result set is too large"; + } else if (e instanceof IllegalArgumentException || e instanceof NullPointerException) { + errorMessage = "Invalid request format or missing data: " + ExceptionUtil.getMessage(e); + } else { + errorMessage = ExceptionUtil.getMessage(e); + } + response.setError(errorMessage); + return new TbProtoQueueMsg<>(request.getKey(), FromEdqsMsg.newBuilder() + .setResponseMsg(TransportProtos.EdqsResponseMsg.newBuilder() + .setValue(JacksonUtil.toString(response)) + .build()) + .build(), request.getHeaders()); + } + private EdqsResponse processRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { EdqsResponse response = new EdqsResponse(); try { @@ -243,22 +248,20 @@ public class EdqsProcessor implements TbQueueHandler, TenantId tenantId = getTenantId(edqsMsg); ObjectType objectType = ObjectType.valueOf(eventMsg.getObjectType()); EdqsEventType eventType = EdqsEventType.valueOf(eventMsg.getEventType()); - String key = eventMsg.getKey(); Long version = eventMsg.hasVersion() ? eventMsg.getVersion() : null; + EdqsObject object = mapper.deserialize(objectType, eventMsg.getData().toByteArray(), false); if (version != null) { - if (!versionsStore.isNew(key, version)) { + if (!versionsStore.isNew(mapper.getKey(object), version)) { return; } } else if (!ObjectType.unversionedTypes.contains(objectType)) { - log.warn("[{}] {} {} doesn't have version", tenantId, objectType, key); + log.warn("[{}] {} doesn't have version: {}", tenantId, objectType, object); } if (backup) { - stateService.save(tenantId, objectType, key, eventType, edqsMsg); + stateService.save(tenantId, objectType, object.stringKey(), eventType, edqsMsg); } - EdqsObject object = converter.deserialize(objectType, eventMsg.getData().toByteArray()); - log.debug("[{}] Processing event [{}] [{}] [{}] [{}]", tenantId, objectType, eventType, key, version); int count = counter.incrementAndGet(); if (count % 100000 == 0) { log.info("Processed {} events", count); @@ -270,6 +273,7 @@ public class EdqsProcessor implements TbQueueHandler, .eventType(eventType) .object(object) .build(); + log.debug("Processing event: {}", event); repository.processEvent(event); } } @@ -292,11 +296,7 @@ public class EdqsProcessor implements TbQueueHandler, eventConsumer.awaitStop(); responseTemplate.stop(); stateService.stop(); - - consumersExecutor.shutdownNow(); - taskExecutor.shutdownNow(); - scheduler.shutdownNow(); - requestExecutor.shutdownNow(); + versionsStore.shutdown(); } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileNameQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileNameQueryProcessor.java index b78e49879e..95a1a79392 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileNameQueryProcessor.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileNameQueryProcessor.java @@ -36,7 +36,7 @@ public abstract class AbstractEntityProfileNameQueryProcessor(getProfileNames(this.filter)); - pattern = RepositoryUtils.toSqlLikePattern(getEntityNameFilter(filter)); + pattern = RepositoryUtils.toEntityNameSqlLikePattern(getEntityNameFilter(filter)); } protected abstract String getEntityNameFilter(T filter); diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileQueryProcessor.java index 301ead7c63..94a7a95404 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileQueryProcessor.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileQueryProcessor.java @@ -43,7 +43,7 @@ public abstract class AbstractEntityProfileQueryProcessor ext private Set> getEntitiesSet(RelationsRepo relations) { Set> result = new HashSet<>(); - Set processed = new HashSet<>(); + Set processed = new HashSet<>(); Queue tasks = new LinkedList<>(); int maxLvl = getMaxLevel() == 0 ? MAXIMUM_QUERY_LEVEL : Math.max(1, getMaxLevel()); for (UUID uuid : getRootEntities()) { @@ -114,7 +115,7 @@ public abstract class AbstractRelationQueryProcessor ext } while (!tasks.isEmpty()) { RelationSearchTask task = tasks.poll(); - if (processed.add(task.entityId)) { + if (processed.add(task)) { var entityLvl = task.lvl + 1; Set entities = EntitySearchDirection.FROM.equals(getDirection()) ? relations.getFrom(task.entityId) : relations.getTo(task.entityId); if (isFetchLastLevelOnly() && entities.isEmpty() && task.previous != null && check(task.previous)) { @@ -157,6 +158,7 @@ public abstract class AbstractRelationQueryProcessor ext protected abstract boolean check(RelationInfo relationInfo); @RequiredArgsConstructor + @EqualsAndHashCode private static class RelationSearchTask { private final UUID entityId; private final int lvl; diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityNameQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityNameQueryProcessor.java index ec88db4d0f..879ed9c7b4 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityNameQueryProcessor.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityNameQueryProcessor.java @@ -30,12 +30,12 @@ public class EntityNameQueryProcessor extends AbstractSimpleQueryProcessor> eventConsumer); + void init(PartitionedQueueConsumerManager> eventConsumer, List> otherConsumers); void process(Set partitions); diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/KafkaEdqsStateService.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/KafkaEdqsStateService.java index ddbdc3253a..7e2e99e662 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/KafkaEdqsStateService.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/KafkaEdqsStateService.java @@ -22,11 +22,13 @@ import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.edqs.EdqsObject; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.edqs.processor.EdqsProcessor; import org.thingsboard.server.edqs.processor.EdqsProducer; +import org.thingsboard.server.edqs.util.EdqsMapper; import org.thingsboard.server.edqs.util.VersionsStore; import org.thingsboard.server.gen.transport.TransportProtos.EdqsEventMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; @@ -34,16 +36,18 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; import org.thingsboard.server.queue.common.state.KafkaQueueStateService; -import org.thingsboard.server.queue.common.state.QueueStateService; +import org.thingsboard.server.queue.discovery.DiscoveryService; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.edqs.EdqsExecutors; import org.thingsboard.server.queue.edqs.KafkaEdqsComponent; import org.thingsboard.server.queue.edqs.KafkaEdqsQueueFactory; import org.thingsboard.server.queue.kafka.TbKafkaAdmin; import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -60,29 +64,34 @@ public class KafkaEdqsStateService implements EdqsStateService { private final EdqsConfig config; private final EdqsPartitionService partitionService; private final KafkaEdqsQueueFactory queueFactory; + private final DiscoveryService discoveryService; + private final EdqsExecutors edqsExecutors; + private final EdqsMapper mapper; private final TopicService topicService; @Autowired @Lazy private EdqsProcessor edqsProcessor; private PartitionedQueueConsumerManager> stateConsumer; - private QueueStateService, TbProtoQueueMsg> queueStateService; + private KafkaQueueStateService, TbProtoQueueMsg> queueStateService; private QueueConsumerManager> eventsToBackupConsumer; private EdqsProducer stateProducer; + private VersionsStore versionsStore; - private final VersionsStore versionsStore = new VersionsStore(); private final AtomicInteger stateReadCount = new AtomicInteger(); private final AtomicInteger eventsReadCount = new AtomicInteger(); - private Boolean ready; + + private boolean ready = false; @Override - public void init(PartitionedQueueConsumerManager> eventConsumer) { + public void init(PartitionedQueueConsumerManager> eventConsumer, List> otherConsumers) { + versionsStore = new VersionsStore(config.getVersionsCacheTtl()); TbKafkaAdmin queueAdmin = queueFactory.getEdqsQueueAdmin(); stateConsumer = PartitionedQueueConsumerManager.>create() .queueKey(new QueueKey(ServiceType.EDQS, config.getStateTopic())) .topic(topicService.buildTopicName(config.getStateTopic())) .pollInterval(config.getPollInterval()) - .msgPackProcessor((msgs, consumer, config) -> { + .msgPackProcessor((msgs, consumer, consumerKey, config) -> { for (TbProtoQueueMsg queueMsg : msgs) { try { ToEdqsMsg msg = queueMsg.getValue(); @@ -98,9 +107,9 @@ public class KafkaEdqsStateService implements EdqsStateService { }) .consumerCreator((config, tpi) -> queueFactory.createEdqsStateConsumer()) .queueAdmin(queueAdmin) - .consumerExecutor(eventConsumer.getConsumerExecutor()) - .taskExecutor(eventConsumer.getTaskExecutor()) - .scheduler(eventConsumer.getScheduler()) + .consumerExecutor(edqsExecutors.getConsumersExecutor()) + .taskExecutor(edqsExecutors.getConsumerTaskExecutor()) + .scheduler(edqsExecutors.getScheduler()) .uncaughtErrorHandler(edqsProcessor.getErrorHandler()) .build(); @@ -119,22 +128,25 @@ public class KafkaEdqsStateService implements EdqsStateService { if (msg.hasEventMsg()) { EdqsEventMsg eventMsg = msg.getEventMsg(); - String key = eventMsg.getKey(); - int count = eventsReadCount.incrementAndGet(); - if (count % 100000 == 0) { - log.info("[events-to-backup] Processed {} msgs", count); - } + ObjectType objectType = ObjectType.valueOf(eventMsg.getObjectType()); + EdqsObject object = mapper.deserialize(objectType, eventMsg.getData().toByteArray(), true); + if (eventMsg.hasVersion()) { - if (!versionsStore.isNew(key, eventMsg.getVersion())) { + if (!versionsStore.isNew(mapper.getKey(object), eventMsg.getVersion())) { continue; } } TenantId tenantId = getTenantId(msg); - ObjectType objectType = ObjectType.valueOf(eventMsg.getObjectType()); EdqsEventType eventType = EdqsEventType.valueOf(eventMsg.getEventType()); + String key = object.stringKey(); log.trace("[{}] Saving to backup [{}] [{}] [{}]", tenantId, objectType, eventType, key); - stateProducer.send(tenantId, objectType, key, msg); + stateProducer.send(tenantId, objectType, object.stringKey(), msg); + + int count = eventsReadCount.incrementAndGet(); + if (count % 100000 == 0) { + log.info("[events-to-backup] Processed {} msgs", count); + } } } catch (Throwable t) { log.error("Failed to process message: {}", queueMsg, t); @@ -143,7 +155,7 @@ public class KafkaEdqsStateService implements EdqsStateService { consumer.commit(); }) .consumerCreator(() -> eventsToBackupKafkaConsumer) - .consumerExecutor(eventConsumer.getConsumerExecutor()) + .consumerExecutor(edqsExecutors.getConsumersExecutor()) .threadPrefix("edqs-events-to-backup") .build(); @@ -155,6 +167,7 @@ public class KafkaEdqsStateService implements EdqsStateService { queueStateService = KafkaQueueStateService., TbProtoQueueMsg>builder() .eventConsumer(eventConsumer) .stateConsumer(stateConsumer) + .otherConsumers(otherConsumers) .eventsStartOffsetsProvider(() -> { // taking start offsets for events topics from the events-to-backup consumer group, // since eventConsumer doesn't use consumer group management and thus offset tracking @@ -185,7 +198,10 @@ public class KafkaEdqsStateService implements EdqsStateService { eventsToBackupConsumer.subscribe(allPartitions); eventsToBackupConsumer.launch(); } - queueStateService.update(new QueueKey(ServiceType.EDQS), partitions); + queueStateService.update(new QueueKey(ServiceType.EDQS), partitions, () -> { + ready = true; + discoveryService.setReady(true); + }); } @Override @@ -195,13 +211,7 @@ public class KafkaEdqsStateService implements EdqsStateService { @Override public boolean isReady() { - if (ready == null) { - Set partitionsInProgress = queueStateService.getPartitionsInProgress(); - if (partitionsInProgress != null && partitionsInProgress.isEmpty()) { - ready = true; // once true - always true, not to change readiness status on each repartitioning - } - } - return ready != null && ready; + return ready; } private TenantId getTenantId(ToEdqsMsg edqsMsg) { @@ -214,6 +224,7 @@ public class KafkaEdqsStateService implements EdqsStateService { stateConsumer.awaitStop(); eventsToBackupConsumer.stop(); stateProducer.stop(); + versionsStore.shutdown(); } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/LocalEdqsStateService.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/LocalEdqsStateService.java index cde21edfaf..00bed5e91d 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/LocalEdqsStateService.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/LocalEdqsStateService.java @@ -29,8 +29,10 @@ import org.thingsboard.server.edqs.util.EdqsRocksDb; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.DiscoveryService; import org.thingsboard.server.queue.edqs.InMemoryEdqsComponent; +import java.util.List; import java.util.Set; import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTopic; @@ -42,20 +44,24 @@ import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTop public class LocalEdqsStateService implements EdqsStateService { private final EdqsRocksDb db; + private final DiscoveryService discoveryService; @Autowired @Lazy private EdqsProcessor processor; private PartitionedQueueConsumerManager> eventConsumer; - private Set partitions; + private List> otherConsumers; + + private boolean ready = false; @Override - public void init(PartitionedQueueConsumerManager> eventConsumer) { + public void init(PartitionedQueueConsumerManager> eventConsumer, List> otherConsumers) { this.eventConsumer = eventConsumer; + this.otherConsumers = otherConsumers; } @Override public void process(Set partitions) { - if (this.partitions == null) { + if (!ready) { db.forEach((key, value) -> { try { ToEdqsMsg edqsMsg = ToEdqsMsg.parseFrom(value); @@ -67,8 +73,13 @@ public class LocalEdqsStateService implements EdqsStateService { }); log.info("Restore completed"); } + ready = true; + discoveryService.setReady(true); + eventConsumer.update(withTopic(partitions, eventConsumer.getTopic())); - this.partitions = partitions; + for (PartitionedQueueConsumerManager consumer : otherConsumers) { + consumer.update(withTopic(partitions, consumer.getTopic())); + } } @Override @@ -87,7 +98,7 @@ public class LocalEdqsStateService implements EdqsStateService { @Override public boolean isReady() { - return partitions != null; + return ready; } @Override diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsConverter.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/DefaultEdqsMapper.java similarity index 80% rename from common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsConverter.java rename to common/edqs/src/main/java/org/thingsboard/server/edqs/util/DefaultEdqsMapper.java index 9a84436f36..0d3fc35b08 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsConverter.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/DefaultEdqsMapper.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.google.protobuf.ByteString; import lombok.RequiredArgsConstructor; @@ -36,6 +35,7 @@ import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.edqs.AttributeKv; import org.thingsboard.server.common.data.edqs.DataPoint; import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.EdqsObjectKey; import org.thingsboard.server.common.data.edqs.Entity; import org.thingsboard.server.common.data.edqs.LatestTsKv; import org.thingsboard.server.common.data.edqs.fields.FieldsUtil; @@ -52,6 +52,7 @@ import org.thingsboard.server.edqs.data.dp.DoubleDataPoint; import org.thingsboard.server.edqs.data.dp.JsonDataPoint; import org.thingsboard.server.edqs.data.dp.LongDataPoint; import org.thingsboard.server.edqs.data.dp.StringDataPoint; +import org.thingsboard.server.edqs.repo.KeyDictionary; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DataPointProto; import org.xerial.snappy.Snappy; @@ -65,19 +66,29 @@ import java.util.UUID; @Service @RequiredArgsConstructor @Slf4j -public class EdqsConverter { +public class DefaultEdqsMapper implements EdqsMapper { private final EdqsStatsService edqsStatsService; @Value("${queue.edqs.string_compression_length_threshold:512}") private int stringCompressionLengthThreshold; - private final Map> converters = new HashMap<>(); - private final Converter defaultConverter = new JsonConverter<>(Entity.class); + private final Map> mappers = new HashMap<>(); + private final Mapper defaultMapper = new JsonMapper<>(Entity.class) { + @Override + public EdqsObjectKey getKey(Entity entity) { + return new Entity.Key(entity.getFields().getId()); + } + }; { - converters.put(ObjectType.RELATION, new JsonConverter<>(EntityRelation.class)); - converters.put(ObjectType.ATTRIBUTE_KV, new Converter() { + mappers.put(ObjectType.RELATION, new JsonMapper<>(EntityRelation.class) { + @Override + public EdqsObjectKey getKey(EntityRelation relation) { + return new EntityRelation.Key(relation.getFrom().getId(), relation.getTo().getId(), relation.getTypeGroup(), relation.getType()); + } + }); + mappers.put(ObjectType.ATTRIBUTE_KV, new Mapper() { @Override public byte[] serialize(ObjectType type, AttributeKv attributeKv) { var proto = TransportProtos.AttributeKvProto.newBuilder() @@ -94,12 +105,12 @@ public class EdqsConverter { } @Override - public AttributeKv deserialize(ObjectType type, byte[] bytes) throws Exception { + public AttributeKv deserialize(ObjectType type, byte[] bytes, boolean onlyKey) throws Exception { TransportProtos.AttributeKvProto proto = TransportProtos.AttributeKvProto.parseFrom(bytes); EntityId entityId = EntityIdFactory.getByTypeAndUuid(ProtoUtils.fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); AttributeScope scope = AttributeScope.values()[proto.getScope().getNumber()]; - DataPoint dataPoint = proto.hasDataPoint() ? fromDataPointProto(proto.getDataPoint()) : null; + DataPoint dataPoint = onlyKey || !proto.hasDataPoint() ? null : fromDataPointProto(proto.getDataPoint()); return AttributeKv.builder() .entityId(entityId) .scope(scope) @@ -108,8 +119,13 @@ public class EdqsConverter { .dataPoint(dataPoint) .build(); } + + @Override + public EdqsObjectKey getKey(AttributeKv attributeKv) { + return new AttributeKv.Key(attributeKv.getEntityId().getId(), attributeKv.getScope(), KeyDictionary.get(attributeKv.getKey())); + } }); - converters.put(ObjectType.LATEST_TS_KV, new Converter() { + mappers.put(ObjectType.LATEST_TS_KV, new Mapper() { @Override public byte[] serialize(ObjectType type, LatestTsKv latestTsKv) { var proto = TransportProtos.LatestTsKvProto.newBuilder() @@ -125,11 +141,11 @@ public class EdqsConverter { } @Override - public LatestTsKv deserialize(ObjectType type, byte[] bytes) throws Exception { + public LatestTsKv deserialize(ObjectType type, byte[] bytes, boolean onlyKey) throws Exception { TransportProtos.LatestTsKvProto proto = TransportProtos.LatestTsKvProto.parseFrom(bytes); EntityId entityId = EntityIdFactory.getByTypeAndUuid(ProtoUtils.fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); - DataPoint dataPoint = proto.hasDataPoint() ? fromDataPointProto(proto.getDataPoint()) : null; + DataPoint dataPoint = onlyKey || !proto.hasDataPoint() ? null : fromDataPointProto(proto.getDataPoint()); return LatestTsKv.builder() .entityId(entityId) .key(proto.getKey()) @@ -137,6 +153,11 @@ public class EdqsConverter { .dataPoint(dataPoint) .build(); } + + @Override + public EdqsObjectKey getKey(LatestTsKv latestTsKv) { + return new LatestTsKv.Key(latestTsKv.getEntityId().getId(), KeyDictionary.get(latestTsKv.getKey())); + } }); } @@ -223,30 +244,30 @@ public class EdqsConverter { @SuppressWarnings("unchecked") @SneakyThrows - public byte[] serialize(ObjectType type, T value) { - Converter converter = (Converter) converters.get(type); - if (converter != null) { - return converter.serialize(type, value); - } else { - return defaultConverter.serialize(type, (Entity) value); - } + public byte[] serialize(T value) { + ObjectType type = value.type(); + Mapper mapper = (Mapper) mappers.getOrDefault(type, defaultMapper); + return mapper.serialize(type, value); } @SneakyThrows - public EdqsObject deserialize(ObjectType type, byte[] bytes) { - Converter converter = converters.get(type); - if (converter != null) { - return converter.deserialize(type, bytes); - } else { - return defaultConverter.deserialize(type, bytes); - } + public EdqsObject deserialize(ObjectType type, byte[] bytes, boolean onlyKey) { + Mapper mapper = mappers.getOrDefault(type, defaultMapper); + return mapper.deserialize(type, bytes, onlyKey); + } + + @SuppressWarnings("unchecked") + @SneakyThrows + public EdqsObjectKey getKey(T object) { + Mapper mapper = (Mapper) mappers.getOrDefault(object.type(), defaultMapper); + return mapper.getKey(object); } @RequiredArgsConstructor - private static class JsonConverter implements Converter { + private static abstract class JsonMapper implements Mapper { private static final SimpleModule module = new SimpleModule(); - private static final ObjectMapper mapper = JsonMapper.builder() + private static final ObjectMapper mapper = com.fasterxml.jackson.databind.json.JsonMapper.builder() .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) .visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE) .visibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE) @@ -267,17 +288,19 @@ public class EdqsConverter { @SneakyThrows @Override - public T deserialize(ObjectType objectType, byte[] bytes) { + public T deserialize(ObjectType objectType, byte[] bytes, boolean onlyKey) { return mapper.readValue(bytes, this.type); } } - private interface Converter { + private interface Mapper { byte[] serialize(ObjectType type, T value) throws Exception; - T deserialize(ObjectType type, byte[] bytes) throws Exception; + T deserialize(ObjectType type, byte[] bytes, boolean onlyKey) throws Exception; + + EdqsObjectKey getKey(T object); } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsMapper.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsMapper.java new file mode 100644 index 0000000000..bd6ed22cd1 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsMapper.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edqs.util; + +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.EdqsObjectKey; + +public interface EdqsMapper { + + byte[] serialize(T value); + + EdqsObject deserialize(ObjectType type, byte[] bytes, boolean onlyKey); + + EdqsObjectKey getKey(T object); + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index f355d83e62..fab98027ed 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -54,7 +54,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; -import java.util.Optional; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -249,11 +248,11 @@ public class RepositoryUtils { } return switch (predicate.getOperation()) { case EQUAL -> value.equals(predicateValue); - case STARTS_WITH -> value.startsWith(predicateValue); - case ENDS_WITH -> value.endsWith(predicateValue); + case STARTS_WITH -> toSqlLikePattern(predicateValue, "^", ".*").matcher(value).matches(); + case ENDS_WITH -> toSqlLikePattern(predicateValue, ".*", "$").matcher(value).matches(); case NOT_EQUAL -> !value.equals(predicateValue); - case CONTAINS -> value.contains(predicateValue); - case NOT_CONTAINS -> !value.contains(predicateValue); + case CONTAINS -> toSqlLikePattern(predicateValue, ".*", ".*").matcher(value).matches(); + case NOT_CONTAINS -> !toSqlLikePattern(predicateValue, ".*", ".*").matcher(value).matches(); case IN -> equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); case NOT_IN -> !equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); }; @@ -304,6 +303,15 @@ public class RepositoryUtils { return true; } else if (filterPredicates.getOperation() == OR) { for (KeyFilterPredicate filterPredicate : filterPredicates.getPredicates()) { + + // Emulate the SQL-like behavior of ThingsBoard's Entity Data Query service: + // for COMPLEX filters, return no results if filter value is empty + if (filterPredicate instanceof StringFilterPredicate stringFilterPredicate) { + if (StringUtils.isEmpty(stringFilterPredicate.getValue().getValue())) { + continue; + } + } + if (simpleKeyFilter.check(value, filterPredicate)) { return true; } @@ -314,25 +322,34 @@ public class RepositoryUtils { } } - public static Pattern toSqlLikePattern(String nameFilter) { - if (StringUtils.isNotBlank(nameFilter)) { - boolean percentSymbolOnStart = nameFilter.startsWith("%"); - boolean percentSymbolOnEnd = nameFilter.endsWith("%"); - if (percentSymbolOnStart) { - nameFilter = nameFilter.substring(1); - } - if (percentSymbolOnEnd) { - nameFilter = nameFilter.substring(0, nameFilter.length() - 1); - } - if (percentSymbolOnStart || percentSymbolOnEnd) { - return Pattern.compile((percentSymbolOnStart ? ".*" : "") + Pattern.quote(nameFilter) + (percentSymbolOnEnd ? ".*" : ""), Pattern.CASE_INSENSITIVE); - } else { - return Pattern.compile(Pattern.quote(nameFilter) + ".*", Pattern.CASE_INSENSITIVE); - } + public static Pattern toEntityNameSqlLikePattern(String filter) { + if (StringUtils.isNotBlank(filter)) { + return toSqlLikePattern(filter, "", ".*", true); } return null; } + private static Pattern toSqlLikePattern(String value, String prefix, String suffix) { + return toSqlLikePattern(value, prefix, suffix, false); + } + + private static Pattern toSqlLikePattern(String value, String prefix, String suffix, boolean ignoreCase) { + String regexValue; + if (value.contains("%") || value.contains("_")) { + regexValue = value + .replace("_", ".") + .replace("%", ".*"); + if ("^".equals(prefix)) { + regexValue = "^" + regexValue + (regexValue.endsWith(".*") ? "" : ".*"); + } else if ("$".equals(suffix)) { + regexValue = (regexValue.startsWith(".*") ? "" : ".*") + regexValue + "$"; + } + } else { + regexValue = prefix + Pattern.quote(value) + suffix; + } + return ignoreCase ? Pattern.compile(regexValue, Pattern.CASE_INSENSITIVE) : Pattern.compile(regexValue); + } + @FunctionalInterface public interface SimpleKeyFilter { diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/VersionsStore.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/VersionsStore.java index 798ac0603d..f348e9cf9e 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/VersionsStore.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/VersionsStore.java @@ -15,32 +15,70 @@ */ package org.thingsboard.server.edqs.util; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.edqs.EdqsObjectKey; +import java.util.Map; +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.concurrent.atomic.AtomicBoolean; @Slf4j public class VersionsStore { - private final Cache versions = Caffeine.newBuilder() - .expireAfterWrite(24, TimeUnit.HOURS) - .build(); + private final ConcurrentMap versions = new ConcurrentHashMap<>(); + private final long expirationMillis; + private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor(); - public boolean isNew(String key, Long version) { + public VersionsStore(int ttlMinutes) { + this.expirationMillis = TimeUnit.MINUTES.toMillis(ttlMinutes); + startCleanupTask(); + } + + public boolean isNew(EdqsObjectKey key, Long version) { AtomicBoolean isNew = new AtomicBoolean(false); - versions.asMap().compute(key, (k, prevVersion) -> { - if (prevVersion == null || prevVersion <= version) { + versions.compute(key, (k, prevVersion) -> { + if (prevVersion == null || prevVersion.value <= version) { isNew.set(true); - return version; + return new TimedValue(version); } else { - log.info("[{}] Version {} is outdated, the latest is {}", key, version, prevVersion); + log.debug("[{}] Version {} is outdated, the latest is {}", key, version, prevVersion); return prevVersion; } }); return isNew.get(); } + private void startCleanupTask() { + cleaner.scheduleAtFixedRate(() -> { + try { + long now = System.currentTimeMillis(); + for (Map.Entry entry : versions.entrySet()) { + if (now - entry.getValue().lastUpdated > expirationMillis) { + versions.remove(entry.getKey(), entry.getValue()); + } + } + } catch (Exception e) { + log.error("Cleanup task failed", e); + } + }, expirationMillis, expirationMillis, TimeUnit.MILLISECONDS); + } + + public void shutdown() { + cleaner.shutdown(); + } + + private static class TimedValue { + private final long lastUpdated; + private final long value; + + public TimedValue(long value) { + this.value = value; + this.lastUpdated = System.currentTimeMillis(); + } + } + } diff --git a/common/message/pom.xml b/common/message/pom.xml index 83100e11ea..e34250fc23 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java index e703183883..60fb1df6aa 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.msg; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import java.io.Serializable; @@ -23,9 +24,6 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -/** - * Created by ashvayka on 13.01.18. - */ @Data public final class TbMsgMetaData implements Serializable { @@ -34,7 +32,7 @@ public final class TbMsgMetaData implements Serializable { private final Map data; public TbMsgMetaData() { - this.data = new ConcurrentHashMap<>(); + data = new ConcurrentHashMap<>(); } public TbMsgMetaData(Map data) { @@ -46,24 +44,30 @@ public final class TbMsgMetaData implements Serializable { * Internal constructor to create immutable TbMsgMetaData.EMPTY * */ private TbMsgMetaData(int ignored) { - this.data = Collections.emptyMap(); + data = Collections.emptyMap(); } public String getValue(String key) { - return this.data.get(key); + return data.get(key); } public void putValue(String key, String value) { if (key != null && value != null) { - this.data.put(key, value); + data.put(key, value); } } public Map values() { - return new HashMap<>(this.data); + return new HashMap<>(data); } public TbMsgMetaData copy() { - return new TbMsgMetaData(this.data); + return new TbMsgMetaData(data); } + + @JsonIgnore + public boolean isEmpty() { + return data == null || data.isEmpty(); + } + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsApiService.java b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsApiService.java index 05864fe863..0fe9971712 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsApiService.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsApiService.java @@ -25,12 +25,6 @@ public interface EdqsApiService { ListenableFuture processRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request); - boolean isEnabled(); - - void setEnabled(boolean enabled); - boolean isSupported(); - boolean isAutoEnable(); - } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsService.java b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsService.java index 32ff57e3e0..faad9a0f4a 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsService.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.msg.edqs; import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.ToCoreEdqsMsg; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; import org.thingsboard.server.common.data.id.EntityId; @@ -36,4 +37,8 @@ public interface EdqsService { void processSystemMsg(ToCoreEdqsMsg request); + boolean isApiEnabled(); + + EdqsState getState(); + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java index 13fd6159fc..d57301fd10 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.msg.plugin; +import com.fasterxml.jackson.databind.JsonNode; import lombok.Builder; import lombok.Data; import org.thingsboard.server.common.data.EntityType; @@ -45,13 +46,14 @@ public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { private final String name; private final EntityId oldProfileId; private final EntityId profileId; + private final JsonNode info; public ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event) { - this(tenantId, entityId, event, null, null, null, null); + this(tenantId, entityId, event, null, null, null, null, null); } @Builder - private ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event, String oldName, String name, EntityId oldProfileId, EntityId profileId) { + private ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event, String oldName, String name, EntityId oldProfileId, EntityId profileId, JsonNode info) { this.tenantId = tenantId; this.entityId = entityId; this.event = event; @@ -59,6 +61,7 @@ public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { this.name = name; this.oldProfileId = oldProfileId; this.profileId = profileId; + this.info = info; } public Optional getRuleChainId() { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java index f31fdfa7a8..8fd535891c 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java @@ -27,7 +27,8 @@ public enum ServiceType { TB_TRANSPORT("TB Transport"), JS_EXECUTOR("JS Executor"), TB_VC_EXECUTOR("TB VC Executor"), - EDQS("TB Entity Data Query Service"); + EDQS("TB Entity Data Query Service"), + TASK_PROCESSOR("Task Processor"); private final String label; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java index ee8990d931..777ce3bf94 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.msg.queue; +import com.google.common.util.concurrent.SettableFuture; import org.thingsboard.server.common.data.id.EntityId; import java.util.UUID; @@ -34,7 +35,7 @@ public interface TbCallback { } }; - default UUID getId(){ + default UUID getId() { return EntityId.NULL_UUID; } @@ -42,4 +43,18 @@ public interface TbCallback { void onFailure(Throwable t); + static TbCallback wrap(SettableFuture future) { + return new TbCallback() { + @Override + public void onSuccess() { + future.set(null); + } + + @Override + public void onFailure(Throwable t) { + future.setException(t); + } + }; + } + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java index 8e8933c324..182cb21ab8 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java @@ -16,13 +16,16 @@ package org.thingsboard.server.common.msg.tools; import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BandwidthBuilder; import io.github.bucket4j.Bucket; -import io.github.bucket4j.Refill; import io.github.bucket4j.local.LocalBucket; import io.github.bucket4j.local.LocalBucketBuilder; import lombok.Getter; +import org.thingsboard.server.common.data.limit.RateLimitEntry; +import org.thingsboard.server.common.data.limit.RateLimitUtil; import java.time.Duration; +import java.util.List; /** * Created by ashvayka on 22.10.18. @@ -38,20 +41,19 @@ public class TbRateLimits { } public TbRateLimits(String limitsConfiguration, boolean refillIntervally) { - LocalBucketBuilder builder = Bucket.builder(); - boolean initialized = false; - for (String limitSrc : limitsConfiguration.split(",")) { - long capacity = Long.parseLong(limitSrc.split(":")[0]); - long duration = Long.parseLong(limitSrc.split(":")[1]); - Refill refill = refillIntervally ? Refill.intervally(capacity, Duration.ofSeconds(duration)) : Refill.greedy(capacity, Duration.ofSeconds(duration)); - builder.addLimit(Bandwidth.classic(capacity, refill)); - initialized = true; - } - if (initialized) { - bucket = builder.build(); - } else { + List limitedApiEntries = RateLimitUtil.parseConfig(limitsConfiguration); + if (limitedApiEntries.isEmpty()) { throw new IllegalArgumentException("Failed to parse rate limits configuration: " + limitsConfiguration); } + LocalBucketBuilder localBucket = Bucket.builder(); + for (RateLimitEntry entry : limitedApiEntries) { + BandwidthBuilder.BandwidthBuilderRefillStage bandwidthBuilder = Bandwidth.builder().capacity(entry.capacity()); + Bandwidth bandwidth = refillIntervally ? + bandwidthBuilder.refillIntervally(entry.capacity(), Duration.ofSeconds(entry.durationSeconds())).build() : + bandwidthBuilder.refillGreedy(entry.capacity(), Duration.ofSeconds(entry.durationSeconds())).build(); + localBucket.addLimit(bandwidth); + } + this.bucket = localBucket.build(); this.configuration = limitsConfiguration; } diff --git a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java similarity index 67% rename from common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java rename to common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java index 9d27687cd9..27a2fe6286 100644 --- a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java +++ b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java @@ -16,14 +16,16 @@ package org.thingsboard.server.common.msg.tools; import org.awaitility.pollinterval.FixedPollInterval; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; -public class RateLimitsTest { +public class TbRateLimitsTest { @Test public void testRateLimits_greedyRefill() { @@ -83,4 +85,41 @@ public class RateLimitsTest { }); } + @Test + @DisplayName("TbRateLimits should construct with single rate limit") + void testSingleLimitConstructor() { + TbRateLimits limits = new TbRateLimits("10:1", false); + assertThat(limits.getConfiguration()).isEqualTo("10:1"); + } + + @Test + @DisplayName("TbRateLimits should construct with multiple rate limits") + void testMultipleLimitConstructor() { + String config = "10:1,100:10"; + TbRateLimits limits = new TbRateLimits(config, false); + assertThat(limits.getConfiguration()).isEqualTo(config); + } + + @Test + @DisplayName("TbRateLimits should throw IllegalArgumentException on empty string") + void testEmptyConfigThrows() { + assertThatThrownBy(() -> new TbRateLimits("", false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Failed to parse rate limits configuration: "); + } + + @Test + @DisplayName("TbRateLimits should throw NumberFormatException on malformed value") + void testMalformedConfigThrows() { + assertThatThrownBy(() -> new TbRateLimits("not_a_number:second", false)) + .isInstanceOf(NumberFormatException.class); + } + + @Test + @DisplayName("TbRateLimits should throw ArrayIndexOutOfBoundsException on missing colon") + void testColonMissingThrows() { + assertThatThrownBy(() -> new TbRateLimits("100", false)) + .isInstanceOf(ArrayIndexOutOfBoundsException.class); + } + } diff --git a/common/pom.xml b/common/pom.xml index 6afae4e378..1bfb9904ed 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard common @@ -50,6 +50,7 @@ version-control script edqs + discovery-api diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 548b2e52c5..ad2243776b 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index 2a208923d9..6518cfc3fe 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -56,11 +56,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509Ce import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.Set; import java.util.TreeMap; import java.util.function.Consumer; @@ -538,13 +536,13 @@ public class JsonConverter { return result; } - public static Set convertToAttributes(JsonElement element) { + public static List convertToAttributes(JsonElement element) { long ts = System.currentTimeMillis(); return convertToAttributes(element, ts); } - public static Set convertToAttributes(JsonElement element, long ts) { - return new HashSet<>(parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).toList()); + public static List convertToAttributes(JsonElement element, long ts) { + return parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).toList(); } private static List parseValues(JsonObject valuesObject) { 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 bb2e0fb2d1..6d010a7a0d 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 @@ -135,6 +135,9 @@ public class ProtoUtils { if (msg.getOldName() != null) { builder.setOldName(msg.getOldName()); } + if (msg.getInfo() != null) { + builder.setInfo(JacksonUtil.toString(msg.getInfo())); + } return builder.build(); } @@ -162,6 +165,9 @@ public class ProtoUtils { var profileType = EntityType.DEVICE.equals(entityId.getEntityType()) ? EntityType.DEVICE_PROFILE : EntityType.ASSET_PROFILE; builder.oldProfileId(EntityIdFactory.getByTypeAndUuid(profileType, new UUID(proto.getOldProfileIdMSB(), proto.getOldProfileIdLSB()))); } + if (proto.hasInfo()) { + builder.info(JacksonUtil.toJsonNode(proto.getInfo())); + } return builder.build(); } @@ -246,7 +252,7 @@ public class ProtoUtils { public static EdgeEvent fromProto(TransportProtos.EdgeEventMsgProto proto) { EdgeEvent edgeEvent = new EdgeEvent(); - TenantId tenantId = new TenantId(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())); + TenantId tenantId = TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())); edgeEvent.setTenantId(tenantId); edgeEvent.setType(EdgeEventType.valueOf(proto.getEntityType())); edgeEvent.setAction(EdgeEventActionType.valueOf(proto.getAction())); @@ -516,7 +522,7 @@ public class ProtoUtils { } private static TransportProtos.ToDeviceRpcRequestActorMsgProto toProto(ToDeviceRpcRequestActorMsg msg) { - TransportProtos.ToDeviceRpcRequestMsg proto = TransportProtos.ToDeviceRpcRequestMsg.newBuilder() + TransportProtos.ToDeviceRpcRequestMsg.Builder builder = TransportProtos.ToDeviceRpcRequestMsg.newBuilder() .setMethodName(msg.getMsg().getBody().getMethod()) .setParams(msg.getMsg().getBody().getParams()) .setExpirationTime(msg.getMsg().getExpirationTime()) @@ -524,7 +530,11 @@ public class ProtoUtils { .setRequestIdLSB(msg.getMsg().getId().getLeastSignificantBits()) .setOneway(msg.getMsg().isOneway()) .setPersisted(msg.getMsg().isPersisted()) - .build(); + .setAdditionalInfo(msg.getMsg().getAdditionalInfo()); + if (msg.getMsg().getRetries() != null) { + builder.setRetries(msg.getMsg().getRetries()); + } + TransportProtos.ToDeviceRpcRequestMsg proto = builder.build(); return TransportProtos.ToDeviceRpcRequestActorMsgProto.newBuilder() .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) @@ -545,7 +555,7 @@ public class ProtoUtils { toDeviceRpcRequestMsg.getOneway(), toDeviceRpcRequestMsg.getExpirationTime(), new ToDeviceRpcRequestBody(toDeviceRpcRequestMsg.getMethodName(), toDeviceRpcRequestMsg.getParams()), - toDeviceRpcRequestMsg.getPersisted(), 0, ""); + toDeviceRpcRequestMsg.getPersisted(), toDeviceRpcRequestMsg.hasRetries() ? toDeviceRpcRequestMsg.getRetries() : null, toDeviceRpcRequestMsg.getAdditionalInfo()); return new ToDeviceRpcRequestActorMsg(proto.getServiceId(), toDeviceRpcRequest); } @@ -835,7 +845,7 @@ public class ProtoUtils { public static Device fromProto(TransportProtos.DeviceProto proto) { Device device = new Device(getEntityId(proto.getDeviceIdMSB(), proto.getDeviceIdLSB(), DeviceId::new)); device.setCreatedTime(proto.getCreatedTime()); - device.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + device.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); device.setName(proto.getDeviceName()); device.setType(proto.getDeviceType()); device.setDeviceProfileId(getEntityId(proto.getDeviceProfileIdMSB(), proto.getDeviceProfileIdLSB(), DeviceProfileId::new)); @@ -927,7 +937,7 @@ public class ProtoUtils { public static DeviceProfile fromProto(TransportProtos.DeviceProfileProto proto) { DeviceProfile deviceProfile = new DeviceProfile(getEntityId(proto.getDeviceProfileIdMSB(), proto.getDeviceProfileIdLSB(), DeviceProfileId::new)); deviceProfile.setCreatedTime(proto.getCreatedTime()); - deviceProfile.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + deviceProfile.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); deviceProfile.setName(proto.getName()); deviceProfile.setDefault(proto.getIsDefault()); deviceProfile.setType(DeviceProfileType.valueOf(proto.getType())); @@ -1018,7 +1028,7 @@ public class ProtoUtils { } public static Tenant fromProto(TransportProtos.TenantProto proto) { - Tenant tenant = new Tenant(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + Tenant tenant = new Tenant(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); tenant.setCreatedTime(proto.getCreatedTime()); tenant.setTenantProfileId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantProfileId::new)); tenant.setTitle(proto.getTitle()); @@ -1132,7 +1142,7 @@ public class ProtoUtils { public static TbResource fromProto(TransportProtos.TbResourceProto proto) { TbResource resource = new TbResource(getEntityId(proto.getResourceIdMSB(), proto.getResourceIdLSB(), TbResourceId::new)); - resource.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + resource.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); resource.setCreatedTime(proto.getCreatedTime()); resource.setTitle(proto.getTitle()); resource.setResourceType(ResourceType.valueOf(proto.getResourceType())); @@ -1188,7 +1198,7 @@ public class ProtoUtils { public static ApiUsageState fromProto(TransportProtos.ApiUsageStateProto proto) { ApiUsageState apiUsageState = new ApiUsageState(getEntityId(proto.getApiUsageStateIdMSB(), proto.getApiUsageStateIdLSB(), ApiUsageStateId::new)); - apiUsageState.setTenantId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantId::new)); + apiUsageState.setTenantId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantId::fromUUID)); apiUsageState.setCreatedTime(proto.getCreatedTime()); apiUsageState.setEntityId(EntityIdFactory.getByTypeAndUuid(fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB()))); apiUsageState.setTransportState(ApiUsageStateValue.valueOf(proto.getTransportState())); diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 2a97fd35d0..3870f17c5b 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -63,6 +63,9 @@ enum EntityTypeProto { MOBILE_APP_BUNDLE = 38; CALCULATED_FIELD = 39; CALCULATED_FIELD_LINK = 40; + JOB = 41; + ADMIN_SETTINGS = 42; + AI_MODEL = 43; } enum ApiUsageRecordKeyProto { @@ -89,6 +92,8 @@ message ServiceInfo { SystemInfoProto systemInfo = 10; repeated string assignedTenantProfiles = 11; string label = 12; + bool ready = 13; + repeated string taskTypes = 14; } message SystemInfoProto { @@ -693,6 +698,8 @@ message ToDeviceRpcRequestMsg { int64 requestIdLSB = 6; bool oneway = 7; bool persisted = 8; + optional int32 retries = 9; + string additionalInfo = 10; } message ToDeviceRpcResponseMsg { @@ -1262,6 +1269,7 @@ message ComponentLifecycleMsgProto { int64 oldProfileIdLSB = 10; int64 profileIdMSB = 11; int64 profileIdLSB = 12; + optional string info = 13; } message EdgeEventMsgProto { @@ -1836,7 +1844,6 @@ message FromEdqsMsg { } message EdqsEventMsg { - string key = 1; string objectType = 2; bytes data = 3; string eventType = 4; @@ -1850,3 +1857,20 @@ message EdqsRequestMsg { message EdqsResponseMsg { string value = 1; } + +message TaskProto { + string value = 1; +} + +message JobStatsMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 jobIdMSB = 3; + int64 jobIdLSB = 4; + optional TaskResultProto taskResult = 5; + optional int32 totalTasksCount = 6; +} + +message TaskResultProto { + string value = 1; +} diff --git a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java index 28b65a7be8..38ef604ab0 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java @@ -23,8 +23,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Isolated; -import java.util.ArrayList; - @Isolated("JsonConverter static settings being modified") public class JsonConverterTest { @@ -53,7 +51,7 @@ public class JsonConverterTest { @Test public void testParseAttributesBigDecimalAsLong() { - var result = new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString("{\"meterReadingDelta\": 1E1}"))); + var result = JsonConverter.convertToAttributes(JsonParser.parseString("{\"meterReadingDelta\": 1E1}")); Assertions.assertEquals(10L, result.get(0).getLongValue().get().longValue()); } @@ -108,4 +106,5 @@ public class JsonConverterTest { JsonConverter.convertToTelemetry(JsonParser.parseString("{\"meterReadingDelta\": 9.9701010061400066E19}"), 0L); }); } + } diff --git a/common/queue/pom.xml b/common/queue/pom.xml index 2e4b14e282..e934be308e 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common @@ -60,6 +60,10 @@ org.thingsboard.common cluster-api + + org.thingsboard.common + discovery-api + org.apache.kafka kafka-clients 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 1beb505595..c6f76a5000 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 @@ -91,7 +91,6 @@ public class DefaultTbQueueRequestTemplate requestTemplate; private final TbQueueProducer responseTemplate; - private final ConcurrentMap pendingRequests; private final ExecutorService loopExecutor; private final ScheduledExecutorService timeoutExecutor; private final ExecutorService callbackExecutor; @@ -67,7 +64,6 @@ public class DefaultTbQueueResponseTemplate(); this.maxPendingRequests = maxPendingRequests; this.pollInterval = pollInterval; this.requestTimeout = requestTimeout; @@ -89,7 +85,6 @@ public class DefaultTbQueueResponseTemplate handler) { - this.responseTemplate.init(); loopExecutor.submit(() -> { while (!stopped) { try { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/PartitionedQueueResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/PartitionedQueueResponseTemplate.java new file mode 100644 index 0000000000..ef1682b97f --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/PartitionedQueueResponseTemplate.java @@ -0,0 +1,187 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.common; + +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.MessagesStats; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueHandler; +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +@Slf4j +public class PartitionedQueueResponseTemplate extends AbstractTbQueueTemplate { + + @Getter + private final PartitionedQueueConsumerManager requestConsumer; + private final TbQueueProducer responseProducer; + + private final TbQueueHandler handler; + private final long pollInterval; + private final int maxPendingRequests; + private final long requestTimeout; + private final MessagesStats stats; + + private final ScheduledExecutorService scheduler; + private final ExecutorService callbackExecutor; + + private final AtomicInteger pendingRequestCount = new AtomicInteger(); + + @Builder + public PartitionedQueueResponseTemplate(String key, + TbQueueHandler handler, + String requestsTopic, + Function> consumerCreator, + TbQueueProducer responseProducer, + long pollInterval, + long requestTimeout, + int maxPendingRequests, + ExecutorService consumerExecutor, + ExecutorService callbackExecutor, + ExecutorService consumerTaskExecutor, + MessagesStats stats) { + this.scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor(key + "-queue-response-template-scheduler"); + this.callbackExecutor = callbackExecutor; + this.handler = handler; + this.requestConsumer = PartitionedQueueConsumerManager.create() + .queueKey(key + "-requests") + .topic(requestsTopic) + .pollInterval(pollInterval) + .msgPackProcessor((requests, consumer, consumerKey, config) -> processRequests(requests, consumer)) + .consumerCreator((config, tpi) -> consumerCreator.apply(tpi)) + .consumerExecutor(consumerExecutor) + .scheduler(scheduler) + .taskExecutor(consumerTaskExecutor) + .build(); + this.responseProducer = responseProducer; + this.pollInterval = pollInterval; + this.maxPendingRequests = maxPendingRequests; + this.requestTimeout = requestTimeout; + this.stats = stats; + } + + private void processRequests(List requests, TbQueueConsumer consumer) { + while (pendingRequestCount.get() >= maxPendingRequests) { + try { + Thread.sleep(pollInterval); + } catch (InterruptedException e) { + log.trace("Failed to wait until the server has capacity to handle new requests", e); + } + } + + requests.forEach(request -> { + long currentTime = System.currentTimeMillis(); + long expireTs = bytesToLong(request.getHeaders().get(EXPIRE_TS_HEADER)); + if (expireTs >= currentTime) { + byte[] requestIdHeader = request.getHeaders().get(REQUEST_ID_HEADER); + if (requestIdHeader == null) { + log.error("[{}] Missing requestId in header", request); + return; + } + byte[] responseTopicHeader = request.getHeaders().get(RESPONSE_TOPIC_HEADER); + if (responseTopicHeader == null) { + log.error("[{}] Missing response topic in header", request); + return; + } + UUID requestId = bytesToUuid(requestIdHeader); + String responseTopic = bytesToString(responseTopicHeader); + try { + pendingRequestCount.getAndIncrement(); + stats.incrementTotal(); + AsyncCallbackTemplate.withCallbackAndTimeout(handler.handle(request), + response -> { + pendingRequestCount.decrementAndGet(); + response.getHeaders().put(REQUEST_ID_HEADER, uuidToBytes(requestId)); + TopicPartitionInfo tpi = TopicPartitionInfo.builder().topic(responseTopic).build(); + responseProducer.send(tpi, response, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + stats.incrementSuccessful(); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to send response {}", requestId, response, t); + sendErrorResponse(requestId, tpi, request, t); + stats.incrementFailed(); + } + }); + }, + e -> { + pendingRequestCount.decrementAndGet(); + if (e.getCause() != null && e.getCause() instanceof TimeoutException) { + log.warn("[{}] Timeout to process the request: {}", requestId, request, e); + } else { + log.trace("[{}] Failed to process the request: {}", requestId, request, e); + } + stats.incrementFailed(); + }, + requestTimeout, + scheduler, + callbackExecutor); + } catch (Throwable e) { + pendingRequestCount.decrementAndGet(); + log.warn("[{}] Failed to process the request: {}", requestId, request, e); + stats.incrementFailed(); + } + } + }); + consumer.commit(); + } + + private void sendErrorResponse(UUID requestId, TopicPartitionInfo tpi, Request request, Throwable cause) { + Response errorResponseMsg = handler.constructErrorResponseMsg(request, cause); + + if (errorResponseMsg != null) { + errorResponseMsg.getHeaders().put(REQUEST_ID_HEADER, uuidToBytes(requestId)); + responseProducer.send(tpi, errorResponseMsg, null); + } + } + + public void subscribe(Set partitions) { + requestConsumer.update(partitions); + } + + public void stop() { + if (requestConsumer != null) { + requestConsumer.stop(); + requestConsumer.awaitStop(); + } + if (responseProducer != null) { + responseProducer.stop(); + } + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java index 2233855a37..db5bac7170 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java @@ -25,7 +25,6 @@ import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.UpdateConfigTask; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.UpdatePartitionsTask; -import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import java.util.Collection; @@ -50,7 +49,7 @@ import java.util.function.Function; public class MainQueueConsumerManager { @Getter - protected final QueueKey queueKey; + protected final Object queueKey; @Getter protected C config; protected final MsgPackProcessor msgPackProcessor; @@ -72,7 +71,7 @@ public class MainQueueConsumerManager msgPackProcessor, BiFunction> consumerCreator, ExecutorService consumerExecutor, @@ -204,7 +203,7 @@ public class MainQueueConsumerManager consumerLoop = consumerExecutor.submit(() -> { ThingsBoardThreadFactory.updateCurrentThreadName(consumerTask.getKey().toString()); - consumerLoop(consumerTask.getConsumer()); + consumerLoop(consumerTask.getKey(), consumerTask.getConsumer()); log.info("[{}] Consumer stopped", consumerTask.getKey()); try { @@ -219,7 +218,7 @@ public class MainQueueConsumerManager consumer) { + private void consumerLoop(Object consumerKey, TbQueueConsumer consumer) { try { while (!stopped && !consumer.isStopped()) { try { @@ -227,7 +226,7 @@ public class MainQueueConsumerManager msgs, TbQueueConsumer consumer, C config) throws Exception { + protected void processMsgs(List msgs, TbQueueConsumer consumer, Object consumerKey, C config) throws Exception { log.trace("Processing {} messages", msgs.size()); - msgPackProcessor.process(msgs, consumer, config); + msgPackProcessor.process(msgs, consumer, consumerKey, config); log.trace("Processed {} messages", msgs.size()); } @@ -274,7 +273,7 @@ public class MainQueueConsumerManager { - void process(List msgs, TbQueueConsumer consumer, C config) throws Exception; + void process(List msgs, TbQueueConsumer consumer, Object consumerKey, C config) throws Exception; } public interface ConsumerWrapper { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/PartitionedQueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/PartitionedQueueConsumerManager.java index 1b19fcab0e..42d5b6bb18 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/PartitionedQueueConsumerManager.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/PartitionedQueueConsumerManager.java @@ -26,7 +26,6 @@ import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.AddPartitionsTask; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.DeletePartitionsTask; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.RemovePartitionsTask; -import org.thingsboard.server.queue.discovery.QueueKey; import java.util.Set; import java.util.concurrent.ExecutorService; @@ -44,7 +43,7 @@ public class PartitionedQueueConsumerManager extends MainQ private final String topic; @Builder(builderMethodName = "create") // not to conflict with super.builder() - public PartitionedQueueConsumerManager(QueueKey queueKey, String topic, long pollInterval, MsgPackProcessor msgPackProcessor, + public PartitionedQueueConsumerManager(Object queueKey, String topic, long pollInterval, MsgPackProcessor msgPackProcessor, BiFunction> consumerCreator, TbQueueAdmin queueAdmin, ExecutorService consumerExecutor, ScheduledExecutorService scheduler, ExecutorService taskExecutor, Consumer uncaughtErrorHandler) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java index ffed499d8d..5025d887cb 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java @@ -25,7 +25,11 @@ import org.thingsboard.server.queue.TbQueueMsg; import java.util.List; import java.util.Set; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Supplier; @Slf4j @@ -39,6 +43,7 @@ public class QueueConsumerManager { @Getter private final TbQueueConsumer consumer; + private Future consumerTask; private volatile boolean stopped; @Builder @@ -63,7 +68,7 @@ public class QueueConsumerManager { public void launch() { log.info("[{}] Launching consumer", name); - consumerExecutor.submit(() -> { + consumerTask = consumerExecutor.submit(() -> { if (threadPrefix != null) { ThingsBoardThreadFactory.addThreadNamePrefix(threadPrefix); } @@ -101,6 +106,13 @@ public class QueueConsumerManager { log.debug("[{}] Stopping consumer", name); stopped = true; consumer.unsubscribe(); + try { + if (consumerTask != null) { + consumerTask.get(10, TimeUnit.SECONDS); + } + } catch (InterruptedException | ExecutionException | TimeoutException e) { + log.error("[{}] Failed to await consumer loop stop", name, e); + } } public interface MsgPackProcessor { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java index be019caaa7..be379fb76d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java @@ -18,10 +18,12 @@ package org.thingsboard.server.queue.common.state; import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import java.util.Collections; + public class DefaultQueueStateService extends QueueStateService { public DefaultQueueStateService(PartitionedQueueConsumerManager eventConsumer) { - super(eventConsumer); + super(eventConsumer, Collections.emptyList()); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java index bf02afe86c..2a38c9a86c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java @@ -22,8 +22,11 @@ import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; import org.thingsboard.server.queue.discovery.QueueKey; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTopic; @@ -34,17 +37,20 @@ public class KafkaQueueStateService private final PartitionedQueueConsumerManager stateConsumer; private final Supplier> eventsStartOffsetsProvider; + private final Set partitionsInProgress = ConcurrentHashMap.newKeySet(); + @Builder public KafkaQueueStateService(PartitionedQueueConsumerManager eventConsumer, PartitionedQueueConsumerManager stateConsumer, + List> otherConsumers, Supplier> eventsStartOffsetsProvider) { - super(eventConsumer); + super(eventConsumer, otherConsumers != null ? otherConsumers : Collections.emptyList()); this.stateConsumer = stateConsumer; this.eventsStartOffsetsProvider = eventsStartOffsetsProvider; } @Override - protected void addPartitions(QueueKey queueKey, Set partitions) { + protected void addPartitions(QueueKey queueKey, Set partitions, Runnable whenAllProcessed) { Map eventsStartOffsets = eventsStartOffsetsProvider != null ? eventsStartOffsetsProvider.get() : null; // remembering the offsets before subscribing to states Set statePartitions = withTopic(partitions, stateConsumer.getTopic()); @@ -57,11 +63,17 @@ public class KafkaQueueStateService log.info("Finished partition {} (still in progress: {})", statePartition, partitionsInProgress); if (partitionsInProgress.isEmpty()) { log.info("All partitions processed"); + if (whenAllProcessed != null) { + whenAllProcessed.run(); + } } TopicPartitionInfo eventPartition = statePartition.withTopic(eventConsumer.getTopic()); if (this.partitions.get(queueKey).contains(eventPartition)) { eventConsumer.addPartitions(Set.of(eventPartition), null, eventsStartOffsets != null ? eventsStartOffsets::get : null); + for (PartitionedQueueConsumerManager consumer : otherConsumers) { + consumer.addPartitions(Set.of(statePartition.withTopic(consumer.getTopic()))); + } } } finally { readLock.unlock(); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java index 29426fab63..e58d5eb036 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java @@ -25,9 +25,9 @@ import org.thingsboard.server.queue.discovery.QueueKey; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -37,19 +37,19 @@ import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTop public abstract class QueueStateService { protected final PartitionedQueueConsumerManager eventConsumer; + protected final List> otherConsumers; @Getter protected final Map> partitions = new HashMap<>(); - protected final Set partitionsInProgress = ConcurrentHashMap.newKeySet(); - protected boolean initialized; protected final ReadWriteLock partitionsLock = new ReentrantReadWriteLock(); - protected QueueStateService(PartitionedQueueConsumerManager eventConsumer) { + protected QueueStateService(PartitionedQueueConsumerManager eventConsumer, List> otherConsumers) { this.eventConsumer = eventConsumer; + this.otherConsumers = otherConsumers; } - public void update(QueueKey queueKey, Set newPartitions) { + public void update(QueueKey queueKey, Set newPartitions, Runnable whenAllProcessed) { newPartitions = withTopic(newPartitions, eventConsumer.getTopic()); var writeLock = partitionsLock.writeLock(); writeLock.lock(); @@ -71,17 +71,29 @@ public abstract class QueueStateService partitions) { + protected void addPartitions(QueueKey queueKey, Set partitions, Runnable whenAllProcessed) { + if (whenAllProcessed != null) { + whenAllProcessed.run(); + } eventConsumer.addPartitions(partitions); + for (PartitionedQueueConsumerManager consumer : otherConsumers) { + consumer.addPartitions(withTopic(partitions, consumer.getTopic())); + } } protected void removePartitions(QueueKey queueKey, Set partitions) { eventConsumer.removePartitions(partitions); + for (PartitionedQueueConsumerManager consumer : otherConsumers) { + consumer.removePartitions(withTopic(partitions, consumer.getTopic())); + } } public void delete(Set partitions) { @@ -100,10 +112,9 @@ public abstract class QueueStateService partitions) { eventConsumer.delete(withTopic(partitions, eventConsumer.getTopic())); - } - - public Set getPartitionsInProgress() { - return initialized ? partitionsInProgress : null; + for (PartitionedQueueConsumerManager consumer : otherConsumers) { + consumer.removePartitions(withTopic(partitions, consumer.getTopic())); + } } public void stop() { 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 609d3f8eee..5f23f35070 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 @@ -24,11 +24,13 @@ import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbTransportService; +import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ServiceInfo; import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.task.TaskProcessor; import org.thingsboard.server.queue.util.AfterContextReady; import java.net.InetAddress; @@ -40,7 +42,12 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -import static org.thingsboard.common.util.SystemUtil.*; +import static org.thingsboard.common.util.SystemUtil.getCpuCount; +import static org.thingsboard.common.util.SystemUtil.getCpuUsage; +import static org.thingsboard.common.util.SystemUtil.getDiscSpaceUsage; +import static org.thingsboard.common.util.SystemUtil.getMemoryUsage; +import static org.thingsboard.common.util.SystemUtil.getTotalDiscSpace; +import static org.thingsboard.common.util.SystemUtil.getTotalMemory; @Component @@ -59,15 +66,21 @@ public class DefaultTbServiceInfoProvider implements TbServiceInfoProvider { @Value("${service.rule_engine.assigned_tenant_profiles:}") private Set assignedTenantProfiles; - @Autowired + @Autowired(required = false) private EdqsConfig edqsConfig; @Autowired private ApplicationContext applicationContext; + @Autowired(required = false) + private List> availableTaskProcessors; + private List serviceTypes; + private List taskTypes; private ServiceInfo serviceInfo; + private boolean ready = true; + @PostConstruct public void init() { if (StringUtils.isEmpty(serviceId)) { @@ -78,19 +91,25 @@ public class DefaultTbServiceInfoProvider implements TbServiceInfoProvider { } } log.info("Current Service ID: {}", serviceId); - if (serviceType.equalsIgnoreCase("monolith")) { - serviceTypes = List.of(ServiceType.values()); - } else { - serviceTypes = Collections.singletonList(ServiceType.of(serviceType)); - } + serviceTypes = isMonolith() ? + List.of(ServiceType.values()) : + Collections.singletonList(ServiceType.of(serviceType)); if (!serviceTypes.contains(ServiceType.TB_RULE_ENGINE) || assignedTenantProfiles == null) { assignedTenantProfiles = Collections.emptySet(); } if (serviceTypes.contains(ServiceType.EDQS)) { + ready = false; if (StringUtils.isBlank(edqsConfig.getLabel())) { edqsConfig.setLabel(serviceId); } } + if (CollectionsUtil.isNotEmpty(availableTaskProcessors)) { + taskTypes = availableTaskProcessors.stream() + .map(TaskProcessor::getJobType) + .toList(); + } else { + taskTypes = Collections.emptyList(); + } generateNewServiceInfoWithCurrentSystemInfo(); } @@ -113,6 +132,11 @@ public class DefaultTbServiceInfoProvider implements TbServiceInfoProvider { return serviceInfo; } + @Override + public boolean isMonolith() { + return serviceType.equalsIgnoreCase("monolith"); + } + @Override public boolean isService(ServiceType serviceType) { return serviceTypes.contains(serviceType); @@ -127,10 +151,21 @@ public class DefaultTbServiceInfoProvider implements TbServiceInfoProvider { if (CollectionsUtil.isNotEmpty(assignedTenantProfiles)) { builder.addAllAssignedTenantProfiles(assignedTenantProfiles.stream().map(UUID::toString).collect(Collectors.toList())); } - builder.setLabel(edqsConfig.getLabel()); + if (edqsConfig != null) { + builder.setLabel(edqsConfig.getLabel()); + } + builder.setReady(ready); + builder.addAllTaskTypes(taskTypes.stream().map(JobType::name).toList()); return serviceInfo = builder.build(); } + @Override + public boolean setReady(boolean ready) { + boolean changed = this.ready != ready; + this.ready = ready; + return changed; + } + private TransportProtos.SystemInfoProto getCurrentSystemInfoProto() { TransportProtos.SystemInfoProto.Builder builder = TransportProtos.SystemInfoProto.newBuilder(); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DiscoveryService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DiscoveryService.java index 5d309014dd..28d61bde0f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DiscoveryService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DiscoveryService.java @@ -25,4 +25,6 @@ public interface DiscoveryService { boolean isMonolith(); + void setReady(boolean ready); + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DummyDiscoveryService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DummyDiscoveryService.java index 442b845e81..8a4e9102e2 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DummyDiscoveryService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DummyDiscoveryService.java @@ -55,4 +55,13 @@ public class DummyDiscoveryService implements DiscoveryService { public boolean isMonolith() { return true; } + + @Override + public void setReady(boolean ready) { + boolean changed = serviceInfoProvider.setReady(ready); + if (changed) { + serviceInfoProvider.generateNewServiceInfoWithCurrentSystemInfo(); + } + } + } 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 ec5c675e32..47a6732578 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 @@ -19,6 +19,7 @@ import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; import jakarta.annotation.PostConstruct; import lombok.Data; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; @@ -29,6 +30,7 @@ import org.thingsboard.server.common.data.exception.TenantNotFoundException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; @@ -38,16 +40,19 @@ import org.thingsboard.server.queue.discovery.event.ClusterTopologyChangeEvent; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.discovery.event.ServiceListChangedEvent; import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.PropertyUtils; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.EnumMap; 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.ConcurrentHashMap; @@ -62,6 +67,7 @@ import static org.thingsboard.server.common.data.DataConstants.MAIN_QUEUE_NAME; @Service @Slf4j +@RequiredArgsConstructor public class HashPartitionService implements PartitionService { @Value("${queue.core.topic:tb_core}") @@ -82,13 +88,17 @@ public class HashPartitionService implements PartitionService { private Integer edgePartitions; @Value("${queue.edqs.partitions:12}") private Integer edqsPartitions; + @Value("${queue.tasks.partitions:12}") + private Integer defaultTasksPartitions; + @Value("${queue.tasks.partitions_per_type:}") + private String tasksPartitionsPerType; @Value("${queue.partitions.hash_function_name:murmur3_128}") private String hashFunctionName; private final ApplicationEventPublisher applicationEventPublisher; private final TbServiceInfoProvider serviceInfoProvider; - private final TenantRoutingInfoService tenantRoutingInfoService; - private final QueueRoutingInfoService queueRoutingInfoService; + private final Optional tenantRoutingInfoService; + private final Optional queueRoutingInfoService; private final TopicService topicService; protected volatile ConcurrentMap> myPartitions = new ConcurrentHashMap<>(); @@ -105,18 +115,6 @@ public class HashPartitionService implements PartitionService { private HashFunction hashFunction; - public HashPartitionService(TbServiceInfoProvider serviceInfoProvider, - TenantRoutingInfoService tenantRoutingInfoService, - ApplicationEventPublisher applicationEventPublisher, - QueueRoutingInfoService queueRoutingInfoService, - TopicService topicService) { - this.serviceInfoProvider = serviceInfoProvider; - this.tenantRoutingInfoService = tenantRoutingInfoService; - this.applicationEventPublisher = applicationEventPublisher; - this.queueRoutingInfoService = queueRoutingInfoService; - this.topicService = topicService; - } - @PostConstruct public void init() { this.hashFunction = forName(hashFunctionName); @@ -140,6 +138,19 @@ public class HashPartitionService implements PartitionService { QueueKey edqsKey = new QueueKey(ServiceType.EDQS); partitionSizesMap.put(edqsKey, edqsPartitions); partitionTopicsMap.put(edqsKey, "edqs"); // placeholder, not used + + Map tasksPartitions = new EnumMap<>(JobType.class); + PropertyUtils.getProps(tasksPartitionsPerType).forEach((type, partitions) -> { + tasksPartitions.put(JobType.valueOf(type), Integer.parseInt(partitions)); + }); + for (JobType type : JobType.values()) { + tasksPartitions.putIfAbsent(type, defaultTasksPartitions); + } + tasksPartitions.forEach((type, partitions) -> { + QueueKey queueKey = new QueueKey(ServiceType.TASK_PROCESSOR, type.name()); + partitionSizesMap.put(queueKey, partitions); + partitionTopicsMap.put(queueKey, type.getTasksTopic()); + }); } @AfterStartUp(order = AfterStartUp.QUEUE_INFO_INITIALIZATION) @@ -169,6 +180,10 @@ public class HashPartitionService implements PartitionService { } private List getQueueRoutingInfos() { + if (queueRoutingInfoService.isEmpty()) { + return Collections.emptyList(); + } + List queueRoutingInfoList; String serviceType = serviceInfoProvider.getServiceType(); @@ -179,7 +194,7 @@ public class HashPartitionService implements PartitionService { if (getQueuesRetries > 0) { log.info("Try to get queue routing info."); try { - queueRoutingInfoList = queueRoutingInfoService.getAllQueuesRoutingInfo(); + queueRoutingInfoList = queueRoutingInfoService.get().getAllQueuesRoutingInfo(); break; } catch (Exception e) { log.info("Failed to get queues routing info: {}!", e.getMessage()); @@ -195,7 +210,7 @@ public class HashPartitionService implements PartitionService { } } } else { - queueRoutingInfoList = queueRoutingInfoService.getAllQueuesRoutingInfo(); + queueRoutingInfoList = queueRoutingInfoService.get().getAllQueuesRoutingInfo(); } return queueRoutingInfoList; } @@ -454,8 +469,8 @@ public class HashPartitionService implements PartitionService { if (serviceInfoProvider.isService(ServiceType.TB_RULE_ENGINE)) { partitionSizesMap.keySet().stream() .filter(queueKey -> queueKey.getType() == ServiceType.TB_RULE_ENGINE && - !queueKey.getTenantId().isSysTenantId() && - !newPartitions.containsKey(queueKey)) + !queueKey.getTenantId().isSysTenantId() && + !newPartitions.containsKey(queueKey)) .forEach(removed::add); } removed.forEach(queueKey -> { @@ -629,7 +644,11 @@ public class HashPartitionService implements PartitionService { } private TenantRoutingInfo getRoutingInfo(TenantId tenantId) { - return tenantRoutingInfoMap.computeIfAbsent(tenantId, tenantRoutingInfoService::getRoutingInfo); + if (tenantRoutingInfoService.isPresent()) { + return tenantRoutingInfoMap.computeIfAbsent(tenantId, __ -> tenantRoutingInfoService.get().getRoutingInfo(tenantId)); + } else { + return new TenantRoutingInfo(tenantId, null, false); + } } protected TenantId getIsolatedOrSystemTenantId(ServiceType serviceType, TenantId tenantId) { @@ -675,6 +694,10 @@ public class HashPartitionService implements PartitionService { for (String transportType : instance.getTransportsList()) { tbTransportServicesByType.computeIfAbsent(transportType, t -> new ArrayList<>()).add(instance); } + for (String taskType : instance.getTaskTypesList()) { + QueueKey queueKey = new QueueKey(ServiceType.TASK_PROCESSOR, taskType); + queueServiceList.computeIfAbsent(queueKey, key -> new ArrayList<>()).add(instance); + } } @NotNull @@ -689,7 +712,7 @@ public class HashPartitionService implements PartitionService { if (!responsibleServices.isEmpty()) { // if there are any dedicated servers TenantProfileId profileId; if (tenantId != null && !tenantId.isSysTenantId()) { - TenantRoutingInfo routingInfo = tenantRoutingInfoService.getRoutingInfo(tenantId); + TenantRoutingInfo routingInfo = tenantRoutingInfoService.get().getRoutingInfo(tenantId); profileId = routingInfo.getProfileId(); } else { profileId = null; 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 cf9f27ee39..d73f531b5b 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 @@ -177,6 +177,19 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } } + @Override + public void setReady(boolean ready) { + log.debug("Marking current service as {}", ready ? "ready" : "NOT ready"); + boolean changed = serviceInfoProvider.setReady(ready); + if (changed) { + try { + publishCurrentServer(); + } catch (Exception e) { + log.error("Failed to update server readiness status", e); + } + } + } + private boolean currentServerExists() { if (nodePath == null) { return false; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java index c3658d098a..dd8afd735e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java @@ -17,12 +17,13 @@ package org.thingsboard.server.queue.edqs; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && ('${service.type:null}'=='edqs' || " + "(('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && " + "'${queue.edqs.mode:null}'=='local'))") -public @interface EdqsComponent { -} +public @interface EdqsComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsConfig.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsConfig.java index 3c927f135b..18f46bd358 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsConfig.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsConfig.java @@ -44,6 +44,10 @@ public class EdqsConfig { private int maxPendingRequests; @Value("${queue.edqs.max_request_timeout:20000}") private int maxRequestTimeout; + @Value("${queue.edqs.request_executor_size:50}") + private int requestExecutorSize; + @Value("${queue.edqs.versions_cache_ttl:60}") + private int versionsCacheTtl; public String getLabel() { if (partitioningStrategy == EdqsPartitioningStrategy.NONE) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsExecutors.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsExecutors.java new file mode 100644 index 0000000000..8e804410fe --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsExecutors.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.edqs; + +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +@Lazy +@Component +@Getter +@RequiredArgsConstructor +public class EdqsExecutors { + + private final EdqsConfig edqsConfig; + + private ExecutorService consumersExecutor; + private ExecutorService consumerTaskExecutor; + private ScheduledExecutorService scheduler; + private ListeningExecutorService requestExecutor; + + @PostConstruct + private void init() { + consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("edqs-consumer")); + consumerTaskExecutor = ThingsBoardExecutors.newWorkStealingPool(4, "edqs-consumer-task-executor"); + scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edqs-scheduler"); + requestExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(edqsConfig.getRequestExecutorSize(), "edqs-requests")); + } + + @PreDestroy + private void destroy() { + if (consumersExecutor != null) { + consumersExecutor.shutdownNow(); + } + if (consumerTaskExecutor != null) { + consumerTaskExecutor.shutdownNow(); + } + if (scheduler != null) { + scheduler.shutdownNow(); + } + if (requestExecutor != null) { + requestExecutor.shutdownNow(); + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueueFactory.java index 5c0d68779a..ed021e3380 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueueFactory.java @@ -19,8 +19,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueHandler; import org.thingsboard.server.queue.TbQueueProducer; -import org.thingsboard.server.queue.TbQueueResponseTemplate; +import org.thingsboard.server.queue.common.PartitionedQueueResponseTemplate; import org.thingsboard.server.queue.common.TbProtoQueueMsg; public interface EdqsQueueFactory { @@ -33,7 +34,7 @@ public interface EdqsQueueFactory { TbQueueProducer> createEdqsStateProducer(); - TbQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate(); + PartitionedQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate(TbQueueHandler, TbProtoQueueMsg> handler); TbQueueAdmin getEdqsQueueAdmin(); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java index e414d24fd9..3e1d1411aa 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.edqs; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && '${service.type:null}'=='monolith' && '${queue.edqs.mode:null}'=='local' && '${queue.type:null}'=='in-memory'") -public @interface InMemoryEdqsComponent { -} +public @interface InMemoryEdqsComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsQueueFactory.java index 8c670e66c0..6c7618f7db 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsQueueFactory.java @@ -17,16 +17,15 @@ package org.thingsboard.server.queue.edqs; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.StatsType; import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueHandler; import org.thingsboard.server.queue.TbQueueProducer; -import org.thingsboard.server.queue.TbQueueResponseTemplate; -import org.thingsboard.server.queue.common.DefaultTbQueueResponseTemplate; +import org.thingsboard.server.queue.common.PartitionedQueueResponseTemplate; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; @@ -39,6 +38,7 @@ public class InMemoryEdqsQueueFactory implements EdqsQueueFactory { private final InMemoryStorage storage; private final EdqsConfig edqsConfig; + private final EdqsExecutors edqsExecutors; private final StatsFactory statsFactory; private final TbQueueAdmin queueAdmin; @@ -63,17 +63,21 @@ public class InMemoryEdqsQueueFactory implements EdqsQueueFactory { } @Override - public TbQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate() { - TbQueueConsumer> requestConsumer = new InMemoryTbQueueConsumer<>(storage, edqsConfig.getRequestsTopic()); + public PartitionedQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate(TbQueueHandler, TbProtoQueueMsg> handler) { TbQueueProducer> responseProducer = new InMemoryTbQueueProducer<>(storage, edqsConfig.getResponsesTopic()); - return DefaultTbQueueResponseTemplate., TbProtoQueueMsg>builder() - .requestTemplate(requestConsumer) - .responseTemplate(responseProducer) - .maxPendingRequests(edqsConfig.getMaxPendingRequests()) - .requestTimeout(edqsConfig.getMaxRequestTimeout()) + return PartitionedQueueResponseTemplate., TbProtoQueueMsg>builder() + .key("edqs") + .handler(handler) + .requestsTopic(edqsConfig.getRequestsTopic()) + .consumerCreator(tpi -> new InMemoryTbQueueConsumer<>(storage, edqsConfig.getRequestsTopic())) + .responseProducer(responseProducer) .pollInterval(edqsConfig.getPollInterval()) + .requestTimeout(edqsConfig.getMaxRequestTimeout()) + .maxPendingRequests(edqsConfig.getMaxPendingRequests()) + .consumerExecutor(edqsExecutors.getConsumersExecutor()) + .callbackExecutor(edqsExecutors.getRequestExecutor()) + .consumerTaskExecutor(edqsExecutors.getConsumerTaskExecutor()) .stats(statsFactory.createMessagesStats(StatsType.EDQS.getName())) - .executor(ThingsBoardExecutors.newWorkStealingPool(5, "edqs")) .build(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java index 3a2b282724..36593bfa1c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java @@ -17,12 +17,13 @@ package org.thingsboard.server.queue.edqs; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && ('${service.type:null}'=='edqs' || " + "(('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && " + "'${queue.edqs.mode:null}'=='local' && '${queue.type:null}'=='kafka'))") -public @interface KafkaEdqsComponent { -} +public @interface KafkaEdqsComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsQueueFactory.java index ab88943b10..f071c30942 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsQueueFactory.java @@ -16,14 +16,13 @@ package org.thingsboard.server.queue.edqs; import org.springframework.stereotype.Component; -import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.StatsType; import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueHandler; import org.thingsboard.server.queue.TbQueueProducer; -import org.thingsboard.server.queue.TbQueueResponseTemplate; -import org.thingsboard.server.queue.common.DefaultTbQueueResponseTemplate; +import org.thingsboard.server.queue.common.PartitionedQueueResponseTemplate; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.TopicService; @@ -45,6 +44,7 @@ public class KafkaEdqsQueueFactory implements EdqsQueueFactory { private final TbKafkaAdmin edqsRequestsAdmin; private final TbKafkaAdmin edqsStateAdmin; private final EdqsConfig edqsConfig; + private final EdqsExecutors edqsExecutors; private final TbServiceInfoProvider serviceInfoProvider; private final TbKafkaConsumerStatsService consumerStatsService; private final TopicService topicService; @@ -53,7 +53,7 @@ public class KafkaEdqsQueueFactory implements EdqsQueueFactory { private final AtomicInteger consumerCounter = new AtomicInteger(); public KafkaEdqsQueueFactory(TbKafkaSettings kafkaSettings, TbKafkaTopicConfigs topicConfigs, - EdqsConfig edqsConfig, TbServiceInfoProvider serviceInfoProvider, + EdqsConfig edqsConfig, EdqsExecutors edqsExecutors, TbServiceInfoProvider serviceInfoProvider, TbKafkaConsumerStatsService consumerStatsService, TopicService topicService, StatsFactory statsFactory) { this.edqsEventsAdmin = new TbKafkaAdmin(kafkaSettings, topicConfigs.getEdqsEventsConfigs()); @@ -61,6 +61,7 @@ public class KafkaEdqsQueueFactory implements EdqsQueueFactory { this.edqsStateAdmin = new TbKafkaAdmin(kafkaSettings, topicConfigs.getEdqsStateConfigs()); this.kafkaSettings = kafkaSettings; this.edqsConfig = edqsConfig; + this.edqsExecutors = edqsExecutors; this.serviceInfoProvider = serviceInfoProvider; this.consumerStatsService = consumerStatsService; this.topicService = topicService; @@ -116,25 +117,29 @@ public class KafkaEdqsQueueFactory implements EdqsQueueFactory { } @Override - public TbQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate() { - var requestConsumer = createEdqsMsgConsumer(edqsConfig.getRequestsTopic(), - "edqs-requests-consumer-" + serviceInfoProvider.getServiceId(), - "edqs-requests-consumer-group", - false, edqsRequestsAdmin); + public PartitionedQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate(TbQueueHandler, TbProtoQueueMsg> handler) { var responseProducer = TbKafkaProducerTemplate.>builder() .settings(kafkaSettings) .clientId("edqs-response-producer-" + serviceInfoProvider.getServiceId()) .defaultTopic(topicService.buildTopicName(edqsConfig.getResponsesTopic())) .admin(edqsRequestsAdmin) .build(); - return DefaultTbQueueResponseTemplate., TbProtoQueueMsg>builder() - .requestTemplate(requestConsumer) - .responseTemplate(responseProducer) - .maxPendingRequests(edqsConfig.getMaxPendingRequests()) - .requestTimeout(edqsConfig.getMaxRequestTimeout()) + return PartitionedQueueResponseTemplate., TbProtoQueueMsg>builder() + .key("edqs") + .handler(handler) + .requestsTopic(topicService.buildTopicName(edqsConfig.getRequestsTopic())) + .consumerCreator(tpi -> createEdqsMsgConsumer(edqsConfig.getRequestsTopic(), + "edqs-requests-consumer-" + serviceInfoProvider.getServiceId() + "-" + tpi.getPartition().orElse(999), + "edqs-requests-consumer-group", + false, edqsRequestsAdmin)) + .responseProducer(responseProducer) .pollInterval(edqsConfig.getPollInterval()) + .requestTimeout(edqsConfig.getMaxRequestTimeout()) + .maxPendingRequests(edqsConfig.getMaxPendingRequests()) + .consumerExecutor(edqsExecutors.getConsumersExecutor()) + .callbackExecutor(edqsExecutors.getRequestExecutor()) + .consumerTaskExecutor(edqsExecutors.getConsumerTaskExecutor()) .stats(statsFactory.createMessagesStats(StatsType.EDQS.getName())) - .executor(ThingsBoardExecutors.newWorkStealingPool(5, "edqs")) .build(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java index 9d1088e188..7a9c01b72f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java @@ -93,8 +93,7 @@ public class TbKafkaConsumerStatsService { log.info("[{}] Topic partitions with lag: [{}].", groupId, builder.toString()); } } catch (Exception e) { - log.warn("[{}] Failed to get consumer group stats. Reason - {}.", groupId, e.getMessage()); - log.trace("Detailed error: ", e); + log.warn("[{}] Failed to get consumer group stats", groupId, e); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java index cac6f2ea1e..f0b3694d74 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java @@ -75,10 +75,6 @@ public class TbKafkaProducerTemplate implements TbQueuePro topics = ConcurrentHashMap.newKeySet(); } - @Override - public void init() { - } - void addAnalyticHeaders(List
headers) { headers.add(new RecordHeader("_producerId", getClientId().getBytes(StandardCharsets.UTF_8))); headers.add(new RecordHeader("_threadName", Thread.currentThread().getName().getBytes(StandardCharsets.UTF_8))); 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 aebda5a5bc..5d5834d20a 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 @@ -62,6 +62,8 @@ public class TbKafkaTopicConfigs { private String edqsRequestsProperties; @Value("${queue.kafka.topic-properties.edqs-state:}") private String edqsStateProperties; + @Value("${queue.kafka.topic-properties.tasks:}") + private String tasksProperties; @Getter private Map coreConfigs; @@ -99,6 +101,8 @@ public class TbKafkaTopicConfigs { private Map edqsRequestsConfigs; @Getter private Map edqsStateConfigs; + @Getter + private Map tasksConfigs; @PostConstruct private void init() { @@ -122,6 +126,7 @@ public class TbKafkaTopicConfigs { edqsEventsConfigs = PropertyUtils.getProps(edqsEventsProperties); edqsRequestsConfigs = PropertyUtils.getProps(edqsRequestsProperties); edqsStateConfigs = PropertyUtils.getProps(edqsStateProperties); + tasksConfigs = PropertyUtils.getProps(tasksProperties); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/memory/InMemoryTbQueueProducer.java b/common/queue/src/main/java/org/thingsboard/server/queue/memory/InMemoryTbQueueProducer.java index 49c52ed21a..43d820bc7e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/memory/InMemoryTbQueueProducer.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/memory/InMemoryTbQueueProducer.java @@ -33,11 +33,6 @@ public class InMemoryTbQueueProducer implements TbQueuePro this.defaultTopic = defaultTopic; } - @Override - public void init() { - - } - @Override public void send(TopicPartitionInfo tpi, T msg, TbQueueCallback callback) { boolean result = storage.put(tpi.getFullTopicName(), msg); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java index 194f0ce962..a410ac5d04 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/RemoteNotificationRuleProcessor.java @@ -22,6 +22,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.JavaSerDesUtil; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger.DeduplicationStrategy; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; @@ -47,7 +48,7 @@ public class RemoteNotificationRuleProcessor implements NotificationRuleProcesso @Override public void process(NotificationRuleTrigger trigger) { try { - if (trigger.deduplicate() && deduplicationService.alreadyProcessed(trigger)) { + if (!DeduplicationStrategy.NONE.equals(trigger.getDeduplicationStrategy()) && deduplicationService.alreadyProcessed(trigger)) { return; } 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 085d04f28c..d3233f8dee 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 @@ -27,6 +27,7 @@ import org.thingsboard.server.gen.js.JsInvokeProtos; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.TbQueueConsumer; @@ -41,6 +42,7 @@ import org.thingsboard.server.queue.edqs.EdqsConfig; import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; import org.thingsboard.server.queue.memory.InMemoryTbQueueProducer; +import org.thingsboard.server.queue.settings.TasksQueueConfig; import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueEdgeSettings; @@ -66,6 +68,7 @@ public class InMemoryMonolithQueueFactory implements TbCoreQueueFactory, TbRuleE private final TbQueueEdgeSettings edgeSettings; private final TbQueueCalculatedFieldSettings calculatedFieldSettings; private final EdqsConfig edqsConfig; + private final TasksQueueConfig tasksQueueConfig; private final InMemoryStorage storage; @Override @@ -258,9 +261,13 @@ public class InMemoryMonolithQueueFactory implements TbCoreQueueFactory, TbRuleE .build(); } + @Override + public TbQueueConsumer> createJobStatsConsumer() { + return new InMemoryTbQueueConsumer<>(storage, tasksQueueConfig.getStatsTopic()); + } + @Scheduled(fixedRateString = "${queue.in_memory.stats.print-interval-ms:60000}") private void printInMemoryStats() { storage.printStats(); } - } 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 2269004f90..866f8d235e 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 @@ -29,6 +29,7 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.gen.js.JsInvokeProtos; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; @@ -63,6 +64,7 @@ import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaSettings; import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; +import org.thingsboard.server.queue.settings.TasksQueueConfig; import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueEdgeSettings; @@ -92,6 +94,7 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi private final TbQueueCalculatedFieldSettings calculatedFieldSettings; private final TbKafkaConsumerStatsService consumerStatsService; private final EdqsConfig edqsConfig; + private final TasksQueueConfig tasksQueueConfig; private final TbQueueAdmin coreAdmin; private final TbKafkaAdmin ruleEngineAdmin; @@ -110,6 +113,7 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi private final TbQueueAdmin cfStateAdmin; private final TbQueueAdmin edqsEventsAdmin; private final TbKafkaAdmin edqsRequestsAdmin; + private final TbQueueAdmin tasksAdmin; private final AtomicLong consumerCount = new AtomicLong(); private final AtomicLong edgeConsumerCount = new AtomicLong(); @@ -126,7 +130,8 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi TbQueueCalculatedFieldSettings calculatedFieldSettings, TbKafkaConsumerStatsService consumerStatsService, TbKafkaTopicConfigs kafkaTopicConfigs, - EdqsConfig edqsConfig) { + EdqsConfig edqsConfig, + TasksQueueConfig tasksQueueConfig) { this.topicService = topicService; this.kafkaSettings = kafkaSettings; this.serviceInfoProvider = serviceInfoProvider; @@ -140,6 +145,7 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi this.edgeSettings = edgeSettings; this.calculatedFieldSettings = calculatedFieldSettings; this.edqsConfig = edqsConfig; + this.tasksQueueConfig = tasksQueueConfig; this.coreAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCoreConfigs()); this.ruleEngineAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getRuleEngineConfigs()); @@ -158,6 +164,7 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi this.cfStateAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCalculatedFieldStateConfigs()); this.edqsEventsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsEventsConfigs()); this.edqsRequestsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsRequestsConfigs()); + this.tasksAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getTasksConfigs()); } @Override @@ -641,6 +648,19 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi .build(); } + @Override + public TbQueueConsumer> createJobStatsConsumer() { + return TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(tasksQueueConfig.getStatsTopic())) + .clientId("job-stats-consumer-" + serviceInfoProvider.getServiceId()) + .groupId(topicService.buildTopicName("job-stats-consumer-group")) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), JobStatsMsg.parseFrom(msg.getData()), msg.getHeaders())) + .admin(tasksAdmin) + .statsService(consumerStatsService) + .build(); + } + @PreDestroy private void destroy() { if (coreAdmin != null) { 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 ea7c56f0aa..70009aa29d 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 @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.js.JsInvokeProtos; import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; @@ -59,6 +60,7 @@ import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaSettings; import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; +import org.thingsboard.server.queue.settings.TasksQueueConfig; import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueEdgeSettings; @@ -88,6 +90,7 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { private final TbQueueEdgeSettings edgeSettings; private final TbQueueCalculatedFieldSettings calculatedFieldSettings; private final EdqsConfig edqsConfig; + private final TasksQueueConfig tasksQueueConfig; private final TbQueueAdmin coreAdmin; private final TbQueueAdmin ruleEngineAdmin; @@ -105,6 +108,7 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { private final TbQueueAdmin cfAdmin; private final TbQueueAdmin edqsEventsAdmin; private final TbKafkaAdmin edqsRequestsAdmin; + private final TbQueueAdmin tasksAdmin; private final AtomicLong consumerCount = new AtomicLong(); private final AtomicLong edgeConsumerCount = new AtomicLong(); @@ -122,6 +126,7 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { TbQueueTransportNotificationSettings transportNotificationSettings, TbQueueCalculatedFieldSettings calculatedFieldSettings, EdqsConfig edqsConfig, + TasksQueueConfig tasksQueueConfig, TbKafkaTopicConfigs kafkaTopicConfigs) { this.topicService = topicService; this.kafkaSettings = kafkaSettings; @@ -136,6 +141,7 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { this.edgeSettings = edgeSettings; this.calculatedFieldSettings = calculatedFieldSettings; this.edqsConfig = edqsConfig; + this.tasksQueueConfig = tasksQueueConfig; this.coreAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCoreConfigs()); this.ruleEngineAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getRuleEngineConfigs()); @@ -153,6 +159,7 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { this.cfAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCalculatedFieldConfigs()); this.edqsEventsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsEventsConfigs()); this.edqsRequestsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsRequestsConfigs()); + this.tasksAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getTasksConfigs()); } @Override @@ -520,6 +527,19 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { .build(); } + @Override + public TbQueueConsumer> createJobStatsConsumer() { + return TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(tasksQueueConfig.getStatsTopic())) + .clientId("job-stats-consumer-" + serviceInfoProvider.getServiceId()) + .groupId(topicService.buildTopicName("job-stats-consumer-group")) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), JobStatsMsg.parseFrom(msg.getData()), msg.getHeaders())) + .admin(tasksAdmin) + .statsService(consumerStatsService) + .build(); + } + @PreDestroy private void destroy() { if (coreAdmin != null) { 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 b4884ae72c..3b67ea4f9f 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 @@ -438,4 +438,5 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { cfAdmin.destroy(); } } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java index 037d1f2087..37c15d5b87 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java @@ -18,6 +18,7 @@ package org.thingsboard.server.queue.provider; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; @@ -165,4 +166,6 @@ public interface TbCoreQueueFactory extends TbUsageStatsClientQueueFactory, Hous TbQueueProducer> createToCalculatedFieldNotificationMsgProducer(); + TbQueueConsumer> createJobStatsConsumer(); + } 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 a7a34992cd..cb7e6dd1f4 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 @@ -121,4 +121,5 @@ public class TbTransportQueueProducerProvider implements TbQueueProducerProvider public TbQueueProducer> getCalculatedFieldsNotificationsMsgProducer() { throw new RuntimeException("Not Implemented! Should not be used by Transport!"); } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/settings/TasksQueueConfig.java b/common/queue/src/main/java/org/thingsboard/server/queue/settings/TasksQueueConfig.java new file mode 100644 index 0000000000..7c94596139 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/settings/TasksQueueConfig.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.settings; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Getter +@Component +public class TasksQueueConfig { + + @Value("${queue.tasks.poll_interval:500}") + private int pollInterval; + + @Value("${queue.tasks.partitioning_strategy:tenant}") + private String partitioningStrategy; + + @Value("${queue.tasks.stats.topic:jobs.stats}") + private String statsTopic; + + @Value("${queue.tasks.stats.poll_interval:500}") + private int statsPollInterval; + + @Value("${queue.tasks.stats.processing_interval:1000}") + private int statsProcessingInterval; + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/InMemoryTaskProcessorQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/InMemoryTaskProcessorQueueFactory.java new file mode 100644 index 0000000000..76effa6304 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/InMemoryTaskProcessorQueueFactory.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.memory.InMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; +import org.thingsboard.server.queue.memory.InMemoryTbQueueProducer; +import org.thingsboard.server.queue.settings.TasksQueueConfig; + +@Component +@ConditionalOnExpression("'${queue.type:null}'=='in-memory'") +@RequiredArgsConstructor +public class InMemoryTaskProcessorQueueFactory implements TaskProcessorQueueFactory { + + private final InMemoryStorage storage; + private final TasksQueueConfig tasksQueueConfig; + + @Override + public TbQueueConsumer> createTaskConsumer(JobType jobType) { + return new InMemoryTbQueueConsumer<>(storage, jobType.getTasksTopic()); + } + + @Override + public TbQueueProducer> createJobStatsProducer() { + return new InMemoryTbQueueProducer<>(storage, tasksQueueConfig.getStatsTopic()); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/InMemoryTaskProducerQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/InMemoryTaskProducerQueueFactory.java new file mode 100644 index 0000000000..7f0cae7eb1 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/InMemoryTaskProducerQueueFactory.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.memory.InMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryTbQueueProducer; + +@Component +@ConditionalOnExpression("'${queue.type:null}' == 'in-memory'") +@RequiredArgsConstructor +public class InMemoryTaskProducerQueueFactory implements TaskProducerQueueFactory { + + private final InMemoryStorage storage; + + @Override + public TbQueueProducer> createTaskProducer(JobType jobType) { + return new InMemoryTbQueueProducer<>(storage, jobType.getTasksTopic()); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/JobStatsService.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/JobStatsService.java new file mode 100644 index 0000000000..0b7d18fde4 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/JobStatsService.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.task.TaskResult; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TaskResultProto; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +@Lazy +@Service +@Slf4j +public class JobStatsService { + + private final TbQueueProducer> producer; + + public JobStatsService(TaskProcessorQueueFactory queueFactory) { + this.producer = queueFactory.createJobStatsProducer(); + } + + public void reportTaskResult(TenantId tenantId, JobId jobId, TaskResult result) { + report(tenantId, jobId, JobStatsMsg.newBuilder() + .setTaskResult(TaskResultProto.newBuilder() + .setValue(JacksonUtil.toString(result)) + .build())); + } + + public void reportAllTasksSubmitted(TenantId tenantId, JobId jobId, int tasksCount) { + report(tenantId, jobId, JobStatsMsg.newBuilder() + .setTotalTasksCount(tasksCount)); + } + + private void report(TenantId tenantId, JobId jobId, JobStatsMsg.Builder statsMsg) { + log.debug("[{}][{}] Reporting: {}", tenantId, jobId, statsMsg); + statsMsg.setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setJobIdMSB(jobId.getId().getMostSignificantBits()) + .setJobIdLSB(jobId.getId().getLeastSignificantBits()); + + // using job id as msg key so that all stats for a certain job are submitted to the same partition + TbProtoQueueMsg msg = new TbProtoQueueMsg<>(jobId.getId(), statsMsg.build()); + producer.send(TopicPartitionInfo.builder().topic(producer.getDefaultTopic()).build(), msg, TbQueueCallback.EMPTY); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/KafkaTaskProcessorQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/KafkaTaskProcessorQueueFactory.java new file mode 100644 index 0000000000..6b95b0530c --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/KafkaTaskProcessorQueueFactory.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +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; +import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; +import org.thingsboard.server.queue.kafka.TbKafkaSettings; +import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; +import org.thingsboard.server.queue.settings.TasksQueueConfig; + +@Component +@ConditionalOnExpression("'${queue.type:null}'=='kafka'") +public class KafkaTaskProcessorQueueFactory implements TaskProcessorQueueFactory { + + private final TopicService topicService; + private final TbServiceInfoProvider serviceInfoProvider; + private final TasksQueueConfig tasksQueueConfig; + private final TbKafkaSettings kafkaSettings; + private final TbKafkaConsumerStatsService consumerStatsService; + + private final TbQueueAdmin tasksAdmin; + + public KafkaTaskProcessorQueueFactory(TopicService topicService, + TbServiceInfoProvider serviceInfoProvider, + TasksQueueConfig tasksQueueConfig, + TbKafkaSettings kafkaSettings, + TbKafkaConsumerStatsService consumerStatsService, + TbKafkaTopicConfigs kafkaTopicConfigs) { + this.topicService = topicService; + this.serviceInfoProvider = serviceInfoProvider; + this.tasksQueueConfig = tasksQueueConfig; + this.kafkaSettings = kafkaSettings; + this.consumerStatsService = consumerStatsService; + this.tasksAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getTasksConfigs()); + } + + @Override + public TbQueueConsumer> createTaskConsumer(JobType jobType) { + return TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(jobType.getTasksTopic())) + .clientId(jobType.name().toLowerCase() + "-task-consumer-" + serviceInfoProvider.getServiceId()) + .groupId(topicService.buildTopicName(jobType.name().toLowerCase() + "-task-consumer-group")) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), TaskProto.parseFrom(msg.getData()), msg.getHeaders())) + .admin(tasksAdmin) + .statsService(consumerStatsService) + .build(); + } + + @Override + public TbQueueProducer> createJobStatsProducer() { + return TbKafkaProducerTemplate.>builder() + .clientId("job-stats-producer-" + serviceInfoProvider.getServiceId()) + .defaultTopic(topicService.buildTopicName(tasksQueueConfig.getStatsTopic())) + .settings(kafkaSettings) + .admin(tasksAdmin) + .build(); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/KafkaTaskProducerQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/KafkaTaskProducerQueueFactory.java new file mode 100644 index 0000000000..b19db211fe --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/KafkaTaskProducerQueueFactory.java @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +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.TbKafkaProducerTemplate; +import org.thingsboard.server.queue.kafka.TbKafkaSettings; +import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; + +@Component +@ConditionalOnExpression("'${queue.type:null}' == 'kafka' && ('${service.type:null}' == 'monolith' || " + + "'${service.type:null}' == 'tb-core' || '${service.type:null}' == 'tb-rule-engine')") +public class KafkaTaskProducerQueueFactory implements TaskProducerQueueFactory { + + private final TopicService topicService; + private final TbServiceInfoProvider serviceInfoProvider; + private final TbKafkaSettings kafkaSettings; + private final TbQueueAdmin tasksAdmin; + + KafkaTaskProducerQueueFactory(TopicService topicService, + TbServiceInfoProvider serviceInfoProvider, + TbKafkaSettings kafkaSettings, + TbKafkaTopicConfigs kafkaTopicConfigs) { + this.topicService = topicService; + this.kafkaSettings = kafkaSettings; + this.serviceInfoProvider = serviceInfoProvider; + this.tasksAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getTasksConfigs()); + } + + @Override + public TbQueueProducer> createTaskProducer(JobType jobType) { + return TbKafkaProducerTemplate.>builder() + .clientId(jobType.name().toLowerCase() + "-task-producer-" + serviceInfoProvider.getServiceId()) + .defaultTopic(topicService.buildTopicName(jobType.getTasksTopic())) + .settings(kafkaSettings) + .admin(tasksAdmin) + .build(); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessor.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessor.java new file mode 100644 index 0000000000..33c52859ca --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessor.java @@ -0,0 +1,264 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.apache.commons.lang3.tuple.Pair; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SetCache; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.job.task.Task; +import org.thingsboard.server.common.data.job.task.TaskResult; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.queue.QueueConfig; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.MainQueueConsumerManager; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.settings.TasksQueueConfig; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; + +public abstract class TaskProcessor, R extends TaskResult> { + + protected final Logger log = LoggerFactory.getLogger(getClass()); + + @Autowired + private TaskProcessorQueueFactory queueFactory; + @Autowired + private JobStatsService statsService; + @Autowired + private TaskProcessorExecutors executors; + @Autowired + private TasksQueueConfig config; + + private QueueKey queueKey; + private MainQueueConsumerManager, QueueConfig> taskConsumer; + private final ExecutorService taskExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName(getJobType().name().toLowerCase() + "-task-processor")); + + protected final Map, Future>> currentTasks = new ConcurrentHashMap<>(); + + private final SetCache discarded = new SetCache<>(TimeUnit.MINUTES.toMillis(60)); + private final SetCache failed = new SetCache<>(TimeUnit.MINUTES.toMillis(60)); + + private final SetCache deletedTenants = new SetCache<>(TimeUnit.MINUTES.toMillis(60)); + + @PostConstruct + public void init() { + queueKey = new QueueKey(ServiceType.TASK_PROCESSOR, getJobType().name()); + taskConsumer = MainQueueConsumerManager., QueueConfig>builder() + .queueKey(queueKey) + .config(QueueConfig.of(true, config.getPollInterval())) + .msgPackProcessor(this::processMsgs) + .consumerCreator((queueConfig, tpi) -> queueFactory.createTaskConsumer(getJobType())) + .consumerExecutor(executors.getConsumersExecutor()) + .scheduler(executors.getScheduler()) + .taskExecutor(executors.getMgmtExecutor()) + .build(); + } + + @EventListener + public void onPartitionChangeEvent(PartitionChangeEvent event) { + if (event.getServiceType() == ServiceType.TASK_PROCESSOR) { + Set partitions = event.getNewPartitions().get(queueKey); + taskConsumer.update(partitions); + } + } + + @EventListener + public void onComponentLifecycle(ComponentLifecycleMsg event) { + EntityId entityId = event.getEntityId(); + switch (entityId.getEntityType()) { + case JOB -> { + String tasksKey = event.getInfo().get("tasksKey").asText(); + if (event.getEvent() == ComponentLifecycleEvent.STOPPED) { + log.info("Adding job {} ({}) to discarded", entityId, tasksKey); + addToDiscarded(tasksKey); + cancelRunningTasks(tasksKey); + } else if (event.getEvent() == ComponentLifecycleEvent.FAILED) { + log.info("Adding job {} ({}) to failed", entityId, tasksKey); + failed.add(tasksKey); + cancelRunningTasks(tasksKey); + } + } + case TENANT -> { + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + log.info("Adding tenant {} to deleted", entityId); + deletedTenants.add(entityId.getId()); + cancelRunningTasks((TenantId) entityId); + } + } + } + } + + private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig queueConfig) throws Exception { + for (TbProtoQueueMsg msg : msgs) { + try { + @SuppressWarnings("unchecked") + T task = (T) JacksonUtil.fromString(msg.getValue().getValue(), Task.class); + if (discarded.contains(task.getKey())) { + log.debug("Skipping task for discarded job {}: {}", task.getJobId(), task); + reportTaskDiscarded(task); + continue; + } else if (failed.contains(task.getKey())) { + log.debug("Skipping task for failed job {}: {}", task.getJobId(), task); + continue; + } else if (deletedTenants.contains(task.getTenantId().getId())) { + log.debug("Skipping task for deleted tenant {}: {}", task.getTenantId(), task); + continue; + } + + processTask(task, consumerKey); + } catch (InterruptedException e) { + throw e; + } catch (Exception e) { + log.error("Failed to process msg: {}", msg, e); + } + } + consumer.commit(); + } + + private void processTask(T task, Object consumerKey) throws InterruptedException { + task.setAttempt(task.getAttempt() + 1); + log.debug("Processing task: {}", task); + Future future = null; + try { + long startNs = System.nanoTime(); + long timeoutMs = getProcessingTimeout(task); + + future = taskExecutor.submit(() -> process(task)); + currentTasks.put(consumerKey, Pair.of(task, future)); + + R result; + try { + result = future.get(timeoutMs, TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + throw e.getCause(); + } catch (TimeoutException e) { + throw new TimeoutException("Timeout after " + timeoutMs + " ms"); + } + + long timingNs = System.nanoTime() - startNs; + log.info("Processed task in {} ms: {}", timingNs / 1000000.0, task); + reportTaskResult(task, result); + } catch (InterruptedException e) { + throw e; + } catch (CancellationException e) { + if (!failed.contains(task.getKey()) && !deletedTenants.contains(task.getTenantId().getId())) { + reportTaskDiscarded(task); + } + } catch (Throwable e) { + log.error("Failed to process task (attempt {}): {}", task.getAttempt(), task, e); + if (task.getAttempt() <= task.getRetries()) { + processTask(task, consumerKey); + } else { + reportTaskFailure(task, e); + } + } finally { + if (future != null && !future.isDone()) { + future.cancel(true); + } + currentTasks.remove(consumerKey); + } + } + + public abstract R process(T task) throws Exception; + + private void cancelRunningTasks(String tasksKey) { + cancelRunningTasks(task -> task.getKey().equals(tasksKey)); + } + + private void cancelRunningTasks(TenantId tenantId) { + cancelRunningTasks(task -> task.getTenantId().equals(tenantId)); + } + + private void cancelRunningTasks(Predicate> filter) { + currentTasks.values().forEach(entry -> { + Task task = entry.getKey(); + Future future = entry.getValue(); + if (filter.test(task)) { + log.debug("Cancelling running task {}", task); + future.cancel(true); + } + }); + } + + private void reportTaskFailure(T task, Throwable error) { + R taskResult = task.toFailed(error); + reportTaskResult(task, taskResult); + } + + private void reportTaskDiscarded(T task) { + R taskResult = task.toDiscarded(); + reportTaskResult(task, taskResult); + } + + private void reportTaskResult(T task, R result) { + result.setKey(task.getKey()); + result.setFinishTs(System.currentTimeMillis()); + statsService.reportTaskResult(task.getTenantId(), task.getJobId(), result); + } + + public void addToDiscarded(String tasksKey) { + discarded.add(tasksKey); + } + + protected V wait(Future future) throws Exception { + try { + return future.get(); // will be interrupted after task processing timeout + } catch (InterruptedException e) { + future.cancel(true); // interrupting the underlying task + throw e; + } + } + + @PreDestroy + public void destroy() { + taskConsumer.stop(); + taskConsumer.awaitStop(); + taskExecutor.shutdownNow(); + } + + public abstract long getProcessingTimeout(T task); + + public abstract JobType getJobType(); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessorExecutors.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessorExecutors.java new file mode 100644 index 0000000000..3aa6a0f004 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessorExecutors.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +@Getter +@Lazy +@Component +public class TaskProcessorExecutors { + + private ExecutorService consumersExecutor; + private ExecutorService mgmtExecutor; + private ScheduledExecutorService scheduler; + + @PostConstruct + private void init() { + consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("task-consumer")); + mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(4, "task-consumer-mgmt"); + scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("task-consumer-scheduler"); + } + + @PreDestroy + private void destroy() { + if (consumersExecutor != null) { + consumersExecutor.shutdownNow(); + } + if (mgmtExecutor != null) { + mgmtExecutor.shutdownNow(); + } + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessorQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessorQueueFactory.java new file mode 100644 index 0000000000..c5e8035d74 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProcessorQueueFactory.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.gen.transport.TransportProtos.JobStatsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +public interface TaskProcessorQueueFactory { + + TbQueueConsumer> createTaskConsumer(JobType jobType); + + TbQueueProducer> createJobStatsProducer(); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProducerQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProducerQueueFactory.java new file mode 100644 index 0000000000..ffb64a07ce --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/task/TaskProducerQueueFactory.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.task; + +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +public interface TaskProducerQueueFactory { + + TbQueueProducer> createTaskProducer(JobType jobType); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java index bf6bfc7a2d..fb6ce213c2 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'") -public @interface TbCoreComponent { -} +public @interface TbCoreComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java index 9216b40c7a..735df499fe 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("('${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.lwm2m.enabled}'=='true')) && '${transport.lwm2m.bootstrap.enabled:false}'=='true'") -public @interface TbLwM2mBootstrapTransportComponent { -} +public @interface TbLwM2mBootstrapTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java index ddc3093349..8055870872 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.lwm2m.enabled}'=='true')") -public @interface TbLwM2mTransportComponent { -} +public @interface TbLwM2mTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java index ef77763a07..0b03de3570 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-rule-engine'") -public @interface TbRuleEngineComponent { -} +public @interface TbRuleEngineComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java index ca84ea7305..914b5456ff 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java @@ -18,12 +18,13 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +@Inherited @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.snmp.enabled}'=='true')") @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) -public @interface TbSnmpTransportComponent { -} +public @interface TbSnmpTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java index c879deadf3..3d6a0416e8 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true') || '${service.type:null}'=='tb-transport'") -public @interface TbTransportComponent { -} +public @interface TbTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java index 99132f1877..1c1fdd041d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-vc-executor'") -public @interface TbVersionControlComponent { -} +public @interface TbVersionControlComponent {} diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java index 9e493220ca..e5bb62bc04 100644 --- a/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java +++ b/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java @@ -37,11 +37,23 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThan; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.*; -import static org.mockito.Mockito.*; +import static org.mockito.BDDMockito.RETURNS_DEEP_STUBS; +import static org.mockito.BDDMockito.atLeastOnce; +import static org.mockito.BDDMockito.lenient; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.spy; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willReturn; import static org.mockito.hamcrest.MockitoHamcrest.longThat; @Slf4j @@ -96,7 +108,6 @@ public class DefaultTbQueueRequestTemplateTest { inst.init(); assertThat(inst.nextCleanupNs, equalTo(0L)); verify(queueAdmin, times(1)).createTopicIfNotExists(topic); - verify(requestTemplate, times(1)).init(); verify(responseTemplate, times(1)).subscribe(); verify(executorMock, times(1)).submit(any(Runnable.class)); diff --git a/common/script/pom.xml b/common/script/pom.xml index 61bbddc6c3..999d895513 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index d247399ce6..d92e8e78cd 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 3908326c96..bda91efa78 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java index 77888db255..347490b3fb 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java @@ -16,13 +16,24 @@ package org.thingsboard.script.api; import lombok.Getter; +import org.thingsboard.common.util.RecoveryAware; +import java.io.Serial; import java.util.UUID; -public class TbScriptException extends RuntimeException { +public class TbScriptException extends RuntimeException implements RecoveryAware { + + @Serial private static final long serialVersionUID = -1958193538782818284L; - public static enum ErrorCode {COMPILATION, TIMEOUT, RUNTIME, OTHER} + public enum ErrorCode { + + COMPILATION, + TIMEOUT, + RUNTIME, + OTHER + + } @Getter private final UUID scriptId; @@ -37,4 +48,10 @@ public class TbScriptException extends RuntimeException { this.errorCode = errorCode; this.body = body; } + + @Override + public boolean isUnrecoverable() { + return errorCode == ErrorCode.COMPILATION; + } + } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java index 3507dd87e6..4aedab8081 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java @@ -20,6 +20,7 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import delight.nashornsandbox.NashornSandbox; import delight.nashornsandbox.NashornSandboxes; +import delight.nashornsandbox.exceptions.ScriptCPUAbuseException; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.Getter; @@ -153,8 +154,12 @@ public class NashornJsInvokeService extends AbstractJsInvokeService { } scriptInfoMap.put(scriptId, scriptInfo); return scriptId; - } catch (Exception e) { + } catch (ScriptException e) { throw new TbScriptException(scriptId, TbScriptException.ErrorCode.COMPILATION, jsScript, e); + } catch (ScriptCPUAbuseException e) { + throw new TbScriptException(scriptId, TbScriptException.ErrorCode.TIMEOUT, jsScript, e); + } catch (Exception e) { + throw new TbScriptException(scriptId, TbScriptException.ErrorCode.OTHER, jsScript, e); } }); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java index 25a7ede547..e2195a6bbf 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java @@ -27,6 +27,7 @@ import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.mvel2.CompileException; import org.mvel2.ExecutionContext; import org.mvel2.MVEL; import org.mvel2.ParserContext; @@ -52,11 +53,11 @@ import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.util.Calendar; import java.util.Collections; -import java.util.Map; import java.util.Optional; import java.util.Random; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Executor; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -66,9 +67,9 @@ import java.util.concurrent.locks.ReentrantLock; @Service public class DefaultTbelInvokeService extends AbstractScriptInvokeService implements TbelInvokeService { - protected final Map scriptIdToHash = new ConcurrentHashMap<>(); - protected final Map scriptMap = new ConcurrentHashMap<>(); - protected Cache compiledScriptsCache; + private final ConcurrentMap scriptIdToHash = new ConcurrentHashMap<>(); + private final ConcurrentMap scriptMap = new ConcurrentHashMap<>(); + private Cache compiledScriptsCache; private SandboxedParserConfiguration parserConfig; private final Optional apiUsageStateClient; @@ -204,8 +205,10 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem lock.unlock(); } return scriptId; - } catch (Exception e) { + } catch (CompileException e) { throw new TbScriptException(scriptId, TbScriptException.ErrorCode.COMPILATION, scriptBody, e); + } catch (Exception e) { + throw new TbScriptException(scriptId, TbScriptException.ErrorCode.OTHER, scriptBody, e); } }); } @@ -246,7 +249,7 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem } } - private Serializable compileScript(String scriptBody) { + private static Serializable compileScript(String scriptBody) throws CompileException { return MVEL.compileExpression(scriptBody, new ParserContext()); } @@ -269,4 +272,5 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem protected StatsType getStatsType() { return StatsType.TBEL_INVOKE; } + } 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 b782040a99..072a17835d 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 @@ -23,6 +23,7 @@ import org.mvel2.ExecutionContext; import org.mvel2.ParserConfiguration; import org.mvel2.execution.ExecutionArrayList; import org.mvel2.execution.ExecutionHashMap; +import org.mvel2.execution.ExecutionLinkedHashSet; import org.mvel2.util.MethodStub; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.geo.Coordinates; @@ -46,6 +47,7 @@ import java.util.Base64; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -386,6 +388,12 @@ public class TbUtils { Object.class))); parserConfig.addImport("isArray", new MethodStub(TbUtils.class.getMethod("isArray", Object.class))); + parserConfig.addImport("newSet", new MethodStub(TbUtils.class.getMethod("newSet", + ExecutionContext.class))); + parserConfig.addImport("toSet", new MethodStub(TbUtils.class.getMethod("toSet", + ExecutionContext.class, List.class))); + parserConfig.addImport("isSet", new MethodStub(TbUtils.class.getMethod("isSet", + Object.class))); } public static String btoa(String input) { @@ -1481,6 +1489,19 @@ public class TbUtils { return obj != null && obj.getClass().isArray(); } + public static Set newSet(ExecutionContext ctx) { + return new ExecutionLinkedHashSet<>(ctx); + } + + public static Set toSet(ExecutionContext ctx, List list) { + Set newSet = new LinkedHashSet<>(list); + return new ExecutionLinkedHashSet<>(newSet, ctx); + } + + public static boolean isSet(Object obj) { + return obj instanceof Set; + } + 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. " + diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java index 7515cb5269..c6023154ea 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java @@ -25,11 +25,11 @@ public class TbelCfCtx implements TbelCfObject { @Getter private final Map args; @Getter - private final long msgTs; + private final long latestTs; - public TbelCfCtx(Map args, long lastUpdateTs) { + public TbelCfCtx(Map args, long latestTs) { this.args = Collections.unmodifiableMap(args); - this.msgTs = lastUpdateTs != -1 ? lastUpdateTs : System.currentTimeMillis(); + this.latestTs = latestTs != -1 ? latestTs : System.currentTimeMillis(); } @Override diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/TbScriptExceptionTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/TbScriptExceptionTest.java new file mode 100644 index 0000000000..330b895504 --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/TbScriptExceptionTest.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.script.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class TbScriptExceptionTest { + + @Test + void givenCompilationError_whenCheckingIsUnrecoverable_thenReturnsTrue() { + // GIVEN + var exception = new TbScriptException(null, TbScriptException.ErrorCode.COMPILATION, null, null); + + // WHEN-THEN + assertThat(exception.isUnrecoverable()).isTrue(); + } + + @ParameterizedTest + @EnumSource( + value = TbScriptException.ErrorCode.class, + mode = EnumSource.Mode.EXCLUDE, + names = "COMPILATION" + ) + void givenRecoverableErrorCodes_whenCheckingIsUnrecoverable_thenReturnsFalse(TbScriptException.ErrorCode errorCode) { + // GIVEN + var exception = new TbScriptException(null, errorCode, null, null); + + // WHEN-THEN + assertThat(exception.isUnrecoverable()).isFalse(); + } + +} 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 6d793f2c8d..4dcbd2d69c 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 @@ -28,6 +28,7 @@ import org.mvel2.ParserContext; import org.mvel2.SandboxedParserConfiguration; import org.mvel2.execution.ExecutionArrayList; import org.mvel2.execution.ExecutionHashMap; +import org.mvel2.execution.ExecutionLinkedHashSet; import java.io.IOException; import java.math.BigDecimal; @@ -39,14 +40,18 @@ import java.util.Base64; import java.util.Calendar; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.Set; import java.util.concurrent.ExecutionException; import static java.lang.Character.MAX_RADIX; import static java.lang.Character.MIN_RADIX; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @Slf4j @@ -1184,10 +1189,11 @@ public class TbUtilsTest { @Test public void isList() throws ExecutionException, InterruptedException { - List liat = List.of(0x35); - assertTrue(TbUtils.isList(liat)); - assertFalse(TbUtils.isMap(liat)); - assertFalse(TbUtils.isArray(liat)); + List list = List.of(0x35); + assertTrue(TbUtils.isList(list)); + assertFalse(TbUtils.isMap(list)); + assertFalse(TbUtils.isArray(list)); + assertFalse(TbUtils.isSet(list)); } @Test @@ -1195,6 +1201,52 @@ public class TbUtilsTest { byte [] array = new byte[]{1, 2, 3}; assertTrue(TbUtils.isArray(array)); assertFalse(TbUtils.isList(array)); + assertFalse(TbUtils.isSet(array)); + } + + @Test + public void isSet() throws ExecutionException, InterruptedException { + Set set = toSet(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xBB, (byte) 0xAA}); + assertTrue(TbUtils.isSet(set)); + assertFalse(TbUtils.isList(set)); + assertFalse(TbUtils.isArray(set)); + } + @Test + public void setTest() throws ExecutionException, InterruptedException { + Set actual = TbUtils.newSet(ctx); + Set expected = toSet(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xCC}); + actual.add((byte) 0xDD); + actual.add((byte) 0xCC); + actual.add((byte) 0xCC); + assertTrue(expected.containsAll(actual)); + List list = toList(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xBB, (byte) 0xAA}); + actual.addAll(list); + assertEquals(4, actual.size()); + assertTrue(actual.containsAll(expected)); + actual = TbUtils.toSet(ctx, list); + expected = toSet(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xDA}); + actual.add((byte) 0xDA); + actual.remove((byte) 0xBB); + actual.remove((byte) 0xAA); + assertTrue(expected.containsAll(actual)); + assertEquals(actual.size(), 3); + actual.clear(); + assertTrue(actual.isEmpty()); + actual = TbUtils.toSet(ctx, list); + Set actualClone = TbUtils.toSet(ctx, list); + Set actualClone_asc = TbUtils.toSet(ctx, list); + Set actualClone_desc = TbUtils.toSet(ctx, list); + ((ExecutionLinkedHashSet)actualClone).sort(); + ((ExecutionLinkedHashSet)actualClone_asc).sort(true); + ((ExecutionLinkedHashSet)actualClone_desc).sort(false); + assertEquals(list.toString(), actual.toString()); + assertNotEquals(list.toString(), actualClone.toString()); + Collections.sort(list); + assertEquals(list.toString(), actualClone.toString()); + assertEquals(list.toString(), actualClone_asc.toString()); + Collections.sort(list, Collections.reverseOrder()); + assertNotEquals(list.toString(), actualClone_asc.toString()); + assertEquals(list.toString(), actualClone_desc.toString()); } private static List toList(byte[] data) { @@ -1204,5 +1256,13 @@ public class TbUtilsTest { } return result; } + + private static Set toSet(byte[] data) { + Set result = new LinkedHashSet<>(); + for (Byte b : data) { + result.add(b); + } + return result; + } } diff --git a/common/stats/pom.xml b/common/stats/pom.xml index 90d619c133..f2d18688fa 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 314f79833b..c4f6f85e64 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index c1e3f77c4a..6cdc499a41 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index 01d0476c04..72a5731f8f 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java index 37f7829c12..431b623da9 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java @@ -383,7 +383,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl LwM2mPath path = instant.getKey(); LwM2mNode node = instant.getValue(); LwM2mClient lwM2MClient = clientContext.getClientByEndpoint(registration.getEndpoint()); - ObjectModel objectModelVersion = lwM2MClient.getObjectModel(path.toString(), modelProvider); + ObjectModel objectModelVersion = lwM2MClient.getObjectModel(convertObjectIdToVersionedId(path.toString(), lwM2MClient), modelProvider); if (objectModelVersion != null) { ResourceUpdateResult updateResource = new ResourceUpdateResult(lwM2MClient); if (node instanceof LwM2mObject) { diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index d84757981a..71a1de3228 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 5aea93528a..7f9da9e0b9 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -28,6 +28,8 @@ import io.netty.handler.codec.mqtt.MqttFixedHeader; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttMessageBuilders; import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; +import io.netty.handler.codec.mqtt.MqttProperties.IntegerProperty; +import io.netty.handler.codec.mqtt.MqttProperties.MqttPropertyType; import io.netty.handler.codec.mqtt.MqttPubAckMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttQoS; @@ -84,6 +86,7 @@ import org.thingsboard.server.transport.mqtt.limits.SessionLimits; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; import org.thingsboard.server.transport.mqtt.session.GatewaySessionHandler; import org.thingsboard.server.transport.mqtt.session.MqttTopicMatcher; +import org.thingsboard.server.transport.mqtt.session.SparkplugDeviceSessionContext; import org.thingsboard.server.transport.mqtt.session.SparkplugNodeSessionHandler; import org.thingsboard.server.transport.mqtt.util.ReturnCodeResolver; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType; @@ -101,6 +104,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Map.Entry; import java.util.Optional; import java.util.UUID; import java.util.concurrent.Callable; @@ -120,9 +124,14 @@ import static org.thingsboard.server.common.transport.service.DefaultTransportSe import static org.thingsboard.server.common.transport.service.DefaultTransportService.SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG; import static org.thingsboard.server.common.transport.service.DefaultTransportService.SUBSCRIBE_TO_RPC_ASYNC_MSG; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.OFFLINE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NBIRTH; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NCMD; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NDEATH; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.SPARKPLUG_BD_SEQUENCE_NUMBER_KEY; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProto; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.parseTopicPublish; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProtoFromJsonNode; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic.parseTopic; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.parseTopicPublish; /** * @author Andrew Shvayka @@ -444,11 +453,23 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } + /** + * It may be the case that an Edge Node has many dynamic associated devices. + * Publish: spBv1.0/G1/DBIRTH/E1/+ + * Publish: spBv1.0/G1/DDATA/E1/+ + * Publish: spBv1.0/G1/DCMD/E1/+ + * Publish: spBv1.0/G1/DDEATH/E1/+ + * @param ctx + * @param topicName + * @param mqttMsg + */ + private void handleSparkplugPublishMsg(ChannelHandlerContext ctx, String topicName, MqttPublishMessage mqttMsg) { int msgId = mqttMsg.variableHeader().packetId(); try { SparkplugTopic sparkplugTopic = parseTopicPublish(topicName); - if (sparkplugTopic.isNode()) { + boolean isWildcardInPublish = topicName.contains("+"); + if (!isWildcardInPublish && sparkplugTopic.isNode()) { // A node topic SparkplugBProto.Payload sparkplugBProtoNode = SparkplugBProto.Payload.parseFrom(ProtoMqttAdaptor.toBytes(mqttMsg.payload())); switch (sparkplugTopic.getType()) { @@ -457,21 +478,28 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement case NDATA: sparkplugSessionHandler.onAttributesTelemetryProto(msgId, sparkplugBProtoNode, sparkplugTopic); break; + case NDEATH: + if (sparkplugSessionHandler.onValidateNDEATH(sparkplugBProtoNode)) { + doDisconnect(); + break; + } else { + throw new ThingsboardException(SPARKPLUG_BD_SEQUENCE_NUMBER_KEY + " of " + NDEATH.name() + " is not equals " + + SPARKPLUG_BD_SEQUENCE_NUMBER_KEY + " of " + NBIRTH.name(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } default: } } else { // A device topic SparkplugBProto.Payload sparkplugBProtoDevice = SparkplugBProto.Payload.parseFrom(ProtoMqttAdaptor.toBytes(mqttMsg.payload())); - switch (sparkplugTopic.getType()) { - case DBIRTH: - case DCMD: - case DDATA: - sparkplugSessionHandler.onAttributesTelemetryProto(msgId, sparkplugBProtoDevice, sparkplugTopic); - break; - case DDEATH: - sparkplugSessionHandler.onDeviceDisconnect(mqttMsg, sparkplugTopic.getDeviceId()); - break; - default: + if (isWildcardInPublish) { + for (Entry entry : sparkplugSessionHandler.getDevices().entrySet()) { + String deviceName = entry.getKey(); + SparkplugTopic sparkplugTopicDevice = sparkplugTopic; + sparkplugTopicDevice.updateDeviceIdPlus(deviceName); + handleSparkplugPublishDeviceMsg(sparkplugTopicDevice, msgId, mqttMsg, sparkplugBProtoDevice); + } + } else { + handleSparkplugPublishDeviceMsg(sparkplugTopic, msgId, mqttMsg, sparkplugBProtoDevice); } } } catch (RuntimeException e) { @@ -484,6 +512,36 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } + /** + * It may be the case that an Edge Node has many dynamic associated devices. + * Publish: spBv1.0/G1/DBIRTH/E1/+ + * Publish: spBv1.0/G1/DDATA/E1/+ + * Publish: spBv1.0/G1/DCMD/E1/+ + * Publish: spBv1.0/G1/DDEATH/E1/+ + * @param sparkplugTopic + * @param msgId + * @param mqttMsg + * @throws AdaptorException + * @throws ThingsboardException + * @throws InvalidProtocolBufferException + */ + private void handleSparkplugPublishDeviceMsg(SparkplugTopic sparkplugTopic, int msgId, + MqttPublishMessage mqttMsg, SparkplugBProto.Payload sparkplugBProtoDevice) + throws AdaptorException, ThingsboardException, InvalidProtocolBufferException { + // A device topic + switch (sparkplugTopic.getType()) { + case DBIRTH: + case DCMD: + case DDATA: + sparkplugSessionHandler.onAttributesTelemetryProto(msgId, sparkplugBProtoDevice, sparkplugTopic); + break; + case DDEATH: + sparkplugSessionHandler.onDeviceDisconnect(mqttMsg, sparkplugTopic.getDeviceId()); + break; + default: + } + } + private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { try { Matcher fwMatcher; @@ -786,7 +844,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } try { if (sparkplugSessionHandler != null) { - sparkplugSessionHandler.handleSparkplugSubscribeMsg(grantedQoSList, subscription, reqQoS); + sparkplugSessionHandler.handleSparkplugSubscribeMsg(subscription); activityReported = true; } else { switch (topic) { @@ -877,13 +935,22 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement registerSubQoS(topic, grantedQoSList, reqQoS); } - public void processAttributesRpcSubscribeSparkplugNode(List grantedQoSList, MqttQoS reqQoS) { + /** + * 3.0.0 Edge Node Session Establishment: + * ncmd-subscribe + * [tck-id-message-flow-edge-node-ncmd-subscribe] The MQTT client associated with the Edge + * Node MUST subscribe to a topic of the form spBv1.0/group_id/NCMD/edge_node_id where + * group_id is the Sparkplug Group ID and the edge_node_id is the Sparkplug Edge Node ID for + * this Edge Node. It MUST subscribe on this topic with a QoS of 1. + */ + public void processAttributesRpcSubscribeSparkplugNode() { + List grantedQoSList = new ArrayList<>(); transportService.process(TransportProtos.TransportToDeviceActorMsg.newBuilder() .setSessionInfo(deviceSessionCtx.getSessionInfo()) .setSubscribeToAttributes(SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG) .setSubscribeToRPC(SUBSCRIBE_TO_RPC_ASYNC_MSG) .build(), null); - registerSubQoS(MqttTopics.DEVICE_ATTRIBUTES_TOPIC, grantedQoSList, reqQoS); + registerSubQoS(MqttTopics.DEVICE_ATTRIBUTES_TOPIC, grantedQoSList, AT_LEAST_ONCE); } public void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { @@ -1180,12 +1247,14 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void checkSparkplugNodeSession(MqttConnectMessage connectMessage, ChannelHandlerContext ctx, SessionMetaData sessionMetaData) { try { if (sparkplugSessionHandler == null) { - SparkplugTopic sparkplugTopicNode = validatedSparkplugTopicConnectedNode(connectMessage); - if (sparkplugTopicNode != null) { + SparkplugTopic sparkplugTopic = validatedSparkplugConnectedWillTopic(connectMessage); + if (sparkplugTopic != null) { SparkplugBProto.Payload sparkplugBProtoNode = SparkplugBProto.Payload.parseFrom(connectMessage.payload().willMessageInBytes()); - sparkplugSessionHandler = new SparkplugNodeSessionHandler(this, deviceSessionCtx, sessionId, true, sparkplugTopicNode); - sparkplugSessionHandler.onAttributesTelemetryProto(0, sparkplugBProtoNode, sparkplugTopicNode); + sparkplugSessionHandler = new SparkplugNodeSessionHandler(this, deviceSessionCtx, sessionId, true, sparkplugTopic); + sparkplugSessionHandler.onAttributesTelemetryProto(0, sparkplugBProtoNode, sparkplugTopic); sessionMetaData.setOverwriteActivityTime(true); + // ncmd-subscribe + processAttributesRpcSubscribeSparkplugNode(); } else { log.trace("[{}][{}] Failed to fetch sparkplugDevice connect: sparkplugTopicName without SparkplugMessageType.NDEATH.", sessionId, deviceSessionCtx.getDeviceInfo().getDeviceName()); throw new ThingsboardException("Invalid request body", ThingsboardErrorCode.BAD_REQUEST_PARAMS); @@ -1198,12 +1267,33 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - private SparkplugTopic validatedSparkplugTopicConnectedNode(MqttConnectMessage connectMessage) throws ThingsboardException { + /** + * The Death Certificate topic and payload described here are not “published” as an MQTT message by a client, + * but provided as parameters within the MQTT CONNECT control packet when this Sparkplug Edge Node first establishes the MQTT Client session. + * - NDEATH message MUST be registered as a Will Message in the MQTT CONNECT packet. + * -- in the MQTT CONNECT packet The NDEATH message MUST set the MQTT Will QoS to 1. + * -- in the MQTT CONNECT packet The NDEATH message MUST set the MQTT Will Retained flag to false. + * -- If the MQTT client is using MQTT v3.1.1, the Edge Node’s MQTT CONNECT packet MUST set the Clean Session flag to true. + * -- If the MQTT client is using MQTT v5.0, the Edge Node’s MQTT CONNECT packet MUST set the Clean Start flag to true and the Session Expiry Interval to 0 + * @param connectMessage + * @return + * @throws ThingsboardException + */ + private SparkplugTopic validatedSparkplugConnectedWillTopic(MqttConnectMessage connectMessage) throws ThingsboardException { if (StringUtils.isNotBlank(connectMessage.payload().willTopic()) && connectMessage.payload().willMessageInBytes() != null && connectMessage.payload().willMessageInBytes().length > 0) { - SparkplugTopic sparkplugTopicNode = parseTopicPublish(connectMessage.payload().willTopic()); + SparkplugTopic sparkplugTopicNode = parseTopic(connectMessage.payload().willTopic()); if (NDEATH.equals(sparkplugTopicNode.getType())) { + if (connectMessage.variableHeader().willQos() != 1 || connectMessage.variableHeader().isWillRetain()) + return null; + if (!connectMessage.variableHeader().isCleanSession()) return null; + int mqttVer = connectMessage.variableHeader().version(); + if (mqttVer == 5) { + Object sessionExpiryIntervalObj = connectMessage.variableHeader().properties().isEmpty() ? null : connectMessage.variableHeader().properties().getProperty(MqttPropertyType.SESSION_EXPIRY_INTERVAL.value()); + Integer sessionExpiryInterval = sessionExpiryIntervalObj == null ? null : ((IntegerProperty) sessionExpiryIntervalObj).value(); + if (sessionExpiryInterval == null || sessionExpiryInterval != 0) return null; + } return sparkplugTopicNode; } } @@ -1302,18 +1392,30 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement @Override public void onAttributeUpdate(UUID sessionId, TransportProtos.AttributeUpdateNotificationMsg notification) { - log.trace("[{}] Received attributes update notification to device", sessionId); try { if (sparkplugSessionHandler != null) { - log.trace("[{}] Received attributes update notification to sparkplug device", sessionId); - notification.getSharedUpdatedList().forEach(tsKvProto -> { - if (sparkplugSessionHandler.getNodeBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { - SparkplugTopic sparkplugTopic = new SparkplugTopic(sparkplugSessionHandler.getSparkplugTopicNode(), - SparkplugMessageType.NCMD); + log.trace("[{}] Received attributes update notification to sparkplug Edge Node", sessionId); + notification.getSharedUpdatedList().forEach(tsKvProtoShared -> { + SparkplugMessageType messageType = NCMD; + TransportProtos.TsKvProto tsKvProto = tsKvProtoShared; + if ("JSON_V".equals(tsKvProtoShared.getKv().getType().name())) { + try { + messageType = SparkplugMessageType.parseMessageType(tsKvProtoShared.getKv().getKey()); + tsKvProto = getTsKvProtoFromJsonNode(JacksonUtil.toJsonNode(tsKvProtoShared.getKv().getJsonV()), tsKvProtoShared.getTs()); + } catch (ThingsboardException e) { + messageType = null; + log.error("Failed attributes update notification to sparkplug Edge Node [{}]. ", sparkplugSessionHandler.getSparkplugTopicNode().getEdgeNodeId(), e); + } + } + if (messageType != null && messageType.isSubscribe() && messageType.isNode() + && sparkplugSessionHandler.getNodeBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { + SparkplugTopic sparkplugTopic = new SparkplugTopic(sparkplugSessionHandler.getSparkplugTopicNode(), messageType); sparkplugSessionHandler.createSparkplugMqttPublishMsg(tsKvProto, sparkplugTopic.toString(), sparkplugSessionHandler.getNodeBirthMetrics().get(tsKvProto.getKv().getKey())) .ifPresent(sparkplugSessionHandler::writeAndFlush); + } else { + log.trace("Failed attributes update notification to sparkplug Edge Node [{}]. ", sparkplugSessionHandler.getSparkplugTopicNode().getEdgeNodeId()); } }); } else { @@ -1322,7 +1424,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement adaptor.convertToPublish(deviceSessionCtx, notification, topic).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); } } catch (Exception e) { - log.trace("[{}] Failed to convert device attributes update to MQTT msg", sessionId, e); + log.trace("[{}] Failed to convert device/Edge Node attributes update to MQTT msg", sessionId, e); } } @@ -1359,7 +1461,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - private void onGetSessionLimitsRpc(TransportProtos.SessionInfoProto sessionInfo, ChannelHandlerContext ctx, int msgId, TransportProtos.ToServerRpcRequestMsg rpcRequestMsg) { + private void onGetSessionLimitsRpc(TransportProtos.SessionInfoProto sessionInfo, ChannelHandlerContext ctx, int msgId, TransportProtos. + ToServerRpcRequestMsg rpcRequestMsg) { var tenantProfile = context.getTenantProfileCache().get(deviceSessionCtx.getTenantId()); DefaultTenantProfileConfiguration profile = tenantProfile.getDefaultProfileConfiguration(); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java index 8977591391..b517cda103 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.transport.mqtt; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.mqtt.enabled}'=='true')") -public @interface TbMqttTransportComponent { -} +public @interface TbMqttTransportComponent {} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index fa06150b1a..91e7dedbf2 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -315,6 +315,15 @@ public abstract class AbstractGatewaySessionHandler getDevices () { + return this.devices; + } + private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final String deviceName, final int msgId, final T msg) { return new TransportServiceCallback() { @Override diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java index 1be6274edb..042ea129a4 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.mqtt.session; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -33,9 +34,10 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.stream.Collectors; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DCMD; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProto; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProtoFromJsonNode; @Slf4j public class SparkplugDeviceSessionContext extends AbstractGatewayDeviceSessionContext { @@ -51,27 +53,49 @@ public class SparkplugDeviceSessionContext extends AbstractGatewayDeviceSessionC super(parent, deviceInfo, deviceProfile, mqttQoSMap, transportService); } - public Map getDeviceBirthMetrics() { + public Map getDeviceBirthMetrics() { return deviceBirthMetrics; } - public void setDeviceBirthMetrics(java.util.List metrics) { - this.deviceBirthMetrics.putAll(metrics.stream() - .collect(Collectors.toMap(SparkplugBProto.Payload.Metric::getName, metric -> metric))); + public void setDeviceBirthMetrics(java.util.List metrics) { + for (var metric : metrics) { + if (metric.hasName()) { + this.deviceBirthMetrics.put(metric.getName(), metric); + } else { + throw new IllegalArgumentException("The metric name of device: '" + this.getDeviceInfo().getDeviceName() + "' must not be empty or null! Metric: [" + metric + "]"); + } + if (metric.hasAlias() && this.parent.getNodeAlias().putIfAbsent(metric.getAlias(), metric.getName()) != null) { + throw new DuplicateKeyException("The alias '" + metric.getAlias() + "' already exists in device: '" + this.getDeviceInfo().getDeviceName() + "'"); + } + } } @Override public void onAttributeUpdate(UUID sessionId, TransportProtos.AttributeUpdateNotificationMsg notification) { log.trace("[{}] Received attributes update notification to sparkplug device", sessionId); - notification.getSharedUpdatedList().forEach(tsKvProto -> { - if (getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { + notification.getSharedUpdatedList().forEach(tsKvProtoShared -> { + SparkplugMessageType messageType = DCMD; + TransportProtos.TsKvProto tsKvProto = tsKvProtoShared; + if ("JSON_V".equals(tsKvProtoShared.getKv().getType().name())) { + try { + messageType = SparkplugMessageType.parseMessageType(tsKvProtoShared.getKv().getKey()); + tsKvProto = getTsKvProtoFromJsonNode(JacksonUtil.toJsonNode(tsKvProtoShared.getKv().getJsonV()), tsKvProtoShared.getTs()); + } catch (ThingsboardException e) { + messageType = null; + log.error("Failed attributes update notification to sparkplug device [{}]. ", deviceInfo.getDeviceName(), e); + } + } + if (messageType != null && messageType.isSubscribe() && messageType.isDevice() + && getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { SparkplugTopic sparkplugTopic = new SparkplugTopic(parent.getSparkplugTopicNode(), - SparkplugMessageType.DCMD, deviceInfo.getDeviceName()); + messageType, deviceInfo.getDeviceName()); parent.createSparkplugMqttPublishMsg(tsKvProto, - sparkplugTopic.toString(), - getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) + sparkplugTopic.toString(), + getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) .ifPresent(this.parent::writeAndFlush); + } else { + log.trace("Failed attributes update notification to sparkplug device [{}]. ", deviceInfo.getDeviceName()); } }); } @@ -81,20 +105,22 @@ public class SparkplugDeviceSessionContext extends AbstractGatewayDeviceSessionC log.trace("[{}] Received RPC Request notification to sparkplug device", sessionId); try { SparkplugMessageType messageType = SparkplugMessageType.parseMessageType(rpcRequest.getMethodName()); - SparkplugRpcRequestHeader header = JacksonUtil.fromString(rpcRequest.getParams(), SparkplugRpcRequestHeader.class); - header.setMessageType(messageType.name()); - TransportProtos.TsKvProto tsKvProto = getTsKvProto(header.getMetricName(), header.getValue(), new Date().getTime()); - if (getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { - SparkplugTopic sparkplugTopic = new SparkplugTopic(parent.getSparkplugTopicNode(), - messageType, deviceInfo.getDeviceName()); - parent.createSparkplugMqttPublishMsg(tsKvProto, - sparkplugTopic.toString(), - getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) - .ifPresent(payload -> parent.sendToDeviceRpcRequest(payload, rpcRequest, sessionInfo)); - } else { - parent.sendErrorRpcResponse(sessionInfo, rpcRequest.getRequestId(), - ThingsboardErrorCode.BAD_REQUEST_PARAMS, " Failed send To Device Rpc Request: " + - rpcRequest.getMethodName() + ". This device does not have a metricName: [" + tsKvProto.getKv().getKey() + "]"); + if (messageType.isSubscribe()) { + SparkplugRpcRequestHeader header = JacksonUtil.fromString(rpcRequest.getParams(), SparkplugRpcRequestHeader.class); + header.setMessageType(messageType.name()); + TransportProtos.TsKvProto tsKvProto = getTsKvProto(header.getMetricName(), header.getValue(), new Date().getTime()); + if (getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { + SparkplugTopic sparkplugTopic = new SparkplugTopic(parent.getSparkplugTopicNode(), + messageType, deviceInfo.getDeviceName()); + parent.createSparkplugMqttPublishMsg(tsKvProto, + sparkplugTopic.toString(), + getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) + .ifPresent(payload -> parent.sendToDeviceRpcRequest(payload, rpcRequest, sessionInfo)); + } else { + parent.sendErrorRpcResponse(sessionInfo, rpcRequest.getRequestId(), + ThingsboardErrorCode.BAD_REQUEST_PARAMS, " Failed send To Device Rpc Request: " + + rpcRequest.getMethodName() + ". This device does not have a metricName: [" + tsKvProto.getKv().getKey() + "]"); + } } } catch (ThingsboardException e) { parent.sendErrorRpcResponse(sessionInfo, rpcRequest.getRequestId(), diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java index 217a1713ca..cdb562c063 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java @@ -18,19 +18,15 @@ package org.thingsboard.server.transport.mqtt.session; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; -import com.google.protobuf.Descriptors; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; -import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttTopicSubscription; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.eclipse.leshan.core.ResponseCode; +import org.springframework.dao.DuplicateKeyException; import org.springframework.util.CollectionUtils; 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.device.profile.MqttDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -41,7 +37,9 @@ import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType; +import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic; +import org.thingsboard.server.transport.mqtt.util.sparkplug.SpecVersion; import java.util.ArrayList; import java.util.List; @@ -50,26 +48,32 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DBIRTH; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NBIRTH; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.parseMessageType; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.SPARKPLUG_BD_SEQUENCE_NUMBER_KEY; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.SPARKPLUG_SEQUENCE_NUMBER_KEY; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.createMetric; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.fromSparkplugBMetricToKeyValueProto; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.validatedValueByTypeMetric; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.parseTopicSubscribe; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_REGEXP; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_STATE_REGEXP; /** * Created by nickAS21 on 12.12.22 */ @Slf4j +@SpecVersion(spec = "sparkplug", version = "3.0.0") public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler { @Getter private final SparkplugTopic sparkplugTopicNode; @Getter private final Map nodeBirthMetrics; + @Getter + private final Map nodeAlias; private final MqttTransportHandler parent; public SparkplugNodeSessionHandler(MqttTransportHandler parent, DeviceSessionCtx deviceSessionCtx, UUID sessionId, @@ -78,23 +82,27 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler(); + this.nodeAlias = new ConcurrentHashMap<>(); } - public void setNodeBirthMetrics(java.util.List metrics) { - this.nodeBirthMetrics.putAll(metrics.stream() - .collect(Collectors.toMap(SparkplugBProto.Payload.Metric::getName, metric -> metric))); + public void setNodeBirthMetrics(java.util.List metrics) throws AdaptorException { + for (var metric : metrics) { + if (metric.hasName()) { + this.nodeBirthMetrics.put(metric.getName(), metric); + } else { + throw new AdaptorException("The metric name of edgeNode: '" + this.sparkplugTopicNode.getEdgeNodeId() + "' must not be empty or null! Metric: [" + metric + "]"); + } + if (metric.hasAlias() && this.nodeAlias.putIfAbsent(metric.getAlias(), metric.getName()) != null) { + throw new AdaptorException("The alias '" + metric.getAlias() + "' already exists in edgeNode: '" + this.sparkplugTopicNode.getEdgeNodeId() + "'"); + } + } } - public TransportProtos.PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - DeviceSessionCtx deviceSessionCtx = (DeviceSessionCtx) ctx; - byte[] bytes = getBytes(inbound.payload()); - Descriptors.Descriptor telemetryDynamicMsgDescriptor = ProtoConverter.validateDescriptor(deviceSessionCtx.getTelemetryDynamicMsgDescriptor()); - try { - return JsonConverter.convertToTelemetryProto(JsonParser.parseString(ProtoConverter.dynamicMsgToJson(bytes, telemetryDynamicMsgDescriptor))); - } catch (Exception e) { - log.debug("Failed to decode post telemetry request", e); - throw new AdaptorException(e); - } + + public boolean onValidateNDEATH(SparkplugBProto.Payload sparkplugBProto) throws ThingsboardException { + return sparkplugBProto.getMetricsCount() == 1 && SPARKPLUG_BD_SEQUENCE_NUMBER_KEY.equals(sparkplugBProto.getMetrics(0).getName()) + && this.nodeBirthMetrics.get(SPARKPLUG_BD_SEQUENCE_NUMBER_KEY) != null + && sparkplugBProto.getMetrics(0).getLongValue() == this.nodeBirthMetrics.get(SPARKPLUG_BD_SEQUENCE_NUMBER_KEY).getLongValue(); } public void onAttributesTelemetryProto(int msgId, SparkplugBProto.Payload sparkplugBProto, SparkplugTopic topic) throws AdaptorException, ThingsboardException { @@ -115,7 +123,11 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler grantedQoSList, MqttTopicSubscription subscription, - MqttQoS reqQoS) throws ThingsboardException { - SparkplugTopic sparkplugTopic = parseTopicSubscribe(subscription.topicFilter()); - if (sparkplugTopic.getGroupId() == null) { - // TODO SUBSCRIBE NameSpace - } else if (sparkplugTopic.getType() == null) { - // TODO SUBSCRIBE GroupId - } else if (sparkplugTopic.isNode()) { - // SUBSCRIBE Node - parent.processAttributesRpcSubscribeSparkplugNode(grantedQoSList, reqQoS); + /** + * Subscribe: spBv1.0/STATE/my_primary_hos -> Implemented as status via checkSparkplugNodeSession + * Subscribe: CMD/DATA -> Implemented after connection: SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG/SUBSCRIBE_TO_RPC_ASYNC_MSG + * @param subscription + * @throws ThingsboardException + */ + public void handleSparkplugSubscribeMsg(MqttTopicSubscription subscription) throws ThingsboardException { + String topic = subscription.topicFilter(); + if (topic != null && topic.startsWith(TOPIC_STATE_REGEXP)) { + log.trace("Subscribing on it’s own spBv1.0/STATE/[the Sparkplug Host Application] - Implemented as status via checkSparkplugNodeSession"); + } else if (this.validateTopicDataSubscribe(topic)) { + // TODO if need subscription DATA + log.trace("Subscribing on it’s own [" + topic + "] - Implemented as SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG/SUBSCRIBE_TO_RPC_ASYNC_MSG via checkSparkplugNode/DeviceSession"); } else { - // SUBSCRIBE Device - DO NOTHING, WE HAVE ALREADY SUBSCRIBED. - // TODO: track that node subscribed to # or to particular device. + log.trace("Failed to subscribe to the topic: [" + topic + "]."); } } @@ -192,7 +206,8 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler onDeviceConnectProto(SparkplugTopic topic) throws ThingsboardException { + private ListenableFuture onDeviceConnectProto(SparkplugTopic topic) throws + ThingsboardException { try { String deviceType = this.gateway.getDeviceType() + " device"; return onDeviceConnect(topic.getNodeDeviceName(), deviceType); @@ -202,22 +217,45 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler convertToPostTelemetry(SparkplugBProto.Payload sparkplugBProto, Set attributesMetricNames, String topicTypeName) throws AdaptorException { + /** + * Sparkplug 3.0.0 -> 6.4.6. Metric + * https://sparkplug.eclipse.org/specification/version/3.0/documents/sparkplug-specification-3.0.0.pdf#%5B%7B%22num%22%3A339%2C%22gen%22%3A0%7D%2C%7B%22name%22%3A%22XYZ%22%7D%2C0%2C455.52%2Cnull%5D + * [tck-id-payloads-name-requirement] The name MUST be included with every metric unless aliases are being used. ◦ All UTF-8 characters are allowed in the metric name. However, special characters including but not limited to the following are discouraged: . , \ @ # $ % ^ & * ( ) [ ] { } | ! ` ~ : ; ' " < > ?. This is because many Sparkplug Host Applications may have issues handling them. + * • alias (are optional and not required): + * - This is an unsigned 64-bit integer representing an optional alias for a Sparkplug B payload. + * - If aliases are used, the following rules apply: + * -- [tck-id-payloads-alias-uniqueness] If supplied in an NBIRTH or BIRTH it MUST be a unique number across this Edge Node’s entire set of metrics. + * -- no two metrics for the same Edge Node can have the same alias. + * -- [tck-id-payloads-alias-birth-requirement] NBIRTH and DBIRTH messages MUST include both a metric name and alias. + * -- [tck-id-payloads-alias-data-cmd-requirement] NDATA, DDATA, NCMD, and DCMD messages MUST only include an alias and the metric name MUST be excluded. + * @param sparkplugBProto + * @param attributesMetricNames + * @param topicTypeName + * @return + * @throws AdaptorException + */ + private List convertToPostTelemetry(SparkplugBProto.Payload + sparkplugBProto, Set attributesMetricNames, String topicTypeName) throws AdaptorException { try { List msgs = new ArrayList<>(); for (SparkplugBProto.Payload.Metric protoMetric : sparkplugBProto.getMetricsList()) { - if (attributesMetricNames == null || !matches(attributesMetricNames, protoMetric)) { - long ts = protoMetric.getTimestamp(); - String key = "bdSeq".equals(protoMetric.getName()) ? - topicTypeName + " " + protoMetric.getName() : protoMetric.getName(); - Optional keyValueProtoOpt = fromSparkplugBMetricToKeyValueProto(key, protoMetric); - keyValueProtoOpt.ifPresent(kvProto -> msgs.add(postTelemetryMsgCreated(kvProto, ts))); + String metricName = protoMetric.hasName() ? protoMetric.getName() : protoMetric.hasAlias() ? this.nodeAlias.get(protoMetric.getAlias()) : null; + if (metricName == null) { + throw new ThingsboardException("Metric without metricName and alias.", ThingsboardErrorCode.INVALID_ARGUMENTS); + } else { + if (attributesMetricNames == null || !matches(attributesMetricNames, metricName)) { + long ts = protoMetric.getTimestamp(); + String key = SPARKPLUG_BD_SEQUENCE_NUMBER_KEY.equals(protoMetric.getName()) ? + topicTypeName + " " + protoMetric.getName() : protoMetric.getName(); + Optional keyValueProtoOpt = fromSparkplugBMetricToKeyValueProto(key, protoMetric); + keyValueProtoOpt.ifPresent(kvProto -> msgs.add(postTelemetryMsgCreated(kvProto, ts))); + } } } if (DBIRTH.name().equals(topicTypeName)) { TransportProtos.KeyValueProto.Builder keyValueProtoBuilder = TransportProtos.KeyValueProto.newBuilder(); - keyValueProtoBuilder.setKey(topicTypeName + " " + "seq"); + keyValueProtoBuilder.setKey(topicTypeName + " " + SPARKPLUG_SEQUENCE_NUMBER_KEY); keyValueProtoBuilder.setType(TransportProtos.KeyValueType.LONG_V); keyValueProtoBuilder.setLongV(sparkplugBProto.getSeq()); msgs.add(postTelemetryMsgCreated(keyValueProtoBuilder.build(), sparkplugBProto.getTimestamp())); @@ -235,13 +273,18 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler msgs = new ArrayList<>(); for (SparkplugBProto.Payload.Metric protoMetric : sparkplugBProto.getMetricsList()) { - if (matches(attributesMetricNames, protoMetric)) { - TransportApiProtos.AttributesMsg.Builder deviceAttributesMsgBuilder = TransportApiProtos.AttributesMsg.newBuilder(); - Optional msgOpt = getPostAttributeMsg(protoMetric); - if (msgOpt.isPresent()) { - deviceAttributesMsgBuilder.setDeviceName(deviceName); - deviceAttributesMsgBuilder.setMsg(msgOpt.get()); - msgs.add(deviceAttributesMsgBuilder.build()); + String metricName = protoMetric.hasName() ? protoMetric.getName() : protoMetric.hasAlias() ? this.nodeAlias.get(protoMetric.getAlias()) : null; + if (metricName == null) { + throw new ThingsboardException("Metric without metricName and alias.", ThingsboardErrorCode.INVALID_ARGUMENTS); + } else { + if (matches(attributesMetricNames, metricName)) { + TransportApiProtos.AttributesMsg.Builder deviceAttributesMsgBuilder = TransportApiProtos.AttributesMsg.newBuilder(); + Optional msgOpt = getPostAttributeMsg(protoMetric); + if (msgOpt.isPresent()) { + deviceAttributesMsgBuilder.setDeviceName(deviceName); + deviceAttributesMsgBuilder.setMsg(msgOpt.get()); + msgs.add(deviceAttributesMsgBuilder.build()); + } } } } @@ -252,8 +295,7 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler attributesMetricNames, SparkplugBProto.Payload.Metric protoMetric) { - String metricName = protoMetric.getName(); + private boolean matches(Set attributesMetricNames, String metricName) { for (String attributeMetricFilter : attributesMetricNames) { if (metricName.equals(attributeMetricFilter) || (attributeMetricFilter.endsWith("*") && metricName.startsWith( @@ -264,7 +306,8 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler getPostAttributeMsg(SparkplugBProto.Payload.Metric protoMetric) throws ThingsboardException { + private Optional getPostAttributeMsg(SparkplugBProto.Payload.Metric + protoMetric) throws ThingsboardException { Optional keyValueProtoOpt = fromSparkplugBMetricToKeyValueProto(protoMetric.getName(), protoMetric); if (keyValueProtoOpt.isPresent()) { TransportProtos.PostAttributeMsg.Builder builder = TransportProtos.PostAttributeMsg.newBuilder(); @@ -285,7 +328,9 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler= 4 && splitTopic.length <= 5 && + splitTopic[0].equals(this.sparkplugTopicNode.getNamespace()) && + splitTopic[1].equals(this.sparkplugTopicNode.getGroupId()) && + splitTopic[3].equals(this.sparkplugTopicNode.getEdgeNodeId())) { + SparkplugMessageType messageType = parseMessageType(splitTopic[2]); + return messageType.isData(); + } + return false; } - } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/DeviceDescriptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/DeviceDescriptor.java new file mode 100644 index 0000000000..cf684884f3 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/DeviceDescriptor.java @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.mqtt.util.sparkplug; + +public class DeviceDescriptor extends EdgeNodeDescriptor { + + private final String deviceId; + private final String descriptorString; + + public DeviceDescriptor(String groupId, String edgeNodeId, String deviceId) { + super(groupId, edgeNodeId); + this.deviceId = deviceId; + this.descriptorString = groupId + "/" + edgeNodeId + "/" + deviceId; + } + + public DeviceDescriptor(String descriptorString) { + super(descriptorString.substring(0, descriptorString.lastIndexOf("/"))); + this.deviceId = descriptorString.substring(descriptorString.lastIndexOf("/") + 1); + this.descriptorString = descriptorString; + } + + public DeviceDescriptor(EdgeNodeDescriptor edgeNodeDescriptor, String deviceId) { + super(edgeNodeDescriptor.getGroupId(), edgeNodeDescriptor.getEdgeNodeId()); + this.deviceId = deviceId; + this.descriptorString = edgeNodeDescriptor.getDescriptorString() + "/" + deviceId; + } + + public String getDeviceId() { + return deviceId; + } + + /** + * Returns a {@link String} representing the Device's Descriptor of the form: + * "//". + * + * @return a {@link String} representing the Device's Descriptor. + */ + @Override + public String getDescriptorString() { + return descriptorString; + } + + public String getEdgeNodeDescriptorString() { + return super.getDescriptorString(); + } + + @Override + public int hashCode() { + return this.getDescriptorString().hashCode(); + } + + @Override + public boolean equals(Object object) { + if (object instanceof DeviceDescriptor) { + return this.getDescriptorString().equals(((DeviceDescriptor) object).getDescriptorString()); + } + return this.getDescriptorString().equals(object); + } + + @Override + public String toString() { + return getDescriptorString(); + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/EdgeNodeDescriptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/EdgeNodeDescriptor.java new file mode 100644 index 0000000000..c8ea2c457e --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/EdgeNodeDescriptor.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.mqtt.util.sparkplug; + +import com.fasterxml.jackson.annotation.JsonValue; + +public class EdgeNodeDescriptor implements SparkplugDescriptor{ + + private final String groupId; + private final String edgeNodeId; + private final String descriptorString; + + public EdgeNodeDescriptor(String groupId, String edgeNodeId) { + this.groupId = groupId; + this.edgeNodeId = edgeNodeId; + this.descriptorString = groupId + "/" + edgeNodeId; + } + + /** + * Creates and EdgeNodeDescriptor from a {@link String} of the form group_name/edge_node_name + * + * @param descriptorString the {@link String} representation of an EdgeNodeDescriptor + */ + public EdgeNodeDescriptor(String descriptorString) { + String[] tokens = descriptorString.split("/"); + this.groupId = tokens[0]; + this.edgeNodeId = tokens[1]; + this.descriptorString = descriptorString; + } + + public String getGroupId() { + return groupId; + } + + public String getEdgeNodeId() { + return edgeNodeId; + } + + /** + * Returns a {@link String} representing the Edge Node's Descriptor of the form: "/". + * + * @return a {@link String} representing the Edge Node's Descriptor. + */ + @Override + public String getDescriptorString() { + return descriptorString; + } + + @Override + public int hashCode() { + return this.getDescriptorString().hashCode(); + } + + @Override + public boolean equals(Object object) { + if (object instanceof EdgeNodeDescriptor) { + return this.getDescriptorString().equals(((EdgeNodeDescriptor) object).getDescriptorString()); + } + return this.getDescriptorString().equals(object); + } + + @Override + @JsonValue + public String toString() { + return getDescriptorString(); + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java index c500d5ddd6..fd0bfb98cf 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java @@ -52,7 +52,24 @@ public enum MetricDataType { File(18, SparkplugMetricUtil.File.class), Template(19, SparkplugBProto.Payload.Template.class), - // PropertyValue Types (20 and 21) are NOT metric datatypes + // Additional PropertyValue Types (PropertyValue Types (20 and 21) are NOT metric datatypes) + PropertySet(20, SparkplugBProto.Payload.PropertySet.class), + PropertySetList(21, SparkplugBProto.Payload.PropertySetList.class), + + // Array Types + Int8Array(22, Byte[].class), + Int16Array(23, Short[].class), + Int32Array(24, Integer[].class), + Int64Array(25, Long[].class), + UInt8Array(26, Short[].class), + UInt16Array(27, Integer[].class), + UInt32Array(28, Long[].class), + UInt64Array(29, BigInteger[].class), + FloatArray(30, Float[].class), + DoubleArray(31, Double[].class), + BooleanArray(32, Boolean[].class), + StringArray(33, String[].class), + DateTimeArray(34, Date[].class), // Unknown Unknown(0, Object.class); @@ -140,6 +157,36 @@ public enum MetricDataType { return File; case 19: return Template; + case 20: + return PropertySet; + case 21: + return PropertySetList; + case 22: + return Int8Array; + case 23: + return Int16Array; + case 24: + return Int32Array; + case 25: + return Int64Array; + case 26: + return UInt8Array; + case 27: + return UInt16Array; + case 28: + return UInt32Array; + case 29: + return UInt64Array; + case 30: + return FloatArray; + case 31: + return DoubleArray; + case 32: + return BooleanArray; + case 33: + return StringArray; + case 34: + return DateTimeArray; default: return Unknown; } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugDescriptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugDescriptor.java new file mode 100644 index 0000000000..9deff97bf4 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugDescriptor.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.mqtt.util.sparkplug; + +public interface SparkplugDescriptor { + + /** + * Returns the String representation of this {@link SparkplugDescriptor} + * + * @return the String representation of this {@link SparkplugDescriptor} + */ + public String getDescriptorString(); +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java index deb04034f2..f5b0929a46 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java @@ -91,10 +91,14 @@ public enum SparkplugMessageType { return STATE.equals(type) ? "sparkplugConnectionState" : type.name(); } + public boolean isState() { + return this.equals(STATE); + } + public boolean isDeath() { return this.equals(DDEATH) || this.equals(NDEATH); } - + public boolean isCommand() { return this.equals(DCMD) || this.equals(NCMD); } @@ -110,4 +114,19 @@ public enum SparkplugMessageType { public boolean isRecord() { return this.equals(DRECORD) || this.equals(NRECORD); } + public boolean isSubscribe() { + return isCommand() || isData() || isRecord(); + } + + public boolean isNode() { + return this.equals(NBIRTH) + || this.equals(NCMD) || this.equals(NDATA) + ||this.equals(NDEATH) || this.equals(NRECORD); + } + public boolean isDevice() { + return this.equals(DBIRTH) + || this.equals(DCMD) || this.equals(DDATA) + ||this.equals(DDEATH) || this.equals(DRECORD); + } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java index a6a071a740..f005235d53 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java @@ -29,6 +29,8 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto; +import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto.Payload.Metric; +import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto.Payload.Metric.Builder; import java.math.BigDecimal; import java.nio.ByteBuffer; @@ -45,6 +47,9 @@ import static org.thingsboard.common.util.JacksonUtil.newArrayNode; @Slf4j public class SparkplugMetricUtil { + public static final String SPARKPLUG_SEQUENCE_NUMBER_KEY = "seq"; + public static final String SPARKPLUG_BD_SEQUENCE_NUMBER_KEY = "bdSeq"; + public static Optional fromSparkplugBMetricToKeyValueProto(String key, SparkplugBProto.Payload.Metric protoMetric) throws ThingsboardException { // Check if the null flag has been set indicating that the value is null if (protoMetric.getIsNull()) { @@ -141,13 +146,20 @@ public class SparkplugMetricUtil { return Optional.empty(); } } + public static SparkplugBProto.Payload.Metric createMetric(Object value, long ts, String key, MetricDataType metricDataType, Long alias) throws ThingsboardException { + Builder metric = Metric.newBuilder(); + metric.setTimestamp(ts) + .setDatatype(metricDataType.toIntValue()); + if (alias >= 0) { + metric.setAlias(alias); + } + if (StringUtils.isNotBlank(key)) { + metric.setName(key); + } + return addToMetricValue(value, metric.build(), metricDataType); + } - public static SparkplugBProto.Payload.Metric createMetric(Object value, long ts, String key, MetricDataType metricDataType) throws ThingsboardException { - SparkplugBProto.Payload.Metric metric = SparkplugBProto.Payload.Metric.newBuilder() - .setTimestamp(ts) - .setName(key) - .setDatatype(metricDataType.toIntValue()) - .build(); + public static SparkplugBProto.Payload.Metric addToMetricValue(Object value, SparkplugBProto.Payload.Metric metric, MetricDataType metricDataType) throws ThingsboardException { switch (metricDataType) { case Int8: // (byte) return metric.toBuilder().setIntValue(((Byte) value).intValue()).build(); @@ -189,6 +201,12 @@ public class SparkplugMetricUtil { return metric; } + public static TransportProtos.TsKvProto getTsKvProtoFromJsonNode(JsonNode kvProto, long ts) throws ThingsboardException { + String kvProtoKey = kvProto.fieldNames().next(); + String kvProtoValue = kvProto.get(kvProtoKey).asText(); + return getTsKvProto(kvProtoKey, kvProtoValue, ts); + } + public static TransportProtos.TsKvProto getTsKvProto(String key, Object value, long ts) throws ThingsboardException { try { TransportProtos.TsKvProto.Builder tsKvProtoBuilder = TransportProtos.TsKvProto.newBuilder(); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java index 278ea52ccb..6901cf8078 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java @@ -15,65 +15,129 @@ */ package org.thingsboard.server.transport.mqtt.util.sparkplug; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; + +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.parseMessageType; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_REGEXP; /** * Created by nickAS21 on 12.12.22 + * A Sparkplug MQTT Topic */ @JsonInclude(JsonInclude.Include.NON_NULL) public class SparkplugTopic { /** * The Sparkplug namespace version. - * For the Sparkplug™ B version of the specification, the UTF-8 string constant for the namespace element will be: “spBv1.0” */ - private String namespace; + private final String namespace; + + /** + * The SparkplugDesciptor for this Edge Node or Device + */ + @JsonIgnore + private final SparkplugDescriptor sparkplugDescriptor; + + /** + * The {@link EdgeNodeDescriptor} for this Edge Node or Device + */ + private final EdgeNodeDescriptor edgeNodeDescriptor; /** * The ID of the logical grouping of Edge of Network (EoN) Nodes and devices. */ - private String groupId; + private final String groupId; /** * The ID of the Edge of Network (EoN) Node. */ - private String edgeNodeId; + private final String edgeNodeId; /** * The ID of the device. */ private String deviceId; + /** + * The ID if this is a Sparkplug Host Application topic + */ + private final String hostApplicationId; + /** * The message type. */ - private SparkplugMessageType type; + private final SparkplugMessageType type; + + public SparkplugTopic() { + this.namespace = null; + this.sparkplugDescriptor = null; + this.edgeNodeDescriptor = null; + this.groupId = null; + this.edgeNodeId = null; + this.deviceId = null; + this.hostApplicationId = null; + this.type = null; + } + + public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type) { + super(); + this.namespace = sparkplugTopic.namespace; + this.groupId = sparkplugTopic.groupId; + this.edgeNodeId = sparkplugTopic.edgeNodeId; + this.sparkplugDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.deviceId = null; + this.type = type; + this.hostApplicationId = null; + } - /** - * Constructor (device). + public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type, String deviceId) { + super(); + this.namespace = sparkplugTopic.namespace; + this.groupId = sparkplugTopic.groupId; + this.edgeNodeId = sparkplugTopic.edgeNodeId; + this.deviceId = deviceId; + this.sparkplugDescriptor = deviceId == null + ? new EdgeNodeDescriptor(groupId, edgeNodeId) + : new DeviceDescriptor(groupId, edgeNodeId, deviceId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.type = type; + this.hostApplicationId = null; + } + + /** + * A Constructor for Device Topics * - * @param namespace the namespace. - * @param groupId the group ID. - * @param edgeNodeId the edge node ID. - * @param deviceId the device ID. - * @param type the message type. + * @param namespace the namespace + * @param groupId the Group ID + * @param edgeNodeId the Edge Node ID + * @param deviceId the Device ID + * @param type the message type */ public SparkplugTopic(String namespace, String groupId, String edgeNodeId, String deviceId, SparkplugMessageType type) { super(); this.namespace = namespace; + this.sparkplugDescriptor = deviceId == null + ? new EdgeNodeDescriptor(groupId, edgeNodeId) + : new DeviceDescriptor(groupId, edgeNodeId, deviceId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); this.groupId = groupId; this.edgeNodeId = edgeNodeId; this.deviceId = deviceId; + this.hostApplicationId = null; this.type = type; } /** - * Constructor (node). - * - * @param namespace the namespace. - * @param groupId the group ID. - * @param edgeNodeId the edge node ID. - * @param type the message type. + * A Constructor for Edge Node Topics + * @param namespace the namespace + * @param groupId the group ID + * @param edgeNodeId the edge node ID + * @param type the message type */ public SparkplugTopic(String namespace, String groupId, String edgeNodeId, SparkplugMessageType type) { super(); @@ -81,33 +145,110 @@ public class SparkplugTopic { this.groupId = groupId; this.edgeNodeId = edgeNodeId; this.deviceId = null; + this.sparkplugDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.hostApplicationId = null; this.type = type; } - public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type) { - super(); - this.namespace = sparkplugTopic.namespace; - this.groupId = sparkplugTopic.groupId; - this.edgeNodeId = sparkplugTopic.edgeNodeId; - this.deviceId = null; - this.type = type; + /** + * A Constructor for Device Topics + * + * @param namespace the namespace + * @param deviceDescriptor the {@link EdgeNodeDescriptor} + * @param type the message type + */ + public SparkplugTopic(String namespace, DeviceDescriptor deviceDescriptor, SparkplugMessageType type) { + this(namespace, deviceDescriptor.getGroupId(), deviceDescriptor.getEdgeNodeId(), deviceDescriptor.getDeviceId(), + type); + } + + /** + * A Constructor for Edge Node Topics + * + * @param namespace the namespace + * @param edgeNodeDescriptor the {@link EdgeNodeDescriptor} + * @param type the message type + */ + public SparkplugTopic(String namespace, EdgeNodeDescriptor edgeNodeDescriptor, SparkplugMessageType type) { + this(namespace, edgeNodeDescriptor.getGroupId(), edgeNodeDescriptor.getEdgeNodeId(), type); } - public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type, String deviceId) { + + /** + * A Constructor for Host Application Topics + * + * @param namespace the namespace + * @param hostApplicationId the Host Application ID + */ + public SparkplugTopic(String namespace, String hostApplicationId, SparkplugMessageType type) { super(); - this.namespace = sparkplugTopic.namespace; - this.groupId = sparkplugTopic.groupId; - this.edgeNodeId = sparkplugTopic.edgeNodeId; - this.deviceId = deviceId; + this.namespace = namespace; + this.hostApplicationId = hostApplicationId; this.type = type; + this.sparkplugDescriptor = null; + this.edgeNodeDescriptor = null; + this.groupId = null; + this.edgeNodeId = null; + this.deviceId = null; + } + + public static SparkplugTopic parseTopic(String topicString) throws ThingsboardException { + try { + if (isValidIdElementToUTF8(topicString)) { + SparkplugMessageType messageType; + String[] splitTopic = topicString.split(TOPIC_SPLIT_REGEXP); + if (TOPIC_ROOT_SPB_V_1_0.equals(splitTopic[0])) { + if (splitTopic.length == 3) { + messageType = parseMessageType(splitTopic[1]); + if (messageType.isState()) + return new SparkplugTopic(TOPIC_ROOT_SPB_V_1_0, splitTopic[2], messageType); + } else if (splitTopic.length == 4) { + messageType = parseMessageType(splitTopic[2]); + if (messageType.isNode()) + return new SparkplugTopic(TOPIC_ROOT_SPB_V_1_0, splitTopic[1], splitTopic[3], messageType); + } else if (splitTopic.length == 5) { + messageType = parseMessageType(splitTopic[2]); + if (messageType.isDevice()) + return new SparkplugTopic(TOPIC_ROOT_SPB_V_1_0, splitTopic[1], splitTopic[3], splitTopic[4], messageType); + + } + } + } + throw new ThingsboardException("Invalid Sparkplug topic from String: " + topicString, ThingsboardErrorCode.INVALID_ARGUMENTS); + } catch ( + Exception e) { + throw new ThingsboardException(e, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } /** - * @return the Sparkplug namespace version + * Returns the Sparkplug namespace version. + * + * @return the namespace */ public String getNamespace() { return namespace; } + /** + * Returns the {@link SparkplugDescriptor} + * + * @return the SparkplugDescriptor + */ + public SparkplugDescriptor getSparkplugDescriptor() { + return sparkplugDescriptor; + } + + /** + * Returns the {@link EdgeNodeDescriptor} + * + * @return the EdgeNodeDescriptor + */ + public EdgeNodeDescriptor getEdgeNodeDescriptor() { + return edgeNodeDescriptor; + } + /** * Returns the ID of the logical grouping of Edge of Network (EoN) Nodes and devices. * @@ -118,20 +259,39 @@ public class SparkplugTopic { } /** - * @return the ID of the Edge of Network (EoN) Node + * Returns the ID of the Edge of Network (EoN) Node. + * + * @return the edge node ID */ public String getEdgeNodeId() { return edgeNodeId; } /** + * Returns the ID of the device. + * * @return the device ID */ public String getDeviceId() { return deviceId; } + public void updateDeviceIdPlus(String deviceIdNew) { + this.deviceId = this.deviceId.equals("+") ? deviceIdNew : this.deviceId; + } + + /** + * Returns the Host Application ID if this is a Host topic + * + * @return the Host Application ID + */ + public String getHostApplicationId() { + return hostApplicationId; + } + /** + * Returns the message type. + * * @return the message type */ public SparkplugMessageType getType() { @@ -140,12 +300,15 @@ public class SparkplugTopic { @Override public String toString() { - StringBuilder sb = new StringBuilder(getNamespace()).append("/") - .append(getGroupId()).append("/") - .append(getType()).append("/") - .append(getEdgeNodeId()); - if (getDeviceId() != null) { - sb.append("/").append(getDeviceId()); + StringBuilder sb = new StringBuilder(); + if (hostApplicationId == null) { + sb.append(getNamespace()).append("/").append(getGroupId()).append("/").append(getType()).append("/") + .append(getEdgeNodeId()); + if (getDeviceId() != null) { + sb.append("/").append(getDeviceId()); + } + } else { + sb.append(getNamespace()).append("/").append(getType()).append("/").append(hostApplicationId); } return sb.toString(); } @@ -165,5 +328,13 @@ public class SparkplugTopic { public String getNodeDeviceName() { return isNode() ? edgeNodeId : deviceId; } + + public static boolean isValidIdElementToUTF8(String deviceIdElement) { + if (deviceIdElement == null) { + return false; + } + String regex = "^(?!.*//)[^+#]*$"; + return deviceIdElement.matches(regex); + } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java new file mode 100644 index 0000000000..a6e2e34553 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.mqtt.util.sparkplug; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.transport.mqtt.TbMqttTransportComponent; + +import java.util.HashMap; +import java.util.Map; + +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic.parseTopic; + +@Slf4j +@Service +@TbMqttTransportComponent +public class SparkplugTopicService { + + private static final Map SPLIT_TOPIC_CACHE = new HashMap<>(); + public static final String TOPIC_ROOT_SPB_V_1_0 = "spBv1.0"; + public static final String TOPIC_ROOT_CERT_SP = "$sparkplug/certificates/"; + public static final String TOPIC_SPLIT_REGEXP = "/"; + public static final String TOPIC_STATE_REGEXP = TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_REGEXP + STATE.name() + TOPIC_SPLIT_REGEXP; + + public static SparkplugTopic getSplitTopic(String topic) throws ThingsboardException { + SparkplugTopic sparkplugTopic = SPLIT_TOPIC_CACHE.get(topic); + if (sparkplugTopic == null) { + // validation topic + sparkplugTopic = parseTopic(topic); + SPLIT_TOPIC_CACHE.put(topic, sparkplugTopic); + } + return sparkplugTopic; + } + + /** + * all ID Element MUST be a UTF-8 string + * and with the exception of the reserved characters of + (plus), / (forward slash). + * Publish: $sparkplug/certificates/spBv1.0/G1/NBIRTH/E1 + * Publish: spBv1.0/G1/NBIRTH/E1 + * Publish: $sparkplug/certificates/spBv1.0/G1/DBIRTH/E1/D1 + * Publish: spBv1.0/G1/DBIRTH/E1/D1 + * @param topic + * @return + * @throws ThingsboardException + */ + public static SparkplugTopic parseTopicPublish(String topic) throws ThingsboardException { + topic = topic.startsWith(TOPIC_ROOT_CERT_SP) ? topic.substring(TOPIC_ROOT_CERT_SP.length()) : topic; + topic = topic.indexOf("+") > 0 ? topic.substring(0, topic.indexOf("+")): topic; + return getSplitTopic(topic); + } +} + diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicUtil.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicUtil.java deleted file mode 100644 index aaaedcda60..0000000000 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicUtil.java +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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.mqtt.util.sparkplug; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; -import org.thingsboard.server.common.data.exception.ThingsboardException; - -import java.util.HashMap; -import java.util.Map; - -/** - * Provides utility methods for handling Sparkplug MQTT message topics. - */ -public class SparkplugTopicUtil { - - private static final Map SPLIT_TOPIC_CACHE = new HashMap(); - private static final String TOPIC_INVALID_NUMBER = "Invalid number of topic elements: "; - public static final String NAMESPACE = "spBv1.0"; - - public static String[] getSplitTopic(String topic) { - String[] splitTopic = SPLIT_TOPIC_CACHE.get(topic); - if (splitTopic == null) { - splitTopic = topic.split("/"); - SPLIT_TOPIC_CACHE.put(topic, splitTopic); - } - - return splitTopic; - } - - /** - * Serializes a {@link SparkplugTopic} instance in to a JSON string. - * - * @param topic a {@link SparkplugTopic} instance - * @return a JSON string - * @throws JsonProcessingException - */ - public static String sparkplugTopicToString(SparkplugTopic topic) throws JsonProcessingException { - ObjectMapper mapper = new ObjectMapper(); - return mapper.writeValueAsString(topic); - } - - /** - * Parses a Sparkplug MQTT message topic string and returns a {@link SparkplugTopic} instance. - * - * @param topic a topic string - * @return a {@link SparkplugTopic} instance - * @throws ThingsboardException if an error occurs while parsing - */ - public static SparkplugTopic parseTopicSubscribe(String topic) throws ThingsboardException { - // TODO "+", "$" - topic = topic.indexOf("#") > 0 ? topic.substring(0, topic.indexOf("#")) : topic; - return parseTopic(SparkplugTopicUtil.getSplitTopic(topic)); - } - - public static SparkplugTopic parseTopicPublish(String topic) throws ThingsboardException { - if (topic.contains("#") || topic.contains("$") || topic.contains("+")) { - throw new ThingsboardException("Invalid of topic elements for Publish", ThingsboardErrorCode.INVALID_ARGUMENTS); - } else { - String[] splitTopic = SparkplugTopicUtil.getSplitTopic(topic); - if (splitTopic.length < 4 || splitTopic.length > 5) { - throw new ThingsboardException(TOPIC_INVALID_NUMBER + splitTopic.length, ThingsboardErrorCode.INVALID_ARGUMENTS); - } - return parseTopic(splitTopic); - } - } - - /** - * Parses a Sparkplug MQTT message topic string and returns a {@link SparkplugTopic} instance. - * - * @param splitTopic a topic split into tokens - * @return a {@link SparkplugTopic} instance - * @throws Exception if an error occurs while parsing - */ - @SuppressWarnings("incomplete-switch") - public static SparkplugTopic parseTopic(String[] splitTopic) throws ThingsboardException { - int length = splitTopic.length; - if (length == 0) { - throw new ThingsboardException(TOPIC_INVALID_NUMBER + length, ThingsboardErrorCode.INVALID_ARGUMENTS); - } else { - SparkplugMessageType type; - String namespace, edgeNodeId, groupId, deviceId; - namespace = validateNameSpace(splitTopic[0]); - groupId = length > 1 ? splitTopic[1] : null; - type = length > 2 ? SparkplugMessageType.parseMessageType(splitTopic[2]) : null; - edgeNodeId = length > 3 ? splitTopic[3] : null; - deviceId = length > 4 ? splitTopic[4] : null; - return new SparkplugTopic(namespace, groupId, edgeNodeId, deviceId, type); - } - } - - /** - * For the Sparkplug™ B version of the specification, the UTF-8 string constant for the namespace element will be: "spBv1.0" - * @param nameSpace - * @return - */ - private static String validateNameSpace(String nameSpace) throws ThingsboardException { - if (NAMESPACE.equals(nameSpace)) return nameSpace; - throw new ThingsboardException("The namespace [" + nameSpace + "] is not valid and must be [" + NAMESPACE + "] for the Sparkplug™ B version.", ThingsboardErrorCode.INVALID_ARGUMENTS); - } - -} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SpecVersion.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SpecVersion.java new file mode 100644 index 0000000000..07f5cbf53f --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SpecVersion.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.mqtt.util.sparkplug; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.TYPE) +public @interface SpecVersion { + String spec() default ""; + String version(); +} \ No newline at end of file diff --git a/common/transport/mqtt/src/main/proto/sparkplug.proto b/common/transport/mqtt/src/main/proto/sparkplug.proto index 8b5bc6fd22..c28a4e6410 100644 --- a/common/transport/mqtt/src/main/proto/sparkplug.proto +++ b/common/transport/mqtt/src/main/proto/sparkplug.proto @@ -21,51 +21,71 @@ import "google/protobuf/any.proto"; option java_package = "org.thingsboard.server.gen.transport.mqtt"; option java_outer_classname = "SparkplugBProto"; + +enum DataType { + // Indexes of Data Types + + // Unknown placeholder for future expansion. + Unknown = 0; + + // Basic Types + Int8 = 1; + Int16 = 2; + Int32 = 3; + Int64 = 4; + UInt8 = 5; + UInt16 = 6; + UInt32 = 7; + UInt64 = 8; + Float = 9; + Double = 10; + Boolean = 11; + String = 12; + DateTime = 13; + Text = 14; + + // Additional Metric Types + UUID = 15; + DataSet = 16; + Bytes = 17; + File = 18; + Template = 19; + + // Additional PropertyValue Types + PropertySet = 20; + PropertySetList = 21; + + // Array Types + Int8Array = 22; + Int16Array = 23; + Int32Array = 24; + Int64Array = 25; + UInt8Array = 26; + UInt16Array = 27; + UInt32Array = 28; + UInt64Array = 29; + FloatArray = 30; + DoubleArray = 31; + BooleanArray = 32; + StringArray = 33; + DateTimeArray = 34; +} + message Payload { - /* - // Indexes of Data Types - // Unknown placeholder for future expansion. - Unknown = 0; - // Basic Types - Int8 = 1; - Int16 = 2; - Int32 = 3; - Int64 = 4; - UInt8 = 5; - UInt16 = 6; - UInt32 = 7; - UInt64 = 8; - Float = 9; - Double = 10; - Boolean = 11; - String = 12; - DateTime = 13; - Text = 14; - // Additional Metric Types - UUID = 15; - DataSet = 16; - Bytes = 17; - File = 18; - Template = 19; - - // Additional PropertyValue Types - PropertySet = 20; - PropertySetList = 21; - */ message Template { message Parameter { - optional string name = 1; - optional uint32 type = 2; + optional string name = 1; + optional uint32 type = 2; oneof value { - uint32 int_value = 3; - uint64 long_value = 4; - float float_value = 5; - double double_value = 6; - bool boolean_value = 7; - string string_value = 8; + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; ParameterValueExtension extension_value = 9; } @@ -74,12 +94,12 @@ message Payload { } } - optional string version = 1; // The version of the Template to prevent mismatches - repeated Metric metrics = 2; // Each metric is the name of the metric and the datatype of the member but does not contain a value - repeated Parameter parameters = 3; - optional string template_ref = 4; // Reference to a template if this is extending a Template or an instance - must exist if an instance - optional bool is_definition = 5; - google.protobuf.Any extensions = 6; + optional string version = 1; // The version of the Template to prevent mismatches + repeated Metric metrics = 2; // Each metric is the name of the metric and the datatype of the member but does not contain a value + repeated Parameter parameters = 3; + optional string template_ref = 4; // Reference to a template if this is extending a Template or an instance - must exist if an instance + optional bool is_definition = 5; + google.protobuf.Any extensions = 6; } message DataSet { @@ -87,118 +107,118 @@ message Payload { message DataSetValue { oneof value { - uint32 int_value = 1; - uint64 long_value = 2; - float float_value = 3; - double double_value = 4; - bool boolean_value = 5; - string string_value = 6; - DataSetValueExtension extension_value = 7; + uint32 int_value = 1; + uint64 long_value = 2; + float float_value = 3; + double double_value = 4; + bool boolean_value = 5; + string string_value = 6; + DataSetValueExtension extension_value = 7; } message DataSetValueExtension { - google.protobuf.Any extensions = 1; + google.protobuf.Any extensions = 1; } } message Row { - repeated DataSetValue elements = 1; - google.protobuf.Any extensions = 2; // For third party extensions + repeated DataSetValue elements = 1; + google.protobuf.Any extensions = 2; // For third party extensions } - optional uint64 num_of_columns = 1; - repeated string columns = 2; - repeated uint32 types = 3; - repeated Row rows = 4; - google.protobuf.Any extensions = 5; // For third party extensions + optional uint64 num_of_columns = 1; + repeated string columns = 2; + repeated uint32 types = 3; + repeated Row rows = 4; + google.protobuf.Any extensions = 5; // For third party extensions } message PropertyValue { - optional uint32 type = 1; - optional bool is_null = 2; + optional uint32 type = 1; + optional bool is_null = 2; oneof value { - uint32 int_value = 3; - uint64 long_value = 4; - float float_value = 5; - double double_value = 6; - bool boolean_value = 7; - string string_value = 8; - PropertySet propertyset_value = 9; - PropertySetList propertysets_value = 10; // List of Property Values - PropertyValueExtension extension_value = 11; + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; + PropertySet propertyset_value = 9; + PropertySetList propertysets_value = 10; // List of Property Values + PropertyValueExtension extension_value = 11; } message PropertyValueExtension { - google.protobuf.Any extensions = 1; + google.protobuf.Any extensions = 1; } } message PropertySet { - repeated string keys = 1; // Names of the properties - repeated PropertyValue values = 2; - google.protobuf.Any extensions = 3; + repeated string keys = 1; // Names of the properties + repeated PropertyValue values = 2; + google.protobuf.Any extensions = 3; } message PropertySetList { repeated PropertySet propertyset = 1; - google.protobuf.Any extensions = 2; + google.protobuf.Any extensions = 2; } message MetaData { // Bytes specific metadata - optional bool is_multi_part = 1; + optional bool is_multi_part = 1; // General metadata - optional string content_type = 2; // Content/Media type - optional uint64 size = 3; // File size, String size, Multi-part size, etc - optional uint64 seq = 4; // Sequence number for multi-part messages + optional string content_type = 2; // Content/Media type + optional uint64 size = 3; // File size, String size, Multi-part size, etc + optional uint64 seq = 4; // Sequence number for multi-part messages // File metadata - optional string file_name = 5; // File name - optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc) - optional string md5 = 7; // md5 of data + optional string file_name = 5; // File name + optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc) + optional string md5 = 7; // md5 of data // Catchalls and future expansion - optional string description = 8; // Could be anything such as json or xml of custom properties - google.protobuf.Any extensions = 9; + optional string description = 8; // Could be anything such as json or xml of custom properties + google.protobuf.Any extensions = 9; } message Metric { - optional string name = 1; // Metric name - should only be included on birth - optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages - optional uint64 timestamp = 3; // Timestamp associated with data acquisition time - optional uint32 datatype = 4; // DataType of the metric/tag value - optional bool is_historical = 5; // If this is historical data and should not update real time tag - optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag - optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes. - optional MetaData metadata = 8; // Metadata for the payload + optional string name = 1; // Metric name - should only be included on birth + optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages + optional uint64 timestamp = 3; // Timestamp associated with data acquisition time + optional uint32 datatype = 4; // DataType of the metric/tag value + optional bool is_historical = 5; // If this is historical data and should not update real time tag + optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag + optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes. + optional MetaData metadata = 8; // Metadata for the payload optional PropertySet properties = 9; oneof value { - uint32 int_value = 10; - uint64 long_value = 11; - float float_value = 12; - double double_value = 13; - bool boolean_value = 14; - string string_value = 15; - bytes bytes_value = 16; // Bytes, File - DataSet dataset_value = 17; - Template template_value = 18; - MetricValueExtension extension_value = 19; + uint32 int_value = 10; + uint64 long_value = 11; + float float_value = 12; + double double_value = 13; + bool boolean_value = 14; + string string_value = 15; + bytes bytes_value = 16; // Bytes, File + DataSet dataset_value = 17; + Template template_value = 18; + MetricValueExtension extension_value = 19; } message MetricValueExtension { - google.protobuf.Any extensions = 1; + google.protobuf.Any extensions = 1; } } - optional uint64 timestamp = 1; // Timestamp at message sending time - repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs - optional uint64 seq = 3; // Sequence number - optional string uuid = 4; // UUID to track message type in terms of schema definitions - optional bytes body = 5; // To optionally bypass the whole definition above - google.protobuf.Any extensions = 6; + optional uint64 timestamp = 1; // Timestamp at message sending time + repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs + optional uint64 seq = 3; // Sequence number + optional string uuid = 4; // UUID to track message type in terms of schema definitions + optional bytes body = 5; // To optionally bypass the whole definition above + google.protobuf.Any extensions = 6; } \ No newline at end of file diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 7180adc018..dcb627b1b0 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 122696317e..ecf85b1dac 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 2cd41064da..366d2b3d9b 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.common.transport @@ -60,6 +60,10 @@ org.thingsboard.common util + + org.thingsboard.common + discovery-api + com.google.code.gson gson diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index edb1e4694b..97f438a8e1 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -26,7 +26,7 @@ import java.security.GeneralSecurityException; import java.security.KeyStore; @Data -@EqualsAndHashCode(callSuper = false) +@EqualsAndHashCode(callSuper = true) public class KeystoreSslCredentials extends AbstractSslCredentials { private String type; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index 6c7e0cf842..4d269f6964 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -46,7 +46,7 @@ import java.util.List; import java.util.stream.Collectors; @Data -@EqualsAndHashCode(callSuper = false) +@EqualsAndHashCode(callSuper = true) public class PemSslCredentials extends AbstractSslCredentials { private static final String DEFAULT_KEY_ALIAS = "server"; @@ -72,7 +72,7 @@ public class PemSslCredentials extends AbstractSslCredentials { try (InputStream inStream = ResourceUtils.getInputStream(this, this.certFile)) { try (PEMParser pemParser = new PEMParser(new InputStreamReader(inStream))) { Object object; - while((object = pemParser.readObject()) != null) { + while ((object = pemParser.readObject()) != null) { if (object instanceof X509CertificateHolder) { X509Certificate x509Cert = certConverter.getCertificate((X509CertificateHolder) object); certificates.add(x509Cert); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java index a5ac06bfb6..d03a3d03f2 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java @@ -82,16 +82,18 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi if (!checkEntityRateLimit(dataPoints, getTenantRateLimits(tenantId))) { return TbPair.of(EntityType.TENANT, false); } - if (isGateway && !checkEntityRateLimit(dataPoints, getGatewayDeviceRateLimits(tenantId, deviceId))) { - return TbPair.of(EntityType.DEVICE, true); + if (isGateway) { + if (!checkEntityRateLimit(dataPoints, getGatewayDeviceRateLimits(tenantId, deviceId))) { + return TbPair.of(EntityType.DEVICE, true); + } + } else if (gatewayId == null && deviceId != null) { + if (!checkEntityRateLimit(dataPoints, getDeviceRateLimits(tenantId, deviceId))) { + return TbPair.of(EntityType.DEVICE, false); + } } if (gatewayId != null && !checkEntityRateLimit(dataPoints, getGatewayRateLimits(tenantId, gatewayId))) { return TbPair.of(EntityType.DEVICE, true); } - if (!isGateway && deviceId != null && !checkEntityRateLimit(dataPoints, getDeviceRateLimits(tenantId, deviceId))) { - return TbPair.of(EntityType.DEVICE, false); - } - return null; } diff --git a/common/util/pom.xml b/common/util/pom.xml index 6719dc628a..bad961ac71 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common @@ -114,7 +114,14 @@ net.objecthunter exp4j - ${exp4j.version} + + + com.networknt + json-schema-validator + + + com.github.ben-manes.caffeine + caffeine diff --git a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java index 2c214460f6..001513b008 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java @@ -26,11 +26,13 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Clock; import java.util.Base64; import java.util.Iterator; @Slf4j public final class AzureIotHubUtil { + private static final String BASE_DIR_PATH = System.getProperty("user.dir"); private static final String APP_DIR = "application"; private static final String SRC_DIR = "src"; @@ -52,41 +54,37 @@ public final class AzureIotHubUtil { } } - private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; - private static final long ONE_SECOND_IN_MILLISECONDS = 1000; + private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; // one year private static final String SAS_TOKEN_FORMAT = "SharedAccessSignature sr=%s&sig=%s&se=%s"; private static final String USERNAME_FORMAT = "%s/%s/?api-version=2018-06-30"; - private AzureIotHubUtil() { - } + private AzureIotHubUtil() {} public static String buildUsername(String host, String deviceId) { return String.format(USERNAME_FORMAT, host, deviceId); } - public static String buildSasToken(String host, String sasKey) { + public static String buildSasToken(String host, String sasKey, Clock clock) { try { - final String targetUri = URLEncoder.encode(host.toLowerCase(), "UTF-8"); - final long expiryTime = buildExpiresOn(); + final String targetUri = URLEncoder.encode(host.toLowerCase(), StandardCharsets.UTF_8); + final long expiryTime = buildExpiresOn(clock); String toSign = targetUri + "\n" + expiryTime; byte[] keyBytes = Base64.getDecoder().decode(sasKey.getBytes(StandardCharsets.UTF_8)); SecretKeySpec signingKey = new SecretKeySpec(keyBytes, "HmacSHA256"); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(signingKey); byte[] rawHmac = mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8)); - String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), "UTF-8"); + String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), StandardCharsets.UTF_8); return String.format(SAS_TOKEN_FORMAT, targetUri, signature, expiryTime); } catch (Exception e) { - throw new RuntimeException("Failed to build SAS token!!!", e); + throw new RuntimeException("Failed to build SAS token!", e); } } - private static long buildExpiresOn() { - long expiresOnDate = System.currentTimeMillis(); - expiresOnDate += SAS_TOKEN_VALID_SECS * ONE_SECOND_IN_MILLISECONDS; - return expiresOnDate / ONE_SECOND_IN_MILLISECONDS; + private static long buildExpiresOn(Clock clock) { + return clock.instant().plusSeconds(SAS_TOKEN_VALID_SECS).getEpochSecond(); } public static String getDefaultCaCert() { diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index 5903c10ac2..cd61c7ac20 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -37,9 +37,10 @@ import com.google.common.collect.Lists; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.Contract; +import org.thingsboard.server.common.data.Views; import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.data.Views; import java.io.File; import java.io.IOException; @@ -61,9 +62,6 @@ import java.util.function.BiFunction; import java.util.function.UnaryOperator; import java.util.regex.Pattern; -/** - * Created by Valerii Sosliuk on 5/12/2017. - */ @Slf4j public class JacksonUtil { @@ -109,6 +107,7 @@ public class JacksonUtil { } } + @Contract("null, _ -> null") // so that IDE doesn't show NPE warning when input is not null public static T fromString(String string, Class clazz) { try { return string != null ? OBJECT_MAPPER.readValue(string, clazz) : null; diff --git a/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java new file mode 100644 index 0000000000..57ddd8a5ac --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaId; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; + +import java.util.Set; + +public final class JsonSchemaUtils { + + private JsonSchemaUtils() {} + + /** + * Validates that the provided ObjectNode is a valid JSON Schema (Draft 2020-12). + * + * @param schemaNode the JSON Schema document as an ObjectNode + * @return true if the schema is well-formed, false otherwise + */ + public static boolean isValidJsonSchema(ObjectNode schemaNode) { + Set errors = JsonSchemaFactory + .getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(SchemaLocation.of(SchemaId.V202012)) + .validate(schemaNode); + return errors.isEmpty(); + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbActorError.java b/common/util/src/main/java/org/thingsboard/common/util/RecoveryAware.java similarity index 89% rename from common/message/src/main/java/org/thingsboard/server/common/msg/TbActorError.java rename to common/util/src/main/java/org/thingsboard/common/util/RecoveryAware.java index 8c322d8eb5..e1553bec36 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbActorError.java +++ b/common/util/src/main/java/org/thingsboard/common/util/RecoveryAware.java @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg; +package org.thingsboard.common.util; -public interface TbActorError { +public interface RecoveryAware { boolean isUnrecoverable(); diff --git a/common/util/src/main/java/org/thingsboard/common/util/SetCache.java b/common/util/src/main/java/org/thingsboard/common/util/SetCache.java new file mode 100644 index 0000000000..9676434534 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/SetCache.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.concurrent.TimeUnit; + +public class SetCache { + + private static final Object DUMMY_VALUE = Boolean.TRUE; + + private final Cache cache; + + public SetCache(long valueTtlMs) { + this.cache = Caffeine.newBuilder() + .expireAfterWrite(valueTtlMs, TimeUnit.MILLISECONDS) + .build(); + } + + public void add(K key) { + cache.put(key, DUMMY_VALUE); + } + + public boolean contains(K key) { + return cache.asMap().containsKey(key); + } + + public void remove(K key) { + cache.invalidate(key); + } + +} diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 53a45a8197..5d6079b313 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 2206d8a7d7..187c7e6ea5 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard dao @@ -59,6 +59,10 @@ org.thingsboard.common util + + org.thingsboard.common + discovery-api + com.networknt json-schema-validator diff --git a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java index 43b651bb24..7b158fe9a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java @@ -43,10 +43,9 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; -public abstract class DaoUtil { +public final class DaoUtil { - private DaoUtil() { - } + private DaoUtil() {} public static PageData toPageData(Page> page) { List data = convertDataList(page.getContent()); @@ -98,17 +97,17 @@ public abstract class DaoUtil { return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), pageLink.toSort(sortOrders, columnMap, addDefaultSorting)); } - public static List convertDataList(Collection> toDataList) { - List list = Collections.emptyList(); - if (toDataList != null && !toDataList.isEmpty()) { - list = new ArrayList<>(); - for (ToData object : toDataList) { - if (object != null) { - list.add(object.toData()); - } + public static List convertDataList(Collection> toConvert) { + if (CollectionUtils.isEmpty(toConvert)) { + return Collections.emptyList(); + } + List converted = new ArrayList<>(toConvert.size()); + for (ToData object : toConvert) { + if (object != null) { + converted.add(object.toData()); } } - return list; + return converted; } public static T getData(ToData data) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java new file mode 100644 index 0000000000..b0d4b6fdb6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import org.thingsboard.server.common.data.ai.AiModel; + +import static java.util.Objects.requireNonNull; +import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Deleted; +import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Saved; + +sealed interface AiModelCacheEvictEvent permits Saved, Deleted { + + AiModelCacheKey cacheKey(); + + record Saved(AiModelCacheKey cacheKey, AiModel savedModel) implements AiModelCacheEvictEvent { + + public Saved { + requireNonNull(cacheKey); + requireNonNull(savedModel); + } + + } + + record Deleted(AiModelCacheKey cacheKey) implements AiModelCacheEvictEvent { + + public Deleted { + requireNonNull(cacheKey); + } + + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java new file mode 100644 index 0000000000..6b73ad7b28 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.thingsboard.server.cache.VersionedCacheKey; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; + +import static java.util.Objects.requireNonNull; + +record AiModelCacheKey(UUID tenantId, UUID modelId) implements VersionedCacheKey { + + AiModelCacheKey { + requireNonNull(tenantId); + requireNonNull(modelId); + + if (TenantId.SYS_TENANT_ID.getId().equals(tenantId)) { + throw new IllegalArgumentException("Tenant ID must not be the system tenant ID"); + } + if (EntityId.NULL_UUID.equals(modelId)) { + throw new IllegalArgumentException("Model ID must not be reserved null UUID"); + } + } + + static AiModelCacheKey of(TenantId tenantId, AiModelId modelId) { + return new AiModelCacheKey(tenantId.getId(), modelId.getId()); + } + + @Override + public boolean isVersioned() { + return true; + } + + @NonNull + @Override + public String toString() { + return /* cache name */ "_" + tenantId + "_" + modelId; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java new file mode 100644 index 0000000000..165efcd4e2 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.VersionedCaffeineTbCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.ai.AiModel; + +@Component("AiModelCache") +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +class AiModelCaffeineCache extends VersionedCaffeineTbCache { + + AiModelCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.AI_MODEL_CACHE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java new file mode 100644 index 0000000000..e788685bfa --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ExportableEntityDao; +import org.thingsboard.server.dao.TenantEntityDao; + +import java.util.Optional; +import java.util.Set; + +public interface AiModelDao extends TenantEntityDao, ExportableEntityDao { + + Optional findByTenantIdAndId(TenantId tenantId, AiModelId modelId); + + boolean deleteById(TenantId tenantId, AiModelId modelId); + + Set deleteByTenantId(TenantId tenantId); + + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java new file mode 100644 index 0000000000..7bec37875f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.CacheSpecsMap; +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.ai.AiModel; + +@Component("AiModelCache") +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +class AiModelRedisCache extends VersionedRedisTbCache { + + AiModelRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.AI_MODEL_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiModel.class)); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java new file mode 100644 index 0000000000..b091a29247 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -0,0 +1,149 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entity.CachedVersionedEntityService; +import org.thingsboard.server.dao.model.sql.AiModelEntity; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.sql.JpaExecutorService; + +import java.util.Optional; +import java.util.Set; + +import static org.thingsboard.server.dao.service.Validator.validatePageLink; + +@Service +@RequiredArgsConstructor +class AiModelServiceImpl extends CachedVersionedEntityService implements AiModelService { + + private final DataValidator aiModelValidator; + + private final JpaExecutorService jpaExecutor; + private final AiModelDao aiModelDao; + + @Override + @TransactionalEventListener + public void handleEvictEvent(AiModelCacheEvictEvent event) { + var cacheKey = event.cacheKey(); + if (event instanceof AiModelCacheEvictEvent.Saved savedEvent) { + cache.put(cacheKey, savedEvent.savedModel()); + } else if (event instanceof AiModelCacheEvictEvent.Deleted) { + cache.evict(cacheKey); + } else { + throw new UnsupportedOperationException("Unsupported event type: " + event.getClass().getSimpleName()); + } + } + + @Override + @Transactional + public AiModel save(AiModel model) { + aiModelValidator.validate(model, AiModel::getTenantId); + + AiModel savedModel; + try { + savedModel = aiModelDao.saveAndFlush(model.getTenantId(), model); + } catch (Exception e) { + checkConstraintViolation(e, + "ai_model_name_unq_key", "AI model with such name already exist!", + "ai_model_external_id_unq_key", "AI model with such external ID already exists!"); + throw e; + } + + var cacheKey = AiModelCacheKey.of(savedModel.getTenantId(), savedModel.getId()); + publishEvictEvent(new AiModelCacheEvictEvent.Saved(cacheKey, savedModel)); + + return savedModel; + } + + @Override + public Optional findAiModelById(TenantId tenantId, AiModelId modelId) { + return Optional.ofNullable(aiModelDao.findById(tenantId, modelId.getId())); + } + + @Override + public PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink) { + validatePageLink(pageLink, AiModelEntity.ALLOWED_SORT_PROPERTIES); + return aiModelDao.findAllByTenantId(tenantId, pageLink); + } + + @Override + public Optional findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + var cacheKey = AiModelCacheKey.of(tenantId, modelId); + return Optional.ofNullable(cache.get(cacheKey, () -> aiModelDao.findByTenantIdAndId(tenantId, modelId).orElse(null))); + } + + @Override + public FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId) { + return FluentFuture.from(jpaExecutor.submit(() -> findAiModelByTenantIdAndId(tenantId, modelId))); + } + + @Override + @Transactional + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return deleteByTenantIdAndIdInternal(tenantId, modelId); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return findAiModelByTenantIdAndId(tenantId, (AiModelId) entityId) + .map(model -> model); // necessary to cast to HasId + } + + @Override + public long countByTenantId(TenantId tenantId) { + return aiModelDao.countByTenantId(tenantId); + } + + @Override + @Transactional + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + deleteByTenantIdAndIdInternal(tenantId, new AiModelId(id.getId())); + } + + private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelId modelId) { + boolean deleted = aiModelDao.deleteByTenantIdAndId(tenantId, modelId); + if (deleted) { + publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, modelId))); + } + return deleted; + } + + @Override + @Transactional + public void deleteByTenantId(TenantId tenantId) { + Set deleted = aiModelDao.deleteByTenantId(tenantId); + deleted.forEach(id -> publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, id)))); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java index 0351490f70..d001258ba4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.alarm; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.alarm.Alarm; @@ -47,15 +48,14 @@ import java.util.List; import java.util.Set; import java.util.UUID; -/** - * Created by ashvayka on 11.05.17. - */ public interface AlarmDao extends Dao { Alarm findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type); Alarm findLatestActiveByOriginatorAndType(TenantId tenantId, EntityId originator, String type); + FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); + ListenableFuture findLatestByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); Alarm findAlarmById(TenantId tenantId, UUID key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java index 23e05e1bfa..15d8789ecd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.alarm; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.Function; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -169,6 +170,11 @@ public class BaseAlarmService extends AbstractCachedEntityService findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { + return alarmDao.findLatestActiveByOriginatorAndTypeAsync(tenantId, originator, type); + } + @Override public PageData findAlarmDataByQueryForEntities(TenantId tenantId, AlarmDataQuery query, Collection orderedEntityIds) { 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 777a77d054..9803670d4b 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 @@ -33,6 +33,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.kv.AttributesSaveResult; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.dao.service.Validator; @@ -41,7 +42,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; @@ -101,26 +101,29 @@ public class BaseAttributesService implements AttributesService { } @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 doSave(tenantId, entityId, scope, attribute); + return doSave(tenantId, entityId, scope, List.of(attribute)); } @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 -> doSave(tenantId, entityId, scope, attribute)).collect(Collectors.toList()); - return Futures.allAsList(saveFutures); + return doSave(tenantId, entityId, scope, attributes); } - private ListenableFuture doSave(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { - ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); - return Futures.transform(future, version -> { - edqsService.onUpdate(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, attribute, version)); - return version; - }, MoreExecutors.directExecutor()); + private ListenableFuture doSave(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes) { + List> futures = new ArrayList<>(attributes.size()); + for (AttributeKvEntry attribute : attributes) { + ListenableFuture future = Futures.transform(attributesDao.save(tenantId, entityId, scope, attribute), version -> { + edqsService.onUpdate(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, attribute, version)); + return version; + }, MoreExecutors.directExecutor()); + futures.add(future); + } + return Futures.transform(Futures.allAsList(futures), AttributesSaveResult::of, MoreExecutors.directExecutor()); } @Override 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 559828911f..d99413f13a 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 @@ -37,6 +37,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.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.edqs.EdqsService; @@ -56,7 +57,6 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; @@ -150,7 +150,7 @@ public class CachedAttributesService implements AttributesService { List cachedAttributes = wrappedCachedAttributes.values().stream() .map(TbCacheValueWrapper::get) .filter(Objects::nonNull) - .collect(Collectors.toList()); + .toList(); if (wrappedCachedAttributes.size() == attributeKeys.size()) { log.trace("[{}][{}] Found all attributes from cache: {}", entityId, scope, attributeKeys); return Futures.immediateFuture(cachedAttributes); @@ -159,8 +159,6 @@ public class CachedAttributesService implements AttributesService { Set notFoundAttributeKeys = new HashSet<>(attributeKeys); notFoundAttributeKeys.removeAll(wrappedCachedAttributes.keySet()); - List notFoundKeys = notFoundAttributeKeys.stream().map(k -> new AttributeCacheKey(scope, entityId, k)).collect(Collectors.toList()); - // DB call should run in DB executor, not in cache-related executor return jpaExecutorService.submit(() -> { log.trace("[{}][{}] Lookup attributes from db: {}", entityId, scope, notFoundAttributeKeys); @@ -222,33 +220,31 @@ public class CachedAttributesService implements AttributesService { } @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 doSave(tenantId, entityId, scope, attribute); + return doSave(tenantId, entityId, scope, List.of(attribute)); } @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); + return doSave(tenantId, entityId, scope, attributes); + } + private ListenableFuture doSave(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes) { List> futures = new ArrayList<>(attributes.size()); for (var attribute : attributes) { - futures.add(doSave(tenantId, entityId, scope, attribute)); + ListenableFuture future = Futures.transform(attributesDao.save(tenantId, entityId, scope, attribute), version -> { + BaseAttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(((BaseAttributeKvEntry) attribute).getKv(), attribute.getLastUpdateTs(), version); + put(entityId, scope, attributeKvEntry); + edqsService.onUpdate(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, attributeKvEntry, version)); + return version; + }, cacheExecutor); + futures.add(future); } - - return Futures.allAsList(futures); - } - - private ListenableFuture doSave(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { - ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); - return Futures.transform(future, version -> { - BaseAttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(((BaseAttributeKvEntry) attribute).getKv(), attribute.getLastUpdateTs(), version); - put(entityId, scope, attributeKvEntry); - edqsService.onUpdate(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, attributeKvEntry, version)); - return version; - }, cacheExecutor); + return Futures.transform(Futures.allAsList(futures), AttributesSaveResult::of, MoreExecutors.directExecutor()); } private void put(EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { @@ -270,7 +266,7 @@ public class CachedAttributesService implements AttributesService { edqsService.onDelete(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, key, version)); } return key; - }, cacheExecutor)).collect(Collectors.toList())); + }, cacheExecutor)).toList()); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index 0c5df18e80..c0cb886747 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -58,6 +58,22 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements @Override public CalculatedField save(CalculatedField calculatedField) { CalculatedField oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); + return doSave(calculatedField, oldCalculatedField); + } + + @Override + public CalculatedField save(CalculatedField calculatedField, boolean doValidate) { + CalculatedField oldCalculatedField = null; + if (doValidate) { + oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); + } else if (calculatedField.getId() != null) { + oldCalculatedField = findById(calculatedField.getTenantId(), calculatedField.getId()); + } + return doSave(calculatedField, oldCalculatedField); + } + + + private CalculatedField doSave(CalculatedField calculatedField, CalculatedField oldCalculatedField) { try { TenantId tenantId = calculatedField.getTenantId(); log.trace("Executing save calculated field, [{}]", calculatedField); @@ -83,6 +99,13 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return calculatedFieldDao.findById(tenantId, calculatedFieldId.getId()); } + @Override + public CalculatedField findByEntityIdAndName(EntityId entityId, String name) { + log.trace("Executing findByEntityIdAndName [{}], calculatedFieldName[{}]", entityId, name); + validateId(entityId.getId(), id -> INCORRECT_ENTITY_ID + id); + return calculatedFieldDao.findByEntityIdAndName(entityId, name); + } + @Override public List findCalculatedFieldIdsByEntityId(TenantId tenantId, EntityId entityId) { log.trace("Executing findCalculatedFieldIdsByEntityId [{}]", entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java index aadae93893..d5465cb8a1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java @@ -35,6 +35,8 @@ public interface CalculatedFieldDao extends Dao { List findAll(); + CalculatedField findByEntityIdAndName(EntityId entityId, String name); + PageData findAll(PageLink pageLink); PageData findAllByTenantId(TenantId tenantId, PageLink pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index be7cbf7f84..2fca546fc9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -20,6 +20,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasCustomerId; import org.thingsboard.server.common.data.HasEmail; @@ -41,19 +42,25 @@ import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityFilterType; import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityListFilter; import org.thingsboard.server.common.data.query.EntityNameFilter; import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.common.stats.EdqsStatsService; import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; import java.util.ArrayList; import java.util.Collections; +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.concurrent.ExecutionException; @@ -88,6 +95,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe @Lazy EntityServiceRegistry entityServiceRegistry; + @Autowired + private EdqsService edqsService; + @Autowired @Lazy private EdqsApiService edqsApiService; @@ -104,7 +114,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe long startNs = System.nanoTime(); Long result; - if (edqsApiService.isEnabled() && validForEdqs(query) && !tenantId.isSysTenantId()) { + if (edqsService.isApiEnabled() && validForEdqs(query) && !tenantId.isSysTenantId()) { EdqsRequest request = EdqsRequest.builder() .entityCountQuery(query) .build(); @@ -126,7 +136,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe long startNs = System.nanoTime(); PageData result; - if (edqsApiService.isEnabled() && validForEdqs(query)) { + if (edqsService.isApiEnabled() && validForEdqs(query)) { EdqsRequest request = EdqsRequest.builder() .entityDataQuery(query) .build(); @@ -199,6 +209,30 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return fetchAndConvert(tenantId, entityId, Function.identity()); } + @Override + public Map fetchEntityInfos(TenantId tenantId, CustomerId customerId, Set entityIds) { + Map infos = new HashMap<>(); + entityIds.stream() + .collect(Collectors.groupingBy(EntityId::getEntityType)) + .forEach((entityType, ids) -> { + EntityListFilter filter = new EntityListFilter(); + filter.setEntityType(entityType); + filter.setEntityList(ids.stream().map(Object::toString).toList()); + EntityDataQuery query = new EntityDataQuery(filter, new EntityDataPageLink(ids.size(), 0, null, null), + List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.NAME_PROPERTY)), Collections.emptyList(), Collections.emptyList()); + + entityQueryDao.findEntityDataByQuery(tenantId, customerId, query).getData().forEach(entityData -> { + EntityId entityId = entityData.getEntityId(); + Optional.ofNullable(entityData.getLatest().get(EntityKeyType.ENTITY_FIELD)) + .map(fields -> fields.get(ModelConstants.NAME_PROPERTY)) + .map(TsValue::getValue).ifPresent(name -> { + infos.put(entityId, new EntityInfo(entityId, name)); + }); + }); + }); + return infos; + } + private Optional fetchAndConvert(TenantId tenantId, EntityId entityId, Function, T> converter) { EntityDaoService entityDaoService = entityServiceRegistry.getServiceByEntityType(entityId.getEntityType()); Optional> entityOpt = entityDaoService.findEntity(tenantId, entityId); @@ -295,7 +329,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe } if ((query.getEntityFields() == null || query.getEntityFields().isEmpty()) && - (query.getLatestValues() == null || query.getLatestValues().isEmpty())) { + (query.getLatestValues() == null || query.getLatestValues().isEmpty())) { return false; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java index 1ca2973936..2f23d20f00 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.housekeeper.HousekeeperTask; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.msg.housekeeper.HousekeeperClient; import org.thingsboard.server.dao.eventsourcing.ActionCause; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; @@ -46,7 +47,7 @@ public class CleanUpService { private final Set skippedEntities = EnumSet.of( EntityType.ALARM, EntityType.QUEUE, EntityType.TB_RESOURCE, EntityType.OTA_PACKAGE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_TEMPLATE, - EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_MODEL ); @TransactionalEventListener(fallbackExecution = true) // after transaction commit @@ -76,6 +77,9 @@ public class CleanUpService { submitTask(HousekeeperTask.deleteEvents(tenantId, entityId)); submitTask(HousekeeperTask.deleteAlarms(tenantId, entityId)); submitTask(HousekeeperTask.deleteCalculatedFields(tenantId, entityId)); + if (Job.SUPPORTED_ENTITY_TYPES.contains(entityId.getEntityType())) { + submitTask(HousekeeperTask.deleteJobs(tenantId, entityId)); + } } public void removeTenantEntities(TenantId tenantId, EntityType... entityTypes) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java b/dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java new file mode 100644 index 0000000000..360aa0063b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java @@ -0,0 +1,263 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +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.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobFilter; +import org.thingsboard.server.common.data.job.JobResult; +import org.thingsboard.server.common.data.job.JobStats; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.job.task.TaskResult; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.service.ConstraintValidator; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.job.JobStatus.CANCELLED; +import static org.thingsboard.server.common.data.job.JobStatus.COMPLETED; +import static org.thingsboard.server.common.data.job.JobStatus.FAILED; +import static org.thingsboard.server.common.data.job.JobStatus.PENDING; +import static org.thingsboard.server.common.data.job.JobStatus.QUEUED; +import static org.thingsboard.server.common.data.job.JobStatus.RUNNING; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DefaultJobService extends AbstractEntityService implements JobService { + + private final JobDao jobDao; + private final EntityService entityService; + + @Transactional + @Override + public Job saveJob(TenantId tenantId, Job job) { + if (jobDao.existsByTenantAndKeyAndStatusOneOf(tenantId, job.getKey(), QUEUED, PENDING, RUNNING)) { + throw new IllegalArgumentException("The same job is already queued or running"); + } + if (jobDao.existsByTenantIdAndTypeAndStatusOneOf(tenantId, job.getType(), PENDING, RUNNING)) { + job.setStatus(QUEUED); + } else { + job.setStatus(PENDING); + } + return saveJob(tenantId, job, true, null); + } + + @Override + public Job findJobById(TenantId tenantId, JobId jobId) { + return jobDao.findById(tenantId, jobId.getId()); + } + + @Transactional + @Override + public void cancelJob(TenantId tenantId, JobId jobId) { + Job job = findForUpdate(tenantId, jobId); + if (!job.getStatus().isOneOf(QUEUED, PENDING, RUNNING)) { + throw new IllegalArgumentException("Job already " + job.getStatus().name().toLowerCase()); + } + job.getResult().setCancellationTs(System.currentTimeMillis()); + JobStatus prevStatus = job.getStatus(); + if (job.getStatus() == QUEUED) { + job.setStatus(CANCELLED); // setting cancelled status right away, because we don't expect stats for cancelled tasks + } else if (job.getStatus() == PENDING) { + job.setStatus(RUNNING); + } + saveJob(tenantId, job, true, prevStatus); + } + + @Transactional + @Override + public void markAsFailed(TenantId tenantId, JobId jobId, String error) { + Job job = findForUpdate(tenantId, jobId); + job.getResult().setGeneralError(error); + JobStatus prevStatus = job.getStatus(); + job.setStatus(FAILED); + saveJob(tenantId, job, true, prevStatus); + } + + @Transactional + @Override + public void processStats(TenantId tenantId, JobId jobId, JobStats jobStats) { + Job job = findForUpdate(tenantId, jobId); + if (job == null) { + log.debug("[{}][{}] Got stale stats: {}", tenantId, jobId, jobStats); + return; + } + JobStatus prevStatus = job.getStatus(); + if (job.getStatus() == PENDING) { + job.setStatus(RUNNING); + } + + JobResult result = job.getResult(); + if (jobStats.getTotalTasksCount() != null) { + result.setTotalCount(jobStats.getTotalTasksCount()); + } + + boolean publishEvent = false; + long lastFinishTs = 0; + for (TaskResult taskResult : jobStats.getTaskResults()) { + if (!taskResult.getKey().equals(job.getConfiguration().getTasksKey())) { + log.debug("Ignoring task result {} with outdated key {}", taskResult, job.getConfiguration().getTasksKey()); + continue; + } + + result.processTaskResult(taskResult); + + if (result.getCancellationTs() > 0) { + if (!taskResult.isDiscarded() && System.currentTimeMillis() > result.getCancellationTs()) { + log.info("Got task result for cancelled job {}: {}, re-notifying processors about cancellation", jobId, taskResult); + // task processor forgot the task is cancelled + publishEvent = true; + } + } + if (taskResult.getFinishTs() > lastFinishTs) { + lastFinishTs = taskResult.getFinishTs(); + } + } + + if (job.getStatus() == RUNNING) { + if (result.getTotalCount() != null && result.getCompletedCount() >= result.getTotalCount()) { + if (result.getCancellationTs() > 0) { + job.setStatus(CANCELLED); + } else if (result.getFailedCount() > 0) { + job.setStatus(FAILED); + publishEvent = true; + } else { + job.setStatus(COMPLETED); + publishEvent = true; + } + result.setFinishTs(lastFinishTs); + job.getConfiguration().setToReprocess(null); + } + } + + saveJob(tenantId, job, publishEvent, prevStatus); + } + + private Job saveJob(TenantId tenantId, Job job, boolean publishEvent, JobStatus prevStatus) { + ConstraintValidator.validateFields(job); + if (!Job.SUPPORTED_ENTITY_TYPES.contains(job.getEntityId().getEntityType())) { + throw new IllegalArgumentException("Unsupported entity type " + job.getEntityId().getEntityType()); + } + if (job.getStatus() == PENDING) { + job.getResult().setStartTs(System.currentTimeMillis()); + } + + job = jobDao.save(tenantId, job); + if (publishEvent) { + eventPublisher.publishEvent(SaveEntityEvent.builder() + .tenantId(tenantId) + .entityId(job.getId()) + .entity(job) + .build()); + } + log.info("[{}] Saved job: {}", tenantId, job); + if (prevStatus != null && job.getStatus() != prevStatus) { + log.info("[{}][{}][{}] New job status: {} -> {}", tenantId, job.getId(), job.getType(), prevStatus, job.getStatus()); + if (job.getStatus().isOneOf(CANCELLED, COMPLETED, FAILED) && prevStatus != QUEUED) { // if prev status is QUEUED - means there are already running jobs with this type, no need to check for waiting job + checkWaitingJobs(tenantId, job.getType()); + } + } + return job; + } + + private void checkWaitingJobs(TenantId tenantId, JobType jobType) { + Job queuedJob = jobDao.findOldestByTenantIdAndTypeAndStatusForUpdate(tenantId, jobType, QUEUED); + if (queuedJob == null) { + return; + } + queuedJob.setStatus(PENDING); + saveJob(tenantId, queuedJob, true, QUEUED); + } + + @Override + public PageData findJobsByFilter(TenantId tenantId, JobFilter filter, PageLink pageLink) { + PageData jobs = jobDao.findByTenantIdAndFilter(tenantId, filter, pageLink); + + Set entityIds = jobs.getData().stream() + .map(Job::getEntityId) + .collect(Collectors.toSet()); + Map entityInfos = entityService.fetchEntityInfos(tenantId, null, entityIds); + jobs.getData().forEach(job -> { + EntityInfo entityInfo = entityInfos.get(job.getEntityId()); + if (entityInfo != null) { + job.setEntityName(entityInfo.getName()); + } + }); + return jobs; + } + + @Override + public Job findLatestJobByKey(TenantId tenantId, String key) { + return jobDao.findLatestByTenantIdAndKey(tenantId, key); + } + + @Override + public void deleteJob(TenantId tenantId, JobId jobId) { + Job job = findJobById(tenantId, jobId); + if (!job.getStatus().isOneOf(CANCELLED, COMPLETED, FAILED)) { + throw new IllegalArgumentException("Job must be cancelled, completed or failed"); + } + jobDao.removeById(tenantId, jobId.getId()); + } + + @Override + public int deleteJobsByEntityId(TenantId tenantId, EntityId entityId) { // TODO: cancel all jobs for this entity + return jobDao.removeByEntityId(tenantId, entityId); + } + + private Job findForUpdate(TenantId tenantId, JobId jobId) { + return jobDao.findByIdForUpdate(tenantId, jobId); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return Optional.ofNullable(findJobById(tenantId, (JobId) entityId)); + } + + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + jobDao.removeById(tenantId, id.getId()); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + jobDao.removeByTenantId(tenantId); + } + + @Override + public EntityType getEntityType() { + return EntityType.JOB; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/job/JobDao.java b/dao/src/main/java/org/thingsboard/server/dao/job/JobDao.java new file mode 100644 index 0000000000..498fc9f2de --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/job/JobDao.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobFilter; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.Dao; + +public interface JobDao extends Dao { + + PageData findByTenantIdAndFilter(TenantId tenantId, JobFilter filter, PageLink pageLink); + + Job findByIdForUpdate(TenantId tenantId, JobId jobId); + + Job findLatestByTenantIdAndKey(TenantId tenantId, String key); + + boolean existsByTenantAndKeyAndStatusOneOf(TenantId tenantId, String key, JobStatus... statuses); + + boolean existsByTenantIdAndTypeAndStatusOneOf(TenantId tenantId, JobType type, JobStatus... statuses); + + boolean existsByTenantIdAndEntityIdAndStatusOneOf(TenantId tenantId, EntityId entityId, JobStatus... statuses); + + Job findOldestByTenantIdAndTypeAndStatusForUpdate(TenantId tenantId, JobType type, JobStatus status); + + void removeByTenantId(TenantId tenantId); + + int removeByEntityId(TenantId tenantId, EntityId entityId); + +} 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 148908d063..3960c67525 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 @@ -457,6 +457,7 @@ public class ModelConstants { */ public static final String MOBILE_APP_TABLE_NAME = "mobile_app"; public static final String MOBILE_APP_PKG_NAME_PROPERTY = "pkg_name"; + public static final String MOBILE_APP_TITLE_PROPERTY = "title"; public static final String MOBILE_APP_APP_SECRET_PROPERTY = "app_secret"; public static final String MOBILE_APP_PLATFORM_TYPE_PROPERTY = "platform_type"; public static final String MOBILE_APP_STATUS_PROPERTY = "status"; @@ -739,6 +740,26 @@ public class ModelConstants { public static final String CALCULATED_FIELD_LINK_ENTITY_ID = ENTITY_ID_COLUMN; public static final String CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID = "calculated_field_id"; + /** + * Tasks constants. + */ + public static final String JOB_TABLE_NAME = "job"; + public static final String JOB_TYPE_PROPERTY = "type"; + public static final String JOB_KEY_PROPERTY = "key"; + public static final String JOB_ENTITY_ID_PROPERTY = "entity_id"; + public static final String JOB_ENTITY_TYPE_PROPERTY = "entity_type"; + public static final String JOB_STATUS_PROPERTY = "status"; + public static final String JOB_CONFIGURATION_PROPERTY = "configuration"; + public static final String JOB_RESULT_PROPERTY = "result"; + + /** + * AI model constants. + */ + public static final String AI_MODEL_TABLE_NAME = "ai_model"; + public static final String AI_MODEL_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; + public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY; + public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java new file mode 100644 index 0000000000..d4f3d36db6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.sql; + +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.Type; +import org.hibernate.proxy.HibernateProxy; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.BaseVersionedEntity; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +@Getter +@Setter +@ToString +@Entity +@Table(name = ModelConstants.AI_MODEL_TABLE_NAME) +public class AiModelEntity extends BaseVersionedEntity { + + public static final Map COLUMN_MAP = Map.of( + "createdTime", "created_time", + "provider", "(configuration ->> 'provider')", + "modelId", "(configuration ->> 'modelId')" + ); + + public static final Set ALLOWED_SORT_PROPERTIES = Collections.unmodifiableSet( + new LinkedHashSet<>(List.of("createdTime", "name", "provider", "modelId")) + ); + + @Column(name = ModelConstants.AI_MODEL_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") + private UUID tenantId; + + @Column(name = ModelConstants.AI_MODEL_NAME_COLUMN_NAME, nullable = false) + private String name; + + @Type(JsonBinaryType.class) + @Column(name = ModelConstants.AI_MODEL_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") + private AiModelConfig configuration; + + @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY, columnDefinition = "UUID") + private UUID externalId; + + public AiModelEntity() {} + + public AiModelEntity(AiModel aiModel) { + super(aiModel); + tenantId = getTenantUuid(aiModel.getTenantId()); + name = aiModel.getName(); + configuration = aiModel.getConfiguration(); + externalId = getUuid(aiModel.getExternalId()); + } + + @Override + public AiModel toData() { + var model = new AiModel(new AiModelId(id)); + model.setCreatedTime(createdTime); + model.setVersion(version); + model.setTenantId(TenantId.fromUUID(tenantId)); + model.setName(name); + model.setConfiguration(configuration); + model.setExternalId(getEntityId(externalId, AiModelId::new)); + return model; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + AiModelEntity that = (AiModelEntity) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/JobEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/JobEntity.java new file mode 100644 index 0000000000..16d7d33edc --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/JobEntity.java @@ -0,0 +1,105 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.sql; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobConfiguration; +import org.thingsboard.server.common.data.job.JobResult; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.util.mapping.JsonConverter; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@Entity +@Table(name = ModelConstants.JOB_TABLE_NAME) +public class JobEntity extends BaseSqlEntity { + + @Column(name = ModelConstants.TENANT_ID_PROPERTY, nullable = false) + private UUID tenantId; + + @Enumerated(EnumType.STRING) + @Column(name = ModelConstants.JOB_TYPE_PROPERTY, nullable = false) + private JobType type; + + @Column(name = ModelConstants.JOB_KEY_PROPERTY, nullable = false) + private String key; + + @Column(name = ModelConstants.JOB_ENTITY_ID_PROPERTY, nullable = false) + private UUID entityId; + + @Enumerated(EnumType.STRING) + @Column(name = ModelConstants.JOB_ENTITY_TYPE_PROPERTY, nullable = false) + private EntityType entityType; + + @Enumerated(EnumType.STRING) + @Column(name = ModelConstants.JOB_STATUS_PROPERTY, nullable = false) + private JobStatus status; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.JOB_CONFIGURATION_PROPERTY, nullable = false) + private JsonNode configuration; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.JOB_RESULT_PROPERTY) + private JsonNode result; + + public JobEntity(Job job) { + super(job); + this.tenantId = getTenantUuid(job.getTenantId()); + this.type = job.getType(); + this.key = job.getKey(); + this.entityId = job.getEntityId().getId(); + this.entityType = job.getEntityId().getEntityType(); + this.status = job.getStatus(); + this.configuration = toJson(job.getConfiguration()); + this.result = toJson(job.getResult()); + } + + @Override + public Job toData() { + Job job = new Job(); + job.setId(new JobId(id)); + job.setCreatedTime(createdTime); + job.setTenantId(getTenantId(tenantId)); + job.setType(type); + job.setKey(key); + job.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); + job.setStatus(status); + job.setConfiguration(fromJson(configuration, JobConfiguration.class)); + job.setResult(fromJson(result, JobResult.class)); + return job; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java index 70f711750d..d9c22c6e9b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java @@ -53,6 +53,9 @@ public class MobileAppEntity extends BaseSqlEntity { @Column(name = ModelConstants.MOBILE_APP_PKG_NAME_PROPERTY) private String pkgName; + @Column(name = ModelConstants.MOBILE_APP_TITLE_PROPERTY) + private String title; + @Column(name = ModelConstants.MOBILE_APP_APP_SECRET_PROPERTY) private String appSecret; @@ -82,6 +85,7 @@ public class MobileAppEntity extends BaseSqlEntity { this.tenantId = mobile.getTenantId().getId(); } this.pkgName = mobile.getPkgName(); + this.title = mobile.getTitle(); this.appSecret = mobile.getAppSecret(); this.platformType = mobile.getPlatformType(); this.status = mobile.getStatus(); @@ -98,6 +102,7 @@ public class MobileAppEntity extends BaseSqlEntity { } mobile.setCreatedTime(createdTime); mobile.setPkgName(pkgName); + mobile.setTitle(title); mobile.setAppSecret(appSecret); mobile.setPlatformType(platformType); mobile.setStatus(status); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java index 1ffafd305e..6ee4bdd89f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java @@ -38,6 +38,7 @@ import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.nio.ByteBuffer; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.EXTERNAL_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.OTA_PACKAGE_CHECKSUM_ALGORITHM_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.OTA_PACKAGE_CHECKSUM_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.OTA_PACKAGE_CONTENT_TYPE_COLUMN; @@ -105,6 +106,9 @@ public class OtaPackageEntity extends BaseSqlEntity { @Column(name = ModelConstants.OTA_PACKAGE_ADDITIONAL_INFO_COLUMN) private JsonNode additionalInfo; + @Column(name = EXTERNAL_ID_PROPERTY) + private UUID externalId; + public OtaPackageEntity() { super(); } @@ -128,6 +132,7 @@ public class OtaPackageEntity extends BaseSqlEntity { this.data = otaPackage.getData().array(); this.dataSize = otaPackage.getDataSize(); this.additionalInfo = otaPackage.getAdditionalInfo(); + this.externalId = getUuid(otaPackage.getExternalId()); } @Override @@ -153,6 +158,8 @@ public class OtaPackageEntity extends BaseSqlEntity { otaPackage.setHasData(true); } otaPackage.setAdditionalInfo(additionalInfo); + otaPackage.setExternalId(getEntityId(externalId, OtaPackageId::new)); return otaPackage; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java index c1e83cf511..a1625f2595 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java @@ -100,6 +100,9 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { @Column(name = ModelConstants.OTA_PACKAGE_ADDITIONAL_INFO_COLUMN) private JsonNode additionalInfo; + @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY) + private UUID externalId; + @Transient private boolean hasData; @@ -125,11 +128,12 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { this.checksum = otaPackageInfo.getChecksum(); this.dataSize = otaPackageInfo.getDataSize(); this.additionalInfo = otaPackageInfo.getAdditionalInfo(); + this.externalId = getUuid(otaPackageInfo.getExternalId()); } public OtaPackageInfoEntity(UUID id, long createdTime, UUID tenantId, UUID deviceProfileId, OtaPackageType type, String title, String version, String tag, String url, String fileName, String contentType, ChecksumAlgorithm checksumAlgorithm, String checksum, Long dataSize, - Object additionalInfo, boolean hasData) { + Object additionalInfo, UUID externalId, boolean hasData) { this.id = id; this.createdTime = createdTime; this.tenantId = tenantId; @@ -146,6 +150,7 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { this.dataSize = dataSize; this.hasData = hasData; this.additionalInfo = JacksonUtil.convertValue(additionalInfo, JsonNode.class); + this.externalId = externalId; } @Override @@ -168,6 +173,8 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { otaPackageInfo.setDataSize(dataSize); otaPackageInfo.setAdditionalInfo(additionalInfo); otaPackageInfo.setHasData(hasData); + otaPackageInfo.setExternalId(getEntityId(externalId, OtaPackageId::new)); return otaPackageInfo; } + } 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 259cd908fc..7befb1db62 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 @@ -28,7 +28,9 @@ 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.BufferedRateExecutorType; import org.thingsboard.server.dao.util.NoSqlAnyDao; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; /** * Created by ashvayka on 24.10.18. @@ -38,8 +40,6 @@ import org.thingsboard.server.dao.util.NoSqlAnyDao; @NoSqlAnyDao public class CassandraBufferedRateReadExecutor extends AbstractBufferedRateExecutor { - static final String BUFFER_NAME = "Read"; - public CassandraBufferedRateReadExecutor( @Value("${cassandra.query.buffer_size}") int queueLimit, @Value("${cassandra.query.concurrent_limit}") int concurrencyLimit, @@ -51,9 +51,10 @@ public class CassandraBufferedRateReadExecutor extends AbstractBufferedRateExecu @Value("${cassandra.query.print_queries_freq:0}") int printQueriesFreq, @Autowired StatsFactory statsFactory, @Autowired EntityService entityService, - @Autowired RateLimitService rateLimitService) { - super(queueLimit, concurrencyLimit, maxWaitTime, dispatcherThreads, callbackThreads, pollMs, printQueriesFreq, statsFactory, - entityService, rateLimitService, printTenantNames); + @Autowired RateLimitService rateLimitService, + @Autowired(required = false) TbServiceInfoProvider serviceInfoProvider) { + super(queueLimit, concurrencyLimit, maxWaitTime, dispatcherThreads, callbackThreads, pollMs, printQueriesFreq, + BufferedRateExecutorType.READ, serviceInfoProvider, rateLimitService, statsFactory, entityService, printTenantNames); } @Scheduled(fixedDelayString = "${cassandra.query.rate_limit_print_interval_ms}") @@ -67,11 +68,6 @@ public class CassandraBufferedRateReadExecutor extends AbstractBufferedRateExecu super.stop(); } - @Override - public String getBufferName() { - return BUFFER_NAME; - } - @Override protected SettableFuture create() { return SettableFuture.create(); 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 9c894a7eac..9b7db4fae4 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 @@ -28,7 +28,9 @@ 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.BufferedRateExecutorType; import org.thingsboard.server.dao.util.NoSqlAnyDao; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; /** * Created by ashvayka on 24.10.18. @@ -38,8 +40,6 @@ import org.thingsboard.server.dao.util.NoSqlAnyDao; @NoSqlAnyDao public class CassandraBufferedRateWriteExecutor extends AbstractBufferedRateExecutor { - static final String BUFFER_NAME = "Write"; - public CassandraBufferedRateWriteExecutor( @Value("${cassandra.query.buffer_size}") int queueLimit, @Value("${cassandra.query.concurrent_limit}") int concurrencyLimit, @@ -51,9 +51,10 @@ public class CassandraBufferedRateWriteExecutor extends AbstractBufferedRateExec @Value("${cassandra.query.print_queries_freq:0}") int printQueriesFreq, @Autowired StatsFactory statsFactory, @Autowired EntityService entityService, - @Autowired RateLimitService rateLimitService) { - super(queueLimit, concurrencyLimit, maxWaitTime, dispatcherThreads, callbackThreads, pollMs, printQueriesFreq, statsFactory, - entityService, rateLimitService, printTenantNames); + @Autowired RateLimitService rateLimitService, + @Autowired(required = false) TbServiceInfoProvider serviceInfoProvider) { + super(queueLimit, concurrencyLimit, maxWaitTime, dispatcherThreads, callbackThreads, pollMs, printQueriesFreq, + BufferedRateExecutorType.WRITE, serviceInfoProvider, rateLimitService, statsFactory, entityService, printTenantNames); } @Scheduled(fixedDelayString = "${cassandra.query.rate_limit_print_interval_ms}") @@ -67,11 +68,6 @@ public class CassandraBufferedRateWriteExecutor extends AbstractBufferedRateExec super.stop(); } - @Override - public String getBufferName() { - return BUFFER_NAME; - } - @Override protected SettableFuture create() { return SettableFuture.create(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java index de83fe4df7..ea7d3488b2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java @@ -186,6 +186,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS defaultNotifications.create(tenantId, DefaultNotifications.exceededRateLimitsForSysadmin, sysAdmins.getId()); defaultNotifications.create(tenantId, DefaultNotifications.newPlatformVersion, sysAdmins.getId()); defaultNotifications.create(tenantId, DefaultNotifications.taskProcessingFailure, tenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.resourcesShortage, sysAdmins.getId()); return; } @@ -221,6 +222,9 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS if (!isNotificationConfigured(tenantId, NotificationType.TASK_PROCESSING_FAILURE)) { defaultNotifications.create(tenantId, DefaultNotifications.taskProcessingFailure, sysAdmins.getId()); } + if (!isNotificationConfigured(tenantId, NotificationType.RESOURCES_SHORTAGE)) { + defaultNotifications.create(tenantId, DefaultNotifications.resourcesShortage, sysAdmins.getId()); + } } else { var requiredNotificationTypes = List.of(NotificationType.EDGE_CONNECTION, NotificationType.EDGE_COMMUNICATION_FAILURE); var existingNotificationTypes = notificationTemplateService.findNotificationTemplatesByTenantIdAndNotificationTypes( diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index cb7b281d67..7cee3d04ae 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -49,6 +49,7 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.NewPl 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.config.RateLimitsNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.rule.trigger.config.ResourcesShortageNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.TaskProcessingFailureNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; @@ -372,6 +373,20 @@ public class DefaultNotifications { .build()) .build(); + public static final DefaultNotification resourcesShortage = DefaultNotification.builder() + .name("Resources shortage notification") + .type(NotificationType.RESOURCES_SHORTAGE) + .subject("Warning: ${resource} shortage for ${serviceId}") + .text("${resource} usage is at ${usage}%.") + .icon("warning") + .rule(DefaultRule.builder() + .name("Resources shortage") + .triggerConfig(ResourcesShortageNotificationRuleTriggerConfig.builder().cpuThreshold(0.8f).storageThreshold(0.8f).ramThreshold(0.8f).build()) + .description("Send notification to system admins on resource shortage") + .build()) + .color(RED_COLOR) + .build(); + private final NotificationTemplateService templateService; private final NotificationRuleService ruleService; diff --git a/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java index bc84a58cd9..70112fa72f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java @@ -29,6 +29,7 @@ import java.util.UUID; @Component public class HybridClientRegistrationRepository implements ClientRegistrationRepository { + private static final String defaultRedirectUriTemplate = "{baseUrl}/login/oauth2/code/{registrationId}"; @Autowired @@ -37,11 +38,13 @@ public class HybridClientRegistrationRepository implements ClientRegistrationRep @Override public ClientRegistration findByRegistrationId(String registrationId) { OAuth2Client oAuth2Client = oAuth2ClientService.findOAuth2ClientById(TenantId.SYS_TENANT_ID, new OAuth2ClientId(UUID.fromString(registrationId))); - return oAuth2Client == null ? - null : toSpringClientRegistration(oAuth2Client); + if (oAuth2Client == null) { + return null; + } + return toSpringClientRegistration(oAuth2Client); } - private ClientRegistration toSpringClientRegistration(OAuth2Client oAuth2Client){ + private ClientRegistration toSpringClientRegistration(OAuth2Client oAuth2Client) { String registrationId = oAuth2Client.getUuidId().toString(); // NONE is used if we need pkce-based code challenge @@ -67,4 +70,5 @@ public class HybridClientRegistrationRepository implements ClientRegistrationRep .redirectUri(defaultRedirectUriTemplate) .build(); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java index 16d8517b6d..343a2485ce 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java @@ -54,6 +54,7 @@ import static org.thingsboard.server.dao.service.Validator.validatePageLink; @Slf4j @RequiredArgsConstructor public class BaseOtaPackageService extends AbstractCachedEntityService implements OtaPackageService { + public static final String INCORRECT_OTA_PACKAGE_ID = "Incorrect otaPackageId "; public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; @@ -73,7 +74,7 @@ public class BaseOtaPackageService extends AbstractCachedEntityService Hashing.md5(); + case SHA256 -> Hashing.sha256(); + case SHA384 -> Hashing.sha384(); + case SHA512 -> Hashing.sha512(); + case CRC32 -> Hashing.crc32(); + case MURMUR3_32 -> Hashing.murmur3_32(); + case MURMUR3_128 -> Hashing.murmur3_128(); + default -> throw new DataValidationException("Unknown checksum algorithm!"); + }; } @Override @@ -171,6 +160,12 @@ public class BaseOtaPackageService extends AbstractCachedEntityService otaPackageInfoDao.findById(tenantId, otaPackageId.getId()), true); } + @Override + public OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version) { + log.trace("Executing findOtaPackageByTenantIdAndTitle [{}] [{}] [{}]", tenantId, title, version); + return otaPackageDao.findOtaPackageByTenantIdAndTitleAndVersion(tenantId, title, version); + } + @Override public ListenableFuture findOtaPackageInfoByIdAsync(TenantId tenantId, OtaPackageId otaPackageId) { log.trace("Executing findOtaPackageInfoByIdAsync [{}]", otaPackageId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java index f8f877e55e..cc1ff58ded 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java @@ -16,12 +16,17 @@ package org.thingsboard.server.dao.ota; import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.TenantEntityWithDataDao; -public interface OtaPackageDao extends Dao, TenantEntityWithDataDao { +public interface OtaPackageDao extends Dao, TenantEntityWithDataDao, ExportableEntityDao { Long sumDataSizeByTenantId(TenantId tenantId); + OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version); + } 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 bf73c59708..bf941256f5 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 @@ -263,7 +263,7 @@ public class BaseResourceService extends AbstractCachedEntityService INCORRECT_RESOURCE_ID + id); - return cache.getAndPutInTransaction(new ResourceInfoCacheKey(tenantId, resourceId), + return cache.getAndPutInTransaction(new ResourceInfoCacheKey(resourceId), () -> resourceInfoDao.findById(tenantId, resourceId.getId()), true); } @@ -712,7 +712,7 @@ public class BaseResourceService extends AbstractCachedEntityService { + + @Override + public boolean isValid(ObjectNode schema, ConstraintValidatorContext context) { + return schema == null || JsonSchemaUtils.isValidJsonSchema(schema); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java new file mode 100644 index 0000000000..afbcabe91b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.thingsboard.server.common.data.validation.NoNullChar; + +public final class NoNullCharValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value == null || !value.contains("\u0000"); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/RateLimitValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/RateLimitValidator.java new file mode 100644 index 0000000000..bf776d4dc7 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/RateLimitValidator.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.limit.RateLimitUtil; +import org.thingsboard.server.common.data.validation.RateLimit; + +@Slf4j +public class RateLimitValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { + return value == null || RateLimitUtil.isValid(value); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java b/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java index e051e99dc5..f94026ce89 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java @@ -26,11 +26,14 @@ import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.dao.exception.IncorrectParameterException; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.regex.Pattern; -public class Validator { +public final class Validator { + + private Validator() {} public static final Pattern PROPERTY_PATTERN = Pattern.compile("^[\\p{L}0-9_-]+$"); // Unicode letters, numbers, '_' and '-' allowed @@ -204,22 +207,61 @@ public class Validator { } /** - * This method validate PageLink page link. If pageLink is invalid than throw - * IncorrectParameterException exception + * Validates the specified PageLink object delegating to {@link #validatePageLink(PageLink, Set)} + * with no restrictions on allowed sort properties. * - * @param pageLink the page link + * @param pageLink the PageLink object to validate + * @throws IncorrectParameterException if the pageLink is null, has invalid page size, + * invalid page number, or invalid sort property + * @see #validatePageLink(PageLink, Set) */ public static void validatePageLink(PageLink pageLink) { + validatePageLink(pageLink, null); + } + + /** + * Validates the specified PageLink object ensuring that: + *
    + *
  • The PageLink object is not null
  • + *
  • The page size is greater than zero
  • + *
  • The page number is non-negative
  • + *
  • If sorting is specified, the sort property is valid and allowed
  • + *
+ * + *

When {@code allowedSortProperties} is provided, the sort property + * must be contained within this set. If {@code allowedSortProperties} is null, + * only basic sort property validation is performed. + * + * @param pageLink the PageLink object to validate. + * @param allowedSortProperties a Set of allowed sort property names, or null to skip + * this validation. If provided and the PageLink contains + * a sort order, the sort property must be in this set. + * @throws IncorrectParameterException if any of the following conditions are met: + *

    + *
  • {@code pageLink} is null
  • + *
  • page size is less than 1
  • + *
  • page number is negative
  • + *
  • sort property is malformed
  • + *
  • sort property is not in the {@code allowedSortProperties} set (when the set is provided and not null)
  • + *
+ */ + public static void validatePageLink(PageLink pageLink, Set allowedSortProperties) { if (pageLink == null) { throw new IncorrectParameterException("Page link must be specified."); } else if (pageLink.getPageSize() < 1) { - throw new IncorrectParameterException("Incorrect page link page size '"+pageLink.getPageSize()+"'. Page size must be greater than zero."); + throw new IncorrectParameterException("Incorrect page link page size '" + pageLink.getPageSize() + "'. Page size must be greater than zero."); } else if (pageLink.getPage() < 0) { - throw new IncorrectParameterException("Incorrect page link page '"+pageLink.getPage()+"'. Page must be positive integer."); + throw new IncorrectParameterException("Incorrect page link page '" + pageLink.getPage() + "'. Page must be positive integer."); } else if (pageLink.getSortOrder() != null) { - if (!isValidProperty(pageLink.getSortOrder().getProperty())) { + String sortProperty = pageLink.getSortOrder().getProperty(); + if (!isValidProperty(sortProperty)) { throw new IncorrectParameterException("Invalid page link sort property"); } + if (allowedSortProperties != null && !allowedSortProperties.contains(sortProperty)) { + throw new IncorrectParameterException( + "Unsupported sort property '" + sortProperty + "'. Only '" + String.join("', '", allowedSortProperties) + "' are allowed." + ); + } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java new file mode 100644 index 0000000000..fdccf2955f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.validator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ai.AiModelDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.tenant.TenantService; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +class AiModelDataValidator extends DataValidator { + + private final TenantService tenantService; + private final AiModelDao aiModelDao; + + @Override + protected AiModel validateUpdate(TenantId tenantId, AiModel model) { + Optional existing = aiModelDao.findByTenantIdAndId(tenantId, model.getId()); + if (existing.isEmpty()) { + throw new DataValidationException("Cannot update non-existent AI model!"); + } + return existing.get(); + } + + @Override + protected void validateDataImpl(TenantId tenantId, AiModel model) { + // ID validation + if (model.getId() != null) { + if (model.getUuidId() == null) { + throw new DataValidationException("AI model UUID should be specified!"); + } + if (model.getId().isNullUid()) { + throw new DataValidationException("AI model UUID must not be the reserved null value!"); + } + } + + // tenant ID validation + if (model.getTenantId() == null || model.getTenantId().getId() == null) { + throw new DataValidationException("AI model should be assigned to tenant!"); + } + if (model.getTenantId().isSysTenantId()) { + throw new DataValidationException("AI model cannot be assigned to the system tenant!"); + } + if (!tenantService.tenantExists(tenantId)) { + throw new DataValidationException("AI model reference a non-existent tenant!"); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java index 47911a8966..c4d3f7255d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java @@ -103,4 +103,5 @@ public class OtaPackageDataValidator extends BaseOtaPackageDataValidator { @Autowired private QueueDao queueDao; + @Lazy @Autowired private TbTenantProfileCache tenantProfileCache; diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidator.java index fae3ab43d6..6f6b6a3528 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidator.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.queue.ProcessingStrategy; import org.thingsboard.server.common.data.queue.SubmitStrategy; import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; @@ -51,9 +52,12 @@ public class TenantProfileDataValidator extends DataValidator { if (tenantProfile.getProfileData() == null) { throw new DataValidationException("Tenant profile data should be specified!"); } - if (tenantProfile.getProfileData().getConfiguration() == null) { + + Optional profileConfiguration = tenantProfile.getProfileConfiguration(); + if (profileConfiguration.isEmpty()) { throw new DataValidationException("Tenant profile data configuration should be specified!"); } + if (tenantProfile.isDefault()) { TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId); if (defaultTenantProfile != null && !defaultTenantProfile.getId().equals(tenantProfile.getId())) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java index b330ce8ee3..8c184c8c35 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java @@ -23,20 +23,8 @@ import java.util.UUID; public interface AdminSettingsDao extends Dao { - /** - * Save or update admin settings object - * - * @param adminSettings the admin settings object - * @return saved admin settings object - */ AdminSettings save(TenantId tenantId, AdminSettings adminSettings); - - /** - * Find admin settings by key. - * - * @param key the key - * @return the admin settings object - */ + AdminSettings findByTenantIdAndKey(UUID tenantId, String key); boolean removeByTenantIdAndKey(UUID tenantId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java index b1b3b25a19..604a37f4bb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java @@ -21,11 +21,16 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AdminSettingsId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; +import java.util.Optional; + @Service @Slf4j public class AdminSettingsServiceImpl implements AdminSettingsService { @@ -87,10 +92,25 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { } @Override - public void deleteAdminSettingsByTenantId(TenantId tenantId) { + public void deleteByTenantId(TenantId tenantId) { adminSettingsDao.removeByTenantId(tenantId.getId()); } + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + adminSettingsDao.removeById(tenantId, id.getId()); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return Optional.ofNullable(adminSettingsDao.findById(tenantId, entityId.getId())); + } + + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + private void dropTokenIfProviderInfoChanged(JsonNode newJsonValue, JsonNode oldJsonValue) { if (newJsonValue.has("enableOauth2") && newJsonValue.get("enableOauth2").asBoolean()) { if (!newJsonValue.get("providerId").equals(oldJsonValue.get("providerId")) || diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java index 05577b68e1..6f0380752f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java @@ -40,9 +40,6 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -/** - * @author Valerii Sosliuk - */ @Slf4j @SqlDao public abstract class JpaAbstractDao, D> diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java new file mode 100644 index 0000000000..cbe681fb58 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java @@ -0,0 +1,89 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.ExportableEntityRepository; +import org.thingsboard.server.dao.model.sql.AiModelEntity; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +interface AiModelRepository extends JpaRepository, ExportableEntityRepository { + + Optional findByTenantIdAndId(UUID tenantId, UUID id); + + Optional findByTenantIdAndName(UUID tenantId, String name); + + @Query( + value = """ + SELECT * + FROM ai_model model + WHERE model.tenant_id = :tenantId + AND (:textSearch IS NULL + OR model.name ILIKE '%' || :textSearch || '%' + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' + OR model.configuration ->> 'modelId' ILIKE '%' || :textSearch || '%') + """, + countQuery = """ + SELECT COUNT(*) + FROM ai_model model + WHERE model.tenant_id = :tenantId + AND (:textSearch IS NULL + OR model.name ILIKE '%' || :textSearch || '%' + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' + OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') + """, + nativeQuery = true + ) + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); + + @Query("SELECT ai_model.id FROM AiModelEntity ai_model WHERE ai_model.tenantId = :tenantId") + Page findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + + @Query("SELECT externalId FROM AiModelEntity WHERE id = :id") + Optional getExternalIdById(@Param("id") UUID id); + + long countByTenantId(UUID tenantId); + + @Transactional + @Modifying + @Query("DELETE FROM AiModelEntity ai_model WHERE ai_model.id IN (:ids)") + int deleteByIdIn(@Param("ids") Set ids); + + @Transactional + @Modifying + @Query(value = """ + DELETE FROM ai_model + WHERE tenant_id = :tenantId + RETURNING id + """, nativeQuery = true + ) + Set deleteByTenantId(@Param("tenantId") UUID tenantId); + + @Transactional + @Modifying + @Query("DELETE FROM AiModelEntity ai_model WHERE ai_model.tenantId = :tenantId AND ai_model.id IN (:ids)") + int deleteByTenantIdAndIdIn(@Param("tenantId") UUID tenantId, @Param("ids") Set ids); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java new file mode 100644 index 0000000000..882c86555b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java @@ -0,0 +1,139 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; +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.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +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.page.SortOrder; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.ai.AiModelDao; +import org.thingsboard.server.dao.model.sql.AiModelEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static java.util.stream.Collectors.toSet; + +@SqlDao +@Component +@RequiredArgsConstructor +class JpaAiModelDao extends JpaAbstractDao implements AiModelDao { + + private final AiModelRepository aiModelRepository; + + @Override + public Optional findByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.findByTenantIdAndId(tenantId.getId(), modelId.getId()).map(DaoUtil::getData); + } + + @Override + public AiModel findByTenantIdAndName(UUID tenantId, String name) { + return DaoUtil.getData(aiModelRepository.findByTenantIdAndName(tenantId, name)); + } + + @Override + public AiModel findByTenantIdAndExternalId(UUID tenantId, UUID externalId) { + return DaoUtil.getData(aiModelRepository.findByTenantIdAndExternalId(tenantId, externalId)); + } + + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public PageData findByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.toPageData(aiModelRepository.findByTenantId( + tenantId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), toPageRequest(pageLink)) + ); + } + + @Override + public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.pageToPageData(aiModelRepository.findIdsByTenantId(tenantId, toPageRequest(pageLink)).map(AiModelId::new)); + } + + private static PageRequest toPageRequest(PageLink pageLink) { + Sort sort; + SortOrder sortOrder = pageLink.getSortOrder(); + if (sortOrder == null) { + sort = Sort.by(Sort.Direction.ASC, "id"); + } else { + sort = JpaSort.unsafe( + Sort.Direction.fromString(sortOrder.getDirection().name()), + AiModelEntity.COLUMN_MAP.getOrDefault(sortOrder.getProperty(), sortOrder.getProperty()) + ).and(Sort.by(Sort.Direction.ASC, "id")); + } + return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort); + } + + @Override + public AiModelId getExternalIdByInternal(AiModelId internalId) { + return aiModelRepository.getExternalIdById(internalId.getId()).map(AiModelId::new).orElse(null); + } + + @Override + public Long countByTenantId(TenantId tenantId) { + return aiModelRepository.countByTenantId(tenantId.getId()); + } + + @Override + public boolean deleteById(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.deleteByIdIn(Set.of(modelId.getId())) > 0; + } + + @Override + public Set deleteByTenantId(TenantId tenantId) { + return aiModelRepository.deleteByTenantId(tenantId.getId()).stream() + .map(AiModelId::new) + .collect(toSet()); + } + + @Override + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(modelId.getId())) > 0; + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + + @Override + protected Class getEntityClass() { + return AiModelEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return aiModelRepository; + } + +} 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 c21d8ae928..647a46aeda 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 @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.sql.alarm; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -79,9 +80,6 @@ import static org.thingsboard.server.common.data.page.SortOrder.Direction.ASC; import static org.thingsboard.server.dao.DaoUtil.convertTenantEntityTypesToDto; import static org.thingsboard.server.dao.DaoUtil.toPageable; -/** - * Created by Valerii Sosliuk on 5/19/2017. - */ @Slf4j @Component @SqlDao @@ -124,6 +122,11 @@ public class JpaAlarmDao extends JpaAbstractDao implements A return latest.isEmpty() ? null : DaoUtil.getData(latest.get(0)); } + @Override + public FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { + return FluentFuture.from(service.submit(() -> findLatestActiveByOriginatorAndType(tenantId, originator, type))); + } + @Override public ListenableFuture findLatestByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { return service.submit(() -> findLatestByOriginatorAndType(tenantId, originator, type)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 4e99fb57e4..4b55884792 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -48,9 +48,6 @@ import java.util.UUID; import static org.thingsboard.server.dao.DaoUtil.convertTenantEntityInfosToDto; -/** - * Created by Valerii Sosliuk on 5/19/2017. - */ @Component @SqlDao @Slf4j 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 48a1eb6d5a..801c436d2f 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 @@ -16,10 +16,8 @@ package org.thingsboard.server.dao.sql.attributes; import org.springframework.stereotype.Repository; -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; @@ -27,8 +25,6 @@ import java.sql.Types; import java.util.List; @Repository -@Transactional -@SqlDao public class AttributeKvInsertRepository extends AbstractVersionedInsertRepository { 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') " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index be122816ba..8ccdb88db0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -28,6 +28,8 @@ public interface CalculatedFieldRepository extends JpaRepository findCalculatedFieldIdsByTenantIdAndEntityId(UUID tenantId, UUID entityId); List findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java index e59ff3f4e6..bbce4e2721 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java @@ -79,7 +79,6 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF JsonNode configuration = JacksonUtil.toJsonNode((String) row.get("configuration")); long version = row.get("version") != null ? (long) row.get("version") : 0; String debugSettings = (String) row.get("debug_settings"); - Object externalIdObj = row.get("external_id"); CalculatedField calculatedField = new CalculatedField(); calculatedField.setId(new CalculatedFieldId(id)); @@ -89,7 +88,12 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF calculatedField.setType(type); calculatedField.setName(name); calculatedField.setConfigurationVersion(configurationVersion); - calculatedField.setConfiguration(JacksonUtil.treeToValue(configuration, CalculatedFieldConfiguration.class)); + try { + calculatedField.setConfiguration(JacksonUtil.treeToValue(configuration, CalculatedFieldConfiguration.class)); + } catch (Exception e) { + log.error("Invalid configuration for CalculatedField [{}]. Skipping.", id, e); + return null; + } calculatedField.setVersion(version); calculatedField.setDebugSettings(JacksonUtil.fromString(debugSettings, DebugSettings.class)); @@ -118,7 +122,6 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF EntityType entityType = EntityType.valueOf((String) row.get("entity_type")); UUID entityId = (UUID) row.get("entity_id"); UUID calculatedFieldId = (UUID) row.get("calculated_field_id"); - JsonNode configuration = JacksonUtil.toJsonNode((String) row.get("configuration")); CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(); calculatedFieldLink.setId(new CalculatedFieldLinkId(id)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index 4bb52c29db..2632b0237b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -65,6 +65,11 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao findAll(PageLink pageLink) { log.debug("Try to find calculated fields by pageLink [{}]", pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/job/JobRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/job/JobRepository.java new file mode 100644 index 0000000000..df391be1b1 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/job/JobRepository.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.dao.model.sql.JobEntity; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface JobRepository extends JpaRepository { + + @Query("SELECT j FROM JobEntity j WHERE j.tenantId = :tenantId " + + "AND (:types IS NULL OR j.type IN (:types)) " + + "AND (:statuses IS NULL OR j.status IN (:statuses)) " + + "AND (:entities IS NULL OR j.entityId IN :entities) " + + "AND (:startTime <= 0 OR j.createdTime >= :startTime) " + + "AND (:endTime <= 0 OR j.createdTime <= :endTime) " + + "AND (:searchText IS NULL OR ilike(j.key, concat('%', :searchText, '%')) = true)") + Page findByTenantIdAndTypesAndStatusesAndEntitiesAndTimeAndSearchText(@Param("tenantId") UUID tenantId, + @Param("types") List types, + @Param("statuses") List statuses, + @Param("entities") List entities, + @Param("startTime") long startTime, + @Param("endTime") long endTime, + @Param("searchText") String searchText, + Pageable pageable); + + @Query(value = "SELECT * FROM job j WHERE j.id = :id FOR UPDATE", nativeQuery = true) + JobEntity findByIdForUpdate(UUID id); + + @Query("SELECT j FROM JobEntity j WHERE j.tenantId = :tenantId AND j.key = :key " + + "ORDER BY j.createdTime DESC") + JobEntity findLatestByTenantIdAndKey(@Param("tenantId") UUID tenantId, @Param("key") String key, Limit limit); + + boolean existsByTenantIdAndKeyAndStatusIn(UUID tenantId, String key, List statuses); + + boolean existsByTenantIdAndTypeAndStatusIn(UUID tenantId, JobType type, List statuses); + + boolean existsByTenantIdAndEntityIdAndStatusIn(UUID tenantId, UUID entityId, List statuses); + + @Query(value = "SELECT * FROM job j WHERE j.tenant_id = :tenantId AND j.type = :type " + + "AND j.status = :status ORDER BY j.created_time ASC, j.id ASC LIMIT 1 FOR UPDATE", nativeQuery = true) + JobEntity findOldestByTenantIdAndTypeAndStatusForUpdate(UUID tenantId, String type, String status); + + @Transactional + @Modifying + @Query("DELETE FROM JobEntity j WHERE j.tenantId = :tenantId") + void deleteByTenantId(UUID tenantId); + + @Transactional + @Modifying + @Query("DELETE FROM JobEntity j WHERE j.entityId = :entityId") + int deleteByEntityId(UUID entityId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/job/JpaJobDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/job/JpaJobDao.java new file mode 100644 index 0000000000..40d5177ad6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/job/JpaJobDao.java @@ -0,0 +1,116 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.job; + +import com.google.common.base.Strings; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Limit; +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.id.EntityId; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobFilter; +import org.thingsboard.server.common.data.job.JobStatus; +import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.job.JobDao; +import org.thingsboard.server.dao.model.sql.JobEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.Arrays; +import java.util.UUID; + +@Component +@SqlDao +@RequiredArgsConstructor +public class JpaJobDao extends JpaAbstractDao implements JobDao { + + private final JobRepository jobRepository; + + @Override + public PageData findByTenantIdAndFilter(TenantId tenantId, JobFilter filter, PageLink pageLink) { + return DaoUtil.toPageData(jobRepository.findByTenantIdAndTypesAndStatusesAndEntitiesAndTimeAndSearchText(tenantId.getId(), + CollectionsUtil.isEmpty(filter.getTypes()) ? null : filter.getTypes(), + CollectionsUtil.isEmpty(filter.getStatuses()) ? null : filter.getStatuses(), + CollectionsUtil.isEmpty(filter.getEntities()) ? null : filter.getEntities(), + filter.getStartTime() != null ? filter.getStartTime() : 0, + filter.getEndTime() != null ? filter.getEndTime() : 0, + Strings.emptyToNull(pageLink.getTextSearch()), DaoUtil.toPageable(pageLink))); + } + + @Override + public Job findByIdForUpdate(TenantId tenantId, JobId jobId) { + return DaoUtil.getData(jobRepository.findByIdForUpdate(jobId.getId())); + } + + @Override + public Job findLatestByTenantIdAndKey(TenantId tenantId, String key) { + return DaoUtil.getData(jobRepository.findLatestByTenantIdAndKey(tenantId.getId(), key, Limit.of(1))); + } + + @Override + public boolean existsByTenantAndKeyAndStatusOneOf(TenantId tenantId, String key, JobStatus... statuses) { + return jobRepository.existsByTenantIdAndKeyAndStatusIn(tenantId.getId(), key, Arrays.stream(statuses).toList()); + } + + @Override + public boolean existsByTenantIdAndTypeAndStatusOneOf(TenantId tenantId, JobType type, JobStatus... statuses) { + return jobRepository.existsByTenantIdAndTypeAndStatusIn(tenantId.getId(), type, Arrays.stream(statuses).toList()); + } + + @Override + public boolean existsByTenantIdAndEntityIdAndStatusOneOf(TenantId tenantId, EntityId entityId, JobStatus... statuses) { + return jobRepository.existsByTenantIdAndEntityIdAndStatusIn(tenantId.getId(), entityId.getId(), Arrays.stream(statuses).toList()); + } + + @Override + public Job findOldestByTenantIdAndTypeAndStatusForUpdate(TenantId tenantId, JobType type, JobStatus status) { + return DaoUtil.getData(jobRepository.findOldestByTenantIdAndTypeAndStatusForUpdate(tenantId.getId(), type.name(), status.name())); + } + + @Override + public void removeByTenantId(TenantId tenantId) { + jobRepository.deleteByTenantId(tenantId.getId()); + } + + @Override + public int removeByEntityId(TenantId tenantId, EntityId entityId) { + return jobRepository.deleteByEntityId(entityId.getId()); + } + + @Override + public EntityType getEntityType() { + return EntityType.JOB; + } + + @Override + protected Class getEntityClass() { + return JobEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return jobRepository; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java index 780f67932e..7322ea4cb6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java @@ -22,6 +22,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -43,24 +44,52 @@ public class JpaOtaPackageDao extends JpaAbstractDao getEntityClass() { - return OtaPackageEntity.class; + public Long sumDataSizeByTenantId(TenantId tenantId) { + return otaPackageRepository.sumDataSizeByTenantId(tenantId.getId()); } + @Transactional @Override - protected JpaRepository getRepository() { - return otaPackageRepository; + public OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version) { + return DaoUtil.getData(otaPackageRepository.findByTenantIdAndTitleAndVersion(tenantId.getId(), title, version)); } + @Transactional @Override - public Long sumDataSizeByTenantId(TenantId tenantId) { - return otaPackageRepository.sumDataSizeByTenantId(tenantId.getId()); + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(otaPackageRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); } @Transactional @Override - public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { - return DaoUtil.toPageData(otaPackageRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + public PageData findByTenantId(UUID tenantId, PageLink pageLink) { + return findAllByTenantId(TenantId.fromUUID(tenantId), pageLink); + } + + @Override + public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.pageToPageData(otaPackageRepository.findIdsByTenantId(tenantId, DaoUtil.toPageable(pageLink)).map(OtaPackageId::new)); + } + + @Transactional + @Override + public OtaPackage findByTenantIdAndExternalId(UUID tenantId, UUID externalId) { + return DaoUtil.getData(otaPackageRepository.findByTenantIdAndExternalId(tenantId, externalId)); + } + + @Override + public OtaPackageId getExternalIdByInternal(OtaPackageId internalId) { + return DaoUtil.toEntityId(otaPackageRepository.getExternalIdById(internalId.getId()), OtaPackageId::new); + } + + @Override + protected Class getEntityClass() { + return OtaPackageEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return otaPackageRepository; } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java index fe7e2bf015..c5ca894a00 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java @@ -26,14 +26,15 @@ import org.thingsboard.server.dao.model.sql.OtaPackageInfoEntity; import java.util.UUID; public interface OtaPackageInfoRepository extends JpaRepository { - @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, CASE WHEN (f.data IS NOT NULL OR f.url IS NOT NULL) THEN true ELSE false END) FROM OtaPackageEntity f WHERE " + + + @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, f.externalId, CASE WHEN (f.data IS NOT NULL OR f.url IS NOT NULL) THEN true ELSE false END) FROM OtaPackageEntity f WHERE " + "f.tenantId = :tenantId " + "AND (:searchText IS NULL OR ilike(f.title, CONCAT('%', :searchText, '%')) = true)") Page findAllByTenantId(@Param("tenantId") UUID tenantId, @Param("searchText") String searchText, Pageable pageable); - @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, true) FROM OtaPackageEntity f WHERE " + + @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, f.externalId, true) FROM OtaPackageEntity f WHERE " + "f.tenantId = :tenantId " + "AND f.deviceProfileId = :deviceProfileId " + "AND f.type = :type " + @@ -45,7 +46,7 @@ public interface OtaPackageInfoRepository extends JpaRepository { +public interface OtaPackageRepository extends JpaRepository, ExportableEntityRepository { @Query(value = "SELECT COALESCE(SUM(ota.data_size), 0) FROM ota_package ota WHERE ota.tenant_id = :tenantId AND ota.data IS NOT NULL", nativeQuery = true) Long sumDataSizeByTenantId(@Param("tenantId") UUID tenantId); Page findByTenantId(UUID tenantId, Pageable pageable); + OtaPackageEntity findByTenantIdAndTitleAndVersion(UUID tenantId, String title, String version); + + @Query("SELECT externalId FROM OtaPackageEntity WHERE id = :id") + UUID getExternalIdById(@Param("id") UUID id); + + @Query("SELECT r.id FROM OtaPackageEntity r WHERE r.tenantId = :tenantId") + Page findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsApiService.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsApiService.java index e486d3b645..f50bb9a68c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsApiService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsApiService.java @@ -35,24 +35,9 @@ public class DummyEdqsApiService implements EdqsApiService { throw new UnsupportedOperationException(); } - @Override - public boolean isEnabled() { - return false; - } - - @Override - public void setEnabled(boolean enabled) { - log.warn("Got request to enable EDQS API, but it isn't supported", new RuntimeException("stacktrace")); - } - @Override public boolean isSupported() { return false; } - @Override - public boolean isAutoEnable() { - return false; - } - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsService.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsService.java index 514e07c323..707273a11b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsService.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.ToCoreEdqsMsg; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; import org.thingsboard.server.common.data.id.EntityId; @@ -47,4 +48,14 @@ public class DummyEdqsService implements EdqsService { @Override public void processSystemMsg(ToCoreEdqsMsg request) {} + @Override + public boolean isApiEnabled() { + return getState().isApiEnabled(); + } + + @Override + public EdqsState getState() { + return new EdqsState(); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java index c27602421d..889de7cb74 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java @@ -22,9 +22,6 @@ import org.thingsboard.server.dao.model.sql.AdminSettingsEntity; import java.util.UUID; -/** - * Created by Valerii Sosliuk on 5/6/2017. - */ public interface AdminSettingsRepository extends JpaRepository { AdminSettingsEntity findByTenantIdAndKey(UUID tenantId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java index 68ce5e9d22..3dafa79c3f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.sql.settings; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -35,21 +35,10 @@ import java.util.UUID; @Component @SqlDao -@Slf4j +@RequiredArgsConstructor public class JpaAdminSettingsDao extends JpaAbstractDao implements AdminSettingsDao, TenantEntityDao { - @Autowired - private AdminSettingsRepository adminSettingsRepository; - - @Override - protected Class getEntityClass() { - return AdminSettingsEntity.class; - } - - @Override - protected JpaRepository getRepository() { - return adminSettingsRepository; - } + private final AdminSettingsRepository adminSettingsRepository; @Override public AdminSettings findByTenantIdAndKey(UUID tenantId, String key) { @@ -77,4 +66,19 @@ public class JpaAdminSettingsDao extends JpaAbstractDao getEntityClass() { + return AdminSettingsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return adminSettingsRepository; + } + + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + } 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 d031dbb8ee..fb558dab0c 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 @@ -38,10 +38,6 @@ import java.util.List; import java.util.UUID; import java.util.stream.Collectors; - -/** - * Created by Valerii Sosliuk on 4/30/2017. - */ @Component @SqlDao public class JpaTenantDao extends JpaAbstractDao implements TenantDao { @@ -97,8 +93,14 @@ public class JpaTenantDao extends JpaAbstractDao implement .collect(Collectors.toList()); } + @Override + public Tenant findTenantByName(TenantId tenantId, String name) { + return DaoUtil.getData(tenantRepository.findFirstByTitle(name)); + } + @Override public List findNextBatch(UUID id, int batchSize) { return tenantRepository.findNextBatch(id, Limit.of(batchSize)); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java index bafa9a6fe6..08859d5f63 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java @@ -48,7 +48,7 @@ public interface TenantRepository extends JpaRepository { "LEFT JOIN TenantProfileEntity p on p.id = t.tenantProfileId " + "WHERE (:textSearch IS NULL OR ilike(t.title, CONCAT('%', :textSearch, '%')) = true)") Page findTenantInfosNextPage(@Param("textSearch") String textSearch, - Pageable pageable); + Pageable pageable); @Query("SELECT t.id FROM TenantEntity t") Page findTenantsIds(Pageable pageable); @@ -59,4 +59,7 @@ public interface TenantRepository extends JpaRepository { @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.TenantFields(t.id, t.createdTime, t.title, t.version," + "t.additionalInfo, t.country, t.state, t.city, t.address, t.address2, t.zip, t.phone, t.email, t.region) FROM TenantEntity t WHERE t.id > :id ORDER BY t.id") List findNextBatch(@Param("id") UUID id, Limit limit); + + TenantEntity findFirstByTitle(String name); + } 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 53a824f026..c14c069f23 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 @@ -29,7 +29,6 @@ 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; import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; -import org.thingsboard.server.dao.util.SqlDao; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -38,7 +37,6 @@ import java.util.concurrent.locks.ReentrantLock; @Component @Slf4j -@SqlDao @RequiredArgsConstructor public class JpaKeyDictionaryDao extends JpaAbstractDaoListeningExecutorService implements KeyDictionaryDao { 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 b40fa520f2..c9d16f00ec 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 @@ -18,11 +18,9 @@ package org.thingsboard.server.dao.sqlts.insert.latest.sql; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.AbstractVersionedInsertRepository; import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.sqlts.insert.latest.InsertLatestTsRepository; -import org.thingsboard.server.dao.util.SqlDao; import org.thingsboard.server.dao.util.SqlTsLatestAnyDao; import java.sql.PreparedStatement; @@ -32,8 +30,6 @@ import java.util.List; @SqlTsLatestAnyDao @Repository -@Transactional -@SqlDao public class SqlLatestInsertTsRepository extends AbstractVersionedInsertRepository implements InsertLatestTsRepository { @Value("${sql.ts_latest.update_by_latest_ts:true}") diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java index 2a8269b15d..8ff3999c4e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java @@ -30,20 +30,8 @@ public interface TenantDao extends Dao { TenantInfo findTenantInfoById(TenantId tenantId, UUID id); - /** - * Save or update tenant object - * - * @param tenant the tenant object - * @return saved tenant object - */ Tenant save(TenantId tenantId, Tenant tenant); - /** - * Find tenants by page link. - * - * @param pageLink the page link - * @return the list of tenant objects - */ PageData findTenants(TenantId tenantId, PageLink pageLink); PageData findTenantInfos(TenantId tenantId, PageLink pageLink); @@ -52,4 +40,6 @@ public interface TenantDao extends Dao { List findTenantIdsByTenantProfileId(TenantProfileId tenantProfileId); + Tenant findTenantByName(TenantId tenantId, String name); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 8c40ca3e14..0df7c36527 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -43,7 +43,6 @@ import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.TenantDataValidator; -import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.trendz.TrendzSettingsService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; import org.thingsboard.server.dao.user.UserService; @@ -76,8 +75,6 @@ public class TenantServiceImpl extends AbstractCachedEntityService tenantDao.existsById(tenantId, tenantId.getId()), false); } - private PaginatedRemover tenantsRemover = new PaginatedRemover<>() { + private final PaginatedRemover tenantsRemover = new PaginatedRemover<>() { @Override protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { 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 9eefcaae1e..cecf4ab587 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 @@ -205,8 +205,8 @@ public class BaseTimeseriesService implements TimeseriesService { ListenableFuture dpsFuture = saveTs ? Futures.transform(Futures.allAsList(tsFutures), SUM_ALL_INTEGERS, MoreExecutors.directExecutor()) : Futures.immediateFuture(0); ListenableFuture> versionsFuture = saveLatest ? Futures.allAsList(latestFutures) : Futures.immediateFuture(null); return Futures.whenAllComplete(dpsFuture, versionsFuture).call(() -> { - Integer dataPoints = Futures.getUnchecked(dpsFuture); - List versions = Futures.getUnchecked(versionsFuture); + Integer dataPoints = dpsFuture.get(); + List versions = versionsFuture.get(); return TimeseriesSaveResult.of(dataPoints, versions); }, MoreExecutors.directExecutor()); } @@ -298,13 +298,13 @@ public class BaseTimeseriesService implements TimeseriesService { long interval = query.getInterval(); if (interval < 1) { throw new IncorrectParameterException("Invalid TsKvQuery: 'interval' must be greater than 0, but got " + interval + - ". Please check your query parameters and ensure 'endTs' is greater than 'startTs' or increase 'interval'."); + ". Please check your query parameters and ensure 'endTs' is greater than 'startTs' or increase 'interval'."); } long step = Math.max(interval, 1000); long intervalCounts = (query.getEndTs() - query.getStartTs()) / step; if (intervalCounts > maxTsIntervals || intervalCounts < 0) { throw new IncorrectParameterException("Incorrect TsKvQuery. Number of intervals is to high - " + intervalCounts + ". " + - "Please increase 'interval' parameter for your query or reduce the time range of the query."); + "Please increase 'interval' parameter for your query or reduce the time range of the query."); } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java index a87e9312e3..4d691db31d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java @@ -32,14 +32,17 @@ import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.cache.limits.RateLimitService; +import org.thingsboard.server.common.data.exception.RateLimitExceededException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.stats.DefaultCounter; import org.thingsboard.server.common.stats.StatsCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.StatsType; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.nosql.CassandraStatementTask; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import java.util.HashMap; import java.util.Map; @@ -64,6 +67,7 @@ public abstract class AbstractBufferedRateExecutor> queue; private final ExecutorService dispatcherExecutor; private final ExecutorService callbackExecutor; @@ -82,19 +86,23 @@ public abstract class AbstractBufferedRateExecutor tenantNamesCache = new HashMap<>(); + private final LimitedApi myLimitedApi; + public AbstractBufferedRateExecutor(int queueLimit, int concurrencyLimit, long maxWaitTime, int dispatcherThreads, - int callbackThreads, long pollMs, int printQueriesFreq, StatsFactory statsFactory, - EntityService entityService, RateLimitService rateLimitService, boolean printTenantNames) { + int callbackThreads, long pollMs, int printQueriesFreq, BufferedRateExecutorType executorType, TbServiceInfoProvider serviceInfoProvider, + RateLimitService rateLimitService, StatsFactory statsFactory, EntityService entityService, boolean printTenantNames) { this.maxWaitTime = maxWaitTime; this.pollMs = pollMs; + this.bufferName = executorType.getDisplayName(); + this.myLimitedApi = resolveLimitedApi(serviceInfoProvider, executorType); this.concurrencyLimit = concurrencyLimit; this.printQueriesFreq = printQueriesFreq; this.queue = new LinkedBlockingDeque<>(queueLimit); - this.dispatcherExecutor = Executors.newFixedThreadPool(dispatcherThreads, ThingsBoardThreadFactory.forName("nosql-" + getBufferName() + "-dispatcher")); - this.callbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreads, "nosql-" + getBufferName() + "-callback"); - this.timeoutExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor("nosql-" + getBufferName() + "-timeout"); + this.dispatcherExecutor = Executors.newFixedThreadPool(dispatcherThreads, ThingsBoardThreadFactory.forName("nosql-" + bufferName + "-dispatcher")); + this.callbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreads, "nosql-" + bufferName + "-callback"); + this.timeoutExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor("nosql-" + bufferName + "-timeout"); this.stats = new BufferedRateExecutorStats(statsFactory); - String concurrencyLevelKey = StatsType.RATE_EXECUTOR.getName() + "." + CONCURRENCY_LEVEL + getBufferName(); //metric name may change with buffer name suffix + String concurrencyLevelKey = StatsType.RATE_EXECUTOR.getName() + "." + CONCURRENCY_LEVEL + bufferName; //metric name may change with buffer name suffix this.concurrencyLevel = statsFactory.createGauge(concurrencyLevelKey, new AtomicInteger(0)); this.entityService = entityService; @@ -114,14 +122,14 @@ public abstract class AbstractBufferedRateExecutor execute(AsyncTaskContext taskCtx); - public abstract String getBufferName(); - private void dispatch() { - log.info("[{}] Buffered rate executor thread started", getBufferName()); + log.info("[{}] Buffered rate executor thread started", bufferName); while (!Thread.interrupted()) { int curLvl = concurrencyLevel.get(); AsyncTaskContext taskCtx = null; @@ -169,7 +185,7 @@ public abstract class AbstractBufferedRateExecutor= printQueriesFreq) { printQueriesIdx.set(0); String query = queryToString(finalTaskCtx); - log.info("[{}][{}] Cassandra query: {}", getBufferName(), taskCtx.getId(), query); + log.info("[{}][{}] Cassandra query: {}", bufferName, taskCtx.getId(), query); } } logTask("Processing", finalTaskCtx); @@ -222,7 +238,7 @@ public abstract class AbstractBufferedRateExecutor taskCtx) { @@ -284,9 +300,9 @@ public abstract class AbstractBufferedRateExecutor 0 - || rateLimitedTenantsCount > 0 - || concurrencyLevel.get() > 0 - || stats.getStatsCounters().stream().anyMatch(counter -> counter.get() > 0) + || rateLimitedTenantsCount > 0 + || concurrencyLevel.get() > 0 + || stats.getStatsCounters().stream().anyMatch(counter -> counter.get() > 0) ) { StringBuilder statsBuilder = new StringBuilder(); @@ -298,7 +314,7 @@ public abstract class AbstractBufferedRateExecutor tsKvEntries, boolean valueNoXssValidation) { @@ -57,11 +56,13 @@ public class KvUtils { throw new DataValidationException("Validation error: key length must be equal or less than 255"); } - if (validatedKeys.getIfPresent(key) == null) { - if (!NoXssValidator.isValid(key)) { - throw new DataValidationException("Validation error: key is malformed"); - } - validatedKeys.put(key, Boolean.TRUE); + Boolean isValid = validatedKeys.asMap().get(key); + if (isValid == null) { + isValid = NoXssValidator.isValid(key); + validatedKeys.put(key, isValid); + } + if (!isValid) { + throw new DataValidationException("Validation error: key is malformed"); } if (valueNoXssValidation) { diff --git a/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql b/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql index b756d282f8..ae70e8c725 100644 --- a/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql +++ b/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql @@ -22,19 +22,19 @@ -- That why we need to define DESC index explicitly as (ts DESC NULLS LAST) CREATE INDEX IF NOT EXISTS idx_rule_node_debug_event_main - ON rule_node_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + ON rule_node_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST); CREATE INDEX IF NOT EXISTS idx_rule_chain_debug_event_main - ON rule_chain_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + ON rule_chain_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST); CREATE INDEX IF NOT EXISTS idx_stats_event_main - ON stats_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + ON stats_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST); CREATE INDEX IF NOT EXISTS idx_lc_event_main - ON lc_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + ON lc_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST); CREATE INDEX IF NOT EXISTS idx_error_event_main - ON error_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + ON error_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST); CREATE INDEX IF NOT EXISTS idx_cf_debug_event_main - ON cf_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + ON cf_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST); diff --git a/dao/src/main/resources/sql/schema-entities-idx.sql b/dao/src/main/resources/sql/schema-entities-idx.sql index 7f52365e33..12f314590a 100644 --- a/dao/src/main/resources/sql/schema-entities-idx.sql +++ b/dao/src/main/resources/sql/schema-entities-idx.sql @@ -73,22 +73,6 @@ CREATE INDEX IF NOT EXISTS idx_edge_event_id ON edge_event(id); CREATE INDEX IF NOT EXISTS idx_rpc_tenant_id_device_id ON rpc(tenant_id, device_id); -CREATE INDEX IF NOT EXISTS idx_device_external_id ON device(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_device_profile_external_id ON device_profile(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_asset_external_id ON asset(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_entity_view_external_id ON entity_view(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_rule_chain_external_id ON rule_chain(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_dashboard_external_id ON dashboard(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_customer_external_id ON customer(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_widgets_bundle_external_id ON widgets_bundle(tenant_id, external_id); - CREATE INDEX IF NOT EXISTS idx_rule_node_external_id ON rule_node(rule_chain_id, external_id); CREATE INDEX IF NOT EXISTS idx_rule_node_type_id_configuration_version ON rule_node(type, id, configuration_version); @@ -129,3 +113,7 @@ CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag); CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(resource_type, public_resource_key); CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_ai_model_tenant_id ON ai_model(tenant_id); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index b425550e7e..6ccf2f6d95 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -216,7 +216,9 @@ CREATE TABLE IF NOT EXISTS ota_package ( data oid, data_size bigint, additional_info varchar, - CONSTRAINT ota_package_tenant_title_version_unq_key UNIQUE (tenant_id, title, version) + external_id uuid, + CONSTRAINT ota_package_tenant_title_version_unq_key UNIQUE (tenant_id, title, version), + CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id) ); CREATE TABLE IF NOT EXISTS queue ( @@ -624,6 +626,7 @@ CREATE TABLE IF NOT EXISTS mobile_app ( created_time bigint NOT NULL, tenant_id uuid, pkg_name varchar(255), + title varchar(255), app_secret varchar(2048), platform_type varchar(32), status varchar(32), @@ -948,3 +951,28 @@ CREATE TABLE IF NOT EXISTS cf_debug_event ( e_result varchar, e_error varchar ) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS job ( + id uuid NOT NULL CONSTRAINT job_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + type varchar NOT NULL, + key varchar NOT NULL, + entity_id uuid NOT NULL, + entity_type varchar NOT NULL, + status varchar NOT NULL, + configuration varchar NOT NULL, + result varchar +); + +CREATE TABLE IF NOT EXISTS ai_model ( + id UUID NOT NULL PRIMARY KEY, + external_id UUID, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + configuration JSONB NOT NULL, + CONSTRAINT ai_model_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT ai_model_external_id_unq_key UNIQUE (tenant_id, external_id) +); diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractNoSqlContainer.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractNoSqlContainer.java index 07a62a6391..cadf7cc4f1 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/AbstractNoSqlContainer.java +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractNoSqlContainer.java @@ -41,7 +41,7 @@ public abstract class AbstractNoSqlContainer { ); @ClassRule(order = 0) - public static final CassandraContainer cassandra = (CassandraContainer) new CassandraContainer("cassandra:4.1") { + public static final CassandraContainer cassandra = (CassandraContainer) new CassandraContainer("cassandra:5.0") { @Override protected void containerIsStarted(InspectContainerResponse containerInfo) { super.containerIsStarted(containerInfo); diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java index 9a6e1e514e..a1009cd255 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao; import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; import org.junit.ClassRule; import org.junit.rules.ExternalResource; import org.testcontainers.containers.GenericContainer; @@ -23,27 +24,33 @@ import org.testcontainers.containers.Network; import org.testcontainers.containers.output.OutputFrame; import java.util.List; +import java.util.Map; 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"; + 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"; + static final String IMAGE = "bitnami/valkey-cluster:8.0"; + static final Map ENVS = Map.of( + "VALKEY_CLUSTER_ANNOUNCE_IP", "127.0.0.1", + "VALKEY_CLUSTER_DYNAMIC_IPS", "no", + "ALLOW_EMPTY_PASSWORD", "yes", + "VALKEY_NODES", NODES + ); - @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); + public static GenericContainer redis1 = new GenericContainer(IMAGE).withEnv(ENVS).withEnv("VALKEY_PORT_NUMBER", "6371").withNetworkMode("host").withLogConsumer(AbstractRedisClusterContainer::consumeLog); @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); + public static GenericContainer redis2 = new GenericContainer(IMAGE).withEnv(ENVS).withEnv("VALKEY_PORT_NUMBER", "6372").withNetworkMode("host").withLogConsumer(AbstractRedisClusterContainer::consumeLog); @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); + public static GenericContainer redis3 = new GenericContainer(IMAGE).withEnv(ENVS).withEnv("VALKEY_PORT_NUMBER", "6373").withNetworkMode("host").withLogConsumer(AbstractRedisClusterContainer::consumeLog); @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); + public static GenericContainer redis4 = new GenericContainer(IMAGE).withEnv(ENVS).withEnv("VALKEY_PORT_NUMBER", "6374").withNetworkMode("host").withLogConsumer(AbstractRedisClusterContainer::consumeLog); @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); + public static GenericContainer redis5 = new GenericContainer(IMAGE).withEnv(ENVS).withEnv("VALKEY_PORT_NUMBER", "6375").withNetworkMode("host").withLogConsumer(AbstractRedisClusterContainer::consumeLog); @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); + public static GenericContainer redis6 = new GenericContainer(IMAGE).withEnv(ENVS).withEnv("VALKEY_PORT_NUMBER", "6376").withNetworkMode("host").withLogConsumer(AbstractRedisClusterContainer::consumeLog); @ClassRule(order = 100) @@ -59,19 +66,18 @@ public class AbstractRedisClusterContainer { 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); + String clusterCreateCommand = "valkey-cli --cluster create " + NODES.replace(","," ") + " --cluster-replicas 1 --cluster-yes"; + log.warn("Command to init ValKey Cluster: {}", clusterCreateCommand); var result = redis6.execInContainer("/bin/sh", "-c", clusterCreateCommand); log.warn("Init cluster result: {}", result); + Assertions.assertThat(result.getExitCode()).isEqualTo(0); Thread.sleep(TimeUnit.SECONDS.toMillis(5)); // otherwise cluster not always ready - log.warn("Connect to nodes: {}", nodes); + 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.nodes", NODES); System.setProperty("redis.cluster.useDefaultPoolConfig", "false"); } @@ -83,9 +89,12 @@ public class AbstractRedisClusterContainer { redis4.stop(); redis5.stop(); redis6.stop(); - List.of("cache.type", "redis.connection.type", "redis.cluster.nodes", "redis.cluster.useDefaultPoolConfig\"") + List.of("cache.type", "redis.connection.type", "redis.cluster.nodes", "redis.cluster.useDefaultPoolConfig") .forEach(System.getProperties()::remove); } }; + private static void consumeLog(Object x) { + log.warn("{}", ((OutputFrame) x).getUtf8StringWithoutLineEnding()); + } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisContainer.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisContainer.java index 816c6609ec..58843a4651 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisContainer.java +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisContainer.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.junit.ClassRule; import org.junit.rules.ExternalResource; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.OutputFrame; import java.util.List; @@ -26,7 +27,9 @@ import java.util.List; public class AbstractRedisContainer { @ClassRule(order = 0) - public static GenericContainer redis = new GenericContainer("redis:7.2") + public static GenericContainer redis = new GenericContainer("bitnami/valkey:8.0") + .withEnv("ALLOW_EMPTY_PASSWORD","yes") + .withLogConsumer(s -> log.warn(((OutputFrame) s).getUtf8String().trim())) .withExposedPorts(6379); @ClassRule(order = 1) diff --git a/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java index 4a14b623f7..d15fc538a1 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java @@ -21,7 +21,7 @@ import org.junit.runner.RunWith; @RunWith(ClasspathSuite.class) @ClassnameFilters( - //All the same tests using redis instead of caffeine. + //All the same tests using ValKey instead of caffeine. { "org.thingsboard.server.dao.service.*ServiceSqlTest", } diff --git a/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java b/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java index d83e9bd137..5d43ffb4ae 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java +++ b/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java @@ -33,7 +33,8 @@ import static org.assertj.core.api.Assertions.assertThat; public class RedisJUnit5Test { @Container - private static final GenericContainer REDIS = new GenericContainer("redis:7.2-bookworm") + private static final GenericContainer REDIS = new GenericContainer("bitnami/valkey:8.0") + .withEnv("ALLOW_EMPTY_PASSWORD","yes") .withLogConsumer(s -> log.error(((OutputFrame) s).getUtf8String().trim())) .withExposedPorts(6379); diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index a44303107c..1c2c0c5519 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -111,6 +111,9 @@ cache.specs.mobileSecretKey.maxSize=10000 cache.specs.trendzSettings.timeToLiveInMinutes=1440 cache.specs.trendzSettings.maxSize=10000 +cache.specs.aiModel.timeToLiveInMinutes=1440 +cache.specs.aiModel.maxSize=10000 + redis.connection.host=localhost redis.connection.port=6379 redis.connection.db=0 @@ -158,4 +161,4 @@ queue.core.poll-interval=5 queue.core.partitions=2 queue.rule-engine.poll-interval=5 -spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect \ No newline at end of file +spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect diff --git a/docker/.env b/docker/.env index 71722247df..58006db8fc 100644 --- a/docker/.env +++ b/docker/.env @@ -1,7 +1,7 @@ TB_QUEUE_TYPE=kafka -# redis or redis-cluster or redis-sentinel -CACHE=redis +# valkey or valkey-cluster or valkey-sentinel +CACHE=valkey DOCKER_REPO=thingsboard diff --git a/docker/.gitignore b/docker/.gitignore index b6c9fcf18c..527b3c2a3b 100644 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -6,15 +6,15 @@ tb-node/postgres/** tb-node/cassandra/** tb-transports/*/log tb-vc-executor/log/** -tb-node/redis-cluster-data-0/** -tb-node/redis-cluster-data-1/** -tb-node/redis-cluster-data-2/** -tb-node/redis-cluster-data-3/** -tb-node/redis-cluster-data-4/** -tb-node/redis-cluster-data-5/** -tb-node/redis-sentinel-data-master/** -tb-node/redis-sentinel-data-slave/** -tb-node/redis-sentinel-data-sentinel/** -tb-node/redis-data/** +tb-node/valkey-cluster-data-0/** +tb-node/valkey-cluster-data-1/** +tb-node/valkey-cluster-data-2/** +tb-node/valkey-cluster-data-3/** +tb-node/valkey-cluster-data-4/** +tb-node/valkey-cluster-data-5/** +tb-node/valkey-sentinel-data-primary/** +tb-node/valkey-sentinel-data-replica/** +tb-node/valkey-sentinel-data-sentinel/** +tb-node/valkey-data/** !.env diff --git a/docker/README.md b/docker/README.md index 1a852c80eb..ee8a932857 100644 --- a/docker/README.md +++ b/docker/README.md @@ -19,11 +19,11 @@ In order to set database type change the value of `DATABASE` variable in `.env` In order to set cache type change the value of `CACHE` variable in `.env` file to one of the following: -- `redis` - use Redis standalone cache (1 node - 1 master); -- `redis-cluster` - use Redis cluster cache (6 nodes - 3 masters, 3 slaves); -- `redis-sentinel` - use Redis sentinel cache (3 nodes - 1 master, 1 slave, 1 sentinel) +- `valkey` - use Valkey standalone cache (1 node - 1 primary); +- `valkey-cluster` - use Valkey cluster cache (6 nodes - 3 primaries, 3 replicas); +- `valkey-sentinel` - use Valkey sentinel cache (3 nodes - 1 primary, 1 replica, 1 sentinel) -**NOTE**: According to the cache type corresponding docker service will be deployed (see `docker-compose.redis.yml`, `docker-compose.redis-cluster.yml`, `docker-compose.redis-sentinel.yml` for details). +**NOTE**: According to the cache type corresponding docker service will be deployed (see `docker-compose.valkey.yml`, `docker-compose.valkey-cluster.yml`, `docker-compose.valkey-sentinel.yml` for details). Execute the following command to create log folders for the services and chown of these folders to the docker container users. To be able to change user, **chown** command is used, which requires sudo permissions (script will request password for a sudo access): diff --git a/docker/cache-redis-cluster.env b/docker/cache-redis-cluster.env deleted file mode 100644 index a3b516063b..0000000000 --- a/docker/cache-redis-cluster.env +++ /dev/null @@ -1,5 +0,0 @@ -CACHE_TYPE=redis -REDIS_CONNECTION_TYPE=cluster -REDIS_NODES=redis-node-0:6379,redis-node-1:6379,redis-node-2:6379,redis-node-3:6379,redis-node-4:6379,redis-node-5:6379 -REDIS_USE_DEFAULT_POOL_CONFIG=false -REDIS_PASSWORD=thingsboard diff --git a/docker/cache-redis.env b/docker/cache-redis.env deleted file mode 100644 index 7b92620666..0000000000 --- a/docker/cache-redis.env +++ /dev/null @@ -1,2 +0,0 @@ -CACHE_TYPE=redis -REDIS_HOST=redis diff --git a/docker/cache-valkey-cluster.env b/docker/cache-valkey-cluster.env new file mode 100644 index 0000000000..86f4682dfd --- /dev/null +++ b/docker/cache-valkey-cluster.env @@ -0,0 +1,5 @@ +CACHE_TYPE=redis +REDIS_CONNECTION_TYPE=cluster +REDIS_NODES=valkey-node-0:6379,valkey-node-1:6379,valkey-node-2:6379,valkey-node-3:6379,valkey-node-4:6379,valkey-node-5:6379 +REDIS_USE_DEFAULT_POOL_CONFIG=false +REDIS_PASSWORD=thingsboard diff --git a/docker/cache-redis-sentinel.env b/docker/cache-valkey-sentinel.env similarity index 70% rename from docker/cache-redis-sentinel.env rename to docker/cache-valkey-sentinel.env index 39a1246d9c..44c867d93d 100644 --- a/docker/cache-redis-sentinel.env +++ b/docker/cache-valkey-sentinel.env @@ -1,7 +1,7 @@ CACHE_TYPE=redis REDIS_CONNECTION_TYPE=sentinel -REDIS_MASTER=mymaster -REDIS_SENTINELS=redis-sentinel:26379 +REDIS_MASTER=myprimary +REDIS_SENTINELS=valkey-sentinel:26379 REDIS_SENTINEL_PASSWORD=sentinel REDIS_USE_DEFAULT_POOL_CONFIG=false REDIS_PASSWORD=thingsboard diff --git a/docker/cache-valkey.env b/docker/cache-valkey.env new file mode 100644 index 0000000000..3971ab4f18 --- /dev/null +++ b/docker/cache-valkey.env @@ -0,0 +1,2 @@ +CACHE_TYPE=redis +REDIS_HOST=valkey diff --git a/docker/compose-utils.sh b/docker/compose-utils.sh index 05a91257a2..fecffeacce 100755 --- a/docker/compose-utils.sh +++ b/docker/compose-utils.sh @@ -64,19 +64,19 @@ function additionalComposeMonitoringArgs() { function additionalComposeCacheArgs() { source .env CACHE_COMPOSE_ARGS="" - CACHE="${CACHE:-redis}" + CACHE="${CACHE:-valkey}" case $CACHE in - redis) - CACHE_COMPOSE_ARGS="-f docker-compose.redis.yml" + valkey) + CACHE_COMPOSE_ARGS="-f docker-compose.valkey.yml" ;; - redis-cluster) - CACHE_COMPOSE_ARGS="-f docker-compose.redis-cluster.yml" + valkey-cluster) + CACHE_COMPOSE_ARGS="-f docker-compose.valkey-cluster.yml" ;; - redis-sentinel) - CACHE_COMPOSE_ARGS="-f docker-compose.redis-sentinel.yml" + valkey-sentinel) + CACHE_COMPOSE_ARGS="-f docker-compose.valkey-sentinel.yml" ;; *) - echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'redis' or 'redis-cluster' or 'redis-sentinel'." >&2 + echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'valkey' or 'valkey-cluster' or 'valkey-sentinel'." >&2 exit 1 esac echo $CACHE_COMPOSE_ARGS @@ -97,19 +97,19 @@ function additionalStartupServices() { exit 1 esac - CACHE="${CACHE:-redis}" + CACHE="${CACHE:-valkey}" case $CACHE in - redis) - ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES redis" + valkey) + ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES valkey" ;; - redis-cluster) - ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5" + valkey-cluster) + ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5" ;; - redis-sentinel) - ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES redis-master redis-slave redis-sentinel" + valkey-sentinel) + ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES valkey-primary valkey-replica valkey-sentinel" ;; *) - echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'redis' or 'redis-cluster' or 'redis-sentinel'." >&2 + echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'valkey' or 'valkey-cluster' or 'valkey-sentinel'." >&2 exit 1 esac @@ -154,32 +154,32 @@ function permissionList() { " fi - CACHE="${CACHE:-redis}" + CACHE="${CACHE:-valkey}" case $CACHE in - redis) + valkey) PERMISSION_LIST="$PERMISSION_LIST - 1001 1001 tb-node/redis-data + 1001 1001 tb-node/valkey-data " ;; - redis-cluster) + valkey-cluster) PERMISSION_LIST="$PERMISSION_LIST - 1001 1001 tb-node/redis-cluster-data-0 - 1001 1001 tb-node/redis-cluster-data-1 - 1001 1001 tb-node/redis-cluster-data-2 - 1001 1001 tb-node/redis-cluster-data-3 - 1001 1001 tb-node/redis-cluster-data-4 - 1001 1001 tb-node/redis-cluster-data-5 + 1001 1001 tb-node/valkey-cluster-data-0 + 1001 1001 tb-node/valkey-cluster-data-1 + 1001 1001 tb-node/valkey-cluster-data-2 + 1001 1001 tb-node/valkey-cluster-data-3 + 1001 1001 tb-node/valkey-cluster-data-4 + 1001 1001 tb-node/valkey-cluster-data-5 " ;; - redis-sentinel) + valkey-sentinel) PERMISSION_LIST="$PERMISSION_LIST - 1001 1001 tb-node/redis-sentinel-data-master - 1001 1001 tb-node/redis-sentinel-data-slave - 1001 1001 tb-node/redis-sentinel-data-sentinel + 1001 1001 tb-node/valkey-sentinel-data-primary + 1001 1001 tb-node/valkey-sentinel-data-replica + 1001 1001 tb-node/valkey-sentinel-data-sentinel " ;; *) - echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'redis' or 'redis-cluster' or 'redis-sentinel'." >&2 + echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'valkey' or 'valkey-cluster' or 'valkey-sentinel'." >&2 exit 1 esac diff --git a/docker/docker-compose.cassandra.volumes.yml b/docker/docker-compose.cassandra.volumes.yml index c6d64c484e..8e92069a06 100644 --- a/docker/docker-compose.cassandra.volumes.yml +++ b/docker/docker-compose.cassandra.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: cassandra: volumes: diff --git a/docker/docker-compose.confluent.yml b/docker/docker-compose.confluent.yml index e832b72519..46cb0235ee 100644 --- a/docker/docker-compose.confluent.yml +++ b/docker/docker-compose.confluent.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-js-executor: env_file: diff --git a/docker/docker-compose.edqs.volumes.yml b/docker/docker-compose.edqs.volumes.yml index 3a45542b99..cebc7dd290 100644 --- a/docker/docker-compose.edqs.volumes.yml +++ b/docker/docker-compose.edqs.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-edqs1: volumes: diff --git a/docker/docker-compose.edqs.yml b/docker/docker-compose.edqs.yml index 21bace143a..3ebb526a2d 100644 --- a/docker/docker-compose.edqs.yml +++ b/docker/docker-compose.edqs.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-core1: env_file: diff --git a/docker/docker-compose.hybrid.yml b/docker/docker-compose.hybrid.yml index dc5e875fc4..6b4129ec56 100644 --- a/docker/docker-compose.hybrid.yml +++ b/docker/docker-compose.hybrid.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: postgres: restart: always @@ -29,7 +27,7 @@ services: - ./tb-node/postgres:/var/lib/postgresql/data cassandra: restart: always - image: "cassandra:4.1" + image: "cassandra:5.0" ports: - "9042" volumes: diff --git a/docker/docker-compose.kafka.yml b/docker/docker-compose.kafka.yml index 9d33f33941..026070cc89 100644 --- a/docker/docker-compose.kafka.yml +++ b/docker/docker-compose.kafka.yml @@ -14,18 +14,14 @@ # limitations under the License. # -version: '3.0' - services: kafka: restart: always - image: "bitnami/kafka:3.7.0" + image: "bitnami/kafka:4.0" ports: - "9092:9092" env_file: - kafka.env - depends_on: - - zookeeper tb-js-executor: env_file: - queue-kafka.env diff --git a/docker/docker-compose.postgres.volumes.yml b/docker/docker-compose.postgres.volumes.yml index 3d2e026ef4..696189a639 100644 --- a/docker/docker-compose.postgres.volumes.yml +++ b/docker/docker-compose.postgres.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: postgres: volumes: diff --git a/docker/docker-compose.postgres.yml b/docker/docker-compose.postgres.yml index cd0373af42..bf43e1823d 100644 --- a/docker/docker-compose.postgres.yml +++ b/docker/docker-compose.postgres.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: postgres: restart: always diff --git a/docker/docker-compose.prometheus-grafana.yml b/docker/docker-compose.prometheus-grafana.yml index 81650c6b26..38699f1040 100644 --- a/docker/docker-compose.prometheus-grafana.yml +++ b/docker/docker-compose.prometheus-grafana.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - volumes: prometheus_data: {} grafana_data: {} diff --git a/docker/docker-compose.redis-cluster.volumes.yml b/docker/docker-compose.redis-cluster.volumes.yml deleted file mode 100644 index 5a735f9eeb..0000000000 --- a/docker/docker-compose.redis-cluster.volumes.yml +++ /dev/null @@ -1,58 +0,0 @@ -# -# Copyright © 2016-2025 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -version: '3.0' - -services: - # Redis cluster - redis-node-0: - volumes: - - redis-cluster-data-0:/bitnami/redis/data - redis-node-1: - volumes: - - redis-cluster-data-1:/bitnami/redis/data - redis-node-2: - volumes: - - redis-cluster-data-2:/bitnami/redis/data - redis-node-3: - volumes: - - redis-cluster-data-3:/bitnami/redis/data - redis-node-4: - volumes: - - redis-cluster-data-4:/bitnami/redis/data - redis-node-5: - volumes: - - redis-cluster-data-5:/bitnami/redis/data - -volumes: - redis-cluster-data-0: - external: - name: ${REDIS_CLUSTER_DATA_VOLUME_0} - redis-cluster-data-1: - external: - name: ${REDIS_CLUSTER_DATA_VOLUME_1} - redis-cluster-data-2: - external: - name: ${REDIS_CLUSTER_DATA_VOLUME_2} - redis-cluster-data-3: - external: - name: ${REDIS_CLUSTER_DATA_VOLUME_3} - redis-cluster-data-4: - external: - name: ${REDIS_CLUSTER_DATA_VOLUME_4} - redis-cluster-data-5: - external: - name: ${REDIS_CLUSTER_DATA_VOLUME_5} diff --git a/docker/docker-compose.redis-cluster.yml b/docker/docker-compose.redis-cluster.yml deleted file mode 100644 index e27993bf03..0000000000 --- a/docker/docker-compose.redis-cluster.yml +++ /dev/null @@ -1,157 +0,0 @@ -# -# Copyright © 2016-2025 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -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: - - ./tb-node/redis-cluster-data-0:/bitnami/redis/data - environment: - - 'REDIS_PASSWORD=thingsboard' - - 'REDISCLI_AUTH=thingsboard' - - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' - - redis-node-1: - image: bitnami/redis-cluster:7.2 - volumes: - - ./tb-node/redis-cluster-data-1:/bitnami/redis/data - depends_on: - - redis-node-0 - environment: - - 'REDIS_PASSWORD=thingsboard' - - 'REDISCLI_AUTH=thingsboard' - - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' - - redis-node-2: - image: bitnami/redis-cluster:7.2 - volumes: - - ./tb-node/redis-cluster-data-2:/bitnami/redis/data - depends_on: - - redis-node-1 - environment: - - 'REDIS_PASSWORD=thingsboard' - - 'REDISCLI_AUTH=thingsboard' - - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' - - redis-node-3: - image: bitnami/redis-cluster:7.2 - volumes: - - ./tb-node/redis-cluster-data-3:/bitnami/redis/data - depends_on: - - redis-node-2 - environment: - - 'REDIS_PASSWORD=thingsboard' - - 'REDISCLI_AUTH=thingsboard' - - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' - - redis-node-4: - image: bitnami/redis-cluster:7.2 - volumes: - - ./tb-node/redis-cluster-data-4:/bitnami/redis/data - depends_on: - - redis-node-3 - environment: - - 'REDIS_PASSWORD=thingsboard' - - 'REDISCLI_AUTH=thingsboard' - - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' - - redis-node-5: - image: bitnami/redis-cluster:7.2 - volumes: - - ./tb-node/redis-cluster-data-5:/bitnami/redis/data - depends_on: - - redis-node-0 - - redis-node-1 - - redis-node-2 - - redis-node-3 - - redis-node-4 - environment: - - 'REDIS_PASSWORD=thingsboard' - - 'REDISCLI_AUTH=thingsboard' - - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' - - 'REDIS_CLUSTER_REPLICAS=1' - - 'REDIS_CLUSTER_CREATOR=yes' - -# ThingsBoard setup to use redis-cluster - tb-core1: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-core2: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-rule-engine1: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-rule-engine2: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-mqtt-transport1: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-mqtt-transport2: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-http-transport1: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-http-transport2: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-coap-transport: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-lwm2m-transport: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-snmp-transport: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-vc-executor1: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 - tb-vc-executor2: - env_file: - - cache-redis-cluster.env - depends_on: - - redis-node-5 diff --git a/docker/docker-compose.redis-sentinel.yml b/docker/docker-compose.redis-sentinel.yml deleted file mode 100644 index ed8f314bcd..0000000000 --- a/docker/docker-compose.redis-sentinel.yml +++ /dev/null @@ -1,120 +0,0 @@ -# -# Copyright © 2016-2025 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -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: - - ./tb-node/redis-sentinel-data-master:/bitnami/redis/data - environment: - - 'REDIS_REPLICATION_MODE=master' - - 'REDIS_PASSWORD=thingsboard' - - redis-slave: - image: 'bitnami/redis:7.2' - volumes: - - ./tb-node/redis-sentinel-data-slave:/bitnami/redis/data - environment: - - 'REDIS_REPLICATION_MODE=slave' - - 'REDIS_MASTER_HOST=redis-master' - - 'REDIS_MASTER_PASSWORD=thingsboard' - - 'REDIS_PASSWORD=thingsboard' - depends_on: - - redis-master - - redis-sentinel: - image: 'bitnami/redis-sentinel:7.2' - volumes: - - ./tb-node/redis-sentinel-data-sentinel:/bitnami/redis/data - environment: - - 'REDIS_MASTER_HOST=redis-master' - - 'REDIS_MASTER_SET=mymaster' - - 'REDIS_SENTINEL_PASSWORD=sentinel' - - 'REDIS_MASTER_PASSWORD=thingsboard' - depends_on: - - redis-master - - redis-slave - - # ThingsBoard setup to use redis-sentinel - tb-core1: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-core2: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-rule-engine1: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-rule-engine2: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-mqtt-transport1: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-mqtt-transport2: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-http-transport1: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-http-transport2: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-coap-transport: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-lwm2m-transport: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-snmp-transport: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-vc-executor1: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel - tb-vc-executor2: - env_file: - - cache-redis-sentinel.env - depends_on: - - redis-sentinel diff --git a/docker/docker-compose.valkey-cluster.volumes.yml b/docker/docker-compose.valkey-cluster.volumes.yml new file mode 100644 index 0000000000..e8da9844d9 --- /dev/null +++ b/docker/docker-compose.valkey-cluster.volumes.yml @@ -0,0 +1,56 @@ +# +# Copyright © 2016-2025 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +services: + # Valkey cluster + valkey-node-0: + volumes: + - valkey-cluster-data-0:/bitnami/valkey/data + valkey-node-1: + volumes: + - valkey-cluster-data-1:/bitnami/valkey/data + valkey-node-2: + volumes: + - valkey-cluster-data-2:/bitnami/valkey/data + valkey-node-3: + volumes: + - valkey-cluster-data-3:/bitnami/valkey/data + valkey-node-4: + volumes: + - valkey-cluster-data-4:/bitnami/valkey/data + valkey-node-5: + volumes: + - valkey-cluster-data-5:/bitnami/valkey/data + +volumes: + valkey-cluster-data-0: + external: + name: ${VALKEY_CLUSTER_DATA_VOLUME_0} + valkey-cluster-data-1: + external: + name: ${VALKEY_CLUSTER_DATA_VOLUME_1} + valkey-cluster-data-2: + external: + name: ${VALKEY_CLUSTER_DATA_VOLUME_2} + valkey-cluster-data-3: + external: + name: ${VALKEY_CLUSTER_DATA_VOLUME_3} + valkey-cluster-data-4: + external: + name: ${VALKEY_CLUSTER_DATA_VOLUME_4} + valkey-cluster-data-5: + external: + name: ${VALKEY_CLUSTER_DATA_VOLUME_5} diff --git a/docker/docker-compose.valkey-cluster.yml b/docker/docker-compose.valkey-cluster.yml new file mode 100644 index 0000000000..242fabf44b --- /dev/null +++ b/docker/docker-compose.valkey-cluster.yml @@ -0,0 +1,150 @@ +# +# Copyright © 2016-2025 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +services: +# Valkey cluster +# The latest version of Valkey compatible with ThingsBoard is 8.0 + valkey-node-0: + image: bitnami/valkey-cluster:8.0 + volumes: + - ./tb-node/valkey-cluster-data-0:/bitnami/valkey/data + environment: + - 'VALKEY_PASSWORD=thingsboard' + - 'VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5' + + valkey-node-1: + image: bitnami/valkey-cluster:8.0 + volumes: + - ./tb-node/valkey-cluster-data-1:/bitnami/valkey/data + depends_on: + - valkey-node-0 + environment: + - 'VALKEY_PASSWORD=thingsboard' + - 'VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5' + + valkey-node-2: + image: bitnami/valkey-cluster:8.0 + volumes: + - ./tb-node/valkey-cluster-data-2:/bitnami/valkey/data + depends_on: + - valkey-node-1 + environment: + - 'VALKEY_PASSWORD=thingsboard' + - 'VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5' + + valkey-node-3: + image: bitnami/valkey-cluster:8.0 + volumes: + - ./tb-node/valkey-cluster-data-3:/bitnami/valkey/data + depends_on: + - valkey-node-2 + environment: + - 'VALKEY_PASSWORD=thingsboard' + - 'VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5' + + valkey-node-4: + image: bitnami/valkey-cluster:8.0 + volumes: + - ./tb-node/valkey-cluster-data-4:/bitnami/valkey/data + depends_on: + - valkey-node-3 + environment: + - 'VALKEY_PASSWORD=thingsboard' + - 'VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5' + + valkey-node-5: + image: bitnami/valkey-cluster:8.0 + volumes: + - ./tb-node/valkey-cluster-data-5:/bitnami/valkey/data + depends_on: + - valkey-node-0 + - valkey-node-1 + - valkey-node-2 + - valkey-node-3 + - valkey-node-4 + environment: + - 'VALKEY_PASSWORD=thingsboard' + - 'REDISCLI_AUTH=thingsboard' + - 'VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5' + - 'VALKEY_CLUSTER_REPLICAS=1' + - 'VALKEY_CLUSTER_CREATOR=yes' + +# ThingsBoard setup to use valkey-cluster + tb-core1: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-core2: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-rule-engine1: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-rule-engine2: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-mqtt-transport1: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-mqtt-transport2: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-http-transport1: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-http-transport2: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-coap-transport: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-lwm2m-transport: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-snmp-transport: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-vc-executor1: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 + tb-vc-executor2: + env_file: + - cache-valkey-cluster.env + depends_on: + - valkey-node-5 diff --git a/docker/docker-compose.redis-sentinel.volumes.yml b/docker/docker-compose.valkey-sentinel.volumes.yml similarity index 58% rename from docker/docker-compose.redis-sentinel.volumes.yml rename to docker/docker-compose.valkey-sentinel.volumes.yml index 485291d905..3f4a09a912 100644 --- a/docker/docker-compose.redis-sentinel.volumes.yml +++ b/docker/docker-compose.valkey-sentinel.volumes.yml @@ -14,27 +14,25 @@ # limitations under the License. # -version: '3.0' - services: - # Redis sentinel - redis-master: + # Valkey sentinel + valkey-primary: volumes: - - redis-sentinel-data-master:/bitnami/redis/data - redis-slave: + - valkey-sentinel-data-primary:/bitnami/valkey/data + valkey-replica: volumes: - - redis-sentinel-data-slave:/bitnami/redis/data - redis-sentinel: + - valkey-sentinel-data-replica:/bitnami/valkey/data + valkey-sentinel: volumes: - - redis-sentinel-data-sentinel:/bitnami/redis/data + - valkey-sentinel-data-sentinel:/bitnami/valkey/data volumes: - redis-sentinel-data-master: + valkey-sentinel-data-primary: external: - name: ${REDIS_SENTINEL_DATA_VOLUME_MASTER} - redis-sentinel-data-slave: + name: ${VALKEY_SENTINEL_DATA_VOLUME_PRIMARY} + valkey-sentinel-data-replica: external: - name: ${REDIS_SENTINEL_DATA_VOLUME_SLAVE} - redis-sentinel-data-sentinel: + name: ${VALKEY_SENTINEL_DATA_VOLUME_REPLICA} + valkey-sentinel-data-sentinel: external: - name: ${REDIS_SENTINEL_DATA_VOLUME_SENTINEL} + name: ${VALKEY_SENTINEL_DATA_VOLUME_SENTINEL} diff --git a/docker/docker-compose.valkey-sentinel.yml b/docker/docker-compose.valkey-sentinel.yml new file mode 100644 index 0000000000..3827e0358b --- /dev/null +++ b/docker/docker-compose.valkey-sentinel.yml @@ -0,0 +1,118 @@ +# +# Copyright © 2016-2025 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +services: + # Valkey sentinel + # The latest version of Valkey compatible with ThingsBoard is 8.0 + valkey-primary: + image: 'bitnami/valkey:8.0' + volumes: + - ./tb-node/valkey-sentinel-data-primary:/bitnami/valkey/data + environment: + - 'VALKEY_REPLICATION_MODE=primary' + - 'VALKEY_PASSWORD=thingsboard' + + valkey-replica: + image: 'bitnami/valkey:8.0' + volumes: + - ./tb-node/valkey-sentinel-data-replica:/bitnami/valkey/data + environment: + - 'VALKEY_REPLICATION_MODE=replica' + - 'VALKEY_PRIMARY_HOST=valkey-primary' + - 'VALKEY_PRIMARY_PASSWORD=thingsboard' + - 'VALKEY_PASSWORD=thingsboard' + depends_on: + - valkey-primary + + valkey-sentinel: + image: 'bitnami/valkey-sentinel:8.0' + volumes: + - ./tb-node/valkey-sentinel-data-sentinel:/bitnami/valkey/data + environment: + - 'VALKEY_PRIMARY_HOST=valkey-primary' + - 'VALKEY_MASTER_SET=myprimary' + - 'VALKEY_SENTINEL_PASSWORD=sentinel' + - 'VALKEY_PRIMARY_PASSWORD=thingsboard' + depends_on: + - valkey-primary + - valkey-replica + + # ThingsBoard setup to use valkey-sentinel + tb-core1: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-core2: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-rule-engine1: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-rule-engine2: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-mqtt-transport1: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-mqtt-transport2: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-http-transport1: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-http-transport2: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-coap-transport: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-lwm2m-transport: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-snmp-transport: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-vc-executor1: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel + tb-vc-executor2: + env_file: + - cache-valkey-sentinel.env + depends_on: + - valkey-sentinel diff --git a/docker/docker-compose.redis.volumes.yml b/docker/docker-compose.valkey.volumes.yml similarity index 85% rename from docker/docker-compose.redis.volumes.yml rename to docker/docker-compose.valkey.volumes.yml index 3b23511501..02a7c397a3 100644 --- a/docker/docker-compose.redis.volumes.yml +++ b/docker/docker-compose.valkey.volumes.yml @@ -14,14 +14,12 @@ # limitations under the License. # -version: '3.0' - services: - redis: + valkey: volumes: - - redis-data:/bitnami/redis/data + - valkey-data:/bitnami/valkey/data volumes: - redis-data: + valkey-data: external: - name: ${REDIS_DATA_VOLUME} + name: ${VALKEY_DATA_VOLUME} diff --git a/docker/docker-compose.redis.yml b/docker/docker-compose.valkey.yml similarity index 65% rename from docker/docker-compose.redis.yml rename to docker/docker-compose.valkey.yml index 8ed361428a..d4a620820d 100644 --- a/docker/docker-compose.redis.yml +++ b/docker/docker-compose.valkey.yml @@ -14,85 +14,83 @@ # limitations under the License. # -version: '3.0' - services: -# Redis standalone -# The latest version of Redis compatible with ThingsBoard is 7.2 - redis: +# Valkey standalone +# The latest version of Valkey compatible with ThingsBoard is 8.0 + valkey: restart: always - image: bitnami/redis:7.2 + image: bitnami/valkey:8.0 environment: # ALLOW_EMPTY_PASSWORD is recommended only for development. ALLOW_EMPTY_PASSWORD: "yes" ports: - '6379:6379' volumes: - - ./tb-node/redis-data:/bitnami/redis/data + - ./tb-node/valkey-data:/bitnami/valkey/data -# ThingsBoard setup to use redis-standalone +# ThingsBoard setup to use valkey-standalone tb-core1: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-core2: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-rule-engine1: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-rule-engine2: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-mqtt-transport1: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-mqtt-transport2: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-http-transport1: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-http-transport2: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-coap-transport: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-lwm2m-transport: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-snmp-transport: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-vc-executor1: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey tb-vc-executor2: env_file: - - cache-redis.env + - cache-valkey.env depends_on: - - redis + - valkey diff --git a/docker/docker-compose.volumes.yml b/docker/docker-compose.volumes.yml index 06a904e7fc..a01247c25d 100644 --- a/docker/docker-compose.volumes.yml +++ b/docker/docker-compose.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-core1: volumes: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1cee5ad5ad..224f94c103 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,9 +14,6 @@ # limitations under the License. # - -version: '3.0' - services: zookeeper: restart: always diff --git a/docker/kafka.env b/docker/kafka.env index 9c28885252..bae5973529 100644 --- a/docker/kafka.env +++ b/docker/kafka.env @@ -1,11 +1,19 @@ -ALLOW_PLAINTEXT_LISTENER=yes -KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181 -KAFKA_CFG_LISTENERS=INSIDE://:9093,OUTSIDE://:9092 -KAFKA_CFG_ADVERTISED_LISTENERS=INSIDE://:9093,OUTSIDE://kafka:9092 -KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT +# KRaft settings +KAFKA_CFG_NODE_ID=0 +KAFKA_CFG_PROCESS_ROLES=controller,broker +KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:9093 +# Listeners +KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 +KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://:9092 +KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT +KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER +KAFKA_CFG_INTER_BROKER_LISTENER_NAME=PLAINTEXT +# Kafka settings KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=false -KAFKA_CFG_INTER_BROKER_LISTENER_NAME=INSIDE KAFKA_CFG_LOG_RETENTION_BYTES=1073741824 -KAFKA_CFG_SEGMENT_BYTES=268435456 -KAFKA_CFG_LOG_RETENTION_MS=300000 -KAFKA_CFG_LOG_CLEANUP_POLICY=delete +# default is 1 GB +KAFKA_CFG_SEGMENT_BYTES=26214400 +# default 7 days +#KAFKA_CFG_LOG_RETENTION_MS=300000 +# default +#KAFKA_CFG_LOG_CLEANUP_POLICY=delete diff --git a/docker/queue-confluent.env b/docker/queue-confluent.env index 868a135de3..900504c4ea 100644 --- a/docker/queue-confluent.env +++ b/docker/queue-confluent.env @@ -15,4 +15,4 @@ TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800 TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 -TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600 +TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES=retention.ms:86400000;segment.bytes:52428800;retention.bytes:104857600 diff --git a/edqs/pom.xml b/edqs/pom.xml index 702c606daa..4cca1bd89e 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard edqs diff --git a/edqs/src/main/resources/edqs.yml b/edqs/src/main/resources/edqs.yml index 6e6e975d68..f6b611d5a9 100644 --- a/edqs/src/main/resources/edqs.yml +++ b/edqs/src/main/resources/edqs.yml @@ -71,6 +71,10 @@ queue: max_pending_requests: "${TB_EDQS_MAX_PENDING_REQUESTS:10000}" # Maximum timeout for requests to EDQS max_request_timeout: "${TB_EDQS_MAX_REQUEST_TIMEOUT:20000}" + # Thread pool size for EDQS requests executor + request_executor_size: "${TB_EDQS_REQUEST_EXECUTOR_SIZE:50}" + # Time to live for EDQS versions cache in minutes. Must be bigger than the time taken for the sync process. + versions_cache_ttl: "${TB_EDQS_VERSIONS_CACHE_TTL_MINUTES:60}" # Strings longer than this threshold will be compressed string_compression_length_threshold: "${TB_EDQS_STRING_COMPRESSION_LENGTH_THRESHOLD:512}" stats: diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AbstractEDQTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AbstractEDQTest.java index 330d22a3c6..a7f80fdffc 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AbstractEDQTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AbstractEDQTest.java @@ -60,7 +60,8 @@ import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.stats.DummyEdqsStatsService; -import org.thingsboard.server.edqs.util.EdqsConverter; +import org.thingsboard.server.edqs.util.DefaultEdqsMapper; +import org.thingsboard.server.edqs.util.EdqsMapper; import java.util.Collections; import java.util.List; @@ -79,7 +80,7 @@ public abstract class AbstractEDQTest { @Autowired protected DefaultEdqsRepository repository; @Autowired - protected EdqsConverter edqsConverter; + protected EdqsMapper edqsMapper; @MockBean private DummyEdqsStatsService edqsStatsService; @@ -244,12 +245,12 @@ public abstract class AbstractEDQTest { } protected void addOrUpdate(EntityType entityType, Object entity) { - addOrUpdate(EdqsConverter.toEntity(entityType, entity)); + addOrUpdate(DefaultEdqsMapper.toEntity(entityType, entity)); } protected void addOrUpdate(EdqsObject edqsObject) { - byte[] serialized = edqsConverter.serialize(edqsObject.type(), edqsObject); - edqsObject = edqsConverter.deserialize(edqsObject.type(), serialized); + byte[] serialized = edqsMapper.serialize(edqsObject); + edqsObject = edqsMapper.deserialize(edqsObject.type(), serialized, false); repository.get(tenantId).addOrUpdate(edqsObject); } diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java index 1f90babf01..7444d24b99 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -132,7 +133,7 @@ public class AssetSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List assetTypes) { AssetSearchQueryFilter filter = new AssetSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setAssetTypes(assetTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java index b3f15c2f61..9e30997b88 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -135,7 +136,7 @@ public class DeviceSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List deviceTypes) { DeviceSearchQueryFilter filter = new DeviceSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setDeviceTypes(deviceTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java index f0911570ea..42c64ed602 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java @@ -24,6 +24,7 @@ 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.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EdgeSearchQueryFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -101,7 +102,7 @@ public class EdgeSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List edgeTypes) { EdgeSearchQueryFilter filter = new EdgeSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setEdgeTypes(edgeTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java index fb32759045..c484ef78ae 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityKeyType; @@ -115,7 +116,7 @@ public class EntityViewSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List entityViewTypes) { EntityViewSearchQueryFilter filter = new EntityViewSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setEntityViewTypes(entityViewTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java index 094d46f977..a376e3f672 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityKeyType; @@ -146,7 +147,7 @@ public class RelationsQueryFilterTest extends AbstractEDQTest { private PageData filter(CustomerId customerId, EntityId rootId, int maxLevel, boolean lastLevelOnly, RelationEntityTypeFilter... relationEntityTypeFilters) { RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setFilters(Arrays.asList(relationEntityTypeFilters)); filter.setDirection(EntitySearchDirection.FROM); filter.setFetchLastLevelOnly(lastLevelOnly); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java index 6c7444c92a..f4c256a8cf 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java @@ -70,7 +70,45 @@ public class RepositoryUtilsTest { Arguments.of("loranet 123", getNameFilter(StringOperation.IN, "loranet 123, loranet 124"), true), Arguments.of("loranet 123", getNameFilter(StringOperation.IN, "loranet 125, loranet 126"), false), Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_IN, "loranet 125, loranet 126"), true), - Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_IN, "loranet 123, loranet 126"), false) + Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_IN, "loranet 123, loranet 126"), false), + + // Basic CONTAINS + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "%loranet"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "loranet%"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "%ranet%"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "%123"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "%loranx%"), false), + + // Basic STARTS_WITH + Arguments.of("loranet 123", getNameFilter(StringOperation.STARTS_WITH, "loranet%"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.STARTS_WITH, "lora%"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.STARTS_WITH, "lorax%"), false), + + // Basic ENDS_WITH + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "%123"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "%23"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "%124"), false), + + // CONTAINS with _ + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "loranet_123"), true), // '_' = ' ' + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "loranet_12_"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "loran_t%"), true), + + // STARTS_WITH with _ + Arguments.of("loranet 123", getNameFilter(StringOperation.STARTS_WITH, "loranet_"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.STARTS_WITH, "lora__t%"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.STARTS_WITH, "lor_net%"), true), + + // ENDS_WITH with _ + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "_23"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "_2_"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "_3"), true), + + // Mixed patterns + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "lora__t 1%"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "lora%net%3"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "%o_anet%2_3"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "lora___ ___"), true) ); } diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java index 133816f576..93786553c5 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -113,7 +114,7 @@ public class SingleEntityFilterTest extends AbstractEDQTest { private static EntityDataQuery getEntityDataQuery(DeviceId deviceId) { SingleEntityFilter filter = new SingleEntityFilter(); - filter.setSingleEntity(deviceId); + filter.setSingleEntity(AliasEntityId.fromEntityId(deviceId)); var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 7c4ae21292..30a2386713 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard @@ -41,7 +41,6 @@ ${project.build.directory}/windows ThingsBoard Monitoring Service org.thingsboard.monitoring.ThingsboardMonitoringApplication - diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java index 599fe29525..893c9ec4b4 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java @@ -56,7 +56,6 @@ import org.thingsboard.monitoring.util.ResourceUtils; import javax.security.auth.Destroyable; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; @@ -73,7 +72,7 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { @Setter private LeshanClient leshanClient; - private static final List supportedResources = Collections.singletonList(0); + private static final List supportedResources = List.of(0, 16); private String data = ""; @@ -158,6 +157,7 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { @Override public void onBootstrapFailure(LwM2mServer bsserver, BootstrapRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { + log.debug("onBootstrapFailure [{}] [{}] [{}]", request.getEndpointName(), responseCode, errorMessage); // No implementation needed } @@ -245,10 +245,11 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { @Override public ReadResponse read(LwM2mServer server, int resourceId) { - if (supportedResources.contains(resourceId)) { - return ReadResponse.success(resourceId, data); - } - return super.read(server, resourceId); + return switch (resourceId) { + case 0 -> ReadResponse.success(0, data); + case 16 -> ReadResponse.success(16, "U"); + default -> super.read(server, resourceId); + }; } @SneakyThrows diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java index 57ecce2e33..0446060bb8 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java @@ -20,6 +20,7 @@ import org.eclipse.californium.core.CoapClient; import org.eclipse.californium.core.CoapResponse; import org.eclipse.californium.core.coap.CoAP; import org.eclipse.californium.core.coap.MediaTypeRegistry; +import org.eclipse.californium.elements.config.SystemConfig; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; @@ -35,6 +36,10 @@ import java.io.IOException; @Slf4j public class CoapTransportHealthChecker extends TransportHealthChecker { + static { + SystemConfig.register(); + } + private CoapClient coapClient; protected CoapTransportHealthChecker(CoapTransportMonitoringConfig config, TransportMonitoringTarget target) { diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index 31277e2a55..f777ce7cdf 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -18,19 +18,19 @@ As result, in REPOSITORY column, next images should be present: thingsboard/tb-web-ui thingsboard/tb-js-executor -- Run the black box tests (without ui tests) in the [msa/black-box-tests](../black-box-tests) directory with Redis standalone: +- Run the black box tests (without ui tests) in the [msa/black-box-tests](../black-box-tests) directory with Valkey standalone: mvn clean install -DblackBoxTests.skip=false -- Run the black box tests (without ui tests) in the [msa/black-box-tests](../black-box-tests) directory with Redis standalone with TLS: +- Run the black box tests (without ui tests) in the [msa/black-box-tests](../black-box-tests) directory with Valkey standalone with TLS: mvn clean install -DblackBoxTests.skip=false -DblackBoxTests.redisSsl=true -- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory with Redis cluster: +- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory with the Valkey cluster: mvn clean install -DblackBoxTests.skip=false -DblackBoxTests.redisCluster=true -- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory with Redis sentinel: +- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory with Valkey sentinel: mvn clean install -DblackBoxTests.skip=false -DblackBoxTests.redisSentinel=true diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 407e4d6079..d3b47daa47 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java index 345d0b7144..77e80f25ac 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -38,12 +38,13 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.testng.Assert.fail; +import static org.thingsboard.server.msa.TestUtils.addComposeVersion; @Slf4j public class ContainerTestSuite { - final static boolean IS_REDIS_CLUSTER = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisCluster")); - final static boolean IS_REDIS_SENTINEL = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisSentinel")); - final static boolean IS_REDIS_SSL = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisSsl")); + final static boolean IS_VALKEY_CLUSTER = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisCluster")); + final static boolean IS_VALKEY_SENTINEL = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisSentinel")); + final static boolean IS_VALKEY_SSL = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisSsl")); final static boolean IS_HYBRID_MODE = Boolean.parseBoolean(System.getProperty("blackBoxTests.hybridMode")); private static final String SOURCE_DIR = "./../../docker/"; private static final String TB_CORE_LOG_REGEXP = ".*Starting polling for events.*"; @@ -53,7 +54,7 @@ public class ContainerTestSuite { private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*"; private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400); - private DockerComposeContainer testContainer; + private DockerComposeContainerImpl testContainer; private ThingsBoardDbInstaller installTb; private boolean isActive; @@ -78,11 +79,9 @@ public class ContainerTestSuite { } public void start() { - installTb = new ThingsBoardDbInstaller(); - installTb.createVolumes(); - log.info("System property of blackBoxTests.redisCluster is {}", IS_REDIS_CLUSTER); - log.info("System property of blackBoxTests.redisSentinel is {}", IS_REDIS_SENTINEL); - log.info("System property of blackBoxTests.redisSsl is {}", IS_REDIS_SSL); + log.info("System property of blackBoxTests.redisCluster is {}", IS_VALKEY_CLUSTER); + log.info("System property of blackBoxTests.redisSentinel is {}", IS_VALKEY_SENTINEL); + log.info("System property of blackBoxTests.redisSsl is {}", IS_VALKEY_SSL); log.info("System property of blackBoxTests.hybridMode is {}", IS_HYBRID_MODE); boolean skipTailChildContainers = Boolean.parseBoolean(System.getProperty("blackBoxTests.skipTailChildContainers")); try { @@ -93,22 +92,13 @@ public class ContainerTestSuite { FileUtils.copyDirectory(new File("src/test/resources"), new File(targetDir)); - class DockerComposeContainerImpl> extends DockerComposeContainer { - public DockerComposeContainerImpl(List composeFiles) { - super(composeFiles); - } + installTb = new ThingsBoardDbInstaller(targetDir); + installTb.createVolumes(); - @Override - public void stop() { - super.stop(); - tryDeleteDir(targetDir); - } - } - - if (IS_REDIS_SSL) { - addToFile(targetDir, "cache-redis.env", + if (IS_VALKEY_SSL) { + addToFile(targetDir, "cache-valkey.env", Map.of("TB_REDIS_SSL_ENABLED", "true", - "TB_REDIS_SSL_PEM_CERT", "/redis/certs/redisCA.crt")); + "TB_REDIS_SSL_PEM_CERT", "/valkey/certs/valkeyCA.crt")); } List composeFiles = new ArrayList<>(Arrays.asList( @@ -121,8 +111,8 @@ public class ContainerTestSuite { new File(targetDir + (IS_HYBRID_MODE ? "docker-compose.hybrid-test-extras.yml" : "docker-compose.postgres-test-extras.yml")), new File(targetDir + "docker-compose.postgres.volumes.yml"), new File(targetDir + "docker-compose.kafka.yml"), - new File(targetDir + resolveRedisComposeFile()), - new File(targetDir + resolveRedisComposeVolumesFile()), + new File(targetDir + resolveValkeyComposeFile()), + new File(targetDir + resolveValkeyComposeVolumesFile()), new File(targetDir + ("docker-selenium.yml")) )); addToFile(targetDir, "queue-kafka.env", Map.of("TB_QUEUE_PREFIX", "test")); @@ -132,7 +122,9 @@ public class ContainerTestSuite { composeFiles.add(new File(targetDir + "docker-compose.cassandra.volumes.yml")); } - testContainer = new DockerComposeContainerImpl<>(composeFiles) + addComposeVersion(composeFiles, "3.0"); + + testContainer = new DockerComposeContainerImpl(targetDir, composeFiles) .withPull(false) .withLocalCompose(true) .withOptions("--compatibility") @@ -165,36 +157,37 @@ public class ContainerTestSuite { } } - private static String resolveRedisComposeFile() { - if (IS_REDIS_CLUSTER) { - return "docker-compose.redis-cluster.yml"; + private static String resolveValkeyComposeFile() { + if (IS_VALKEY_CLUSTER) { + return "docker-compose.valkey-cluster.yml"; } - if (IS_REDIS_SENTINEL) { - return "docker-compose.redis-sentinel.yml"; + if (IS_VALKEY_SENTINEL) { + return "docker-compose.valkey-sentinel.yml"; } - if (IS_REDIS_SSL) { - return "docker-compose.redis-ssl.yml"; + if (IS_VALKEY_SSL) { + return "docker-compose.valkey-ssl.yml"; } - return "docker-compose.redis.yml"; + return "docker-compose.valkey.yml"; } - private static String resolveRedisComposeVolumesFile() { - if (IS_REDIS_CLUSTER) { - return "docker-compose.redis-cluster.volumes.yml"; + private static String resolveValkeyComposeVolumesFile() { + if (IS_VALKEY_CLUSTER) { + return "docker-compose.valkey-cluster.volumes.yml"; } - if (IS_REDIS_SENTINEL) { - return "docker-compose.redis-sentinel.volumes.yml"; + if (IS_VALKEY_SENTINEL) { + return "docker-compose.valkey-sentinel.volumes.yml"; } - if (IS_REDIS_SSL) { - return "docker-compose.redis-ssl.volumes.yml"; + if (IS_VALKEY_SSL) { + return "docker-compose.valkey-ssl.volumes.yml"; } - return "docker-compose.redis.volumes.yml"; + return "docker-compose.valkey.volumes.yml"; } public void stop() { if (isActive) { testContainer.stop(); - installTb.savaLogsAndRemoveVolumes(); + installTb.saveLogsAndRemoveVolumes(); + testContainer.cleanup(); setActive(false); } } @@ -261,4 +254,23 @@ public class ContainerTestSuite { public DockerComposeContainer getTestContainer() { return testContainer; } + + static class DockerComposeContainerImpl extends DockerComposeContainer { + + private final String targetDir; + + public DockerComposeContainerImpl(String targetDir, List composeFiles) { + super(composeFiles); + this.targetDir = targetDir; + } + + @Override + public void stop() { + super.stop(); + } + + public void cleanup() { + tryDeleteDir(this.targetDir); + } + } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java index aafe93f9f3..102f4b82ea 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -738,13 +739,13 @@ public class TestRestClient { .as(Long.class); } - public Boolean isEdqsApiEnabled() { + public EdqsState getEdqsState() { return given().spec(requestSpec) - .get("/api/edqs/enabled") + .get("/api/edqs/state") .then() .statusCode(HTTP_OK) .extract() - .as(Boolean.class); + .as(EdqsState.class); } public void assignDeviceToCustomer(CustomerId customerId, DeviceId id) { diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestUtils.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestUtils.java new file mode 100644 index 0000000000..e70e020e88 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestUtils.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.msa; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class TestUtils { + + public static void addComposeVersion(List composeFiles, String version) throws IOException { + for (File composeFile : composeFiles) { + addComposeVersion(composeFile, version); + } + } + + public static void addComposeVersion(File composeFile, String version) throws IOException { + Path composeFilePath = composeFile.toPath(); + String data = Files.readString(composeFilePath); + String versionString = "version: '" + version + "'"; + if (!data.contains(versionString)) { + data += "\n" + versionString + "\n"; + } + Files.writeString(composeFilePath, data); + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java index 9c1a90a4f1..72be05922b 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java @@ -20,6 +20,7 @@ import org.testcontainers.utility.Base58; import org.thingsboard.server.common.data.StringUtils; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -29,18 +30,20 @@ import java.util.StringJoiner; import java.util.stream.Collectors; import java.util.stream.IntStream; +import static org.thingsboard.server.msa.TestUtils.addComposeVersion; + @Slf4j public class ThingsBoardDbInstaller { - final static boolean IS_REDIS_CLUSTER = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisCluster")); - final static boolean IS_REDIS_SENTINEL = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisSentinel")); + final static boolean IS_VALKEY_CLUSTER = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisCluster")); + final static boolean IS_VALKEY_SENTINEL = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisSentinel")); final static boolean IS_HYBRID_MODE = Boolean.parseBoolean(System.getProperty("blackBoxTests.hybridMode")); private final static String POSTGRES_DATA_VOLUME = "tb-postgres-test-data-volume"; private final static String CASSANDRA_DATA_VOLUME = "tb-cassandra-test-data-volume"; - private final static String REDIS_DATA_VOLUME = "tb-redis-data-volume"; - private final static String REDIS_CLUSTER_DATA_VOLUME = "tb-redis-cluster-data-volume"; - private final static String REDIS_SENTINEL_DATA_VOLUME = "tb-redis-sentinel-data-volume"; + private final static String VALKEY_DATA_VOLUME = "tb-valkey-data-volume"; + private final static String VALKEY_CLUSTER_DATA_VOLUME = "tb-valkey-cluster-data-volume"; + private final static String VALKEY_SENTINEL_DATA_VOLUME = "tb-valkey-sentinel-data-volume"; private final static String TB_LOG_VOLUME = "tb-log-test-volume"; private final static String TB_COAP_TRANSPORT_LOG_VOLUME = "tb-coap-transport-log-test-volume"; private final static String TB_LWM2M_TRANSPORT_LOG_VOLUME = "tb-lwm2m-transport-log-test-volume"; @@ -53,12 +56,13 @@ public class ThingsBoardDbInstaller { private final DockerComposeExecutor dockerCompose; + private final String targetDir; private final String postgresDataVolume; private final String cassandraDataVolume; - private final String redisDataVolume; - private final String redisClusterDataVolume; - private final String redisSentinelDataVolume; + private final String valkeyDataVolume; + private final String valkeyClusterDataVolume; + private final String valkeySentinelDataVolume; private final String tbLogVolume; private final String tbCoapTransportLogVolume; private final String tbLwm2mTransportLogVolume; @@ -69,35 +73,38 @@ public class ThingsBoardDbInstaller { private final String tbEdqsLogVolume; private final Map env; - public ThingsBoardDbInstaller() { - log.info("System property of blackBoxTests.redisCluster is {}", IS_REDIS_CLUSTER); - log.info("System property of blackBoxTests.redisCluster is {}", IS_REDIS_SENTINEL); + public ThingsBoardDbInstaller(String targetDir) throws IOException { + this.targetDir = targetDir; + log.info("System property of blackBoxTests.redisCluster is {}", IS_VALKEY_CLUSTER); + log.info("System property of blackBoxTests.redisSentinel is {}", IS_VALKEY_SENTINEL); log.info("System property of blackBoxTests.hybridMode is {}", IS_HYBRID_MODE); List composeFiles = new ArrayList<>(Arrays.asList( - new File("./../../docker/docker-compose.yml"), - new File("./../../docker/docker-compose.volumes.yml"), + new File(targetDir + "docker-compose.yml"), + new File(targetDir + "docker-compose.volumes.yml"), IS_HYBRID_MODE - ? new File("./../../docker/docker-compose.hybrid.yml") - : new File("./../../docker/docker-compose.postgres.yml"), - new File("./../../docker/docker-compose.postgres.volumes.yml"), - resolveRedisComposeFile(), - resolveRedisComposeVolumesFile() + ? new File(targetDir + "docker-compose.hybrid.yml") + : new File(targetDir + "docker-compose.postgres.yml"), + new File(targetDir + "docker-compose.postgres.volumes.yml"), + resolveValkeyComposeFile(targetDir), + resolveValkeyComposeVolumesFile(targetDir) )); if (IS_HYBRID_MODE) { - composeFiles.add(new File("./../../docker/docker-compose.cassandra.volumes.yml")); - composeFiles.add(new File("src/test/resources/docker-compose.hybrid-test-extras.yml")); + composeFiles.add(new File(targetDir + "docker-compose.cassandra.volumes.yml")); + composeFiles.add(new File(targetDir + "docker-compose.hybrid-test-extras.yml")); } else { - composeFiles.add(new File("src/test/resources/docker-compose.postgres-test-extras.yml")); + composeFiles.add(new File(targetDir + "docker-compose.postgres-test-extras.yml")); } + addComposeVersion(composeFiles, "3.0"); + String identifier = Base58.randomString(6).toLowerCase(); String project = identifier + Base58.randomString(6).toLowerCase(); postgresDataVolume = project + "_" + POSTGRES_DATA_VOLUME; cassandraDataVolume = project + "_" + CASSANDRA_DATA_VOLUME; - redisDataVolume = project + "_" + REDIS_DATA_VOLUME; - redisClusterDataVolume = project + "_" + REDIS_CLUSTER_DATA_VOLUME; - redisSentinelDataVolume = project + "_" + REDIS_SENTINEL_DATA_VOLUME; + valkeyDataVolume = project + "_" + VALKEY_DATA_VOLUME; + valkeyClusterDataVolume = project + "_" + VALKEY_CLUSTER_DATA_VOLUME; + valkeySentinelDataVolume = project + "_" + VALKEY_SENTINEL_DATA_VOLUME; tbLogVolume = project + "_" + TB_LOG_VOLUME; tbCoapTransportLogVolume = project + "_" + TB_COAP_TRANSPORT_LOG_VOLUME; tbLwm2mTransportLogVolume = project + "_" + TB_LWM2M_TRANSPORT_LOG_VOLUME; @@ -123,38 +130,38 @@ public class ThingsBoardDbInstaller { env.put("TB_SNMP_TRANSPORT_LOG_VOLUME", tbSnmpTransportLogVolume); env.put("TB_VC_EXECUTOR_LOG_VOLUME", tbVcExecutorLogVolume); env.put("TB_EDQS_LOG_VOLUME", tbEdqsLogVolume); - if (IS_REDIS_CLUSTER) { + if (IS_VALKEY_CLUSTER) { for (int i = 0; i < 6; i++) { - env.put("REDIS_CLUSTER_DATA_VOLUME_" + i, redisClusterDataVolume + '-' + i); + env.put("VALKEY_CLUSTER_DATA_VOLUME_" + i, valkeyClusterDataVolume + '-' + i); } - } else if (IS_REDIS_SENTINEL) { - env.put("REDIS_SENTINEL_DATA_VOLUME_MASTER", redisSentinelDataVolume + "-" + "master"); - env.put("REDIS_SENTINEL_DATA_VOLUME_SLAVE", redisSentinelDataVolume + "-" + "slave"); - env.put("REDIS_SENTINEL_DATA_VOLUME_SENTINEL", redisSentinelDataVolume + "-" + "sentinel"); + } else if (IS_VALKEY_SENTINEL) { + env.put("VALKEY_SENTINEL_DATA_VOLUME_PRIMARY", valkeySentinelDataVolume + "-" + "primary"); + env.put("VALKEY_SENTINEL_DATA_VOLUME_REPLICA", valkeySentinelDataVolume + "-" + "replica"); + env.put("VALKEY_SENTINEL_DATA_VOLUME_SENTINEL", valkeySentinelDataVolume + "-" + "sentinel"); } else { - env.put("REDIS_DATA_VOLUME", redisDataVolume); + env.put("VALKEY_DATA_VOLUME", valkeyDataVolume); } dockerCompose.withEnv(env); } - private static File resolveRedisComposeVolumesFile() { - if (IS_REDIS_CLUSTER) { - return new File("./../../docker/docker-compose.redis-cluster.volumes.yml"); + private static File resolveValkeyComposeVolumesFile(String targetDir) { + if (IS_VALKEY_CLUSTER) { + return new File(targetDir + "docker-compose.valkey-cluster.volumes.yml"); } - if (IS_REDIS_SENTINEL) { - return new File("./../../docker/docker-compose.redis-sentinel.volumes.yml"); + if (IS_VALKEY_SENTINEL) { + return new File(targetDir + "docker-compose.valkey-sentinel.volumes.yml"); } - return new File("./../../docker/docker-compose.redis.volumes.yml"); + return new File(targetDir + "docker-compose.valkey.volumes.yml"); } - private static File resolveRedisComposeFile() { - if (IS_REDIS_CLUSTER) { - return new File("./../../docker/docker-compose.redis-cluster.yml"); + private static File resolveValkeyComposeFile(String targetDir) { + if (IS_VALKEY_CLUSTER) { + return new File(targetDir + "docker-compose.valkey-cluster.yml"); } - if (IS_REDIS_SENTINEL) { - return new File("./../../docker/docker-compose.redis-sentinel.yml"); + if (IS_VALKEY_SENTINEL) { + return new File(targetDir + "docker-compose.valkey-sentinel.yml"); } - return new File("./../../docker/docker-compose.redis.yml"); + return new File(targetDir + "docker-compose.valkey.yml"); } public Map getEnv() { @@ -200,27 +207,27 @@ public class ThingsBoardDbInstaller { if (IS_HYBRID_MODE) { additionalServices.append(" cassandra"); } - if (IS_REDIS_CLUSTER) { + if (IS_VALKEY_CLUSTER) { for (int i = 0; i < 6; i++) { - additionalServices.append(" redis-node-").append(i); - dockerCompose.withCommand("volume create " + redisClusterDataVolume + '-' + i); + additionalServices.append(" valkey-node-").append(i); + dockerCompose.withCommand("volume create " + valkeyClusterDataVolume + '-' + i); dockerCompose.invokeDocker(); } - } else if (IS_REDIS_SENTINEL) { - additionalServices.append(" redis-master"); - dockerCompose.withCommand("volume create " + redisSentinelDataVolume + "-" + "master"); + } else if (IS_VALKEY_SENTINEL) { + additionalServices.append(" valkey-primary"); + dockerCompose.withCommand("volume create " + valkeySentinelDataVolume + "-" + "primary"); dockerCompose.invokeDocker(); - additionalServices.append(" redis-slave"); - dockerCompose.withCommand("volume create " + redisSentinelDataVolume + '-' + "slave"); + additionalServices.append(" valkey-replica"); + dockerCompose.withCommand("volume create " + valkeySentinelDataVolume + '-' + "replica"); dockerCompose.invokeDocker(); - additionalServices.append(" redis-sentinel"); - dockerCompose.withCommand("volume create " + redisSentinelDataVolume + '-' + "sentinel"); + additionalServices.append(" valkey-sentinel"); + dockerCompose.withCommand("volume create " + valkeySentinelDataVolume + '-' + "sentinel"); dockerCompose.invokeDocker(); } else { - additionalServices.append(" redis"); - dockerCompose.withCommand("volume create " + redisDataVolume); + additionalServices.append(" valkey"); + dockerCompose.withCommand("volume create " + valkeyDataVolume); dockerCompose.invokeDocker(); } @@ -240,7 +247,7 @@ public class ThingsBoardDbInstaller { } } - public void savaLogsAndRemoveVolumes() { + public void saveLogsAndRemoveVolumes() { copyLogs(tbLogVolume, "./target/tb-logs/"); copyLogs(tbCoapTransportLogVolume, "./target/tb-coap-transport-logs/"); copyLogs(tbLwm2mTransportLogVolume, "./target/tb-lwm2m-transport-logs/"); @@ -261,7 +268,7 @@ public class ThingsBoardDbInstaller { .add(tbSnmpTransportLogVolume) .add(tbVcExecutorLogVolume) .add(tbEdqsLogVolume) - .add(resolveRedisComposeVolumeLog()); + .add(resolveValkeyComposeVolumeLog()); if (IS_HYBRID_MODE) { rmVolumesCommand.add(cassandraDataVolume); @@ -270,16 +277,16 @@ public class ThingsBoardDbInstaller { dockerCompose.withCommand(rmVolumesCommand.toString()); } - private String resolveRedisComposeVolumeLog() { - if (IS_REDIS_CLUSTER) { - return IntStream.range(0, 6).mapToObj(i -> " " + redisClusterDataVolume + "-" + i).collect(Collectors.joining()); + private String resolveValkeyComposeVolumeLog() { + if (IS_VALKEY_CLUSTER) { + return IntStream.range(0, 6).mapToObj(i -> " " + valkeyClusterDataVolume + "-" + i).collect(Collectors.joining()); } - if (IS_REDIS_SENTINEL) { - return redisSentinelDataVolume + "-" + "master " + " " + - redisSentinelDataVolume + "-" + "slave" + " " + - redisSentinelDataVolume + " " + "sentinel"; + if (IS_VALKEY_SENTINEL) { + return valkeySentinelDataVolume + "-" + "primary " + " " + + valkeySentinelDataVolume + "-" + "replica" + " " + + valkeySentinelDataVolume + " " + "sentinel"; } - return redisDataVolume; + return valkeyDataVolume; } private void copyLogs(String volumeName, String targetDir) { diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java index 748335cd06..25b935a2fe 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java @@ -61,7 +61,7 @@ public class WsClient extends WebSocketClient { latch.countDown(); } } catch (IOException e) { - log.error("ws message can't be read"); + log.error("ws message can't be read", e); } } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index 1b1ed9bf0f..eb30ef8b9c 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -556,22 +556,6 @@ public class MqttClientTest extends AbstractContainerTest { assertThat(provisionResponse.get("status").asText()).isEqualTo("NOT_FOUND"); } - @Test - public void regularDisconnect() throws Exception { - DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); - - MqttMessageListener listener = new MqttMessageListener(); - MqttClient mqttClient = getMqttClient(deviceCredentials, listener, MqttVersion.MQTT_5); - final List returnCodeByteValue = new ArrayList<>(); - MqttClientCallback callbackForDisconnectWithReturnCode = getCallbackWrapperForDisconnectWithReturnCode(returnCodeByteValue); - mqttClient.setCallback(callbackForDisconnectWithReturnCode); - mqttClient.disconnect(); - Thread.sleep(1000); - assertThat(returnCodeByteValue.size()).isEqualTo(1); - MqttReasonCodes.Disconnect returnCode = MqttReasonCodes.Disconnect.valueOf(returnCodeByteValue.get(0)); - assertThat(returnCode).isEqualTo(MqttReasonCodes.Disconnect.NORMAL_DISCONNECT); - } - @Test public void clientSessionTakenOverDisconnect() throws Exception { DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/edqs/EdqsEntityDataQueryTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/edqs/EdqsEntityDataQueryTest.java index 53d8a72e7f..770d32e397 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/edqs/EdqsEntityDataQueryTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/edqs/EdqsEntityDataQueryTest.java @@ -73,7 +73,7 @@ public class EdqsEntityDataQueryTest extends AbstractContainerTest { @BeforeClass public void beforeClass() throws Exception { testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); - await().atMost(60, TimeUnit.SECONDS).until(() -> testRestClient.isEdqsApiEnabled()); + await().atMost(60, TimeUnit.SECONDS).until(() -> testRestClient.getEdqsState().isApiEnabled()); tenantId = testRestClient.postTenant(EntityPrototypes.defaultTenantPrototype("Tenant")).getId(); tenantAdminId = testRestClient.createUserAndLogin(defaultTenantAdmin(tenantId, "tenantAdmin@thingsboard.org"), "tenant"); diff --git a/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml b/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml index 0d527e9eb5..c213a2cb57 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: cassandra: environment: diff --git a/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml b/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml index ccc3f34599..cd09476924 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml @@ -14,7 +14,6 @@ # limitations under the License. # -version: '3.0' services: broker: image: eclipse-mosquitto diff --git a/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml b/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml index f88f3543fb..42b3f144a2 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml @@ -14,6 +14,4 @@ # limitations under the License. # -version: '3.0' - # Placeholder diff --git a/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml b/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml index 309bb6c0fa..0c89d2d6e7 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: rabbitmq: restart: always diff --git a/msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.volumes.yml b/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.volumes.yml similarity index 85% rename from msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.volumes.yml rename to msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.volumes.yml index 3b23511501..02a7c397a3 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.volumes.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.volumes.yml @@ -14,14 +14,12 @@ # limitations under the License. # -version: '3.0' - services: - redis: + valkey: volumes: - - redis-data:/bitnami/redis/data + - valkey-data:/bitnami/valkey/data volumes: - redis-data: + valkey-data: external: - name: ${REDIS_DATA_VOLUME} + name: ${VALKEY_DATA_VOLUME} 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.valkey-ssl.yml similarity index 51% rename from msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.yml rename to msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.yml index 525e8d93dc..8c3029cda1 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.redis-ssl.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.yml @@ -14,117 +14,115 @@ # limitations under the License. # -version: '3.0' - services: -# Redis standalone -# The latest version of Redis compatible with ThingsBoard is 7.2 - redis: +# Valkey standalone +# The latest version of Valkey compatible with ThingsBoard is 8.0 + valkey: restart: always - image: bitnami/redis:7.2 + image: bitnami/valkey:8.0 environment: # ALLOW_EMPTY_PASSWORD is recommended only for development. - 'ALLOW_EMPTY_PASSWORD=yes' - - 'REDIS_TLS_ENABLED=yes' - - 'REDIS_TLS_CERT_FILE=/redis/certs/redis.crt' - - 'REDIS_TLS_KEY_FILE=/redis/certs/redis.key' - - 'REDIS_TLS_CA_FILE=/redis/certs/redisCA.crt' - - 'REDIS_TLS_AUTH_CLIENTS=no' + - 'VALKEY_TLS_ENABLED=yes' + - 'VALKEY_TLS_CERT_FILE=/valkey/certs/valkey.crt' + - 'VALKEY_TLS_KEY_FILE=/valkey/certs/valkey.key' + - 'VALKEY_TLS_CA_FILE=/valkey/certs/valkeyCA.crt' + - 'VALKEY_TLS_AUTH_CLIENTS=no' ports: - '6379:6379' volumes: - - ./tb-node/redis-data:/bitnami/redis/data - - ./redis-certs:/redis/certs + - ./tb-node/valkey-data:/bitnami/valkey/data + - ./valkey-certs:/valkey/certs -# ThingsBoard setup to use redis-standalone +# ThingsBoard setup to use valkey-standalone tb-core1: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-core2: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-rule-engine1: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-rule-engine2: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-mqtt-transport1: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-mqtt-transport2: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-http-transport1: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-http-transport2: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-coap-transport: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-lwm2m-transport: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-snmp-transport: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-vc-executor1: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey tb-vc-executor2: env_file: - - cache-redis.env + - cache-valkey.env volumes: - - ./redis-certs:/redis/certs + - ./valkey-certs:/valkey/certs depends_on: - - redis + - valkey diff --git a/msa/black-box-tests/src/test/resources/redis-certs/redis.crt b/msa/black-box-tests/src/test/resources/redis-certs/redis.crt deleted file mode 100755 index 8fce48c0a1..0000000000 --- a/msa/black-box-tests/src/test/resources/redis-certs/redis.crt +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIE1TCCAr2gAwIBAgIUFdFLz/q0EosJc39HhIac9+XpyRkwDQYJKoZIhvcNAQEL -BQAwWTELMAkGA1UEBhMCdWExDTALBgNVBAgMBGt5aXYxDTALBgNVBAcMBGt5aXYx -CzAJBgNVBAoMAnRiMQswCQYDVQQLDAJ0YjESMBAGA1UEAwwJbG9jYWxob3N0MB4X -DTIzMDkwNDE1NTA0MVoXDTI0MDkwMzE1NTA0MVowYjELMAkGA1UEBhMCQ1IxEzAR -BgNVBAgMCkNoYW5kcmlsbGExEzARBgNVBAcMCkNoYW5kcmlsbGExEjAQBgNVBAMM -CUhPU1RfTkFNRTEVMBMGA1UECgwMTmV3IFJlcHVibGljMIIBIjANBgkqhkiG9w0B -AQEFAAOCAQ8AMIIBCgKCAQEA+ICaK/aSklkUIih3cl4k4RYJL8rLS68d5JVSxpCQ -8MwuAakdU+ptD0b6X4+CcNtR96UlcO3cR15GLLT6s29Kw4Ta5SME+yhuFLUIrWxA -/gJ/pkJGkq1vXYZzdUFjtMlF+VbIw+r2hhSkbTR1hV08iRlvflafS8JB/tznqTFy -QIXu08heRtxVaC6SMHLeHmZdgdJrSOulwg/ctcP6tki+ZU9v+TH71M3mTIOLzuSz -7sqnFMPgW7ER0Utc4fndRfz17LA1NZdSrN0Ch5IO+EZ9gf/25w8makbx7lZoZASm -sAd0Uyq9ZPC0ok+oJjeDwanl/Bo7CGEgdxaFYNKpnizyUQIDAQABo4GLMIGIMAsG -A1UdDwQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGgYDVR0R -BBMwEYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBRAIkMMxT30wZYoNQuDM7uT -qwb+VzAfBgNVHSMEGDAWgBSfpEhvBqWx6usjDlGx7lEx8Fl21DANBgkqhkiG9w0B -AQsFAAOCAgEAMeaZ5K0w2kPSqcZbzV4WwGShrhnSYdsA4dlZhAUNNxsoXX590Ppe -lla+vhFSdk/IwjFxLzmiau5+JlCySeOJv2AaG56JvBBU1Wl8LOk01a7qRctiKRth -AGFGKZoJ50h5W1A0NV7KwcGIkQdebAFPdMmkOd6Do98ZhkzYLRuAK3U0K4wNQJuf -gPt9XtIugRNQOwxolXAj81FfhjZ5CnoaCQYJBFyIenwg4uGjg+D0F9WtAlRq8ww+ -XpWpVw8QgPDk+SVoGXUsBs+wMCDlGu4ozN2lIvG1N5n1q0qn39SUYOSBkEsdM+AS -Yu6LMP4J1SPOwT5UJN2jK7fAYBdChF39nV/xiatfUSy8rWUFQSwGv6JD3X0MAYfT -DyOiYdX8o+AnetjfBHHwVXDobh5d1GiC2DwoUNW7KoEdj5mmhDZKiB2S47/5J7El -UA9CevmmDvf/tN9itPrutSwcb7uwLYRsf7Gx3D3P/2+nQHUKyNcyQCNtgR7RKHBS -EjeedMgtKvrqsdPnk6Ygwj8EMh4owDIDcieqnPZAxhqJOJT2ZORdzyelmpv5aDGc -0XnnRHRInSUgQStfPa9ghOBpSXlhxL1EJFFik+yFOjH4GivhoynCb7zjW+MjlPDV -LAsnmMukBR95ZkpxMnRUEoLTEvTaxmg4Vr/mqXeQUKdU8A812Wrk2hc= ------END CERTIFICATE----- diff --git a/msa/black-box-tests/src/test/resources/redis-certs/redis.key b/msa/black-box-tests/src/test/resources/redis-certs/redis.key deleted file mode 100755 index cbbf83ca2b..0000000000 --- a/msa/black-box-tests/src/test/resources/redis-certs/redis.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD4gJor9pKSWRQi -KHdyXiThFgkvystLrx3klVLGkJDwzC4BqR1T6m0PRvpfj4Jw21H3pSVw7dxHXkYs -tPqzb0rDhNrlIwT7KG4UtQitbED+An+mQkaSrW9dhnN1QWO0yUX5VsjD6vaGFKRt -NHWFXTyJGW9+Vp9LwkH+3OepMXJAhe7TyF5G3FVoLpIwct4eZl2B0mtI66XCD9y1 -w/q2SL5lT2/5MfvUzeZMg4vO5LPuyqcUw+BbsRHRS1zh+d1F/PXssDU1l1Ks3QKH -kg74Rn2B//bnDyZqRvHuVmhkBKawB3RTKr1k8LSiT6gmN4PBqeX8GjsIYSB3FoVg -0qmeLPJRAgMBAAECggEAE6sCCMa8NQ8N0+JGCew/mP0Ifxra2kOi5wuWgJbCkfxn -C8SZyKF+Pj5M5LFUDqCdLS+J9hUtYQyqGzG7weXmEfF67bXG2CYMCGGHrUorHq+N -8NfABC3r6YgRrU8emBlyC1j+DNuU5WnO1cHYJ1UIzIUR2Pr8Ip/eX1CWmUKLm2WW -YvUGzvTG0mM0l9/Q8pcTndAxDuL90GG6TrxtQoy7Ir6dYxTVKHgOwa6RX86VrCkm -jl6Wu7Bvo2fnPvPVx8p1mgWNgtBbnc/VAYjeF7Yi7CETWHTxGM4iGtZNL8/xxlW7 -sm2SmWEu72EupLYgytA9w+EptjbwWfcTaWbWUXTFHQKBgQD5nmYLVvZUR1oc9g2a -Q7XpFQ/jVvwedeKGolMbWTFrEhl1+D8lKN/S3SoRCOALOUJFUCU+L1snN3MsJ5Es -Gb0FLSH1LBBfuHqP+aHn/uGLMW8e7P9thh4DQ1tIzKE5xhh+JDJJqd15YJlsDy1F -0aKWyulS4o9XK5q86rs5QbI4MwKBgQD+2uXEAiFwEXq7pw2e8STrnt0jp09KU6b1 -z/ykyArBdcVkrEudZ+jIrR/6rlSKK+SKQxtx8MG9M/Nm09KFRCAWblxHBwQ87ZnU -8tMAmPHrikLKk1dbbU3BQ3cZkIWMryCo4wzuxeT4mc2goTGNZpNZDpjqjCXbnCgP -T29aPHG3awKBgGFv8UlP4su3JnfTnC+xaprXO+J0G+oP/iKrzmEIif/Pity/0HZC -5Eu9RSRtIHeBHFtOE5uYhK5kOLLtpv9d9KjGm1DGqIWUz1LQEOEsXwIkg8nAnVw1 -VBXV/xYFupGAwCLNIkwa4Hb2vCywJ+3vDNZr0nQmN+nA/Z/syLRq7pR9AoGBALqp -l3pd2SHdG5jP/VD57IHLRMs1YwTcikAmizQh9IbH/MEE1QlALya0buTLxM3C4kxG -ZJaqsSwkHdWltd64DAyB3oKDaB48JNzs0ZDxdNeA1/TJwEUNpNK12EjYKojlSDWK -v1Evjspq1EofZkzb4XZsE6JO7feQw2KbWsKr3NprAoGBAKSSAjDpncxb+9Pr0Lwa -AS7ATgMhot6+lbtZZV6egTGUVvtgd1LyE2ZJkE+5XJRu49X1lSJdDpFT66rG5eNV -+rYnDqXL8c0Z/j1L96z1UMY6DLPj3n+07zgLiNIrOR4UKP/+TGB9MHHFMEpYnij0 -m/f6dg0Ujw6CW31Hq2QdJ9P7 ------END PRIVATE KEY----- diff --git a/msa/black-box-tests/src/test/resources/redis-certs/redisCA.crt b/msa/black-box-tests/src/test/resources/redis-certs/redisCA.crt deleted file mode 100644 index 444dac130b..0000000000 --- a/msa/black-box-tests/src/test/resources/redis-certs/redisCA.crt +++ /dev/null @@ -1,32 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFkzCCA3ugAwIBAgIUK9fjeDv+ESrdFSHMqsod5djzTh4wDQYJKoZIhvcNAQEL -BQAwWTELMAkGA1UEBhMCdWExDTALBgNVBAgMBGt5aXYxDTALBgNVBAcMBGt5aXYx -CzAJBgNVBAoMAnRiMQswCQYDVQQLDAJ0YjESMBAGA1UEAwwJbG9jYWxob3N0MB4X -DTIzMDkwNDE1MDUxN1oXDTMzMDkwMTE1MDUxN1owWTELMAkGA1UEBhMCdWExDTAL -BgNVBAgMBGt5aXYxDTALBgNVBAcMBGt5aXYxCzAJBgNVBAoMAnRiMQswCQYDVQQL -DAJ0YjESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A -MIICCgKCAgEAsaxpM/OLfx0jNVHAiD4dmJ4t5vZQNINtaI/GgnXNv9iM3aDvbYr/ -Cp8mJgkOvI9BZqDEcLscBv+H4mxCqS2IFBqJbtkYHExBgg7V13NRtJwqsWVz0rBG -V1SFou2JSzPkQ6IDxJI+AUW6DfsbCs3o6VRPbriMfY06rNagerG1osaD9yn/EsH9 -BTdALravcvU8mxOghzWH54EzwDUA0mKUetgvgfqkzjDlqzXFnOsIinnDi0Ia3idF -RaBVs7bKokl0Zp1mdtvsEpG4lcmhDtNIogcmeH2LW2zEneHuDX2BGsN9CCwEGj3k -cftuxEck2mQVDoDgX1IpIUGBhIaAixj+UXh8RNSwU96GBwKbFNy/CXNj5+34DcL0 -kBh7p77rcrzm6xEGpP/3YYPoRVBRAX64x4QqzqF5oj6Sf2NGKH9RILmLWdqmq/up -6cMYyzAEOr3nIQ/7OfkdvxOt0oUzeB7QPYbTvM9bCqe3XA+JH1pnLhgySPxdTeVe -nUExge8WPJmdTlH1McminFDiJruFRa069hbky/b5z8BjIBN5S3lC4PdEE9YmDSL1 -u26HnRVwtqC32fkDNI1PT/4p2VaB+DmIxkFXrVUnV0TiyCyxu4jIgXhUiJRHgBn9 -zwB7lEZUhMriOBGJqHa0H0TtnH8z/5GYmipeJZllmQbhI+sMyYBS/+cCAwEAAaNT -MFEwHQYDVR0OBBYEFJ+kSG8GpbHq6yMOUbHuUTHwWXbUMB8GA1UdIwQYMBaAFJ+k -SG8GpbHq6yMOUbHuUTHwWXbUMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL -BQADggIBABCt/bx7/YbvR/0PE4nIzxVovPItR+oYcInRrbwT+VWvL22Su7rf71lc -1vlil4xjdVxSEi6s5KZ69PLJKKXukt1MBCUStDK1HKPPB1SAhtD6nuvkh7YL+2im -A5gmtg3KkD2ZD2mWCHAa8K7NEMah1XiMVMo+ByFNPQExqOk2i3+5kjBrlfElsm0Q -ixM++93T62gTibOjuO8uPP0NUcIHI+RcEalc1hJjFof4gWLnIulaeuUydXw7RhzE -HzeOeZiZWrvW2mjfiANMn27TV5K0dZoh/4+YLqkKn1bdQsYVIWxWx8jZ0Nj0yzMb -Ekkodny3F+BHqkSUb3whRWDKN82valnCSJFAKZFZzueAJgCjANTNdr7S7UUIxZyi -QKll59T4O1yhawRu/cZ6TQWzV7RWdTerFfIjHMwsohDUxlkoACJebLahsBG9IHGN -Tn+P2djY6CXBctbTXhRiYqeb79/TPU0EETv7/ilNHS/tssWcKWkFdai3yMzLvxeH -YTVPMzeAWW/PnQOwYTkgeaj7SIK5bbm5n/gpWk31R5gRWhgJc9FZSa9+oZWbWeYh -3XfsuCuTH+jSs0g+jJUx/cpIVrO38r2hSuhDPugmHgM6yEnKMubhChCPCyXjO0z9 -brEMQ1T9r2sKOYuyNDhN/W9/QsTb/RO4Ug2lYlzRTdehqvimHspW ------END CERTIFICATE----- diff --git a/msa/black-box-tests/src/test/resources/valkey-certs/valkey.crt b/msa/black-box-tests/src/test/resources/valkey-certs/valkey.crt new file mode 100755 index 0000000000..8f06395b44 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/valkey-certs/valkey.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLTCB1AIUSQ2Ce8LOpzqOJhL2aXCTVrd45twwCgYIKoZIzj0EAwIwHDEaMBgG +A1UEAwwRVmFsa2V5IEVDIFJvb3QgQ0EwHhcNMjUwNTI4MDYxNTI5WhcNMjYwNTI4 +MDYxNTI5WjAXMRUwEwYDVQQDDAx2YWxrZXkubG9jYWwwWTATBgcqhkjOPQIBBggq +hkjOPQMBBwNCAATSpimU25+2yIKGNwSQgPN2QaEcfWnj4c8PcTjeXg/1C2+fpMyl +jgknA64GUM2VeoVbG0mTkywlRU57+2yVLKYrMAoGCCqGSM49BAMCA0gAMEUCIDi0 +ri1mBU64a8C0L/QZet0C/+HsbhItq40AmtZyEKWlAiEA8FJG51ZvncgYx8PCLGS7 +WmBCqfnG58W4ga4M6YhEnYA= +-----END CERTIFICATE----- diff --git a/msa/black-box-tests/src/test/resources/valkey-certs/valkey.key b/msa/black-box-tests/src/test/resources/valkey-certs/valkey.key new file mode 100755 index 0000000000..eba80567dc --- /dev/null +++ b/msa/black-box-tests/src/test/resources/valkey-certs/valkey.key @@ -0,0 +1,8 @@ +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIHsaG9ozysmDm7xv+d+i/vl7xJUHcgL93/9rvQfd3PWDoAoGCCqGSM49 +AwEHoUQDQgAE0qYplNuftsiChjcEkIDzdkGhHH1p4+HPD3E43l4P9Qtvn6TMpY4J +JwOuBlDNlXqFWxtJk5MsJUVOe/tslSymKw== +-----END EC PRIVATE KEY----- diff --git a/msa/black-box-tests/src/test/resources/valkey-certs/valkeyCA.crt b/msa/black-box-tests/src/test/resources/valkey-certs/valkeyCA.crt new file mode 100644 index 0000000000..6210085236 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/valkey-certs/valkeyCA.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjDCCATOgAwIBAgIUd7nfNuCY22WidK3CdazyMQUzVrAwCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRVmFsa2V5IEVDIFJvb3QgQ0EwHhcNMjUwNTI4MDYxNTE4WhcN +MjcwNTI4MDYxNTE4WjAcMRowGAYDVQQDDBFWYWxrZXkgRUMgUm9vdCBDQTBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABA4DTobDPtQtjBxYR+szO6p/pen9JWYPuXww +AyYMDEvD5ue3itruuDz0td0BMvn5YO+t1d03VKFVGP1ZpB4XO7ujUzBRMB0GA1Ud +DgQWBBSwBnnqUCfMUoaq7adEANr614SLFDAfBgNVHSMEGDAWgBSwBnnqUCfMUoaq +7adEANr614SLFDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFCp +9xuuj8Z6iSozSA/3cIugBnQqUSMw9BrS+2Qr6v69AiBz2R+DGXZAKTkOzqgxBXjv +fcdX4LrBpLCn1Pw/IDD+Ug== +-----END CERTIFICATE----- diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index b3f584297e..882ba588e1 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 1c16eaa594..4d417081d8 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.1.0", + "version": "4.2.0", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index be52d30d01..95e4516044 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index 909c4ad0ac..a7a8ff1b89 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index 280541452c..b980bd703e 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard msa diff --git a/msa/tb-node/docker/Dockerfile b/msa/tb-node/docker/Dockerfile index dfca56acc7..013a37ef9c 100644 --- a/msa/tb-node/docker/Dockerfile +++ b/msa/tb-node/docker/Dockerfile @@ -16,12 +16,14 @@ FROM thingsboard/openjdk17:bookworm-slim -COPY start-tb-node.sh ${pkg.name}.deb /tmp/ +COPY logback.xml start-tb-node.sh ${pkg.name}.deb /tmp/ RUN chmod a+x /tmp/*.sh \ && mv /tmp/start-tb-node.sh /usr/bin && \ (yes | dpkg -i /tmp/${pkg.name}.deb) && \ rm /tmp/${pkg.name}.deb && \ + mv /tmp/logback.xml ${pkg.installFolder}/conf && \ + chown -R ${pkg.user}:${pkg.user} ${pkg.installFolder}/conf/logback.xml && \ (systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || :) && \ chown -R ${pkg.user}:${pkg.user} /tmp && \ chmod 555 ${pkg.installFolder}/bin/${pkg.name}.jar diff --git a/msa/tb-node/docker/logback.xml b/msa/tb-node/docker/logback.xml new file mode 100644 index 0000000000..269cb89396 --- /dev/null +++ b/msa/tb-node/docker/logback.xml @@ -0,0 +1,38 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + diff --git a/msa/tb-node/docker/start-tb-node.sh b/msa/tb-node/docker/start-tb-node.sh index 7de30564c9..77221398be 100755 --- a/msa/tb-node/docker/start-tb-node.sh +++ b/msa/tb-node/docker/start-tb-node.sh @@ -15,14 +15,21 @@ # limitations under the License. # -CONF_FOLDER="/config" jarfile=${pkg.installFolder}/bin/${pkg.name}.jar configfile=${pkg.name}.conf run_user=${pkg.user} -source "${CONF_FOLDER}/${configfile}" +CONF_FOLDER="/config" +if [ -d "${CONF_FOLDER}" ]; then + LOGGING_CONFIG="${CONF_FOLDER}/logback.xml" + source "${CONF_FOLDER}/${configfile}" + export LOADER_PATH=${CONF_FOLDER},${LOADER_PATH} +else + CONF_FOLDER="/usr/share/${pkg.name}/conf" + LOGGING_CONFIG="/usr/share/${pkg.name}/conf/logback.xml" + source "${CONF_FOLDER}/${configfile}" +fi -export LOADER_PATH=/config,${LOADER_PATH} cd ${pkg.installFolder}/bin @@ -38,7 +45,6 @@ if [ "$INSTALL_TB" == "true" ]; then exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ -Dinstall.load_demo=${loadDemo} \ - -Dspring.jpa.hibernate.ddl-auto=none \ -Dinstall.upgrade=false \ -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ org.springframework.boot.loader.launch.PropertiesLauncher @@ -51,7 +57,6 @@ elif [ "$UPGRADE_TB" == "true" ]; then fromVersion="${FROM_VERSION// }" exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ - -Dspring.jpa.hibernate.ddl-auto=none \ -Dinstall.upgrade=true \ -Dinstall.upgrade.from_version=${fromVersion} \ -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ @@ -62,8 +67,7 @@ else echo "Starting '${project.name}' ..." exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardServerApplication \ - -Dspring.jpa.hibernate.ddl-auto=none \ - -Dlogging.config=/config/logback.xml \ + -Dlogging.config=${LOGGING_CONFIG} \ org.springframework.boot.loader.launch.PropertiesLauncher fi diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 4a278f9dd8..9f5c7cf358 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index f656f37a93..421cf1290f 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 004080a542..f0a46069db 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 0d0d70e405..45f9323ab7 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 596b2d3bbd..b95402ba14 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index aae7594b27..2fbfc5b9f2 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index 9f5a0fdc7f..5b57136d88 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 118783f98d..ee79717ca4 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 088e9ab4a8..b81504aa06 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index b35a30f1b2..e15fc6699c 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index 8df71fc347..de0c04fc9d 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.1.0", + "version": "4.2.0", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 4eba1f5e2f..4aeb5227c8 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index fa0c4e3fe3..b30f1e2da3 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard netty-mqtt - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT jar Netty MQTT Client diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java index 0b50b1883a..a1987cfe98 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java @@ -15,11 +15,11 @@ */ package org.thingsboard.mqtt; -/** - * Created by Valerii Sosliuk on 12/26/2017. - */ +import java.io.Serial; + public class ChannelClosedException extends RuntimeException { + @Serial private static final long serialVersionUID = 6266638352424706909L; public ChannelClosedException() { @@ -40,4 +40,5 @@ public class ChannelClosedException extends RuntimeException { public ChannelClosedException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java index 85b4499e36..ae5168e466 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java @@ -21,9 +21,6 @@ import io.netty.handler.codec.mqtt.MqttPubAckMessage; import io.netty.handler.codec.mqtt.MqttSubAckMessage; import io.netty.handler.codec.mqtt.MqttUnsubAckMessage; -/** - * Created by Valerii Sosliuk on 12/30/2017. - */ public interface MqttClientCallback { /** @@ -53,4 +50,5 @@ public interface MqttClientCallback { default void onDisconnect(MqttMessage mqttDisconnectMessage) { } + } 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 24feb3e58e..e2b6967f06 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java @@ -28,23 +28,46 @@ import java.util.Random; @SuppressWarnings({"WeakerAccess", "unused"}) public final class MqttClientConfig { + + @Getter private final SslContext sslContext; private final String randomClientId; @Getter @Setter private String ownerId; // [TenantId][IntegrationId] or [TenantId][RuleNodeId] for exceptions logging purposes + @Nonnull + @Getter private String clientId; + @Getter private int timeoutSeconds = 60; + @Getter private MqttVersion protocolVersion = MqttVersion.MQTT_3_1; - @Nullable private String username = null; - @Nullable private String password = null; + @Nullable + @Getter + @Setter + private String username = null; + @Nullable + @Getter + @Setter + private String password = null; + @Getter + @Setter private boolean cleanSession = true; - @Nullable private MqttLastWill lastWill; + @Nullable + @Getter + @Setter + private MqttLastWill lastWill; + @Setter + @Getter private Class channelClass = NioSocketChannel.class; + @Getter + @Setter private boolean reconnect = true; + @Getter private long reconnectDelay = 1L; + @Getter private int maxBytesInMessage = 8092; @Getter @@ -74,109 +97,37 @@ public final class MqttClientConfig { public MqttClientConfig(SslContext sslContext) { this.sslContext = sslContext; Random random = new Random(); - String id = "netty-mqtt/"; + StringBuilder id = new StringBuilder("netty-mqtt/"); String[] options = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split(""); - for(int i = 0; i < 8; i++){ - id += options[random.nextInt(options.length)]; + for (int i = 0; i < 8; i++) { + id.append(options[random.nextInt(options.length)]); } - this.clientId = id; - this.randomClientId = id; - } - - @Nonnull - public String getClientId() { - return clientId; + this.clientId = id.toString(); + this.randomClientId = id.toString(); } public void setClientId(@Nullable String clientId) { - if(clientId == null){ + if (clientId == null) { this.clientId = randomClientId; - }else{ + } else { this.clientId = clientId; } } - public int getTimeoutSeconds() { - return timeoutSeconds; - } - public void setTimeoutSeconds(int timeoutSeconds) { - if(timeoutSeconds != -1 && timeoutSeconds <= 0){ + if (timeoutSeconds != -1 && timeoutSeconds <= 0) { throw new IllegalArgumentException("timeoutSeconds must be > 0 or -1"); } this.timeoutSeconds = timeoutSeconds; } - public MqttVersion getProtocolVersion() { - return protocolVersion; - } - public void setProtocolVersion(MqttVersion protocolVersion) { - if(protocolVersion == null){ + if (protocolVersion == null) { throw new NullPointerException("protocolVersion"); } this.protocolVersion = protocolVersion; } - @Nullable - public String getUsername() { - return username; - } - - public void setUsername(@Nullable String username) { - this.username = username; - } - - @Nullable - public String getPassword() { - return password; - } - - public void setPassword(@Nullable String password) { - this.password = password; - } - - public boolean isCleanSession() { - return cleanSession; - } - - public void setCleanSession(boolean cleanSession) { - this.cleanSession = cleanSession; - } - - @Nullable - public MqttLastWill getLastWill() { - return lastWill; - } - - public void setLastWill(@Nullable MqttLastWill lastWill) { - this.lastWill = lastWill; - } - - public Class getChannelClass() { - return channelClass; - } - - public void setChannelClass(Class channelClass) { - this.channelClass = channelClass; - } - - public SslContext getSslContext() { - return sslContext; - } - - public boolean isReconnect() { - return reconnect; - } - - public void setReconnect(boolean reconnect) { - this.reconnect = reconnect; - } - - public long getReconnectDelay() { - return reconnectDelay; - } - /** * Sets the reconnect delay in seconds. Defaults to 1 second. * @param reconnectDelay @@ -189,10 +140,6 @@ public final class MqttClientConfig { this.reconnectDelay = reconnectDelay; } - public int getMaxBytesInMessage() { - return maxBytesInMessage; - } - /** * Sets the maximum number of bytes in the message for the {@link io.netty.handler.codec.mqtt.MqttDecoder}. * Default value is 8092 as specified by Netty. The absolute maximum size is 256MB as set by the MQTT spec. @@ -206,4 +153,5 @@ public final class MqttClientConfig { } this.maxBytesInMessage = maxBytesInMessage; } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java index 543553951d..d8edf722ff 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java @@ -46,7 +46,9 @@ import io.netty.handler.timeout.IdleStateHandler; import io.netty.util.concurrent.DefaultPromise; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.Promise; +import lombok.AccessLevel; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ListeningExecutor; @@ -67,18 +69,28 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j final class MqttClientImpl implements MqttClient { + @Getter(AccessLevel.PACKAGE) private final Set serverSubscriptions = new HashSet<>(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap pendingServerUnsubscribes = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap qos2PendingIncomingPublishes = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap pendingPublishes = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final HashMultimap subscriptions = HashMultimap.create(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap pendingSubscriptions = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final Set pendingSubscribeTopics = new HashSet<>(); + @Getter(AccessLevel.PACKAGE) private final HashMultimap handlerToSubscription = HashMultimap.create(); private final AtomicInteger nextMessageId = new AtomicInteger(1); + @Getter private final MqttClientConfig clientConfig; + @Getter(AccessLevel.PACKAGE) private final MqttHandler defaultHandler; private final ReconnectStrategy reconnectStrategy; @@ -88,14 +100,19 @@ final class MqttClientImpl implements MqttClient { private volatile Channel channel; private volatile boolean disconnected = false; + @Getter private volatile boolean reconnect = false; private String host; private int port; @Getter + @Setter private MqttClientCallback callback; + @Getter private final ListeningExecutor handlerExecutor; + private final static int DISCONNECT_FALLBACK_DELAY_SECS = 1; + /** * Construct the MqttClientImpl with default config */ @@ -190,14 +207,14 @@ final class MqttClientImpl implements MqttClient { } private void scheduleConnectIfRequired(String host, int port, boolean reconnect) { - log.trace("[{}] Scheduling connect to server, isReconnect - {}", channel != null ? channel.id() : "UNKNOWN", reconnect); + log.trace("[{}][{}][{}] Scheduling connect to server, isReconnect - {}", host, port, channel != null ? channel.id() : "UNKNOWN", reconnect); if (clientConfig.isReconnect() && !disconnected) { if (reconnect) { this.reconnect = true; } final long nextReconnectDelay = reconnectStrategy.getNextReconnectDelay(); - log.info("[{}] Scheduling reconnect in [{}] sec", channel != null ? channel.id() : "UNKNOWN", nextReconnectDelay); + log.debug("[{}][{}][{}] Scheduling reconnect in [{}] sec", host, port, channel != null ? channel.id() : "UNKNOWN", nextReconnectDelay); eventLoop.schedule((Runnable) () -> connect(host, port, reconnect), nextReconnectDelay, TimeUnit.SECONDS); } } @@ -238,11 +255,6 @@ final class MqttClientImpl implements MqttClient { this.eventLoop = eventLoop; } - @Override - public ListeningExecutor getHandlerExecutor() { - return this.handlerExecutor; - } - /** * Subscribe on the given topic. When a message is received, MqttClient will invoke the {@link MqttHandler#onMessage(String, ByteBuf)} function of the given handler * @@ -444,50 +456,39 @@ final class MqttClientImpl implements MqttClient { return future; } - /** - * Retrieve the MqttClient configuration - * - * @return The {@link MqttClientConfig} instance we use - */ - @Override - public MqttClientConfig getClientConfig() { - return clientConfig; - } - @Override public void disconnect() { + if (disconnected) { + return; + } + log.trace("[{}] Disconnecting from server", channel != null ? channel.id() : "UNKNOWN"); - disconnected = true; if (this.channel != null) { MqttMessage message = new MqttMessage(new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0)); - ChannelFuture channelFuture = this.sendAndFlushPacket(message); + + sendAndFlushPacket(message).addListener((ChannelFutureListener) future -> { + future.channel().close(); + disconnected = true; + }); eventLoop.schedule(() -> { - if (!channelFuture.isDone()) { + if (channel.isOpen()) { + log.trace("[{}] Channel still open after {} second; forcing close now", channel.id(), DISCONNECT_FALLBACK_DELAY_SECS); this.channel.close(); + disconnected = true; } - }, 500, TimeUnit.MILLISECONDS); + }, DISCONNECT_FALLBACK_DELAY_SECS, TimeUnit.SECONDS); } } - @Override - public void setCallback(MqttClientCallback callback) { - this.callback = callback; - } - ///////////////////////////////////////////// PRIVATE API ///////////////////////////////////////////// - public boolean isReconnect() { - return reconnect; - } - public void onSuccessfulReconnect() { if (callback != null) { callback.onSuccessfulReconnect(); } } - ChannelFuture sendAndFlushPacket(Object message) { if (this.channel == null) { return null; @@ -565,7 +566,7 @@ final class MqttClientImpl implements MqttClient { } private void checkSubscriptions(String topic, Promise promise) { - if (!(this.subscriptions.containsKey(topic) && this.subscriptions.get(topic).size() != 0) && this.serverSubscriptions.contains(topic)) { + if (!(this.subscriptions.containsKey(topic) && !this.subscriptions.get(topic).isEmpty()) && this.serverSubscriptions.contains(topic)) { MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.UNSUBSCRIBE, false, MqttQoS.AT_LEAST_ONCE, false, 0); MqttMessageIdVariableHeader variableHeader = getNewMessageId(); MqttUnsubscribePayload payload = new MqttUnsubscribePayload(Collections.singletonList(topic)); @@ -603,38 +604,6 @@ final class MqttClientImpl implements MqttClient { } } - ConcurrentMap getPendingSubscriptions() { - return pendingSubscriptions; - } - - HashMultimap getSubscriptions() { - return subscriptions; - } - - Set getPendingSubscribeTopics() { - return pendingSubscribeTopics; - } - - HashMultimap getHandlerToSubscription() { - return handlerToSubscription; - } - - Set getServerSubscriptions() { - return serverSubscriptions; - } - - ConcurrentMap getPendingServerUnsubscribes() { - return pendingServerUnsubscribes; - } - - ConcurrentMap getPendingPublishes() { - return pendingPublishes; - } - - ConcurrentMap getQos2PendingIncomingPublishes() { - return qos2PendingIncomingPublishes; - } - private class MqttChannelInitializer extends ChannelInitializer { private final Promise connectFuture; @@ -662,10 +631,7 @@ final class MqttClientImpl implements MqttClient { ch.pipeline().addLast("mqttPingHandler", new MqttPingHandler(MqttClientImpl.this.clientConfig.getTimeoutSeconds())); ch.pipeline().addLast("mqttHandler", new MqttChannelHandler(MqttClientImpl.this, connectFuture)); } - } - MqttHandler getDefaultHandler() { - return defaultHandler; } } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java index 67757d2a7a..909955d062 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java @@ -17,14 +17,18 @@ package org.thingsboard.mqtt; import io.netty.channel.ChannelFuture; import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import lombok.Getter; import lombok.ToString; @ToString @SuppressWarnings({"WeakerAccess", "unused"}) public final class MqttConnectResult { + @Getter private final boolean success; + @Getter private final MqttConnectReturnCode returnCode; + @Getter private final ChannelFuture closeFuture; MqttConnectResult(boolean success, MqttConnectReturnCode returnCode, ChannelFuture closeFuture) { @@ -33,16 +37,4 @@ public final class MqttConnectResult { this.closeFuture = closeFuture; } - public boolean isSuccess() { - return success; - } - - public MqttConnectReturnCode getReturnCode() { - return returnCode; - } - - public ChannelFuture getCloseFuture() { - return closeFuture; - } - } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java index 7ad93462ab..d5125757da 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java @@ -15,16 +15,23 @@ */ package org.thingsboard.mqtt; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; + import java.util.regex.Pattern; final class MqttSubscription { + @Getter(AccessLevel.PACKAGE) private final String topic; private final Pattern topicRegex; + @Getter private final MqttHandler handler; - + @Getter(AccessLevel.PACKAGE) private final boolean once; - + @Getter(AccessLevel.PACKAGE) + @Setter(AccessLevel.PACKAGE) private volatile boolean called; MqttSubscription(String topic, MqttHandler handler, boolean once) { @@ -40,22 +47,6 @@ final class MqttSubscription { this.topicRegex = Pattern.compile(topic.replace("+", "[^/]+").replace("#", ".+") + "$"); } - String getTopic() { - return topic; - } - - public MqttHandler getHandler() { - return handler; - } - - boolean isOnce() { - return once; - } - - boolean isCalled() { - return called; - } - boolean matches(String topic) { return this.topicRegex.matcher(topic).matches(); } @@ -78,7 +69,4 @@ final class MqttSubscription { return result; } - void setCalled(boolean called) { - this.called = called; - } } diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java index 1481b354ee..60e625aa8d 100644 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java +++ b/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java @@ -119,6 +119,26 @@ class MqttClientTest { assertThat(client.isConnected()).isTrue(); } + @Test + void testDisconnectFromBroker() { + // GIVEN + var clientConfig = new MqttClientConfig(); + clientConfig.setOwnerId("Test[Disconnect]"); + clientConfig.setClientId("disconnect"); + + client = MqttClient.create(clientConfig, null, handlerExecutor); + + connect(broker.getHost(), broker.getMqttPort()); + + // WHEN + client.disconnect(); + + // THEN + Awaitility.await("waiting for client to disconnect") + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(client.isConnected()).isFalse()); + } + @Test void testDisconnectDueToKeepAliveIfNoActivity() { // GIVEN diff --git a/pom.xml b/pom.xml index c57239c965..27de67d218 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT pom Thingsboard @@ -42,19 +42,19 @@ 4.0.2 2.4.0-b180830.0359 4.0.5 - 10.1.40 + 10.1.42 2.5.2 3.2.12 3.2.12 3.2.12 - 6.1.15 + 6.1.21 6.2.11 - 6.3.8 + 6.3.9 5.1.5 0.12.5 - 2.0.13 - 2.23.1 - 1.5.5 + 2.0.17 + 2.24.3 + 1.5.6 0.10 4.17.0 4.2.25 @@ -83,16 +83,18 @@ 2.3.32 2.0.1 5.6.0 - 3.9.2 + 3.9.3 3.25.5 1.63.0 - 1.2.6 + 1.2.8 1.18.32 1.2.5 1.2.5 4.1.119.Final 2.0.65.Final 1.1.18 + 1.0.4 + 3.6.12 1.7.1 5.21.0 3.2.5 @@ -103,7 +105,7 @@ 1.19.0 1.78.1 2.0.1 - 42.7.5 + 42.7.7 org/thingsboard/server/gen/**/*, org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* @@ -113,13 +115,20 @@ - 3.7.2 + 3.9.1 8.10.1 3.5.3 2.2 1.12.701 1.128.1 - 2.37.1 + + 1.34.0 + 1.46.3 + 2.48.0 + 2.65.0 + 2.37.1 + 1.51.0 + 1.6.4 1.6.1 1.9.4 @@ -135,6 +144,13 @@ 1.7.5 3.8.0 2.9.0 + 1.1.0 + 2.38.0 + 1.24 + 1.11.0 + 3.49.3 + 0.27.0 + 1.7.0 4.2.1 2.7.3 @@ -158,7 +174,7 @@ 2.12.0 3.0.2 - 6.9.0.202403050737-r + 6.10.1.202505221210-r 0.4.8 1.0.0 @@ -964,6 +980,11 @@ cluster-api ${project.version}
+ + org.thingsboard.common + discovery-api + ${project.version} + org.thingsboard.rule-engine rule-engine-api @@ -1173,7 +1194,7 @@ org.springframework.boot - spring-boot-starter + spring-boot-starter ${spring-boot.version} @@ -1255,6 +1276,16 @@ reactor-netty-http ${reactor-netty.version} + + org.reactivestreams + reactive-streams + ${reactive-streams.version} + + + io.projectreactor + reactor-core + ${reactor-core.version} + org.apache.kafka kafka-clients @@ -2074,10 +2105,55 @@ google-cloud-pubsub ${pubsub.client.version} + + com.google.auth + google-auth-library-credentials + ${google-auth-library.version} + + + com.google.auth + google-auth-library-oauth2-http + ${google-auth-library.version} + + + com.google.http-client + google-http-client + ${google-http-client.version} + + + com.google.http-client + google-http-client-gson + ${google-http-client.version} + + + com.google.api + api-common + ${google-api-common.version} + + + com.google.api + gax + ${google-api-gax.version} + + + com.google.api + gax-grpc + ${google-api-gax.version} + + + com.google.api + gax-httpjson + ${google-api-gax.version} + com.google.api.grpc proto-google-common-protos - ${google.common.protos.version} + ${google-proto-common.version} + + + com.google.api.grpc + proto-google-iam-v1 + ${google-proto-iam-v1.version} org.passay @@ -2331,6 +2407,43 @@ rocksdbjni ${rocksdbjni.version} + + com.google.errorprone + error_prone_annotations + ${error_prone_annotations.version} + + + org.codehaus.mojo + animal-sniffer-annotations + ${animal-sniffer-annotations.version} + + + com.google.auto.value + auto-value-annotations + ${auto-value-annotations.version} + + + org.checkerframework + checker-qual + ${checker-qual.version} + + + io.perfmark + perfmark-api + ${perfmark-api.version} + + + org.threeten + threetenbp + ${threetenbp.version} + + + dev.langchain4j + langchain4j-bom + ${langchain4j.version} + pom + import + diff --git a/rest-client/pom.xml b/rest-client/pom.xml index 828b33e835..392c3b360f 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard rest-client diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 633dbe9c57..67e11430bd 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -19,7 +19,9 @@ import com.auth0.jwt.JWT; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Strings; +import lombok.SneakyThrows; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.concurrent.LazyInitializer; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; @@ -100,6 +102,7 @@ import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; @@ -206,7 +209,9 @@ public class RestClient implements Closeable { private static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization"; private static final long AVG_REQUEST_TIMEOUT = TimeUnit.SECONDS.toMillis(30); protected static final String ACTIVATE_TOKEN_REGEX = "/api/noauth/activate?activateToken="; - private final ExecutorService service = ThingsBoardExecutors.newWorkStealingPool(10, getClass()); + private final LazyInitializer executor = LazyInitializer.builder() + .setInitializer(() -> ThingsBoardExecutors.newWorkStealingPool(10, getClass())) + .get(); protected final RestTemplate restTemplate; protected final RestTemplate loginRestTemplate; protected final String baseURL; @@ -224,22 +229,30 @@ public class RestClient implements Closeable { } public RestClient(RestTemplate restTemplate, String baseURL) { + this(restTemplate, baseURL, null); + } + + public RestClient(RestTemplate restTemplate, String baseURL, String accessToken) { this.restTemplate = restTemplate; this.loginRestTemplate = new RestTemplate(restTemplate.getRequestFactory()); this.baseURL = baseURL; this.restTemplate.getInterceptors().add((request, bytes, execution) -> { HttpRequest wrapper = new HttpRequestWrapper(request); - long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT; - if (calculatedTs > mainTokenExpTs) { - synchronized (RestClient.this) { - if (calculatedTs > mainTokenExpTs) { - if (calculatedTs < refreshTokenExpTs) { - refreshToken(); - } else { - doLogin(); + if (accessToken == null) { + long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT; + if (calculatedTs > mainTokenExpTs) { + synchronized (RestClient.this) { + if (calculatedTs > mainTokenExpTs) { + if (calculatedTs < refreshTokenExpTs) { + refreshToken(); + } else { + doLogin(); + } } } } + } else { + mainToken = accessToken; } wrapper.getHeaders().set(JWT_TOKEN_HEADER_PARAM, "Bearer " + mainToken); return execution.execute(wrapper, bytes); @@ -2404,7 +2417,7 @@ public class RestClient implements Closeable { } public Future> getAttributeKvEntriesAsync(EntityId entityId, List keys) { - return service.submit(() -> getAttributeKvEntries(entityId, keys)); + return getExecutor().submit(() -> getAttributeKvEntries(entityId, keys)); } public List getAttributesByScope(EntityId entityId, String scope, List keys) { @@ -2977,7 +2990,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypes?" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -3065,7 +3078,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypesInfos?widgetsBundleId={widgetsBundleId}&" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -4061,6 +4074,19 @@ public class RestClient implements Closeable { return restTemplate.postForEntity(baseURL + "/api/calculatedField", calculatedField, CalculatedField.class).getBody(); } + public Optional getCalculatedFieldById(CalculatedFieldId calculatedFieldId) { + try { + ResponseEntity calculatedField = restTemplate.getForEntity(baseURL + "/api/calculatedField/{calculatedFieldId}", CalculatedField.class, calculatedFieldId.getId()); + return Optional.ofNullable(calculatedField.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + public PageData getCalculatedFieldsByEntityId(EntityId entityId, PageLink pageLink) { Map params = new HashMap<>(); addPageLinkToParam(params, pageLink); @@ -4072,6 +4098,36 @@ public class RestClient implements Closeable { } + public void deleteCalculatedField(CalculatedFieldId calculatedFieldId) { + restTemplate.delete(baseURL + "/api/calculatedField/{calculatedFieldId}", calculatedFieldId.getId()); + } + + public Optional getLatestCalculatedFieldDebugEvent(CalculatedFieldId calculatedFieldId) { + try { + ResponseEntity jsonNode = restTemplate.getForEntity(baseURL + "/api/calculatedField/{calculatedFieldId}/debug", JsonNode.class, calculatedFieldId.getId()); + return Optional.ofNullable(jsonNode.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + + public Optional testCalculatedFieldScript(JsonNode inputParams) { + try { + ResponseEntity jsonNode = restTemplate.postForEntity(baseURL + "/api/calculatedField/testScript", inputParams, JsonNode.class); + return Optional.ofNullable(jsonNode.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + private String getTimeUrlParams(TimePageLink pageLink) { String urlParams = getUrlParams(pageLink); if (pageLink.getStartTime() != null) { @@ -4191,7 +4247,14 @@ public class RestClient implements Closeable { @Override public void close() { - service.shutdown(); + if (executor.isInitialized()) { + getExecutor().shutdown(); + } + } + + @SneakyThrows + private ExecutorService getExecutor() { + return executor.get(); } } diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 0f089553b2..aa561832b3 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index d943759257..519ca0d54a 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT rule-engine org.thingsboard.rule-engine @@ -98,6 +98,10 @@ jakarta.mail provided + + dev.langchain4j + langchain4j + org.springframework.boot spring-boot-starter-test diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java index c3095836bd..19c1956463 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java @@ -57,6 +57,7 @@ public class AttributesSaveRequest implements CalculatedFieldSystemAwareRequest public static final Strategy PROCESS_ALL = new Strategy(true, true, true); public static final Strategy WS_ONLY = new Strategy(false, true, false); public static final Strategy SKIP_ALL = new Strategy(false, false, false); + public static final Strategy CF_ONLY = new Strategy(false, false, true); } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java new file mode 100644 index 0000000000..ed8931f88e --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.api; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.id.JobId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.Job; + +public interface JobManager { + + ListenableFuture submitJob(Job job); // TODO: rate limits + + void cancelJob(TenantId tenantId, JobId jobId); + + void reprocessJob(TenantId tenantId, JobId jobId); + + void onJobUpdate(Job job); + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java new file mode 100644 index 0000000000..e76cb6b3b2 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.api; + +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; + +public interface RuleEngineAiChatModelService { + + > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest); + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java index 48fda3b781..508ebd214e 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java @@ -16,6 +16,7 @@ package org.thingsboard.rule.engine.api; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; @@ -78,6 +79,8 @@ public interface RuleEngineAlarmService { Alarm findLatestActiveByOriginatorAndType(TenantId tenantId, EntityId originator, String type); + FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); + Alarm findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type); AlarmInfo findAlarmInfoById(TenantId tenantId, AlarmId alarmId); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index 7989b8f9ce..d2687a1b10 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.rule.RuleNodeState; import org.thingsboard.server.common.data.script.ScriptLanguage; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -62,6 +63,7 @@ import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.nosql.CassandraStatementTask; @@ -362,6 +364,10 @@ public interface TbContext { RuleEngineCalculatedFieldQueueService getCalculatedFieldQueueService(); + JobService getJobService(); + + JobManager getJobManager(); + boolean isExternalNodeForceAck(); /** @@ -417,6 +423,10 @@ public interface TbContext { AuditLogService getAuditLogService(); + RuleEngineAiChatModelService getAiChatModelService(); + + AiModelService getAiModelService(); + // Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node MqttClientSettings getMqttClientSettings(); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java index 874a37792d..7d5a7443d3 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java @@ -16,12 +16,9 @@ package org.thingsboard.rule.engine.api; import lombok.Getter; -import org.thingsboard.server.common.msg.TbActorError; +import org.thingsboard.common.util.RecoveryAware; -/** - * Created by ashvayka on 19.01.18. - */ -public class TbNodeException extends Exception implements TbActorError { +public class TbNodeException extends Exception implements RecoveryAware { @Getter private final boolean unrecoverable; diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java index c402a0c984..3bacf8d8c3 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java @@ -56,6 +56,7 @@ public class TimeseriesSaveRequest implements CalculatedFieldSystemAwareRequest public static final Strategy WS_ONLY = new Strategy(false, false, true, false); public static final Strategy LATEST_AND_WS = new Strategy(false, true, true, false); public static final Strategy SKIP_ALL = new Strategy(false, false, false, false); + public static final Strategy CF_ONLY = new Strategy(false, false, false, true); } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 78ddba15d1..ae8faecb0b 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -16,11 +16,11 @@ package org.thingsboard.rule.engine.api.util; import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.util.CollectionUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -29,15 +29,18 @@ import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; -/** - * Created by ashvayka on 19.01.18. - */ -public class TbNodeUtils { +public final class TbNodeUtils { + + private TbNodeUtils() { + throw new IllegalStateException("Utility class"); + } private static final Pattern DATA_PATTERN = Pattern.compile("(\\$\\[)(.*?)(])"); + private static final String ALL_DATA_TEMPLATE = "$[*]"; + private static final String ALL_METADATA_TEMPLATE = "${*}"; + public static T convert(TbNodeConfiguration configuration, Class clazz) throws TbNodeException { try { return JacksonUtil.treeToValue(configuration.getData(), clazz); @@ -47,16 +50,19 @@ public class TbNodeUtils { } public static List processPatterns(List patterns, TbMsg tbMsg) { - if (!CollectionUtils.isEmpty(patterns)) { - return patterns.stream().map(p -> processPattern(p, tbMsg)).collect(Collectors.toList()); + if (CollectionsUtil.isEmpty(patterns)) { + return Collections.emptyList(); } - return Collections.emptyList(); + return patterns.stream().map(p -> processPattern(p, tbMsg)).toList(); } public static String processPattern(String pattern, TbMsg tbMsg) { try { String result = processPattern(pattern, tbMsg.getMetaData()); JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData()); + + result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json)); + if (json.isObject()) { Matcher matcher = DATA_PATTERN.matcher(result); while (matcher.find()) { @@ -64,7 +70,7 @@ public class TbNodeUtils { String[] keys = group.split("\\."); JsonNode jsonNode = json; for (String key : keys) { - if (!StringUtils.isEmpty(key) && jsonNode != null) { + if (StringUtils.isNotEmpty(key) && jsonNode != null) { jsonNode = jsonNode.get(key); } else { jsonNode = null; @@ -83,15 +89,9 @@ public class TbNodeUtils { } } - @Deprecated(since = "3.6.1", forRemoval = true) - public static List processPatterns(List patterns, TbMsgMetaData metaData) { - if (!CollectionUtils.isEmpty(patterns)) { - return patterns.stream().map(p -> processPattern(p, metaData)).collect(Collectors.toList()); - } - return Collections.emptyList(); - } - - public static String processPattern(String pattern, TbMsgMetaData metaData) { + private static String processPattern(String pattern, TbMsgMetaData metaData) { + String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData()); + pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement); return processTemplate(pattern, metaData.values()); } @@ -108,10 +108,11 @@ public class TbNodeUtils { } static String formatDataVarTemplate(String key) { - return "$[" + key + ']'; + return "$[" + key + "]"; } static String formatMetadataVarTemplate(String key) { - return "${" + key + '}'; + return "${" + key + "}"; } + } diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java index 7651a46b62..89d775f305 100644 --- a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java @@ -26,6 +26,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import java.util.Map; + import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -167,4 +169,160 @@ public class TbNodeUtilsTest { assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is("${null}")); assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is(String.format(METADATA_VARIABLE_TEMPLATE, (String) null))); } + + @Test + public void testAllMetadataTemplateReplacement() { + // GIVEN + String pattern = "META ${*}"; + var metadata = new TbMsgMetaData(); + metadata.putValue("meta_key", "meta_value"); + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {\"meta_key\":\"meta_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testMultipleAllMetadataTemplatesReplacement() { + // GIVEN + String pattern = "${*} then again ${*}"; + var metadata = new TbMsgMetaData(); + metadata.putValue("meta_key", "meta_value"); + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "{\"meta_key\":\"meta_value\"} then again {\"meta_key\":\"meta_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataTemplateReplacement() { + // GIVEN + String pattern = "DATA $[*]"; + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "DATA {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testMultipleAllDataTemplatesReplacement() { + // GIVEN + String pattern = "$[*] then again $[*]"; + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "{\"data_key\":\"data_value\"} then again {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataAndAllMetadataTemplatesSimultaneously() { + // GIVEN + String pattern = "META ${*} DATA $[*]"; + + var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value")); + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {\"meta_key\":\"meta_value\"} DATA {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataAndAllMetadataTemplatesSimultaneouslyEmpty() { + // GIVEN + String pattern = "META ${*} DATA $[*]"; + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {} DATA {}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataTemplateArray() { + // GIVEN + String pattern = "DATA $[*]"; + + var msg = TbMsg.newMsg() + .data("[1, \"two\", true]") + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "DATA [1,\"two\",true]"; + assertThat(actual, is(expected)); + } + + @Test + public void testMixedAllDataMetadataAndNormalTemplates() { + // GIVEN + String pattern = "fullMeta=${*}, singleMeta=${meta_key}, fullData=$[*], singleData=$[data_key]"; + var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value")); + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "fullMeta={\"meta_key\":\"meta_value\"}, singleMeta=meta_value, fullData={\"data_key\":\"data_value\"}, singleData=data_value"; + assertThat(actual, is(expected)); + } + } diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 9666fa8059..8fb92b80fd 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT rule-engine org.thingsboard.rule-engine @@ -153,6 +153,10 @@ com.jayway.jsonpath json-path + + dev.langchain4j + langchain4j + diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 7e3c08cfcc..2d74929242 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -44,7 +44,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT; @@ -115,14 +114,13 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { .build()); } } else { - Set attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); - List filteredAttributes = - attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr.getKey(), entityView)).collect(Collectors.toList()); + List attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())).stream() + .filter(attr -> attributeContainsInEntityView(scope, attr.getKey(), entityView)).toList(); ctx.getTelemetryService().saveAttributes(AttributesSaveRequest.builder() .tenantId(ctx.getTenantId()) .entityId(entityView.getId()) .scope(scope) - .entries(filteredAttributes) + .entries(attributes) .callback(getFutureCallback(ctx, msg, entityView)) .build()); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index 78411d7cec..bd576be936 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -70,7 +70,6 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNode { + if (existingAlarm == null || existingAlarm.getStatus().isCleared()) { + return createNewAlarm(ctx, msg, msgAlarm); + } else { + return updateAlarm(ctx, msg, existingAlarm, msgAlarm); + } + }, ctx.getDbCallbackExecutor()); } private Alarm getAlarmFromMessage(TbContext ctx, TbMsg msg) throws IOException { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java new file mode 100644 index 0000000000..b04d4592a2 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java @@ -0,0 +1,136 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.langchain4j.model.chat.request.json.JsonArraySchema; +import dev.langchain4j.model.chat.request.json.JsonBooleanSchema; +import dev.langchain4j.model.chat.request.json.JsonEnumSchema; +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema; +import dev.langchain4j.model.chat.request.json.JsonNullSchema; +import dev.langchain4j.model.chat.request.json.JsonNumberSchema; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; +import dev.langchain4j.model.chat.request.json.JsonStringSchema; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts a Jackson {@link ObjectNode} JSON Schema into a Langchain4j {@link JsonSchema} model. + */ +final class Langchain4jJsonSchemaAdapter { + + private Langchain4jJsonSchemaAdapter() { + throw new AssertionError("Can't instantiate utility class"); + } + + /** + * Creates a Langchain4j {@link JsonSchema} from the given root JSON Schema node. + * + * @param rootSchemaNode a valid JSON Schema as a Jackson {@link ObjectNode} + * @return the corresponding Langchain4j {@link JsonSchema} + */ + public static JsonSchema fromObjectNode(ObjectNode rootSchemaNode) { + return JsonSchema.builder() + .name(rootSchemaNode.get("title").textValue()) + .rootElement(parse(rootSchemaNode)) + .build(); + } + + private static JsonSchemaElement parse(JsonNode schemaNode) { + String description = schemaNode.hasNonNull("description") ? schemaNode.get("description").textValue() : null; + + if (schemaNode.has("enum")) { // enum schemas can be defined without 'type' + return parseEnum(schemaNode).description(description).build(); + } + + String type = schemaNode.get("type").textValue(); + + return switch (type) { + case "string" -> JsonStringSchema.builder().description(description).build(); + case "integer" -> JsonIntegerSchema.builder().description(description).build(); + case "boolean" -> JsonBooleanSchema.builder().description(description).build(); + case "number" -> JsonNumberSchema.builder().description(description).build(); + case "null" -> new JsonNullSchema(); + case "object" -> parseObject(schemaNode).description(description).build(); + case "array" -> parseArray(schemaNode).description(description).build(); + default -> throw new IllegalArgumentException("Unsupported JSON Schema type: " + type); + }; + } + + private static JsonEnumSchema.Builder parseEnum(JsonNode enumSchema) { + var builder = new JsonEnumSchema.Builder(); + + List enumValues = new ArrayList<>(); + for (JsonNode element : enumSchema.get("enum")) { + if (!element.isTextual()) { + throw new IllegalArgumentException("Expected each 'enum' element to be a string, but found: " + element.getNodeType()); + } + enumValues.add(element.textValue()); + } + builder.enumValues(enumValues); + + return builder; + } + + private static JsonObjectSchema.Builder parseObject(JsonNode objectSchema) { + var builder = new JsonObjectSchema.Builder(); + + JsonNode propertiesNode = objectSchema.get("properties"); + if (propertiesNode != null) { + propertiesNode.fields().forEachRemaining(entry -> { + String key = entry.getKey(); + JsonNode value = entry.getValue(); + builder.addProperty(key, parse(value)); + }); + } + + List required = new ArrayList<>(); + JsonNode requiredNode = objectSchema.get("required"); + if (requiredNode != null) { + for (JsonNode value : requiredNode) { + required.add(value.textValue()); + } + } + builder.required(required); + + boolean additionalProperties = true; // default value if 'additionalProperties' is not set + JsonNode additionalPropertiesNode = objectSchema.get("additionalProperties"); + if (additionalPropertiesNode != null) { + if (!additionalPropertiesNode.isBoolean()) { + throw new IllegalArgumentException("Expected 'additionalProperties' to be a boolean, but found: " + additionalPropertiesNode.getNodeType()); + } + additionalProperties = additionalPropertiesNode.booleanValue(); + } + builder.additionalProperties(additionalProperties); + + return builder; + } + + private static JsonArraySchema.Builder parseArray(JsonNode arraySchema) { + var builder = new JsonArraySchema.Builder(); + + if (arraySchema.hasNonNull("items")) { + builder.items(parse(arraySchema.get("items"))); + } + + return builder; + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java new file mode 100644 index 0000000000..3497795771 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -0,0 +1,204 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.FutureCallback; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.response.ChatResponse; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.RuleNode; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.rule.engine.external.TbAbstractExternalNode; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; + +@RuleNode( + type = ComponentType.EXTERNAL, + name = "AI request", + nodeDescription = "Sends a request to an AI model using system and user prompts. Supports JSON mode.", + nodeDetails = """ + Interact with large language models (LLMs) by sending dynamic requests from your rule chain. + You can select a specific AI model and define its behavior using a system prompt (optional context or role) and a user prompt (the main task). + Both prompts can be populated with data and metadata from the incoming message using patterns. + For example, the $[*] and ${*} patterns allow you to access the all message body and all metadata, respectively. +

+ After sending the request, the node waits for a response within a configured timeout. + You can specify the desired response format as Text, JSON, or provide a specific JSON Schema to structure the output. + The AI-generated content is forwarded as the body of the outgoing message; the originator, message type, and metadata from the incoming message remain unchanged. +

+ Output connections: Success, Failure. + """, + configClazz = TbAiNodeConfiguration.class, + configDirective = "tbExternalNodeAiConfig", + iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node", + ruleChainTypes = RuleChainType.CORE +) +public final class TbAiNode extends TbAbstractExternalNode implements TbNode { + + private String systemPrompt; + private String userPrompt; + private ResponseFormat responseFormat; + private int timeoutSeconds; + private AiModelId modelId; + + @Override + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + super.init(ctx); + + var config = TbNodeUtils.convert(configuration, TbAiNodeConfiguration.class); + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; + try { + validateFields(config, errorPrefix); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); + } + + modelId = config.getModelId(); + Optional modelOpt = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId); + if (modelOpt.isEmpty()) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found", true); + } + AiModel model = modelOpt.get(); + AiModelType modelType = model.getConfiguration().modelType(); + if (modelType != AiModelType.CHAT) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true); + } + AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); + if (isJsonModeConfigured(config)) { + if (!chatModelConfig.supportsJsonMode()) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true); + } + // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT + responseFormat = config.getResponseFormat().toLangChainResponseFormat(); + } + + systemPrompt = config.getSystemPrompt(); + userPrompt = config.getUserPrompt(); + timeoutSeconds = config.getTimeoutSeconds(); + super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so + } + + private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) { + var responseFormatType = config.getResponseFormat().type(); + return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA; + } + + @Override + public void onMsg(TbContext ctx, TbMsg msg) { + var ackedMsg = ackIfNeeded(ctx, msg); + + List chatMessages = new ArrayList<>(2); + if (systemPrompt != null) { + chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg))); + } + chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg))); + + var chatRequest = ChatRequest.builder() + .messages(chatMessages) + .responseFormat(responseFormat) + .build(); + + sendChatRequestAsync(ctx, chatRequest).addCallback(new FutureCallback<>() { + @Override + public void onSuccess(ChatResponse chatResponse) { + String response = chatResponse.aiMessage().text(); + if (!isValidJsonObject(response)) { + response = wrapInJsonObject(response); + } + tellSuccess(ctx, ackedMsg.transform() + .data(response) + .build()); + } + + @Override + public void onFailure(@NonNull Throwable t) { + tellFailure(ctx, ackedMsg, t); + } + }, directExecutor()); + } + + private > FluentFuture sendChatRequestAsync(TbContext ctx, ChatRequest chatRequest) { + return ctx.getAiModelService().findAiModelByTenantIdAndIdAsync(ctx.getTenantId(), modelId).transformAsync(modelOpt -> { + if (modelOpt.isEmpty()) { + throw new NoSuchElementException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found"); + } + AiModel model = modelOpt.get(); + AiModelType modelType = model.getConfiguration().modelType(); + if (modelType != AiModelType.CHAT) { + throw new IllegalStateException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType); + } + + @SuppressWarnings("unchecked") + AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); + + chatModelConfig = chatModelConfig + .withTimeoutSeconds(timeoutSeconds) + .withMaxRetries(0); // disable retries to respect timeout set in rule node config + + return ctx.getAiChatModelService().sendChatRequestAsync(chatModelConfig, chatRequest); + }, ctx.getDbCallbackExecutor()); + } + + private static boolean isValidJsonObject(String jsonString) { + try { + JsonNode result = JacksonUtil.toJsonNode(jsonString); + return result != null && result.isObject(); + } catch (IllegalArgumentException e) { + return false; + } + } + + private static String wrapInJsonObject(String response) { + return JacksonUtil.newObjectNode().put("response", response).toString(); + } + + @Override + public void destroy() { + super.destroy(); + systemPrompt = null; + userPrompt = null; + responseFormat = null; + modelId = null; + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java new file mode 100644 index 0000000000..10bb24199e --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.ai; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.validation.Length; + +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; + +@Data +public class TbAiNodeConfiguration implements NodeConfiguration { + + @NotNull + private AiModelId modelId; + + @Pattern(regexp = ".*\\S.*", message = "must not be blank") + @Length(min = 1, max = 10000) + private String systemPrompt; + + @NotBlank + @Length(min = 1, max = 10000) + private String userPrompt; + + @NotNull + @Valid + private TbResponseFormat responseFormat; + + @Min(value = 1, message = "must be at least 1 second") + @Max(value = 600, message = "cannot exceed 600 seconds (10 minutes)") + private int timeoutSeconds; + + private boolean forceAck; + + @Override + public TbAiNodeConfiguration defaultConfiguration() { + var configuration = new TbAiNodeConfiguration(); + configuration.setSystemPrompt( + "You are a helpful AI assistant. Your primary function is to process the user's request and respond with a valid JSON object. " + + "Do not include any text, explanations, or markdown formatting before or after the JSON output." + ); + configuration.setResponseFormat(new TbJsonResponseFormat()); + configuration.setTimeoutSeconds(60); + configuration.setForceAck(true); + return configuration; + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java new file mode 100644 index 0000000000..5c891a9c74 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -0,0 +1,103 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.ai; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.validation.ValidJsonSchema; + +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TbTextResponseFormat.class, name = "TEXT"), + @JsonSubTypes.Type(value = TbJsonResponseFormat.class, name = "JSON"), + @JsonSubTypes.Type(value = TbJsonSchemaResponseFormat.class, name = "JSON_SCHEMA") +}) +public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonResponseFormat, TbJsonSchemaResponseFormat { + + TbResponseFormatType type(); + + ResponseFormat toLangChainResponseFormat(); + + enum TbResponseFormatType { + + TEXT, + JSON, + JSON_SCHEMA + + } + + record TbTextResponseFormat() implements TbResponseFormat { + + @Override + public TbResponseFormatType type() { + return TbResponseFormatType.TEXT; + } + + @Override + public ResponseFormat toLangChainResponseFormat() { + return ResponseFormat.builder() + .type(ResponseFormatType.TEXT) + .build(); + } + + } + + record TbJsonResponseFormat() implements TbResponseFormat { + + @Override + public TbResponseFormatType type() { + return TbResponseFormatType.JSON; + } + + @Override + public ResponseFormat toLangChainResponseFormat() { + return ResponseFormat.builder() + .type(ResponseFormatType.JSON) + .build(); + } + + } + + record TbJsonSchemaResponseFormat(@NotNull @ValidJsonSchema ObjectNode schema) implements TbResponseFormat { + + @Override + public TbResponseFormatType type() { + return TbResponseFormatType.JSON_SCHEMA; + } + + @Override + public ResponseFormat toLangChainResponseFormat() { + return ResponseFormat.builder() + .type(ResponseFormatType.JSON) + .jsonSchema(Langchain4jJsonSchemaAdapter.fromObjectNode(schema)) + .build(); + } + + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java index 374cfec1bf..42d9bc85e5 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.msg.TbMsg; public abstract class TbAbstractExternalNode implements TbNode { - private boolean forceAck; + protected boolean forceAck; public void init(TbContext ctx) { this.forceAck = ctx.isExternalNodeForceAck(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java index 694fed1bf8..87643ae46d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java @@ -45,7 +45,6 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import javax.net.ssl.SSLException; -import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -64,12 +63,10 @@ import java.util.concurrent.TimeoutException; ) public class TbMqttNode extends TbAbstractExternalNode { - private static final Charset UTF8 = StandardCharsets.UTF_8; - - private static final String ERROR = "error"; + private static final int MQTT_3_MAX_CLIENT_ID_LENGTH = 23; + private static final int MQTT_5_MAX_CLIENT_ID_LENGTH = 256; protected TbMqttNodeConfiguration mqttNodeConfiguration; - protected MqttClient mqttClient; @Override @@ -87,9 +84,9 @@ public class TbMqttNode extends TbAbstractExternalNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { - String topic = TbNodeUtils.processPattern(this.mqttNodeConfiguration.getTopicPattern(), msg); + String topic = TbNodeUtils.processPattern(mqttNodeConfiguration.getTopicPattern(), msg); var tbMsg = ackIfNeeded(ctx, msg); - this.mqttClient.publish(topic, Unpooled.wrappedBuffer(getData(tbMsg, mqttNodeConfiguration.isParseToPlainText()).getBytes(UTF8)), + this.mqttClient.publish(topic, Unpooled.wrappedBuffer(getData(tbMsg, mqttNodeConfiguration.isParseToPlainText()).getBytes(StandardCharsets.UTF_8)), MqttQoS.AT_LEAST_ONCE, mqttNodeConfiguration.isRetainedMessage()) .addListener(future -> { if (future.isSuccess()) { @@ -103,7 +100,7 @@ public class TbMqttNode extends TbAbstractExternalNode { private TbMsg processException(TbMsg origMsg, Throwable e) { TbMsgMetaData metaData = origMsg.getMetaData().copy(); - metaData.putValue(ERROR, e.getClass() + ": " + e.getMessage()); + metaData.putValue("error", e.getClass() + ": " + e.getMessage()); return origMsg.transform() .metaData(metaData) .build(); @@ -111,8 +108,8 @@ public class TbMqttNode extends TbAbstractExternalNode { @Override public void destroy() { - if (this.mqttClient != null) { - this.mqttClient.disconnect(); + if (mqttClient != null) { + mqttClient.disconnect(); } } @@ -123,11 +120,11 @@ public class TbMqttNode extends TbAbstractExternalNode { protected MqttClient initClient(TbContext ctx) throws Exception { MqttClientConfig config = new MqttClientConfig(getSslContext()); config.setOwnerId(getOwnerId(ctx)); - if (!StringUtils.isEmpty(this.mqttNodeConfiguration.getClientId())) { + if (!StringUtils.isEmpty(mqttNodeConfiguration.getClientId())) { config.setClientId(getClientId(ctx)); } - config.setCleanSession(this.mqttNodeConfiguration.isCleanSession()); - config.setProtocolVersion(this.mqttNodeConfiguration.getProtocolVersion()); + config.setCleanSession(mqttNodeConfiguration.isCleanSession()); + config.setProtocolVersion(mqttNodeConfiguration.getProtocolVersion()); MqttClientSettings mqttClientSettings = ctx.getMqttClientSettings(); config.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig( @@ -139,32 +136,32 @@ public class TbMqttNode extends TbAbstractExternalNode { prepareMqttClientConfig(config); MqttClient client = getMqttClient(ctx, config); client.setEventLoop(ctx.getSharedEventLoop()); - Promise connectFuture = client.connect(this.mqttNodeConfiguration.getHost(), this.mqttNodeConfiguration.getPort()); + Promise connectFuture = client.connect(mqttNodeConfiguration.getHost(), mqttNodeConfiguration.getPort()); MqttConnectResult result; try { - result = connectFuture.get(this.mqttNodeConfiguration.getConnectTimeoutSec(), TimeUnit.SECONDS); + result = connectFuture.get(mqttNodeConfiguration.getConnectTimeoutSec(), TimeUnit.SECONDS); } catch (TimeoutException ex) { connectFuture.cancel(true); client.disconnect(); - String hostPort = this.mqttNodeConfiguration.getHost() + ":" + this.mqttNodeConfiguration.getPort(); + String hostPort = mqttNodeConfiguration.getHost() + ":" + mqttNodeConfiguration.getPort(); throw new RuntimeException(String.format("Failed to connect to MQTT broker at %s.", hostPort)); } if (!result.isSuccess()) { connectFuture.cancel(true); client.disconnect(); - String hostPort = this.mqttNodeConfiguration.getHost() + ":" + this.mqttNodeConfiguration.getPort(); + String hostPort = mqttNodeConfiguration.getHost() + ":" + mqttNodeConfiguration.getPort(); throw new RuntimeException(String.format("Failed to connect to MQTT broker at %s. Result code is: %s", hostPort, result.getReturnCode())); } return client; } private String getClientId(TbContext ctx) throws TbNodeException { - String clientId = this.mqttNodeConfiguration.isAppendClientIdSuffix() ? - this.mqttNodeConfiguration.getClientId() + "_" + ctx.getServiceId() : - this.mqttNodeConfiguration.getClientId(); - if (clientId.length() > 23) { - throw new TbNodeException("Client ID is too long '" + clientId + "'. " + - "The length of Client ID cannot be longer than 23, but current length is " + clientId.length() + ".", true); + String clientId = mqttNodeConfiguration.isAppendClientIdSuffix() ? + mqttNodeConfiguration.getClientId() + "_" + ctx.getServiceId() : + mqttNodeConfiguration.getClientId(); + int maxLength = mqttNodeConfiguration.getProtocolVersion() == MqttVersion.MQTT_3_1 ? MQTT_3_MAX_CLIENT_ID_LENGTH : MQTT_5_MAX_CLIENT_ID_LENGTH; + if (clientId.length() > maxLength) { + throw new TbNodeException("The length of Client ID cannot be longer than " + maxLength + ", but current length is " + clientId.length() + ".", true); } return clientId; } @@ -174,7 +171,7 @@ public class TbMqttNode extends TbAbstractExternalNode { } protected void prepareMqttClientConfig(MqttClientConfig config) { - ClientCredentials credentials = this.mqttNodeConfiguration.getCredentials(); + ClientCredentials credentials = mqttNodeConfiguration.getCredentials(); if (credentials.getType() == CredentialsType.BASIC) { BasicCredentials basicCredentials = (BasicCredentials) credentials; config.setUsername(basicCredentials.getUsername()); @@ -183,7 +180,7 @@ public class TbMqttNode extends TbAbstractExternalNode { } private SslContext getSslContext() throws SSLException { - return this.mqttNodeConfiguration.isSsl() ? this.mqttNodeConfiguration.getCredentials().initSslContext() : null; + return mqttNodeConfiguration.isSsl() ? mqttNodeConfiguration.getCredentials().initSslContext() : null; } private String getData(TbMsg tbMsg, boolean parseToPlainText) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java index 2ea56ce799..26c5b3fa42 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.mqtt.azure; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.annotations.VisibleForTesting; import io.netty.handler.codec.mqtt.MqttVersion; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.AzureIotHubUtil; @@ -36,6 +37,8 @@ import org.thingsboard.server.common.data.plugin.ComponentClusteringMode; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; +import java.time.Clock; + @Slf4j @RuleNode( type = ComponentType.EXTERNAL, @@ -49,6 +52,8 @@ import org.thingsboard.server.common.data.util.TbPair; ) public class TbAzureIotHubNode extends TbMqttNode { + private Clock clock = Clock.systemUTC(); + @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); @@ -73,7 +78,7 @@ public class TbAzureIotHubNode extends TbMqttNode { config.setUsername(AzureIotHubUtil.buildUsername(mqttNodeConfiguration.getHost(), config.getClientId())); ClientCredentials credentials = mqttNodeConfiguration.getCredentials(); if (CredentialsType.SAS == credentials.getType()) { - config.setPassword(AzureIotHubUtil.buildSasToken(mqttNodeConfiguration.getHost(), ((AzureIotHubSasCredentials) credentials).getSasKey())); + config.setPassword(AzureIotHubUtil.buildSasToken(mqttNodeConfiguration.getHost(), ((AzureIotHubSasCredentials) credentials).getSasKey(), clock)); } } @@ -81,6 +86,11 @@ public class TbAzureIotHubNode extends TbMqttNode { return initClient(ctx); } + @VisibleForTesting + void setClock(Clock clock) { + this.clock = clock; + } + @Override public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { boolean hasChanges = false; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java index 6cc44e9e8b..4bd81050db 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.profile; +import com.google.gson.JsonElement; import com.google.gson.JsonParser; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; @@ -49,6 +50,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.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -66,6 +68,7 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.ENTITY_UNASSIGNED import static org.thingsboard.server.common.data.msg.TbMsgType.INACTIVITY_EVENT; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; +import static org.thingsboard.server.common.data.msg.TbMsgType.TIMESERIES_UPDATED; @Slf4j class DeviceState { @@ -149,7 +152,9 @@ class DeviceState { } boolean stateChanged = false; if (msg.isTypeOf(POST_TELEMETRY_REQUEST)) { - stateChanged = processTelemetry(ctx, msg); + stateChanged = processTelemetryRequest(ctx, msg); + } else if (msg.isTypeOf(TIMESERIES_UPDATED)) { + stateChanged = processTelemetryUpdatedNotification(ctx, msg); } else if (msg.isTypeOf(POST_ATTRIBUTES_REQUEST)) { stateChanged = processAttributesUpdateRequest(ctx, msg); } else if (msg.isTypeOneOf(ACTIVITY_EVENT, INACTIVITY_EVENT)) { @@ -179,7 +184,7 @@ class DeviceState { private boolean processDeviceActivityEvent(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException { String scope = msg.getMetaData().getValue(DataConstants.SCOPE); if (StringUtils.isEmpty(scope)) { - return processTelemetry(ctx, msg); + return processTelemetryRequest(ctx, msg); } else { return processAttributes(ctx, msg, scope); } @@ -253,7 +258,7 @@ class DeviceState { private boolean processAttributes(TbContext ctx, TbMsg msg, String scope) throws ExecutionException, InterruptedException { boolean stateChanged = false; - Set attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); + List attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); if (!attributes.isEmpty()) { SnapshotUpdate update = merge(latestValues, attributes, scope); for (DeviceProfileAlarm alarm : deviceProfile.getAlarmSettings()) { @@ -266,9 +271,22 @@ class DeviceState { return stateChanged; } - protected boolean processTelemetry(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException { + protected boolean processTelemetryRequest(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException { + return processTelemetryUpdate(ctx, msg, JsonParser.parseString(msg.getData())); + } + + protected boolean processTelemetryUpdatedNotification(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException { + JsonElement msgData = JsonParser.parseString(msg.getData()); + JsonElement telemetryData = Optional.ofNullable(JsonParser.parseString(msg.getData())) + .filter(JsonElement::isJsonObject) + .map(e -> e.getAsJsonObject().get("timeseries")) + .orElse(msgData); + return processTelemetryUpdate(ctx, msg, telemetryData); + } + + private boolean processTelemetryUpdate(TbContext ctx, TbMsg msg, JsonElement telemetryData) throws ExecutionException, InterruptedException { boolean stateChanged = false; - Map> tsKvMap = JsonConverter.convertToSortedTelemetry(JsonParser.parseString(msg.getData()), msg.getMetaDataTs()); + Map> tsKvMap = JsonConverter.convertToSortedTelemetry(telemetryData, msg.getMetaDataTs()); // iterate over data by ts (ASC order). for (Map.Entry> entry : tsKvMap.entrySet()) { Long ts = entry.getKey(); @@ -303,7 +321,7 @@ class DeviceState { return new SnapshotUpdate(AlarmConditionKeyType.TIME_SERIES, keys); } - private SnapshotUpdate merge(DataSnapshot latestValues, Set attributes, String scope) { + private SnapshotUpdate merge(DataSnapshot latestValues, List attributes, String scope) { long newTs = 0; Set keys = new HashSet<>(); for (AttributeKvEntry entry : attributes) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java index b2cce52c87..e703e9dd25 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java @@ -92,18 +92,18 @@ public class TbCalculatedFieldsNode implements TbNode { .customerId(msg.getCustomerId()) .entityId(msg.getOriginator()) .entries(tsKvEntryList) + .strategy(TimeseriesSaveRequest.Strategy.CF_ONLY) .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) .tbMsgId(msg.getId()) .tbMsgType(msg.getInternalType()) .callback(new TelemetryNodeCallback(ctx, msg)) .build(); - ctx.getCalculatedFieldQueueService().pushRequestToQueue(timeseriesSaveRequest, timeseriesSaveRequest.getCallback()); + ctx.getTelemetryService().saveTimeseries(timeseriesSaveRequest); } private void processPostAttributesRequest(TbContext ctx, TbMsg msg) { - List newAttributes = new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData()))); - + List newAttributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); if (newAttributes.isEmpty()) { ctx.tellSuccess(msg); return; @@ -114,12 +114,13 @@ public class TbCalculatedFieldsNode implements TbNode { .entityId(msg.getOriginator()) .scope(AttributeScope.valueOf(msg.getMetaData().getValue(SCOPE))) .entries(newAttributes) + .strategy(AttributesSaveRequest.Strategy.CF_ONLY) .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) .tbMsgId(msg.getId()) .tbMsgType(msg.getInternalType()) .callback(new TelemetryNodeCallback(ctx, msg)) .build(); - ctx.getCalculatedFieldQueueService().pushRequestToQueue(attributesSaveRequest, attributesSaveRequest.getCallback()); + ctx.getTelemetryService().saveAttributes(attributesSaveRequest); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index c04f5b474d..280d8de824 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -41,7 +41,6 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -133,7 +132,7 @@ public class TbMsgAttributesNode implements TbNode { return; } String src = msg.getData(); - List newAttributes = new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(src))); + List newAttributes = JsonConverter.convertToAttributes(JsonParser.parseString(src)); if (newAttributes.isEmpty()) { ctx.tellSuccess(msg); return; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index f12a856567..8ea0f70a3f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -19,6 +19,7 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; @@ -33,6 +34,7 @@ import org.thingsboard.server.common.data.id.DomainId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.MobileAppBundleId; import org.thingsboard.server.common.data.id.MobileAppId; import org.thingsboard.server.common.data.id.NotificationRequestId; @@ -145,6 +147,7 @@ public class TenantIdLoader { tenantEntity = ctx.getNotificationRequestService().findNotificationRequestById(ctxTenantId, new NotificationRequestId(id)); break; case NOTIFICATION: + case ADMIN_SETTINGS: return ctxTenantId; case NOTIFICATION_RULE: tenantEntity = ctx.getNotificationRuleService().findNotificationRuleById(ctxTenantId, new NotificationRuleId(id)); @@ -175,6 +178,12 @@ public class TenantIdLoader { tenantEntity = null; } break; + case JOB: + tenantEntity = ctx.getJobService().findJobById(ctxTenantId, new JobId(id)); + break; + case AI_MODEL: + tenantEntity = ctx.getAiModelService().findAiModelById(ctxTenantId, new AiModelId(id)).orElse(null); + break; default: throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java index 6cb8299b67..24fd912997 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java @@ -17,7 +17,7 @@ package org.thingsboard.rule.engine.action; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.FluentFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -60,6 +60,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; +import static com.google.common.util.concurrent.Futures.immediateFailedFuture; +import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -67,6 +69,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @@ -99,6 +102,8 @@ class TbCreateAlarmNodeTest { dbExecutor = new TestDbCallbackExecutor(); metadata = new TbMsgMetaData(); config = new TbCreateAlarmNodeConfiguration(); + + lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); } @Test @@ -212,10 +217,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFuture(alarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(immediateFuture(alarmDetails)); var apiCallResult = AlarmApiCallResult.builder() .successful(true) .created(true) @@ -230,11 +234,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, TbAbstractAlarmNodeConfiguration.ALARM_DETAILS_BUILD_TBEL_TEMPLATE)).willReturn(alarmDetailsScriptMock); @@ -384,10 +388,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingClearedAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFuture(alarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingClearedAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(immediateFuture(alarmDetails)); var apiCallResult = AlarmApiCallResult.builder() .successful(true) .created(true) @@ -402,11 +405,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.JS, config.getAlarmDetailsBuildJs())).willReturn(alarmDetailsScriptMock); @@ -576,10 +579,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(newAlarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingActiveAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(immediateFuture(newAlarmDetails)); doReturn(newEndTs).when(nodeSpy).currentTimeMillis(); var apiCallResult = AlarmApiCallResult.builder() .successful(true) @@ -595,11 +597,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -753,9 +755,8 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingClearedAlarm); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingClearedAlarm))); var apiCallResult = AlarmApiCallResult.builder() .successful(true) .created(true) @@ -770,11 +771,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -941,10 +942,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(newAlarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingActiveAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(immediateFuture(newAlarmDetails)); doReturn(newEndTs).when(nodeSpy).currentTimeMillis(); var apiCallResult = AlarmApiCallResult.builder() .successful(true) @@ -960,11 +960,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -1125,10 +1125,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(alarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingActiveAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(immediateFuture(alarmDetails)); doReturn(endTs).when(nodeSpy).currentTimeMillis(); var apiCallResult = AlarmApiCallResult.builder() .successful(true) @@ -1144,11 +1143,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -1216,11 +1215,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, config.getAlarmType())).willReturn(FluentFuture.from(immediateFuture(null))); var expectedException = new ExecutionException("Failed to execute script.", new RuntimeException("Something went wrong!")); - given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFailedFuture(expectedException)); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(immediateFailedFuture(expectedException)); nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java new file mode 100644 index 0000000000..6eb7b6233b --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -0,0 +1,944 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.ai; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.response.ChatResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; +import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; +import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class TbAiNodeTest { + + @Mock + TbContext ctxMock; + @Mock + AiModelService aiModelServiceMock; + @Mock + RuleEngineAiChatModelService aiChatModelServiceMock; + + TbAiNode aiNode; + TbAiNodeConfiguration config; + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + AiModelId modelId = new AiModelId(UUID.randomUUID()); + RuleNodeId ruleNodeId = new RuleNodeId(UUID.randomUUID()); + + RuleNode ruleNode; + + AiModel model; + AiModelConfig modelConfig; + + boolean externalNodeForceAck = false; + + @BeforeEach + void setup() { + aiNode = new TbAiNode(); + config = new TbAiNodeConfiguration(); + + modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(100) + .maxRetries(2) + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + lenient().when(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).thenReturn(Optional.of(model)); + lenient().when(aiModelServiceMock.findAiModelByTenantIdAndIdAsync(tenantId, modelId)).thenReturn(FluentFuture.from(immediateFuture(Optional.of(model)))); + + ruleNode = new RuleNode(); + ruleNode.setId(ruleNodeId); + ruleNode.setName("Test AI node"); + lenient().when(ctxMock.getSelf()).thenReturn(ruleNode); + + lenient().when(ctxMock.isExternalNodeForceAck()).thenReturn(externalNodeForceAck); + lenient().when(ctxMock.getTenantId()).thenReturn(tenantId); + lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock); + lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock); + lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor()); + } + + @Test + void givenDefaultConfig_whenCalled_thenSetsCorrectValues() { + // GIVEN-WHEN + config = config.defaultConfiguration(); + + // THEN + assertThat(config.getModelId()).isNull(); + assertThat(config.getSystemPrompt()).isEqualTo( + "You are a helpful AI assistant. Your primary function is to process the user's request and respond with a valid JSON object. " + + "Do not include any text, explanations, or markdown formatting before or after the JSON output." + ); + assertThat(config.getUserPrompt()).isNull(); + assertThat(config.getResponseFormat()).isEqualTo(new TbJsonResponseFormat()); + assertThat(config.getTimeoutSeconds()).isEqualTo(60); + assertThat(config.isForceAck()).isTrue(); + } + + /* -- Node initialization tests -- */ + + @Test + void givenNullModelId_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setModelId(null); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasRootCauseInstanceOf(DataValidationException.class) + .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: modelId must not be null") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + @ParameterizedTest + @MethodSource("invalidSystemPrompts") + void givenInvalidSystemPrompt_whenInit_thenThrowsUnrecoverableTbNodeException(String invalidSystemPrompt) { + // GIVEN + config = constructValidConfig(); + config.setSystemPrompt(invalidSystemPrompt); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("'" + ruleNode.getName() + "' node configuration is invalid: systemPrompt"); + } + + static Stream invalidSystemPrompts() { + String tooLongString = "a".repeat(10_001); + return Stream.of( + Arguments.of(""), + Arguments.of(" "), + Arguments.of(tooLongString) + ); + } + + @ParameterizedTest + @MethodSource("validSystemPrompts") + void givenValidSystemPrompt_whenInit_thenInitializesSuccessfully(String validSystemPrompt) { + // GIVEN + config = constructValidConfig(); + config.setSystemPrompt(validSystemPrompt); + + // WHEN-THEN + assertThatNoException().isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + static Stream validSystemPrompts() { + String longString = "a".repeat(10_000); + return Stream.of( + Arguments.of((String) null), + Arguments.of("a"), + Arguments.of("Test system prompt"), + Arguments.of(longString) + ); + } + + @ParameterizedTest + @MethodSource("invalidUserPrompts") + void givenInvalidUserPrompt_whenInit_thenThrowsUnrecoverableTbNodeException(String invalidUserPrompt) { + // GIVEN + config = constructValidConfig(); + config.setUserPrompt(invalidUserPrompt); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("'" + ruleNode.getName() + "' node configuration is invalid: userPrompt"); + } + + static Stream invalidUserPrompts() { + String tooLongString = "a".repeat(10_001); + return Stream.of( + Arguments.of((String) null), + Arguments.of(""), + Arguments.of(" "), + Arguments.of(tooLongString) + ); + } + + @ParameterizedTest + @MethodSource("validUserPrompts") + void givenValidUserPrompt_whenInit_thenInitializesSuccessfully(String validUserPrompt) { + // GIVEN + config = constructValidConfig(); + config.setUserPrompt(validUserPrompt); + + // WHEN-THEN + assertThatNoException().isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + static Stream validUserPrompts() { + String longString = "a".repeat(10_000); + return Stream.of( + Arguments.of("a"), + Arguments.of("Test user prompt"), + Arguments.of(longString) + ); + } + + @Test + void givenNullResponseFormat_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setResponseFormat(null); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasRootCauseInstanceOf(DataValidationException.class) + .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: responseFormat must not be null") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + @ParameterizedTest + @ValueSource(ints = {Integer.MIN_VALUE, 0, 601, Integer.MAX_VALUE}) + void givenInvalidTimeoutSeconds_whenInit_thenThrowsUnrecoverableTbNodeException(int invalidTimeoutSeconds) { + // GIVEN + config = constructValidConfig(); + config.setTimeoutSeconds(invalidTimeoutSeconds); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("'" + ruleNode.getName() + "' node configuration is invalid: timeoutSeconds"); + } + + @ParameterizedTest + @ValueSource(ints = {1, 60, 600}) + void givenValidTimeoutSeconds_whenInit_thenInitializesSuccessfully(int validTimeoutSeconds) { + // GIVEN + config = constructValidConfig(); + config.setTimeoutSeconds(validTimeoutSeconds); + + // WHEN-THEN + assertThatNoException().isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenAiModelNotFound_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.empty()); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] was not found") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + TbAiNodeConfiguration constructValidConfig() { + var config = new TbAiNodeConfiguration(); + config.setModelId(modelId); + config.setSystemPrompt("Test system prompt"); + config.setUserPrompt("Test user prompt"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(60); + config.setForceAck(true); + return config; + } + + + @Test + void givenJsonModeConfiguredButModelDoesNotSupportIt_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setResponseFormat(new TbJsonResponseFormat()); + + modelConfig = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-0") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] does not support 'JSON' response format") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + /* -- Message processing tests -- */ + + @Test + void givenForceAckIsFalse_whenOnMsg_thenTellSuccessIsCalled() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().tellSuccess(any()); + + then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class)); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + void givenLocalForceAckIsFalseButExternalIsTold_whenOnMsg_thenEnqueuesForTellNext() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + given(ctxMock.isExternalNodeForceAck()).willReturn(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS)); + + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + void givenForceAckIsTrue_whenOnMsg_thenEnqueuesForTellNext() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS)); + + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + void givenOnlyUserPromptConfigured_whenOnMsg_thenRequestContainsOnlyUserMessage() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt(null); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(1); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(UserMessage.from("Tell me a joke")); + return true; + }) + ); + } + + @Test + void givenSystemAndUserPromptsConfigured_whenOnMsg_thenRequestContainsBothSystemAndUserMessages() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from("Respond with valid JSON")); + assertThat(actualChatRequest.messages().get(1)).isEqualTo(UserMessage.from("Tell me a joke")); + return true; + }) + ); + } + + @Test + void givenTemplatedPrompts_whenOnMsg_thenRequestContainsSubstitutedMessages() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with $[responseFormat]"); + config.setUserPrompt("Tell me a joke about ${jokeIdea}"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data("{\"responseFormat\":\"valid JSON\"}") + .metaData(new TbMsgMetaData(Map.of("jokeIdea", "JSON"))) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"joke\":\"Why did the JSON go to therapy?\",\"punchline\":\"Because it had too many unresolved references!\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from("Respond with valid JSON")); + assertThat(actualChatRequest.messages().get(1)).isEqualTo(UserMessage.from("Tell me a joke about JSON")); + return true; + }) + ); + } + + @Test + void givenNodeTimeoutIsConfigured_whenOnMsg_thenRequestUsesNodeTimeout() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig.timeoutSeconds()).isEqualTo(config.getTimeoutSeconds()); + return true; + }), any() + ); + } + + @Test + void givenAnyConfig_whenOnMsg_thenRequestHasRetriesDisabled() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig.maxRetries()).isZero(); + return true; + }), any() + ); + } + + @Test + void givenTextResponseFormatAndNonJsonResponse_whenOnMsg_thenWrapsResponseInJsonObject() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setUserPrompt("Tell me a joke about JSON"); + config.setResponseFormat(new TbTextResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + Why did the JSON file break up with the XML file? + Because it found someone less complicated and more flexible!""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().tellSuccess(argThat( + resultMsg -> resultMsg.getData().equals(JacksonUtil.newObjectNode().put("response", chatResponse.aiMessage().text()).toString())) + ); + } + + @Test + void givenModelIsConfigured_whenOnMsg_thenRequestUsesCorrectModelConfig() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig) + .usingRecursiveComparison() + .ignoringFields("timeoutSeconds", "maxRetries") + .isEqualTo(modelConfig); + return true; + }), + any() + ); + } + + @Test + void givenTextResponseFormat_whenOnMsg_thenRequestResponseFormatIsNull() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbTextResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + Why did the JSON file break up with the XML file? + Because it found someone less complicated and more flexible!""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.responseFormat()).isNull(); + return true; + }) + ); + } + + @Test + void givenJsonResponseFormat_whenOnMsg_thenRequestResponseFormatIsJson() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + Why did the JSON file break up with the XML file? + Because it found someone less complicated and more flexible!""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.JSON).build()); + return true; + }) + ); + } + + @Test + void givenJsonSchemaResponseFormat_whenOnMsg_thenRequestResponseFormatIsJsonWithSchema() throws TbNodeException { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + }, + "punchline": { + "type": "string" + } + }, + "required": [ + "joke", + "punchline" + ] + } + """; + + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + { + "joke": "Why do programmers prefer JSON over XML?", + "punchline": "Because it’s less taxing to read!" + }""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var expectedJsonSchema = JsonSchema.builder() + .name("Joke") + .rootElement(JsonObjectSchema.builder() + .addStringProperty("joke") + .addStringProperty("punchline") + .required("joke", "punchline") + .additionalProperties(true) + .build()) + .build(); + + then(aiChatModelServiceMock).should().sendChatRequestAsync( + any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.JSON).jsonSchema(expectedJsonSchema).build()); + return true; + }) + ); + } + + @Test + void givenComprehensiveConfig_whenOnMsg_thenProcessesMessageAndTellsSuccessCorrectly() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig) + .usingRecursiveComparison() + .ignoringFields("timeoutSeconds", "maxRetries") + .isEqualTo(modelConfig); + assertThat(actualChatModelConfig.timeoutSeconds()).isEqualTo(config.getTimeoutSeconds()); + assertThat(actualChatModelConfig.maxRetries()).isEqualTo(0); + return true; + }), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from("Respond with valid JSON")); + assertThat(actualChatRequest.messages().get(1)).isEqualTo(UserMessage.from("Tell me a joke")); + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.JSON).build()); + return true; + }) + ); + + then(ctxMock).should().tellSuccess(argThat(resultMsg -> + resultMsg.getData().equals(chatResponse.aiMessage().text()) && + resultMsg.getMetaData().equals(msg.getMetaData()) && + resultMsg.getType().equals(msg.getType()) && + resultMsg.getOriginator().equals(msg.getOriginator())) + ); + + then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class)); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java index 1e877b80dc..5e93f6910f 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java @@ -212,40 +212,45 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest { assertThatNoException().isThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))); } - @Test - public void givenClientIdIsTooLong_whenInit_thenThrowsException() { - String invalidClientId = "vhfrbeb38ygwfwrgfwefgterhytjytj"; - mqttNodeConfig.setClientId(invalidClientId); + @ParameterizedTest + @MethodSource("provideInvalidClientIdScenarios") + public void givenInvalidClientId_whenInit_thenThrowsException(MqttVersion version, int maxLength, int repeat, String serviceId, boolean appendSuffix) { + String baseClientId = "x".repeat(repeat); + mqttNodeConfig.setClientId(baseClientId); + mqttNodeConfig.setAppendClientIdSuffix(appendSuffix); + mqttNodeConfig.setProtocolVersion(version); given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID)); + String clientId = appendSuffix ? baseClientId + "_" + serviceId : baseClientId; + if (appendSuffix) { + given(ctxMock.getServiceId()).willReturn(serviceId); + } + + String expectedMessage = "The length of Client ID cannot be longer than " + maxLength + ", but current length is " + clientId.length() + "."; + assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))) .isInstanceOf(TbNodeException.class) - .hasMessage("Client ID is too long '" + invalidClientId + "'. " + - "The length of Client ID cannot be longer than 23, but current length is " + invalidClientId.length() + ".") + .hasMessage(expectedMessage) .extracting(e -> ((TbNodeException) e).isUnrecoverable()) .isEqualTo(true); } - @Test - public void givenClientIdIsOkAndAppendClientIdSuffixIsTrue_whenInit_thenClientIdBecomesInvalidAndThrowsException() { - String validClientId = "fertjnhnjj4ge"; - mqttNodeConfig.setClientId("fertjnhnjj4ge"); - mqttNodeConfig.setAppendClientIdSuffix(true); + private static Stream provideInvalidClientIdScenarios() { + return Stream.of( + // MQTT_5, too long clientId + Arguments.of(MqttVersion.MQTT_5, 256, 257, null, false), - given(ctxMock.getTenantId()).willReturn(TENANT_ID); - given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID)); - String serviceId = "test-service"; - given(ctxMock.getServiceId()).willReturn(serviceId); + // MQTT_5, base + suffix exceeds + Arguments.of(MqttVersion.MQTT_5, 256, 250, "test-service", true), - String resultedClientId = validClientId + "_" + serviceId; - assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))) - .isInstanceOf(TbNodeException.class) - .hasMessage("Client ID is too long '" + resultedClientId + "'. " + - "The length of Client ID cannot be longer than 23, but current length is " + resultedClientId.length() + ".") - .extracting(e -> ((TbNodeException) e).isUnrecoverable()) - .isEqualTo(true); + // MQTT_3_1, too long clientId + Arguments.of(MqttVersion.MQTT_3_1, 23, 24, null, false), + + // MQTT_3_1, base + suffix exceeds + Arguments.of(MqttVersion.MQTT_3_1, 23, 5, "verylongservicename", true) + ); } @Test diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java index 433d5d4673..c8c1553fa5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java @@ -34,6 +34,9 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.credentials.CertPemCredentials; import org.thingsboard.rule.engine.mqtt.TbMqttNodeConfiguration; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -77,7 +80,10 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void verifyPrepareMqttClientConfigMethodWithAzureIotHubSasCredentials() throws Exception { - AzureIotHubSasCredentials credentials = new AzureIotHubSasCredentials(); + var fixedClock = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC); + azureIotHubNode.setClock(fixedClock); + + var credentials = new AzureIotHubSasCredentials(); credentials.setSasKey("testSasKey"); credentials.setCaCert("test-ca-cert.pem"); azureIotHubNodeConfig.setCredentials(credentials); @@ -89,7 +95,7 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { azureIotHubNode.prepareMqttClientConfig(mqttClientConfig); assertThat(mqttClientConfig.getUsername()).isEqualTo(AzureIotHubUtil.buildUsername(azureIotHubNodeConfig.getHost(), mqttClientConfig.getClientId())); - assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey())); + assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey(), fixedClock)); } @Test diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java index 5163e21c05..90c35e59b1 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java @@ -2061,6 +2061,74 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { return filter; } + @Test + public void testAlarmCreateAfterTimeseriesUpdated() throws Exception { + init(); + + DeviceProfile deviceProfile = new DeviceProfile(); + DeviceProfileData deviceProfileData = new DeviceProfileData(); + + AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); + highTempFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + highTempFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate highTemperaturePredicate = new NumericFilterPredicate(); + highTemperaturePredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + highTemperaturePredicate.setValue(new FilterPredicateValue<>(30.0)); + highTempFilter.setPredicate(highTemperaturePredicate); + AlarmCondition alarmCondition = new AlarmCondition(); + alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + AlarmRule alarmRule = new AlarmRule(); + alarmRule.setCondition(alarmCondition); + DeviceProfileAlarm dpa = new DeviceProfileAlarm(); + dpa.setId("highTemperatureAlarmID"); + dpa.setAlarmType("highTemperatureAlarm"); + dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + + AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); + lowTempFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + lowTempFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lowTemperaturePredicate = new NumericFilterPredicate(); + lowTemperaturePredicate.setOperation(NumericFilterPredicate.NumericOperation.LESS); + lowTemperaturePredicate.setValue(new FilterPredicateValue<>(10.0)); + lowTempFilter.setPredicate(lowTemperaturePredicate); + AlarmRule clearRule = new AlarmRule(); + AlarmCondition clearCondition = new AlarmCondition(); + clearCondition.setCondition(Collections.singletonList(lowTempFilter)); + clearRule.setCondition(clearCondition); + dpa.setClearRule(clearRule); + + deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfile.setProfileData(deviceProfileData); + + Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) + .thenReturn(Futures.immediateFuture(Collections.emptyList())); + Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); + registerCreateAlarmMock(alarmService.createAlarm(any()), true); + + TbMsg theMsg = TbMsg.newMsg() + .type(TbMsgType.ALARM) + .originator(deviceId) + .copyMetaData(TbMsgMetaData.EMPTY) + .data(TbMsg.EMPTY_STRING) + .build(); + when(ctx.newMsg(any(), any(TbMsgType.class), any(), any(), any(), Mockito.anyString())).thenReturn(theMsg); + + ObjectNode data = JacksonUtil.newObjectNode(); + data.put("temperature", 42); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.TIMESERIES_UPDATED) + .originator(deviceId) + .copyMetaData(TbMsgMetaData.EMPTY) + .dataType(TbMsgDataType.JSON) + .data(JacksonUtil.toString(data)) + .build(); + node.onMsg(ctx, msg); + verify(ctx).tellSuccess(msg); + verify(ctx).enqueueForTellNext(theMsg, "Alarm Created"); + verify(ctx, Mockito.never()).tellFailure(Mockito.any(), Mockito.any()); + } + @Override protected TbNode getTestNode() { return node; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 38417c3922..16698b2841 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -29,6 +29,7 @@ import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; import org.thingsboard.rule.engine.api.RuleEngineRpcService; import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -40,6 +41,7 @@ import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -54,6 +56,7 @@ import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.mobile.app.MobileApp; import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundle; import org.thingsboard.server.common.data.notification.NotificationRequest; @@ -68,6 +71,7 @@ import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; @@ -88,10 +92,12 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; @@ -160,10 +166,13 @@ public class TenantIdLoaderTest { private MobileAppBundleService mobileAppBundleService; @Mock private CalculatedFieldService calculatedFieldService; + @Mock + private JobService jobService; + @Mock + private AiModelService aiModelService; private TenantId tenantId; private TenantProfileId tenantProfileId; - private NotificationId notificationId; private AbstractListeningExecutor dbExecutor; @BeforeEach @@ -175,9 +184,8 @@ public class TenantIdLoaderTest { } }; dbExecutor.init(); - this.tenantId = new TenantId(UUID.randomUUID()); + this.tenantId = TenantId.fromUUID(UUID.randomUUID()); this.tenantProfileId = new TenantProfileId(UUID.randomUUID()); - this.notificationId = new NotificationId(UUID.randomUUID()); when(ctx.getTenantId()).thenReturn(tenantId); @@ -195,6 +203,7 @@ public class TenantIdLoaderTest { switch (entityType) { case TENANT: case NOTIFICATION: + case ADMIN_SETTINGS: break; case CUSTOMER: Customer customer = new Customer(); @@ -419,6 +428,18 @@ public class TenantIdLoaderTest { when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService); doReturn(calculatedFieldLink).when(calculatedFieldService).findCalculatedFieldLinkById(eq(tenantId), any()); break; + case JOB: + Job job = new Job(); + job.setTenantId(tenantId); + when(ctx.getJobService()).thenReturn(jobService); + doReturn(job).when(jobService).findJobById(eq(tenantId), any()); + break; + case AI_MODEL: + AiModel aiModel = new AiModel(); + aiModel.setTenantId(tenantId); + when(ctx.getAiModelService()).thenReturn(aiModelService); + doReturn(Optional.of(aiModel)).when(aiModelService).findAiModelById(eq(tenantId), any()); + break; default: throw new RuntimeException("Unexpected originator EntityType " + entityType); } @@ -455,7 +476,7 @@ public class TenantIdLoaderTest { @Test public void test_findEntityIdAsync_other_tenant() { - checkTenant(new TenantId(UUID.randomUUID()), false); + checkTenant(TenantId.fromUUID(UUID.randomUUID()), false); } } diff --git a/tools/pom.xml b/tools/pom.xml index 7025ac9959..d748e649ab 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 058e0e96ab..1d829679ae 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 03784a855c..10ffdfc07e 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index df92bde39d..1c83f8b443 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index d27ab91259..ea8c750b6d 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index 2cfe62ba95..eeb4873e48 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index e561ae65b5..071c4423b6 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT transport diff --git a/ui-ngx/package.json b/ui-ngx/package.json index e1cc3a90af..4ba512a024 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.1.0", + "version": "4.2.0", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", @@ -140,6 +140,7 @@ "tinymce": "6.8.5", "rollup": "4.22.4", "@babel/core": "7.25.2", - "esbuild": "0.23.0" + "esbuild": "0.23.0", + "jquery.terminal/coveralls-next/form-data": "4.0.4" } } diff --git a/ui-ngx/patches/@angular+material+18.2.14.patch b/ui-ngx/patches/@angular+material+18.2.14.patch new file mode 100644 index 0000000000..db380983a2 --- /dev/null +++ b/ui-ngx/patches/@angular+material+18.2.14.patch @@ -0,0 +1,61 @@ +diff --git a/node_modules/@angular/material/autocomplete/index.d.ts b/node_modules/@angular/material/autocomplete/index.d.ts +index 1ebc198..f572ea2 100755 +--- a/node_modules/@angular/material/autocomplete/index.d.ts ++++ b/node_modules/@angular/material/autocomplete/index.d.ts +@@ -220,6 +220,10 @@ export declare interface MatAutocompleteDefaultOptions { + requireSelection?: boolean; + /** Class or list of classes to be applied to the autocomplete's overlay panel. */ + overlayPanelClass?: string | string[]; ++ ++ backdropClass?: string; ++ ++ hasBackdrop?: boolean; + /** Wheter icon indicators should be hidden for single-selection. */ + hideSingleSelectionIndicator?: boolean; + } +diff --git a/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs b/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs +index 193f5e4..833446a 100755 +--- a/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs ++++ b/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs +@@ -670,6 +670,8 @@ export class MatAutocompleteTrigger { + scrollStrategy: this._scrollStrategy(), + width: this._getPanelWidth(), + direction: this._dir ?? undefined, ++ hasBackdrop: this._defaults?.hasBackdrop, ++ backdropClass: this._defaults?.backdropClass, + panelClass: this._defaults?.overlayPanelClass, + }); + } +diff --git a/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs b/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs +index 3d919a4..5fd6b4d 100755 +--- a/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs ++++ b/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs +@@ -41,6 +41,7 @@ export function MAT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY() { + autoSelectActiveOption: false, + hideSingleSelectionIndicator: false, + requireSelection: false, ++ hasBackdrop: false, + }; + } + /** Autocomplete component. */ +diff --git a/node_modules/@angular/material/fesm2022/autocomplete.mjs b/node_modules/@angular/material/fesm2022/autocomplete.mjs +index e3d0253..36cd6d7 100755 +--- a/node_modules/@angular/material/fesm2022/autocomplete.mjs ++++ b/node_modules/@angular/material/fesm2022/autocomplete.mjs +@@ -65,6 +65,7 @@ function MAT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY() { + autoSelectActiveOption: false, + hideSingleSelectionIndicator: false, + requireSelection: false, ++ hasBackdrop: false, + }; + } + /** Autocomplete component. */ +@@ -926,6 +927,8 @@ class MatAutocompleteTrigger { + scrollStrategy: this._scrollStrategy(), + width: this._getPanelWidth(), + direction: this._dir ?? undefined, ++ hasBackdrop: this._defaults?.hasBackdrop, ++ backdropClass: this._defaults?.backdropClass, + panelClass: this._defaults?.overlayPanelClass, + }); + } diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 79342e2ea8..772921ee7a 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT thingsboard org.thingsboard diff --git a/ui-ngx/src/app/core/http/ai-model.service.ts b/ui-ngx/src/app/core/http/ai-model.service.ts new file mode 100644 index 0000000000..64e003c30e --- /dev/null +++ b/ui-ngx/src/app/core/http/ai-model.service.ts @@ -0,0 +1,54 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { AiModel, AiModelWithUserMsg, CheckConnectivityResult } from '@shared/models/ai-model.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; + +@Injectable({ + providedIn: 'root' +}) +export class AiModelService { + + constructor( + private http: HttpClient + ) {} + + public saveAiModel(aiModel: AiModel, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model', aiModel, defaultHttpOptionsFromConfig(config)); + } + + public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/ai/model${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } + + public getAiModelById(aiModelId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public deleteAiModel(aiModelId: string, config?: RequestConfig) { + return this.http.delete(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model/chat', aiModelWithUserMsg, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 01fed28bf3..572d6cc473 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -99,6 +99,7 @@ import { ResourceService } from '@core/http/resource.service'; import { OAuth2Service } from '@core/http/oauth2.service'; import { MobileAppService } from '@core/http/mobile-app.service'; import { PlatformType } from '@shared/models/oauth2.models'; +import { AiModelService } from '@core/http/ai-model.service'; @Injectable({ providedIn: 'root' @@ -131,6 +132,7 @@ export class EntityService { private resourceService: ResourceService, private oauth2Service: OAuth2Service, private mobileAppService: MobileAppService, + private aiModelService: AiModelService, ) { } private getEntityObservable(entityType: EntityType, entityId: string, @@ -183,6 +185,9 @@ export class EntityService { case EntityType.MOBILE_APP_BUNDLE: observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config); break; + case EntityType.AI_MODEL: + observable = this.aiModelService.getAiModelById(entityId, config); + break; } return observable; } @@ -485,6 +490,10 @@ export class EntityService { pageLink.sortOrder.property = 'title'; entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config); break; + case EntityType.AI_MODEL: + pageLink.sortOrder.property = 'name'; + entitiesObservable = this.aiModelService.getAiModels(pageLink, config); + break; } return entitiesObservable; } @@ -1022,11 +1031,7 @@ export class EntityService { const stateEntityId = stateEntityInfo.entityId; switch (filter.type) { case AliasFilterType.singleEntity: - const aliasEntityId = this.resolveAliasEntityId(filter.singleEntity.entityType, filter.singleEntity.id); - result.entityFilter = { - type: AliasFilterType.singleEntity, - singleEntity: aliasEntityId - }; + result.entityFilter = deepClone(filter); return of(result); case AliasFilterType.entityList: result.entityFilter = deepClone(filter); @@ -1077,9 +1082,8 @@ export class EntityService { rootEntityId = filter.rootEntity.id; } if (rootEntityType && rootEntityId) { - const queryRootEntityId = this.resolveAliasEntityId(rootEntityType, rootEntityId); result.entityFilter = deepClone(filter); - result.entityFilter.rootEntity = queryRootEntityId; + result.entityFilter.rootEntity = {entityType: rootEntityType, id: rootEntityId}; return of(result); } else { return of(result); @@ -1378,44 +1382,9 @@ export class EntityService { if (!entityId) { entityId = filter.defaultStateEntity; } - if (entityId) { - entityId = this.resolveAliasEntityId(entityId.entityType, entityId.id); - } return {entityId}; } - private resolveAliasEntityId(entityType: EntityType | AliasEntityType, id: string): EntityId { - const entityId: EntityId = { - entityType, - id - }; - if (entityType === AliasEntityType.CURRENT_CUSTOMER) { - const authUser = getCurrentAuthUser(this.store); - entityId.entityType = EntityType.CUSTOMER; - if (authUser.authority === Authority.CUSTOMER_USER) { - entityId.id = authUser.customerId; - } - } else if (entityType === AliasEntityType.CURRENT_TENANT){ - const authUser = getCurrentAuthUser(this.store); - entityId.entityType = EntityType.TENANT; - entityId.id = authUser.tenantId; - } else if (entityType === AliasEntityType.CURRENT_USER){ - const authUser = getCurrentAuthUser(this.store); - entityId.entityType = EntityType.USER; - entityId.id = authUser.userId; - } else if (entityType === AliasEntityType.CURRENT_USER_OWNER){ - const authUser = getCurrentAuthUser(this.store); - if (authUser.authority === Authority.TENANT_ADMIN) { - entityId.entityType = EntityType.TENANT; - entityId.id = authUser.tenantId; - } else if (authUser.authority === Authority.CUSTOMER_USER) { - entityId.entityType = EntityType.CUSTOMER; - entityId.id = authUser.customerId; - } - } - return entityId; - } - private createDatasourceFromSubscriptionInfo(subscriptionInfo: SubscriptionInfo): Datasource { subscriptionInfo = this.validateSubscriptionInfo(subscriptionInfo); let datasource: Datasource = null; diff --git a/ui-ngx/src/app/core/http/ota-package.service.ts b/ui-ngx/src/app/core/http/ota-package.service.ts index 09f887c038..0ddbfc44de 100644 --- a/ui-ngx/src/app/core/http/ota-package.service.ts +++ b/ui-ngx/src/app/core/http/ota-package.service.ts @@ -129,15 +129,16 @@ export class OtaPackageService { } return forkJoin(tasks).pipe( mergeMap(([deviceFirmwareUpdate, deviceSoftwareUpdate]) => { - let text = ''; + const lines: string[] = []; if (deviceFirmwareUpdate > 0) { - text += this.translate.instant('ota-update.change-firmware', {count: deviceFirmwareUpdate}); + lines.push(this.translate.instant('ota-update.change-firmware', {count: deviceFirmwareUpdate})); } if (deviceSoftwareUpdate > 0) { - text += text.length ? ' ' : ''; - text += this.translate.instant('ota-update.change-software', {count: deviceSoftwareUpdate}); + lines.push(this.translate.instant('ota-update.change-software', {count: deviceSoftwareUpdate})); } - return text !== '' ? this.dialogService.confirm('', text, null, this.translate.instant('common.proceed')) : of(true); + return lines.length + ? this.dialogService.confirm(this.translate.instant('ota-update.change-ota-setting-title'), lines.join('
'), null, this.translate.instant('common.proceed')) + : of(true); }) ); } diff --git a/ui-ngx/src/app/core/http/public-api.ts b/ui-ngx/src/app/core/http/public-api.ts index c28d80d173..63cc393ce6 100644 --- a/ui-ngx/src/app/core/http/public-api.ts +++ b/ui-ngx/src/app/core/http/public-api.ts @@ -48,3 +48,4 @@ export * from './user-settings.service'; export * from './widget.service'; export * from './usage-info.service'; export * from './trendz-settings.service' +export * from './ai-model.service' diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index dca6dd45eb..1511840c57 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -31,7 +31,8 @@ import { DashboardLayoutsInfo, DashboardState, DashboardStateLayouts, - GridSettings, LayoutType, + GridSettings, + LayoutType, WidgetLayout } from '@shared/models/dashboard.models'; import { deepClone, isDefined, isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined } from '@core/utils'; @@ -61,6 +62,7 @@ import { MediaBreakpoints } from '@shared/models/constants'; import { TranslateService } from '@ngx-translate/core'; import { DashboardPageLayout } from '@home/components/dashboard-page/dashboard-page.models'; import { maxGridsterCol, maxGridsterRow } from '@home/models/dashboard-component.models'; +import { findWidgetModelDefinition } from '@shared/models/widget/widget-model.definition'; @Injectable({ providedIn: 'root' @@ -398,6 +400,14 @@ export class DashboardUtilsService { return datasources; } + public getWidgetDatasources(widget: Widget): Datasource[] { + const widgetDefinition = findWidgetModelDefinition(widget); + if (widgetDefinition) { + return widgetDefinition.datasources(widget); + } + return this.validateAndUpdateDatasources(widget.config.datasources); + } + public createDefaultLayoutData(): DashboardLayout { return { widgets: {}, diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index cef9bbe7a3..d525bee677 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -35,7 +35,7 @@ import { FcRuleNode, ruleNodeTypeDescriptors } from '@shared/models/rule-node.mo import { RuleChainService } from '@core/http/rule-chain.service'; import { RuleChainImport } from '@shared/models/rule-chain.models'; import { Filter, FilterInfo, Filters, FiltersInfo, getFilterId } from '@shared/models/query/query.models'; -import { getWidgetExportDefinition } from '@shared/models/widget/widget-export.models'; +import { findWidgetModelDefinition } from '@shared/models/widget/widget-model.definition'; const WIDGET_ITEM = 'widget_item'; const WIDGET_REFERENCE = 'widget_reference'; @@ -142,7 +142,7 @@ export class ItemBufferService { } } let widgetExportInfo: any; - const exportDefinition = getWidgetExportDefinition(widget); + const exportDefinition = findWidgetModelDefinition(widget); if (exportDefinition) { widgetExportInfo = exportDefinition.prepareExportInfo(dashboard, widget); } @@ -270,7 +270,7 @@ export class ItemBufferService { let callFilterUpdateFunction = false; let newEntityAliases: EntityAliases; let newFilters: Filters; - const exportDefinition = getWidgetExportDefinition(widget); + const exportDefinition = findWidgetModelDefinition(widget); if (exportDefinition && widgetExportInfo || aliasesInfo) { newEntityAliases = deepClone(dashboard.configuration.entityAliases); } diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index 607c5c6dff..4277f96747 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -105,7 +105,8 @@ export enum MenuId { otaUpdates = 'otaUpdates', version_control = 'version_control', api_usage = 'api_usage', - trendz_settings = 'trendz_settings' + trendz_settings = 'trendz_settings', + ai_models = 'ai_models' } declare type MenuFilter = (authState: AuthState) => boolean; @@ -286,6 +287,16 @@ export const menuSectionMap = new Map([ icon: 'mdi:message-cog' } ], + [ + MenuId.ai_models, + { + id: MenuId.ai_models, + name: 'ai-models.ai-models', + type: 'link', + path: '/settings/ai-models', + icon: 'auto_awesome' + } + ], [ MenuId.mobile_center, { @@ -856,7 +867,8 @@ const defaultUserMenuMap = new Map([ {id: MenuId.notification_settings}, {id: MenuId.repository_settings}, {id: MenuId.auto_commit_settings}, - {id: MenuId.trendz_settings} + {id: MenuId.trendz_settings}, + {id: MenuId.ai_models} ] }, { diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index bfaad1148d..734e503aeb 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -51,7 +51,7 @@ import cssjs from '@core/css/css'; import { isNotEmptyTbFunction } from '@shared/models/js-function.models'; import { defaultFormProperties, FormProperty } from '@shared/models/dynamic-form.models'; -const i18nRegExp = new RegExp(`{${i18nPrefix}:[^{}]+}`, 'g'); +const i18nRegExp = new RegExp(`{${i18nPrefix}:([^{}]+)}`, 'g'); const predefinedFunctions: { [func: string]: string } = { Sin: 'return Math.round(1000*Math.sin(time/5000));', @@ -209,21 +209,20 @@ export class UtilsService { } public customTranslation(translationValue: string, defaultValue: string = translationValue): string { - if (translationValue && isString(translationValue)) { - if (translationValue.includes(`{${i18nPrefix}`)) { - const matches = translationValue.match(i18nRegExp); - let result = translationValue; - for (const match of matches) { - const translationId = match.substring(6, match.length - 1); - result = result.replace(match, this.doTranslate(translationId, match)); - } - return result; - } else { - return this.doTranslate(translationValue, defaultValue, customTranslationsPrefix); - } - } else { + if (!translationValue || !isString(translationValue)) { return translationValue; } + if (!translationValue.includes(`{${i18nPrefix}:`)) { + return this.doTranslate(translationValue, defaultValue, customTranslationsPrefix); + } + const matches = translationValue.matchAll(i18nRegExp); + let result = translationValue; + for (const [fullMatch, translationId] of matches) { + if (translationId) { + result = result.replace(fullMatch, this.doTranslate(translationId, fullMatch)); + } + } + return result; } private doTranslate(translationValue: string, defaultValue: string, prefix?: string): string { diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 70432e8cdf..0890b9e623 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -336,6 +336,7 @@ import * as DatapointsLimitComponent from '@shared/components/time/datapoints-li import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component'; import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; +import * as AIModelDialogComponent from '@home/components/ai-model/ai-model-dialog.component'; import { IModulesMap } from '@modules/common/modules-map.models'; import { Observable, of } from 'rxjs'; @@ -668,7 +669,8 @@ class ModulesMap implements IModulesMap { '@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent, '@home/components/widget/widget-container.component': WidgetContainerComponent, '@home/components/profile/queue/tenant-profile-queues.component': TenantProfileQueuesComponent, - '@home/components/queue/queue-form.component': QueueFormComponent + '@home/components/queue/queue-form.component': QueueFormComponent, + '@home/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, }; init(): Observable { diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html new file mode 100644 index 0000000000..860e615410 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -0,0 +1,293 @@ + + +

{{ dialogTitle | translate }}

+ +
+ +
+ + +
+
+
+
+ + ai-models.name + + + + {{ 'ai-models.name-required' | translate }} + + + {{ 'ai-models.name-max-length' | translate }} + + +
+
+
+
ai-models.provider
+
+ + ai-models.ai-provider + + + {{providerTranslationMap.get(provider) | translate}} + + + +
+ @if (providerFieldsList.includes('personalAccessToken')) { + + ai-models.personal-access-token + + + + {{ 'ai-models.personal-access-token-required' | translate }} + + + } + @if (providerFieldsList.includes('projectId')) { + + ai-models.project-id + + + {{ 'ai-models.project-id-required' | translate }} + + + } + @if (providerFieldsList.includes('location')) { + + ai-models.location + + + {{ 'ai-models.location-required' | translate }} + + + } + @if (providerFieldsList.includes('serviceAccountKey')) { + + + } + @if (providerFieldsList.includes('endpoint')) { + + ai-models.endpoint + + + {{ 'ai-models.endpoint-required' | translate }} + + + } + @if (providerFieldsList.includes('serviceVersion')) { + + ai-models.service-version + + + } + @if (providerFieldsList.includes('apiKey')) { + + ai-models.api-key + + + + {{ 'ai-models.api-key-required' | translate }} + + + } + @if (providerFieldsList.includes('region')) { + + ai-models.region + + + {{ 'ai-models.region-required' | translate }} + + + } + @if (providerFieldsList.includes('accessKeyId')) { + + ai-models.access-key-id + + + {{ 'ai-models.access-key-id-required' | translate }} + + + } + @if (providerFieldsList.includes('secretAccessKey')) { + + ai-models.secret-access-key + + + + {{ 'ai-models.secret-access-key-required' | translate }} + + + } +
+
+
+
+
ai-models.configuration
+
+
+ + +
+ @if (modelFieldsList.includes('temperature')) { +
+
+ {{ 'ai-models.temperature' | translate }} +
+ + + + warning + + +
+ } + @if (modelFieldsList.includes('topP')) { +
+
+ {{ 'ai-models.top-p' | translate }} +
+ + + + warning + + +
+ } + @if (modelFieldsList.includes('topK')) { +
+
+ {{ 'ai-models.top-k' | translate }} +
+ + + + warning + + +
+ } + @if (modelFieldsList.includes('presencePenalty')) { +
+
+ {{ 'ai-models.presence-penalty' | translate }} +
+ + + +
+ } + + @if (modelFieldsList.includes('frequencyPenalty')) { +
+
+ {{ 'ai-models.frequency-penalty' | translate }} +
+ + + +
+ } + @if (modelFieldsList.includes('maxOutputTokens')) { +
+
+ {{ 'ai-models.max-output-tokens' | translate }} +
+ + + + warning + + +
+ } +
+
+
+
+
+
+ + + + +
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss new file mode 100644 index 0000000000..c55453c388 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: 850px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; +} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts new file mode 100644 index 0000000000..db5d1d7e23 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Observable, of } from 'rxjs'; +import { StepperOrientation } from '@angular/cdk/stepper'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + AiModel, + AiModelMap, + AiProvider, + AiProviderTranslations, + ModelType, + ProviderFieldsAllList +} from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; +import { map } from 'rxjs/operators'; + +export interface AIModelDialogData { + AIModel?: AiModel; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-ai-model-dialog', + templateUrl: './ai-model-dialog.component.html', + styleUrls: ['./ai-model-dialog.component.scss'] +}) +export class AIModelDialogComponent extends DialogComponent { + + readonly entityType = EntityType; + + selectedIndex = 0; + + dialogTitle = 'ai-models.ai-model'; + + stepperOrientation: Observable; + + aiProvider = AiProvider; + providerMap: AiProvider[] = Object.keys(AiProvider) as AiProvider[]; + providerTranslationMap = AiProviderTranslations; + + provider: AiProvider = AiProvider.OPENAI; + + aiModelForms: FormGroup; + + isAdd = false; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private fb: FormBuilder, + private aiModelService: AiModelService, + private dialog: MatDialog) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + this.provider = this.data.AIModel ? this.data.AIModel.configuration.provider : AiProvider.OPENAI; + + this.aiModelForms = this.fb.group({ + name: [this.data.AIModel ? this.data.AIModel.name : '', [Validators.required, Validators.maxLength(255), Validators.pattern(/.*\S.*/)]], + modelType: [ModelType.CHAT], + configuration: this.fb.group({ + provider: [this.provider, []], + providerConfig: this.fb.group({ + apiKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.apiKey : '', [Validators.required]], + personalAccessToken: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.personalAccessToken : '', [Validators.required]], + endpoint: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.endpoint : '', [Validators.required]], + serviceVersion: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceVersion : ''], + projectId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.projectId : '', [Validators.required]], + location: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.location : '', [Validators.required]], + serviceAccountKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKey : '', [Validators.required]], + fileName: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.fileName : '', [Validators.required]], + region: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.region : '', [Validators.required]], + accessKeyId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.accessKeyId : '', [Validators.required]], + secretAccessKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.secretAccessKey : '', [Validators.required]], + }), + modelId: [this.data.AIModel ? this.data.AIModel.configuration?.modelId : '', [Validators.required]], + temperature: [this.data.AIModel ? this.data.AIModel.configuration?.temperature : null, [Validators.min(0)]], + topP: [this.data.AIModel ? this.data.AIModel.configuration?.topP : null, [Validators.min(0.1), Validators.max(1)]], + topK: [this.data.AIModel ? this.data.AIModel.configuration?.topK : null, [Validators.min(0)]], + frequencyPenalty: [this.data.AIModel ? this.data.AIModel.configuration?.frequencyPenalty : null], + presencePenalty: [this.data.AIModel ? this.data.AIModel.configuration?.presencePenalty : null], + maxOutputTokens: [this.data.AIModel ? this.data.AIModel.configuration?.maxOutputTokens : null, [Validators.min(1)]] + }) + }); + + this.aiModelForms.get('configuration.provider').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((provider: AiProvider) => { + this.provider = provider; + this.aiModelForms.get('configuration.modelId').reset(''); + this.aiModelForms.get('configuration.providerConfig').reset({}); + this.updateValidation(provider); + }) + + this.updateValidation(this.provider); + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(this.provider ? AiModelMap.get(this.provider).modelList || [] : []).pipe( + map(name => name?.filter(option => option.toLowerCase().includes(search))), + ); + } + + private updateValidation(provider: AiProvider) { + ProviderFieldsAllList.forEach(key => { + if (AiModelMap.get(provider).providerFieldsList.includes(key)) { + this.aiModelForms.get('configuration.providerConfig').get(key).enable(); + } else { + this.aiModelForms.get('configuration.providerConfig').get(key).disable(); + } + }) + } + + get providerFieldsList(): string[] { + return AiModelMap.get(this.provider).providerFieldsList; + } + get modelFieldsList(): string[] { + return AiModelMap.get(this.provider).modelFieldsList; + } + + cancel(): void { + this.dialogRef.close(null); + } + + checkConnectivity() { + return this.dialog.open(CheckConnectivityDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + AIModel: this.aiModelForms.value + } + }).afterClosed(); + } + + add(): void { + const aiModel = {...this.data.AIModel, ...this.aiModelForms.value} as AiModel; + this.aiModelService.saveAiModel(aiModel).subscribe(aiModel => this.dialogRef.close(aiModel)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html new file mode 100644 index 0000000000..afc881b10b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html @@ -0,0 +1,57 @@ + + +

ai-models.check-connectivity

+ + +
+
+
+ + +
+
+ check_circle +
+ {{ "ai-models.check-connectivity-success" | translate }} +
+
+
+ cancel +
+
{{ "ai-models.check-connectivity-failed" | translate }}
+ + +
+
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss new file mode 100644 index 0000000000..a85d0111de --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: 560px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; + + .transparent { + background-color: transparent; + } + + .connection-status { + font-weight: 500; + letter-spacing: 0.25px; + text-align: center; + } + + .connection-icon { + height: 32px; + font-size: 32px; + width: 32px; + margin-bottom: 4px; + } + + .error_msg { + text-align: center; + margin-top: 8px; + font-size: 14px; + line-height: 130%; + letter-spacing: 0.25px; + opacity: 0.9; + } + + .success { + color: #198038; + } + + .error { + color: #D12730; + } + + ::ng-deep { + .json-editor { + .tb-json-object-toolbar { + display: none; + } + .tb-json-panel { + margin: 0; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts new file mode 100644 index 0000000000..dd2c27cb47 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { 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 { AiModel, AiModelWithUserMsg, ModelType } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; + +export interface AIModelDialogData { + AIModel?: AiModel; +} + +@Component({ + selector: 'tb-check-connectivity-dialog', + templateUrl: './check-connectivity-dialog.component.html', + styleUrls: ['./check-connectivity-dialog.component.scss'] +}) +export class CheckConnectivityDialogComponent extends DialogComponent { + + showCheckSuccess = false; + checkErrMsg = ''; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private aiModelService: AiModelService) { + super(store, router, dialogRef); + + if (this.data.AIModel) { + const aiModelWithMsg: AiModelWithUserMsg = { + userMessage: { + contents: [ + { + contentType: "TEXT", + text: "What is the capital of Ukraine?" + } + ] + }, + chatModelConfig: { + modelType: ModelType.CHAT, + provider: this.data.AIModel.configuration.provider, + providerConfig: {...this.data.AIModel.configuration.providerConfig}, + modelId: this.data.AIModel.configuration.modelId, + maxRetries: 0, + timeoutSeconds: 20 + } + } + this.aiModelService.checkConnectivity(aiModelWithMsg, { + ignoreErrors: true, + ignoreLoading: true + }).subscribe({ + next: (result) => { + if (result.status === 'SUCCESS') { + this.showCheckSuccess = true; + } else { + try { + this.checkErrMsg = JSON.parse(result.errorDetails); + } catch (e) { + this.checkErrMsg = result.errorDetails; + } + } + }, + error: err => this.checkErrMsg = err.error.message + }); + } + } + + cancel(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html index be29713097..f735491e78 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html @@ -28,9 +28,9 @@
-
-
- +
+
+ alias.name diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss index 0e1847f157..0aa1a0884f 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss @@ -19,7 +19,7 @@ .tb-resolve-multiple-switch { padding: 18px 0 0 18px; @media #{$mat-xs} { - padding: 0 0 18px 0; + padding: 0 0 22px 0; } } } diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html index 5c1bd2c219..c067517aff 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html @@ -38,14 +38,14 @@
-
+
{{$index + 1}}.
- + {{ 'entity.alias-required' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts index 235a41876d..f7bb87455a 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef, Inject, OnInit, SkipSelf } from '@angular/core'; +import { Component, DestroyRef, Inject, SkipSelf } from '@angular/core'; import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; @@ -60,7 +60,7 @@ export interface EntityAliasesDialogData { styleUrls: ['./entity-aliases-dialog.component.scss'] }) export class EntityAliasesDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { + implements ErrorStateMatcher { title: string; disableAdd: boolean; @@ -107,8 +107,7 @@ export class EntityAliasesDialogComponent extends DialogComponent { + this.dashboardUtils.getWidgetDatasources(widget).forEach((datasource) => { if ([DatasourceType.entity, DatasourceType.entityCount, DatasourceType.alarmCount].includes(datasource.type) && datasource.entityAliasId) { this.addWidgetTitleToWidgetsMap(datasource.entityAliasId, widget.config.title); @@ -143,7 +142,9 @@ export class EntityAliasesDialogComponent extends DialogComponent
- -
- calculated-fields.use-message-timestamp + +
+ calculated-fields.use-latest-timestamp
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss index efcd62efd4..e192e3ccc0 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss @@ -45,7 +45,7 @@ &-key { color: #c24c1a; } - &-time-window, &-values, &-func, &-value, &-ts, &-msgTs { + &-time-window, &-values, &-func, &-value, &-ts, &-latestTs { color: #7214D0; } &-start-ts, &-end-ts { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 4aa4eca425..975744b2c6 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -77,7 +77,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent { if (res) { @@ -249,11 +261,16 @@ export class ManageDashboardStatesDialogComponent const originalState = state; const newStateName = this.getNextDuplicatedName(state.name); if (newStateName) { + const newStateId = newStateName.toLowerCase().replace(/\W/g, '_'); + if (this.states[newStateId]) { + this.stateNames.add(newStateName); + this.duplicateState(null, state); + } const duplicatedStates = deepClone(originalState); const duplicatedWidgets = deepClone(this.widgets); const mainWidgets = {}; const rightWidgets = {}; - duplicatedStates.id = newStateName.toLowerCase().replace(/\W/g, '_'); + duplicatedStates.id = newStateId; duplicatedStates.name = newStateName; duplicatedStates.root = false; this.stateNames.add(duplicatedStates.name); 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 4be96f5fdd..eeb835e63f 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 @@ -21,7 +21,8 @@ import { Component, ElementRef, EventEmitter, - Input, NgZone, + Input, + NgZone, OnChanges, OnDestroy, OnInit, @@ -59,7 +60,7 @@ import { EntityTypeTranslation } from '@shared/models/entity-type.models'; import { DialogService } from '@core/services/dialog.service'; import { AddEntityDialogComponent } from './add-entity-dialog.component'; import { AddEntityDialogData, EntityAction } from '@home/models/entity/entity-component.models'; -import { calculateIntervalStartEndTime, HistoryWindowType, Timewindow } from '@shared/models/time/time.models'; +import { getTimePageLinkInterval, Timewindow } from '@shared/models/time/time.models'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { isDefined, isEqual, isNotEmptyStr, isUndefined } from '@core/utils'; @@ -259,7 +260,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa if (this.entitiesTableConfig.useTimePageLink) { this.timewindow = this.entitiesTableConfig.defaultTimewindowInterval; - const interval = this.getTimePageLinkInterval(); + const interval = getTimePageLinkInterval(this.timewindow); this.pageLink = new TimePageLink(10, 0, null, sortOrder, interval.startTime, interval.endTime); } else { @@ -424,7 +425,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa } if (this.entitiesTableConfig.useTimePageLink) { const timePageLink = this.pageLink as TimePageLink; - const interval = this.getTimePageLinkInterval(); + const interval = getTimePageLinkInterval(this.timewindow); timePageLink.startTime = interval.startTime; timePageLink.endTime = interval.endTime; } @@ -434,31 +435,6 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa } } - private getTimePageLinkInterval(): {startTime?: number; endTime?: number} { - const interval: {startTime?: number; endTime?: number} = {}; - switch (this.timewindow.history.historyType) { - case HistoryWindowType.LAST_INTERVAL: - const currentTime = Date.now(); - interval.startTime = currentTime - this.timewindow.history.timewindowMs; - interval.endTime = currentTime; - break; - case HistoryWindowType.FIXED: - interval.startTime = this.timewindow.history.fixedTimewindow.startTimeMs; - interval.endTime = this.timewindow.history.fixedTimewindow.endTimeMs; - break; - case HistoryWindowType.INTERVAL: - const startEndTime = calculateIntervalStartEndTime(this.timewindow.history.quickInterval); - interval.startTime = startEndTime[0]; - interval.endTime = startEndTime[1]; - break; - case HistoryWindowType.FOR_ALL_TIME: - interval.startTime = null; - interval.endTime = null; - break; - } - return interval; - } - private dataLoaded(col?: number, row?: number) { if (isFinite(col) && isFinite(row)) { this.clearCellCache(col, row); diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index 22dfb34a64..25bb1d9700 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -15,8 +15,8 @@ limitations under the License. --> -
- +
+ alias.filter-type @@ -30,18 +30,21 @@
@@ -49,12 +52,13 @@ - + entity.name-starts-with help @@ -66,6 +70,7 @@ - + alias.state-entity-parameter-name -
- - +
{{ 'alias.default-state-entity' | translate }}
+ @@ -88,10 +93,13 @@ - + asset.name-starts-with help @@ -100,10 +108,13 @@ - + device.name-starts-with help @@ -112,10 +123,13 @@ - + entity-view.name-starts-with help @@ -124,36 +138,51 @@ - + edge.name-starts-with
-
+
alias.root-entity
- + {{ 'alias.root-state-entity' | translate }} -
- - alias.state-entity-parameter-name - - - + @if (filterFormGroup.get('rootStateEntity').value) { +
+ + alias.state-entity-parameter-name + + +
+
{{ 'alias.default-state-entity' | translate }}
+ + +
+
+ } @else { + -
- - -
- + } +
+
+
alias.query-options
+
+ relation.direction @@ -161,7 +190,7 @@ - + alias.max-relation-level
+ class="mat-slide" formControlName="fetchLastLevelOnly"> {{ 'alias.last-level-relation' | translate }}
@@ -191,98 +220,113 @@ entityFilterFormGroup.get('type').value === aliasFilterType.edgeSearchQuery || entityFilterFormGroup.get('type').value === aliasFilterType.entityViewSearchQuery ? entityFilterFormGroup.get('type').value : ''"> - -
- - - -
-
- - -
-
- - alias.state-entity-parameter-name - - -
- - - +
+
+
alias.root-entity
+ + {{ 'alias.root-state-entity' | translate }} + + @if (filterFormGroup.get('rootStateEntity').value) { +
+ + alias.state-entity-parameter-name + + +
+
{{ 'alias.default-state-entity' | translate }}
+ + +
+
+ } @else { + + + }
-
-
-
- +
+
alias.query-options
+
+ + relation.direction + + + {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} + + + + + alias.max-relation-level + + +
+ + {{ 'alias.last-level-relation' | translate }} - -
-
-
- - relation.direction - - - {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} - - - - - alias.max-relation-level - - -
-
relation.relation-type
- - - - -
asset.asset-types
- - -
- -
device.device-types
- - -
- -
edge.edge-types
- - -
- -
entity-view.entity-view-types
- - +
+
+
relation.relation-filter
+ + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss index 686a55eb35..89e816eb35 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss @@ -15,25 +15,7 @@ */ :host { .tb-entity-filter { - #relationsQueryFilter { - padding-top: 20px; - - tb-entity-select { - min-height: 92px; - } - } - - .tb-root-state-entity-switch { - padding-left: 10px; - padding-bottom: 10px; - - .root-state-entity-switch { - margin: 0; - } - - .root-state-entity-label { - margin: 5px 0 5px 10px; - } - } + display: flex; + flex-direction: column; } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts index 60e066fabe..048b91d25e 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts @@ -14,19 +14,19 @@ /// limitations under the License. /// -import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { Component, Inject, SkipSelf } from '@angular/core'; import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AbstractControl, + FormGroupDirective, + NgForm, UntypedFormArray, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - FormGroupDirective, - NgForm, Validators } from '@angular/forms'; import { Router } from '@angular/router'; @@ -58,7 +58,7 @@ export interface FiltersDialogData { styleUrls: ['./filters-dialog.component.scss'] }) export class FiltersDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { + implements ErrorStateMatcher { title: string; disableAdd: boolean; @@ -96,15 +96,16 @@ export class FiltersDialogComponent extends DialogComponent { - const datasources = this.dashboardUtils.validateAndUpdateDatasources(widget.config.datasources); - datasources.forEach((datasource) => { - if (datasource.type === DatasourceType.entity && datasource.filterId) { + this.dashboardUtils.getWidgetDatasources(widget).forEach((datasource) => { + if (datasource.type !== DatasourceType.function && datasource.filterId) { widgetsTitleList = this.filterToWidgetsMap[datasource.filterId]; if (!widgetsTitleList) { widgetsTitleList = []; this.filterToWidgetsMap[datasource.filterId] = widgetsTitleList; } - widgetsTitleList.push(widget.config.title); + if (!widgetsTitleList.includes(widget.config.title)) { + widgetsTitleList.push(widget.config.title); + } } }); }); @@ -140,9 +141,6 @@ export class FiltersDialogComponent extends DialogComponent { - this.deviceProfileTransportConfigurationFormGroup.patchValue({configuration}, {emitEvent: false}); + this.deviceProfileTransportConfigurationFormGroup.patchValue({configuration}, {emitEvent: this.isAdd}); }, 0); } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss index d86864d1f3..9ce049107b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss @@ -31,4 +31,10 @@ .mat-expansion-panel-header-title { margin-right: 0; } + + &::ng-deep { + .mat-content.mat-content-hide-toggle { + margin-right: 0; + } + } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html index 06b451a1f8..63a5dfbd1f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html @@ -49,7 +49,7 @@
- diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index aded3e2f78..3c5c8774d5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -693,11 +693,19 @@
- + + + + +
+
+ - +
@@ -725,8 +733,8 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index b1d6652e4a..c6efd9dee3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -114,7 +114,10 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA maxWsSubscriptionsPerRegularUser: [null, [Validators.min(0)]], maxWsSubscriptionsPerPublicUser: [null, [Validators.min(0)]], wsUpdatesPerSessionRateLimit: [null, []], - cassandraQueryTenantRateLimitsConfiguration: [null, []], + cassandraWriteQueryTenantCoreRateLimits: [null, []], + cassandraReadQueryTenantCoreRateLimits: [null, []], + cassandraWriteQueryTenantRuleEngineRateLimits: [null, []], + cassandraReadQueryTenantRuleEngineRateLimits: [null, []], edgeEventRateLimits: [null, []], edgeEventRateLimitsPerEdge: [null, []], edgeUplinkMessagesRateLimits: [null, []], diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html index 238baaf835..93fe799f09 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html @@ -17,31 +17,33 @@ -->
-
- tenant-profile.rate-limits.but-less-than -
+ @if($index > 0) { +
tenant-profile.rate-limits.and-also-less-than
+ }
- + tenant-profile.rate-limits.number-of-messages - - {{ 'tenant-profile.rate-limits.number-of-messages-required' | translate }} - - - {{ 'tenant-profile.rate-limits.number-of-messages-min' | translate }} - + + @if (rateLimit.get('value').hasError('required')) { + tenant-profile.rate-limits.number-of-messages-required + } @else if(rateLimit.get('value').hasError('min')) { + tenant-profile.rate-limits.number-of-messages-min + } - + tenant-profile.rate-limits.per-seconds - - {{ 'tenant-profile.rate-limits.per-seconds-required' | translate }} - - - {{ 'tenant-profile.rate-limits.per-seconds-min' | translate }} - + + @if (rateLimit.get('time').hasError('required')) { + tenant-profile.rate-limits.per-seconds-required + } @else if (rateLimit.get('time').hasError('min')) { + tenant-profile.rate-limits.per-seconds-min + } @else if (rateLimit.get('time').hasError('duplicateTime')) { + tenant-profile.rate-limits.per-seconds-duplicate + } + + } +
+ +
+ + + rule-node-config.ai.advanced-settings + + +
+
+
{{ 'rule-node-config.ai.timeout' | translate }}
+
+ + +
+
+
+ + {{ 'rule-node-config.ai.force-acknowledgement' | translate }} + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts new file mode 100644 index 0000000000..47313da81d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -0,0 +1,117 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { MatDialog } from '@angular/material/dialog'; +import { AIModelDialogComponent, AIModelDialogData } from '@home/components/ai-model/ai-model-dialog.component'; +import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@shared/models/ai-model.models'; +import { deepTrim } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-external-node-ai-config', + templateUrl: './ai-config.component.html', + styleUrls: [] +}) +export class AiConfigComponent extends RuleNodeConfigurationComponent { + + aiConfigForm: UntypedFormGroup; + + entityType = EntityType; + + responseFormat = ResponseFormat; + + disabledResponseFormatType: boolean; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService, + private dialog: MatDialog) { + super(); + } + + protected configForm(): UntypedFormGroup { + return this.aiConfigForm; + } + + protected onConfigurationSet(configuration: RuleNodeConfiguration) { + this.aiConfigForm = this.fb.group({ + modelId: [configuration?.modelId ?? null, [Validators.required]], + systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + responseFormat: this.fb.group({ + type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], + schema: [configuration?.responseFormat?.schema ?? null, [Validators.required]], + }), + timeoutSeconds: [configuration?.timeoutSeconds ?? 60, []], + forceAck: [configuration?.forceAck ?? true, []] + }); + } + + protected validatorTriggers(): string[] { + return ['responseFormat.type']; + } + + protected updateValidators(emitEvent: boolean) { + if (this.aiConfigForm.get('responseFormat.type').value === ResponseFormat.JSON_SCHEMA) { + this.aiConfigForm.get('responseFormat.schema').enable({emitEvent: false}); + } else { + this.aiConfigForm.get('responseFormat.schema').disable({emitEvent: false}); + } + } + + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + if (!this.aiConfigForm.get('systemPrompt').value) { + delete configuration.systemPrompt; + } + return deepTrim(configuration); + } + + onEntityChange($event: AiModel) { + if ($event) { + if (AiRuleNodeResponseFormatTypeOnlyText.includes($event.configuration.provider)) { + if (this.aiConfigForm.get('responseFormat.type').value !== ResponseFormat.TEXT) { + this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: true}); + } + this.disabledResponseFormatType = true; + } + } else { + this.disabledResponseFormatType = false; + } + } + + get getResponseFormatHint() { + return this.translate.instant(`rule-node-config.ai.response-format-hint-${this.aiConfigForm.get('responseFormat.type').value}`); + } + + createModelAi(formControl: string) { + this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd: true + } + }).afterClosed() + .subscribe((model) => { + if (model) { + this.aiConfigForm.get(formControl).patchValue(model.id); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.html index 79c4a2f00d..bab0c6b196 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.html @@ -38,7 +38,9 @@ {{ 'rule-node-config.device-id-required' | translate }} - + + diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts index 7dd934ccd9..dff5f06e54 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts @@ -22,6 +22,7 @@ import { azureIotHubCredentialsTypes, azureIotHubCredentialsTypeTranslations } from '@home/components/rule-node/rule-node-config.models'; +import { MqttVersion } from '@shared/models/mqtt.models'; @Component({ selector: 'tb-external-node-azure-iot-hub-config', @@ -34,6 +35,7 @@ export class AzureIotHubConfigComponent extends RuleNodeConfigurationComponent { allAzureIotHubCredentialsTypes = azureIotHubCredentialsTypes; azureIotHubCredentialsTypeTranslationsMap = azureIotHubCredentialsTypeTranslations; + MqttVersion = MqttVersion; constructor(private fb: UntypedFormBuilder) { super(); diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts index d2ed9c4f26..955c555989 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts @@ -32,6 +32,7 @@ import { HomeComponentsModule } from '@home/components/public-api'; import { CommonRuleNodeConfigModule } from '../common/common-rule-node-config.module'; import { SlackConfigComponent } from './slack-config.component'; import { LambdaConfigComponent } from './lambda-config.component'; +import { AiConfigComponent } from '@home/components/rule-node/external/ai-config.component'; @NgModule({ declarations: [ @@ -47,7 +48,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ], imports: [ CommonModule, @@ -68,7 +70,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ] }) export class ExternalRuleNodeConfigModule { diff --git a/ui-ngx/src/app/modules/home/components/vc/version-control.scss b/ui-ngx/src/app/modules/home/components/vc/version-control.scss index 35a0932f19..f221fc66ad 100644 --- a/ui-ngx/src/app/modules/home/components/vc/version-control.scss +++ b/ui-ngx/src/app/modules/home/components/vc/version-control.scss @@ -16,9 +16,11 @@ :host { .vc-result-message { max-width: 65vw; + max-height: 65vh; padding: 0 8px; text-align: center; word-wrap: break-word; + overflow: auto; &:first-child { padding-top: 48px; } diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index ea49a8b442..bfe14832c4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -146,6 +146,7 @@ export class WidgetActionDialogComponent extends DialogComponent
widgets.radar-chart.radar-axis
+
+
+
+ {{ 'widgets.radar-chart.max-axes-scaling' | translate }} +
+ + {{ 'widgets.radar-chart.separate' | translate }} + {{ 'widgets.radar-chart.common' | translate }} + +
+
{{ 'widgets.radar-chart.axis-label' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts index d66e2fa35c..98b6cf8fbf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts @@ -75,6 +75,7 @@ export class RadarChartBasicConfigComponent extends LatestChartBasicConfigCompon latestChartWidgetConfigForm.addControl('labelColor', this.fb.control(settings.labelColor, [])); latestChartWidgetConfigForm.addControl('fillAreaSettings', this.fb.control(settings.fillAreaSettings, [])); + latestChartWidgetConfigForm.addControl('normalizeAxes', this.fb.control(settings.normalizeAxes, [])); latestChartWidgetConfigForm.addControl('axisShowLabel', this.fb.control(settings.axisShowLabel, [])); latestChartWidgetConfigForm.addControl('axisLabelFont', this.fb.control(settings.axisLabelFont, [])); latestChartWidgetConfigForm.addControl('axisShowTickLabels', this.fb.control(settings.axisShowTickLabels, [])); @@ -97,6 +98,7 @@ export class RadarChartBasicConfigComponent extends LatestChartBasicConfigCompon this.widgetConfig.config.settings.labelColor = config.labelColor; this.widgetConfig.config.settings.fillAreaSettings = config.fillAreaSettings; + this.widgetConfig.config.settings.normalizeAxes = config.normalizeAxes; this.widgetConfig.config.settings.axisShowLabel = config.axisShowLabel; this.widgetConfig.config.settings.axisLabelFont = config.axisLabelFont; this.widgetConfig.config.settings.axisShowTickLabels = config.axisShowTickLabels; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index 61c9f89c7c..5e88f8e794 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html @@ -104,6 +104,7 @@ + [yAxisIds]="yAxisIds" + [supportsUnitConversion]="widgetConfig.typeParameters.supportsUnitConversion">
widget-config.appearance
@@ -311,6 +313,11 @@
+
+ + {{ 'tooltip.hide-zero-tooltip-values' | translate }} + +
{{ 'tooltip.background-color' | translate }}
-
- +
+ widget-config.datasource-type @@ -44,18 +44,20 @@ + class="flex-1" appearance="outline"> @@ -64,6 +66,7 @@
@if (widgetComponent.dashboardWidget.showWidgetTitlePanel) {
- +
} @else { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts index 746daf43b3..14e77ae738 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts @@ -58,6 +58,9 @@ export class BarChartWithLabelsWidgetComponent implements OnInit, OnDestroy, Aft @Input() ctx: WidgetContext; + @Input() + widgetTitlePanel: TemplateRef; + showLegend: boolean; legendClass: string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts index 019c5b86f4..ec42955b4d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts @@ -50,6 +50,7 @@ export interface RadarChartWidgetSettings extends LatestChartWidgetSettings { labelFont: Font; labelColor: string; fillAreaSettings: ChartFillSettings; + normalizeAxes: boolean; axisShowLabel: boolean; axisLabelFont: Font; axisShowTickLabels: boolean; @@ -88,6 +89,7 @@ export const radarChartWidgetDefaultSettings: RadarChartWidgetSettings = { end: 20 } }, + normalizeAxes: false, axisShowLabel: true, axisLabelFont: { family: 'Roboto', @@ -123,6 +125,7 @@ export const radarChartWidgetRadarChartSettings = (settings: RadarChartWidgetSet labelFont: settings.labelFont, labelColor: settings.labelColor, fillAreaSettings: settings.fillAreaSettings, + normalizeAxes: settings.normalizeAxes, axisShowLabel: settings.axisShowLabel, axisLabelFont: settings.axisLabelFont, axisShowTickLabels: settings.axisShowTickLabels, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.models.ts index 04008c8832..9d1dcbde47 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.models.ts @@ -56,6 +56,7 @@ export interface RadarChartSettings extends LatestChartSettings { labelFont: Font; labelColor: string; fillAreaSettings: ChartFillSettings; + normalizeAxes: boolean; axisShowLabel: boolean; axisLabelFont: Font; axisShowTickLabels: boolean; @@ -100,6 +101,7 @@ export const radarChartDefaultSettings: RadarChartSettings = { end: 20 } }, + normalizeAxes: false, axisShowLabel: true, axisLabelFont: { family: 'Roboto', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.ts index 27aa1597bd..0cfc10d4cf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart.ts @@ -31,6 +31,7 @@ import { ComponentStyle } from '@shared/models/widget-settings.models'; import { AreaStyleOption, SeriesLabelOption } from 'echarts/types/src/util/types'; import { RadarIndicatorOption } from 'echarts/types/src/coord/radar/RadarModel'; import { DataKey } from '@shared/models/widget.models'; +import { LatestChartDataItem } from '@home/components/widget/lib/chart/latest-chart.models'; export class TbRadarChart extends TbLatestChart { @@ -141,7 +142,7 @@ export class TbRadarChart extends TbLatestChart { if (dataItem.enabled && dataItem.hasValue) { indicator.push({ name: dataItem.dataKey.label, - color: dataItem.dataKey.color + color: dataItem.dataKey.color, }); value.push(dataItem.value); } @@ -149,10 +150,21 @@ export class TbRadarChart extends TbLatestChart { if (!indicator.length) { indicator.push({}); } + if (this.settings.normalizeAxes && indicator.length > 1) { + const maxDataItem = this.findMaxDataItem(this.dataItems); + indicator.map(value => value.max = maxDataItem.value); + } this.latestChartOption.radar[0].indicator = indicator; this.latestChartOption.series[0].data[0].value = value; } + private findMaxDataItem(array: LatestChartDataItem[]): LatestChartDataItem { + if (!array || array.length === 0) return null; + return array.reduce((maxObj, currentObj) => { + return currentObj.value > maxObj.value ? currentObj : maxObj; + }, array[0]); + } + protected forceRedrawOnResize(): boolean { return true; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html index 39112c484b..fc16fbdc13 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html @@ -19,7 +19,7 @@
@if (widgetComponent.dashboardWidget.showWidgetTitlePanel) {
- +
} @else { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts index a9295e98e4..f471a9e84c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts @@ -23,6 +23,7 @@ import { OnDestroy, OnInit, Renderer2, + TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; @@ -32,7 +33,8 @@ import { ComponentStyle, getDataKey, overlayStyle, - textStyle + textStyle, + ValueFormatProcessor } from '@shared/models/widget-settings.models'; import { isDefinedAndNotNull } from '@core/utils'; import { @@ -48,7 +50,7 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; import { TbTimeSeriesChart } from '@home/components/widget/lib/chart/time-series-chart'; import { WidgetComponent } from '@home/components/widget/widget.component'; -import { TbUnitConverter } from '@shared/models/unit.models'; +import { TbUnit } from '@shared/models/unit.models'; import { UnitService } from '@core/services/unit.service'; @Component({ @@ -67,6 +69,9 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn @Input() ctx: WidgetContext; + @Input() + widgetTitlePanel: TemplateRef; + showLegend: boolean; legendClass: string; @@ -79,8 +84,7 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn visibleRangeItems: RangeItem[]; private decimals = 0; - private units: string = ''; - private unitConvertor: TbUnitConverter; + private units: TbUnit = ''; private rangeItems: RangeItem[]; @@ -99,25 +103,29 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn const unitService = this.ctx.$injector.get(UnitService); this.decimals = this.ctx.decimals; - let units = this.ctx.units; + this.units = this.ctx.units; const dataKey = getDataKey(this.ctx.datasources); if (isDefinedAndNotNull(dataKey?.decimals)) { this.decimals = dataKey.decimals; } if (dataKey?.units) { - units = dataKey.units; + this.units = dataKey.units; } if (dataKey) { dataKey.settings = rangeChartTimeSeriesKeySettings(this.settings); } - this.units = unitService.getTargetUnitSymbol(units); - this.unitConvertor = unitService.geUnitConverter(units); + + const valueFormat = ValueFormatProcessor.fromSettings(this.ctx.$injector, { + units: this.units, + decimals: this.decimals, + ignoreUnitSymbol: true + }); 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; - this.rangeItems = toRangeItems(this.settings.rangeColors, this.unitConvertor); + this.rangeItems = toRangeItems(this.settings.rangeColors, valueFormat); this.visibleRangeItems = this.rangeItems.filter(item => item.visible); this.showLegend = this.settings.showLegend && !!this.rangeItems.length; @@ -131,7 +139,7 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn } ngAfterViewInit() { - const settings = rangeChartTimeSeriesSettings(this.settings, this.rangeItems, this.decimals, this.units, this.unitConvertor); + const settings = rangeChartTimeSeriesSettings(this.settings, this.rangeItems, this.decimals, this.units); this.timeSeriesChart = new TbTimeSeriesChart(this.ctx, settings, this.chartShape.nativeElement, this.renderer); } 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 7134a924fe..de59cc7fa3 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 @@ -22,6 +22,7 @@ import { Font, simpleDateFormat, sortedColorRange, + ValueFormatProcessor, ValueSourceType } from '@shared/models/widget-settings.models'; import { LegendPosition } from '@shared/models/widget.models'; @@ -56,6 +57,7 @@ import { import { TimeSeriesChartTooltipWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; +import { TbUnit } from '@shared/models/unit.models'; export interface RangeItem { index: number; @@ -220,13 +222,13 @@ export const rangeChartDefaultSettings: RangeChartWidgetSettings = { }; export const rangeChartTimeSeriesSettings = (settings: RangeChartWidgetSettings, rangeItems: RangeItem[], - decimals: number, units: string, valueConvertor: (x: number) => number): DeepPartial => { + decimals: number, units: TbUnit): DeepPartial => { let thresholds: DeepPartial[] = settings.showRangeThresholds ? getMarkPoints(rangeItems).map(item => ({ ...{type: ValueSourceType.constant, yAxisId: 'default', units, decimals, - value: valueConvertor(item)}, + value: item}, ...settings.rangeThreshold } as DeepPartial)) : []; if (settings.thresholds?.length) { @@ -239,10 +241,8 @@ export const rangeChartTimeSeriesSettings = (settings: RangeChartWidgetSettings, yAxes: { default: { ...settings.yAxis, - ...{ - decimals, - units - } + decimals, + units } }, xAxis: settings.xAxis, @@ -291,21 +291,22 @@ export const rangeChartTimeSeriesKeySettings = (settings: RangeChartWidgetSettin } }); -export const toRangeItems = (colorRanges: Array, convertValue: (x: number) => number): RangeItem[] => { +export const toRangeItems = (colorRanges: Array, valueFormat: ValueFormatProcessor): RangeItem[] => { const rangeItems: RangeItem[] = []; let counter = 0; const ranges = sortedColorRange(filterIncludingColorRanges(colorRanges)).filter(r => isNumber(r.from) || isNumber(r.to)); for (let i = 0; i < ranges.length; i++) { const range = ranges[i]; let from = range.from; - const to = isDefinedAndNotNull(range.to) ? convertValue(range.to) : range.to; + const to = range.to; if (i > 0) { const prevRange = ranges[i - 1]; if (isNumber(prevRange.to) && isNumber(from) && from < prevRange.to) { from = prevRange.to; } } - from = isDefinedAndNotNull(from) ? convertValue(from) : from; + const formatToValue = isDefinedAndNotNull(to) ? Number(valueFormat.format(to)) : to; + const formatFromValue = isDefinedAndNotNull(from) ? Number(valueFormat.format(from)) : from; rangeItems.push( { index: counter++, @@ -314,12 +315,12 @@ export const toRangeItems = (colorRanges: Array, convertValue: (x: n visible: true, from, to, - label: rangeItemLabel(from, to), - piece: createTimeSeriesChartVisualMapPiece(range.color, from, to) + label: rangeItemLabel(formatFromValue, formatToValue), + piece: createTimeSeriesChartVisualMapPiece(range.color, formatFromValue, formatToValue) } ); if (!isNumber(from) || !isNumber(to)) { - const value = !isNumber(from) ? to : from; + const value = !isNumber(from) ? formatToValue : formatFromValue; rangeItems.push( { index: counter++, 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 index ee4e5b4c70..f8ead65e30 100644 --- 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 @@ -17,9 +17,7 @@ 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 { 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'; @@ -39,6 +37,7 @@ export interface TimeSeriesChartTooltipWidgetSettings { tooltipValueFormatter?: string | TimeSeriesChartTooltipValueFormatFunction; tooltipShowDate: boolean; tooltipDateInterval?: boolean; + tooltipHideZeroValues?: boolean; tooltipDateFormat: DateFormatSettings; tooltipDateFont: Font; tooltipDateColor: string; @@ -103,6 +102,9 @@ export class TimeSeriesChartTooltip { if (!tooltipParams.items.length && !tooltipParams.comparisonItems.length) { return null; } + if (this.settings.tooltipHideZeroValues && !tooltipParams.items.some(value => value.param.value[1] && value.param.value[1] !== 'false')) { + return undefined; + } const tooltipElement: HTMLElement = this.renderer.createElement('div'); this.renderer.setStyle(tooltipElement, 'display', 'flex'); @@ -129,7 +131,9 @@ export class TimeSeriesChartTooltip { this.renderer.appendChild(tooltipItemsElement, this.constructTooltipDateElement(items[0].param, interval)); } for (const item of items) { - this.renderer.appendChild(tooltipItemsElement, this.constructTooltipSeriesElement(item)); + if (!this.settings.tooltipHideZeroValues || (item.param.value[1] && item.param.value[1] !== 'false')) { + this.renderer.appendChild(tooltipItemsElement, this.constructTooltipSeriesElement(item)); + } } } } 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 1efcf30781..fe7335c92d 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 @@ -19,7 +19,7 @@
@if (widgetComponent.dashboardWidget.showWidgetTitlePanel) {
- +
} @else { 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 2fd12b1b98..2e9f297b4a 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 @@ -61,6 +61,9 @@ export class TimeSeriesChartWidgetComponent implements OnInit, OnDestroy, AfterV @Input() ctx: WidgetContext; + @Input() + widgetTitlePanel: TemplateRef; + horizontalLegendPosition = false; showLegend: boolean; 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 b29f809fef..86d952f2e2 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 @@ -98,7 +98,7 @@ import { TimeSeriesChartTooltipValueFormatFunction, TimeSeriesChartTooltipWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; -import { TbUnitConverter } from '@shared/models/unit.models'; +import { TbUnit, TbUnitConverter } from '@shared/models/unit.models'; type TimeSeriesChartDataEntry = [number, any, number, number]; @@ -377,7 +377,7 @@ export type TimeSeriesChartTicksFormatter = export interface TimeSeriesChartYAxisSettings extends TimeSeriesChartAxisSettings { id?: TimeSeriesChartYAxisId; order?: number; - units?: string; + units?: TbUnit; decimals?: number; interval?: number; splitNumber?: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/digital-gauge.ts b/ui-ngx/src/app/modules/home/components/widget/lib/digital-gauge.ts index 81ab5d6bd9..f88be8783b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/digital-gauge.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/digital-gauge.ts @@ -125,6 +125,7 @@ export class TbCanvasDigitalGauge { this.barColorProcessor = ColorProcessor.fromSettings(settings.barColor, this.ctx); this.valueFormat = ValueFormatProcessor.fromSettings(this.ctx.$injector, { units: this.localSettings.units, + decimals: this.localSettings.decimals, ignoreUnitSymbol: true }); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index 93d9d09b6d..f95c8c26d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -167,7 +167,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni private defaultPageSize; private defaultSortOrder = 'entityName'; - private contentsInfo: {[key: string]: CellContentInfo} = {}; + private contentsInfo: {[key: string]: WithOptional} = {}; private stylesInfo: {[key: string]: Observable} = {}; private columnWidth: {[key: string]: string} = {}; private columnDefaultVisibility: {[key: string]: boolean} = {}; @@ -760,7 +760,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni return content$; } - private defaultContent(key: EntityColumn, contentInfo: CellContentInfo, value: any): any { + private defaultContent(key: EntityColumn, contentInfo: WithOptional, value: any): any { if (isDefined(value)) { const entityField = entityFields[key.name]; if (entityField) { @@ -768,7 +768,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni return this.datePipe.transform(value, 'yyyy-MM-dd HH:mm:ss'); } } - return contentInfo.valueFormat.format(value); + return contentInfo.valueFormat?.format(value) ?? value; } else { return ''; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 5dcb80ec41..4380f36186 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -57,7 +57,7 @@ export class DataLayerPatternProcessor { public setup(): Observable { if (this.settings.type === DataLayerPatternType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, this.settings.patternFunction, ['data', 'dsData']).pipe( + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.patternFunction, ['data', 'dsData', 'ctx']).pipe( map((parsed) => { this.patternFunction = parsed; return null; @@ -72,7 +72,7 @@ export class DataLayerPatternProcessor { public processPattern(data: FormattedData, dsData: FormattedData[]): string { let pattern: string; if (this.settings.type === DataLayerPatternType.function) { - pattern = safeExecuteTbFunction(this.patternFunction, [data, dsData]); + pattern = safeExecuteTbFunction(this.patternFunction, [data, dsData, this.dataLayer.getCtx()]); } else { pattern = this.pattern; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html index e64ee71170..c0aceeabb0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html @@ -19,7 +19,7 @@
@if (widgetComponent.dashboardWidget.showWidgetTitlePanel) {
- +
} @else { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index 4ef3c3cd45..013a6c88a9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -21,6 +21,7 @@ import { Input, OnDestroy, OnInit, + TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; @@ -54,6 +55,9 @@ export class MapWidgetComponent implements OnInit, OnDestroy { @Input() ctx: WidgetContext; + @Input() + widgetTitlePanel: TemplateRef; + backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; padding: string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html index aa859b2557..a194812778 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html @@ -27,9 +27,6 @@
-
- -
{{ title }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts index 6dedab0bf2..f28b5e8f92 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts @@ -14,37 +14,27 @@ /// limitations under the License. /// -import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { PageComponent } from '@shared/components/page.component'; +import { ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { WidgetContext } from '@home/models/widget-component.models'; -import { UtilsService } from '@core/services/utils.service'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { isDefined, isNumber } from '@core/utils'; +import { deepClone, isDefined } from '@core/utils'; import { CanvasDigitalGaugeOptions } from '@home/components/widget/lib/canvas-digital-gauge'; import tinycolor from 'tinycolor2'; -import { ColorProcessor, gradientColor } from '@shared/models/widget-settings.models'; -import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; +import { ColorProcessor, gradientColor, ValueFormatProcessor } from '@shared/models/widget-settings.models'; +import { + KnobSettings, + knobWidgetDefaultSettings, + prepareKnobSettings +} from '@shared/models/widget/rpc/knob.component.models'; +import { ValueType } from '@shared/models/constants'; +import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; import GenericOptions = CanvasGauges.GenericOptions; -interface KnobSettings { - minValue: number; - maxValue: number; - initialValue: number; - title: string; - getValueMethod: string; - setValueMethod: string; - requestTimeout: number; - requestPersistent: boolean; - persistentPollingInterval: number; -} - @Component({ selector: 'tb-knob', templateUrl: './knob.component.html', styleUrls: ['./knob.component.scss'] }) -export class KnobComponent extends PageComponent implements OnInit, OnDestroy { +export class KnobComponent extends BasicActionWidgetComponent implements OnInit, OnDestroy { @ViewChild('knob', {static: true}) knobRef: ElementRef; @ViewChild('knobContainer', {static: true}) knobContainerRef: ElementRef; @@ -52,8 +42,6 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { @ViewChild('knobTopPointer', {static: true}) knobTopPointerRef: ElementRef; @ViewChild('knobValueContainer', {static: true}) knobValueContainerRef: ElementRef; @ViewChild('knobValue', {static: true}) knobValueRef: ElementRef; - @ViewChild('knobErrorContainer', {static: true}) knobErrorContainerRef: ElementRef; - @ViewChild('knobError', {static: true}) knobErrorRef: ElementRef; @ViewChild('knobTitleContainer', {static: true}) knobTitleContainerRef: ElementRef; @ViewChild('knobTitle', {static: true}) knobTitleRef: ElementRef; @ViewChild('knobMinmaxContainer', {static: true}) knobMinmaxContainerRef: ElementRef; @@ -64,12 +52,12 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { ctx: WidgetContext; value = '0'; - error = ''; title = ''; minValue: number; maxValue: number; newValue = 0; + private decimals: number; private startDeg = -1; private currentDeg = 0; private rotation = 0; @@ -79,13 +67,6 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { private minDeg = -45; private maxDeg = 225; - private isSimulated: boolean; - private requestTimeout: number; - private requestPersistent: boolean; - private persistentPollingInterval: number; - private getValueMethod: string; - private setValueMethod: string; - private executingUpdateValue: boolean; private scheduledValue: number; private rpcValue: number; @@ -98,8 +79,6 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { private knobValue: JQuery; private knobTitleContainer: JQuery; private knobTitle: JQuery; - private knobErrorContainer: JQuery; - private knobError: JQuery; private knobMinmaxContainer: JQuery; private minmaxLabel: JQuery; private textMeasure: JQuery; @@ -109,9 +88,11 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { private knobResize$: ResizeObserver; - constructor(private utils: UtilsService, - protected store: Store) { - super(store); + private valueSetter: ValueSetter; + private valueFormat: ValueFormatProcessor; + + constructor(protected cd: ChangeDetectorRef) { + super(cd); } ngOnInit(): void { @@ -123,8 +104,6 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { this.knobValue = $(this.knobValueRef.nativeElement); this.knobTitleContainer = $(this.knobTitleContainerRef.nativeElement); this.knobTitle = $(this.knobTitleRef.nativeElement); - this.knobErrorContainer = $(this.knobErrorContainerRef.nativeElement); - this.knobError = $(this.knobErrorRef.nativeElement); this.knobMinmaxContainer = $(this.knobMinmaxContainerRef.nativeElement); this.minmaxLabel = this.knobMinmaxContainer.find('.minmax-label'); this.textMeasure = $(this.textMeasureRef.nativeElement); @@ -145,7 +124,32 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { } private init() { - const settings: KnobSettings = this.ctx.settings; + const settings: KnobSettings = prepareKnobSettings({...deepClone(knobWidgetDefaultSettings), ...this.ctx.settings}); + + let initialValue = isDefined(settings.initialValue) ? settings.initialValue : this.minValue; + + const getInitialStateSettings = + {...settings.initialState, actionLabel: this.ctx.translate.instant('widgets.slider.initial-value')}; + this.createValueGetter(getInitialStateSettings, ValueType.DOUBLE, { + next: (value) => { + if (this.canvasBar) { + this.setValue(value); + } else { + initialValue = value; + } + } + }); + + const valueChangeSettings = {...settings.valueChange, + actionLabel: this.ctx.translate.instant('widgets.slider.on-value-change')}; + this.valueSetter = this.createValueSetter(valueChangeSettings); + + this.decimals = isDefined(this.ctx.decimals) ? this.ctx.decimals : 0; + this.valueFormat = ValueFormatProcessor.fromSettings(this.ctx.$injector, { + units: this.ctx.units, + decimals: this.decimals, + showZeroDecimals: true + }); this.minValue = isDefined(settings.minValue) ? settings.minValue : 0; this.maxValue = isDefined(settings.maxValue) ? settings.maxValue : 100; @@ -279,44 +283,10 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { }); - const subscription = this.ctx.defaultSubscription; - const rpcEnabled = subscription.rpcEnabled; - - this.isSimulated = this.utils.widgetEditMode; - - this.requestTimeout = 500; - if (settings.requestTimeout) { - this.requestTimeout = settings.requestTimeout; - } - this.requestPersistent = false; - if (settings.requestPersistent) { - this.requestPersistent = settings.requestPersistent; - } - this.persistentPollingInterval = 5000; - if (settings.persistentPollingInterval) { - this.persistentPollingInterval = settings.persistentPollingInterval; - } - this.getValueMethod = 'getValue'; - if (settings.getValueMethod && settings.getValueMethod.length) { - this.getValueMethod = settings.getValueMethod; - } - this.setValueMethod = 'setValue'; - if (settings.setValueMethod && settings.setValueMethod.length) { - this.setValueMethod = settings.setValueMethod; - } - import('@home/components/widget/lib/canvas-digital-gauge').then( (gauge) => { this.canvasBar = new gauge.CanvasDigitalGauge(canvasBarData).draw(); - const initialValue = isDefined(settings.initialValue) ? settings.initialValue : this.minValue; this.setValue(initialValue); - if (!rpcEnabled) { - this.onError('Target device is not set!'); - } else { - if (!this.isSimulated) { - this.rpcRequestValue(); - } - } } ); @@ -331,7 +301,7 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { } private turn(ratio: number) { - this.newValue = Number((this.minValue + (this.maxValue - this.minValue) * ratio).toFixed(this.ctx.decimals)); + this.newValue = Number((this.minValue + (this.maxValue - this.minValue) * ratio).toFixed(this.decimals)); if (this.canvasBar.value !== this.newValue) { this.canvasBar.value = this.newValue; } @@ -348,7 +318,6 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { this.canvasBar.update({width: size, height: size} as GenericOptions); } this.setFontSize(this.knobTitle, this.title, this.knobTitleContainer.height(), this.knobTitleContainer.width()); - this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width()); const minmaxHeight = this.knobMinmaxContainer.height(); this.minmaxLabel.css({fontSize: minmaxHeight + 'px', lineHeight: minmaxHeight + 'px'}); this.checkValueSize(); @@ -408,27 +377,7 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { } private formatValue(value: any): string { - return this.ctx.utils.formatValue(value, this.ctx.decimals, getSourceTbUnitSymbol(this.ctx.units), true); - } - - private rpcRequestValue() { - this.error = ''; - this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout, - this.requestPersistent, this.persistentPollingInterval).subscribe( - (responseBody) => { - if (isNumber(responseBody)) { - const numValue = Number(Number(responseBody).toFixed(this.ctx.decimals)); - this.setValue(numValue); - } else { - const errorText = `Unable to parse response: ${responseBody}`; - this.onError(errorText); - } - }, - () => { - const errorText = this.ctx.defaultSubscription.rpcErrorText; - this.onError(errorText); - } - ); + return this.valueFormat.format(value); } private rpcUpdateValue(value: number) { @@ -440,27 +389,19 @@ export class KnobComponent extends PageComponent implements OnInit, OnDestroy { this.rpcValue = value; this.executingUpdateValue = true; } - this.error = ''; - this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, value, this.requestTimeout, - this.requestPersistent, this.persistentPollingInterval).subscribe( - () => { - this.executingUpdateValue = false; - if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { - this.rpcUpdateValue(this.scheduledValue); + if (!this.ctx.isEdit && !this.ctx.isPreview) { + this.updateValue(this.valueSetter, value, { + next: () => { + this.executingUpdateValue = false; + if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { + this.rpcUpdateValue(this.scheduledValue); + } + }, + error: () => { + this.executingUpdateValue = false; } - }, - () => { - this.executingUpdateValue = false; - const errorText = this.ctx.defaultSubscription.rpcErrorText; - this.onError(errorText); - } - ); - } - - private onError(error: string) { - this.error = error; - this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width()); - this.ctx.detectChanges(); + }); + } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index 7bd835de88..802668ed95 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts @@ -43,7 +43,6 @@ import { import { createLabelFromSubscriptionEntityInfo, deepClone, - formatValue, guid, isDefinedAndNotNull, isFirefox, @@ -53,12 +52,19 @@ import { isUndefinedOrNull, mergeDeep, mergeDeepIgnoreArray, + objectHashCode, parseFunction } from '@core/utils'; import { BehaviorSubject, forkJoin, Observable, Observer, of, Subject } from 'rxjs'; import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; -import { ColorProcessor, constantColor, Font } from '@shared/models/widget-settings.models'; +import { + ColorProcessor, + constantColor, + Font, + ValueFormatProcessor, + ValueFormatSettings +} from '@shared/models/widget-settings.models'; import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetAction, WidgetActionType, widgetActionTypeTranslationMap } from '@shared/models/widget.models'; @@ -72,10 +78,12 @@ import { FormProperty, FormPropertyType } from '@shared/models/dynamic-form.models'; +import { TbUnit } from '@shared/models/unit.models'; export interface ScadaSymbolApi { generateElementId: () => string; - formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; + formatValue(value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined; + formatValue(value: any, settings: ValueFormatSettings): string; text: (element: Element | Element[], text: string) => void; font: (element: Element | Element[], font: Font, color: string) => void; icon: (element: Element | Element[], icon: string, size?: number, color?: string, center?: boolean) => void; @@ -91,6 +99,8 @@ export interface ScadaSymbolApi { enable: (element: Element | Element[]) => void; callAction: (event: Event, behaviorId: string, value?: any, observer?: Partial>) => void; setValue: (valueId: string, value: any) => void; + unitSymbol: (unit: TbUnit) => string; + convertUnitValue: (value: any, unit: TbUnit) => number; } export interface ScadaSymbolContext { @@ -502,6 +512,8 @@ export class ScadaSymbolObject { private stateValueSubjects: {[id: string]: BehaviorSubject} = {}; + private valueProcessor: {[id: string]: ValueFormatProcessor} = {}; + private readonly shapeResize$: ResizeObserver; private readonly destroy$ = new Subject(); @@ -615,7 +627,7 @@ export class ScadaSymbolObject { this.context = { api: { generateElementId: () => generateElementId(), - formatValue, + formatValue: this.formatValue.bind(this), text: this.setElementText.bind(this), font: this.setElementFont.bind(this), icon: this.setElementIcon.bind(this), @@ -631,6 +643,8 @@ export class ScadaSymbolObject { enable: this.enableElement.bind(this), callAction: this.callAction.bind(this), setValue: this.setValue.bind(this), + unitSymbol: this.unitSymbol.bind(this), + convertUnitValue: this.convertUnitValue.bind(this), }, tags: {}, properties: {}, @@ -810,6 +824,34 @@ export class ScadaSymbolObject { } } + private unitSymbol(unit: TbUnit): string { + return this.ctx.unitService.getTargetUnitSymbol(unit); + } + + private convertUnitValue(value: number, unit: TbUnit): number { + return this.ctx.unitService.convertUnitValue(value, unit); + } + + private formatValue(value: any, settings: ValueFormatSettings): string; + private formatValue(value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined; + private formatValue(value: any, settingsOrDec?: ValueFormatSettings | number, units?: string, showZeroDecimals?: boolean): string { + let valueFormatSettings: ValueFormatSettings; + if (typeof settingsOrDec === 'object') { + valueFormatSettings = deepClone(settingsOrDec); + } else { + valueFormatSettings = { + units, + decimals: settingsOrDec, + showZeroDecimals + } + } + const id = objectHashCode(valueFormatSettings) + ''; + if (!this.valueProcessor[id]) { + this.valueProcessor[id] = ValueFormatProcessor.fromSettings(this.ctx.$injector, valueFormatSettings); + } + return this.valueProcessor[id].format(value); + } + private onStateValueChanged(id: string, value: any) { if (this.context.values[id] !== value) { this.context.values[id] = value; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html index 04476e69e2..fd5403984d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html @@ -434,6 +434,17 @@
widgets.radar-chart.radar-axis
+
+
+
+ {{ 'widgets.radar-chart.max-axes-scaling' | translate }} +
+ + {{ 'widgets.radar-chart.separate' | translate }} + {{ 'widgets.radar-chart.common' | translate }} + +
+
{{ 'widgets.radar-chart.axis-label' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts index 5e43b62983..94f93984c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts @@ -65,6 +65,7 @@ export class RadarChartWidgetSettingsComponent extends LatestChartWidgetSettings latestChartWidgetSettingsForm.addControl('labelColor', this.fb.control(settings.labelColor, [])); latestChartWidgetSettingsForm.addControl('fillAreaSettings', this.fb.control(settings.fillAreaSettings, [])); + latestChartWidgetSettingsForm.addControl('normalizeAxes', this.fb.control(settings.normalizeAxes, [])); latestChartWidgetSettingsForm.addControl('axisShowLabel', this.fb.control(settings.axisShowLabel, [])); latestChartWidgetSettingsForm.addControl('axisLabelFont', this.fb.control(settings.axisLabelFont, [])); latestChartWidgetSettingsForm.addControl('axisShowTickLabels', this.fb.control(settings.axisShowTickLabels, [])); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html index 9bbe3f6ec6..f1554fef22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html @@ -63,6 +63,7 @@ + [yAxisIds]="yAxisIds" + [supportsUnitConversion]="widgetConfig.typeParameters.supportsUnitConversion">
widgets.time-series-chart.chart-style
@@ -235,6 +237,11 @@
+
+ + {{ 'tooltip.hide-zero-tooltip-values' | translate }} + +
{{ 'tooltip.background-color' | translate }}
-
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss index 002ccbeeb3..f0e09589ea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss @@ -19,25 +19,66 @@ padding: 8px; background-color: #fff; + .css-panel, .html-panel{ + border-color: #c0c0c0; + border-width: 0 1px 1px 1px; + border-style: solid; + } + + .tb-js-func-toolbar{ + padding: 0 3px; + } + + .tb-js-func { + &:not(.tb-fullscreen) { + &.tb-hide-brackets { + padding-bottom: 0; + } + } + } + .tb-fullscreen-panel { .tb-custom-action-editor-container { + height: 100%; + } + + .css-panel, .html-panel{ + border: none; + } + + .left-panel, .right-panel{ height: calc(100% - 40px); } .right-panel { - padding-top: 8px; - padding-left: 3px; + padding: 8px 0 0; } - tb-js-func .tb-js-func-panel { - box-sizing: border-box; + .tb-js-func { + .tb-js-func-panel { + box-sizing: border-box; + } + &.fill-height { + .tb-js-func-toolbar{ + padding: 0 5px; + } + + &.tb-hide-brackets { + .tb-js-func-panel { + border: none; + border-top: 1px solid #c0c0c0; + } + } + } } .mat-mdc-tab-group { .mat-mdc-tab-body-wrapper { height: 100%; + .mat-mdc-tab-body { height: 100%; + & > div { height: 100%; } @@ -70,7 +111,7 @@ .tb-split.tb-split-horizontal, .gutter.gutter-horizontal { float: left; - height: 100%; + height: calc(100% - 40px); } .tb-action-expand-button { @@ -81,20 +122,7 @@ &.tb-fullscreen-editor { position: relative; right: 0; - /* .mat-mdc-button { - .mat-icon { - margin-right: 5px; - } - } */ } - - /* .mat-mdc-button { - min-width: 36px; - padding: 0; - .mat-icon { - margin-right: 0; - } - } */ } .tb-custom-action-editor { @@ -105,3 +133,4 @@ } + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html index c017d3bc0a..bc7185c476 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html @@ -89,7 +89,8 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts index c57b5b2557..1d7497f70a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts @@ -106,6 +106,10 @@ export class TimeSeriesChartThresholdRowComponent implements ControlValueAccesso @coerceBoolean() hideYAxis = false; + @Input() + @coerceBoolean() + supportsUnitConversion = false; + @Output() thresholdRemoved = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html index 8c5c1dd92f..efd87a4d76 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html @@ -33,6 +33,7 @@ [formControl]="thresholdControl" [hideYAxis]="hideYAxis" [yAxisIds]="yAxisIds" + [supportsUnitConversion]="supportsUnitConversion" (thresholdRemoved)="removeThreshold($index)"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts index 4a9a01edf0..9d07ecda88 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts @@ -84,6 +84,10 @@ export class TimeSeriesChartThresholdsPanelComponent implements ControlValueAcce @coerceBoolean() hideYAxis = false; + @Input() + @coerceBoolean() + supportsUnitConversion = true; + thresholdsFormGroup: UntypedFormGroup; private propagateChange = (_val: any) => {}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html index 533ee3fb05..c88dc9c4b9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html @@ -39,6 +39,7 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts index 9c210fc28d..12ac24f9e3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts @@ -75,6 +75,10 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, @coerceBoolean() advanced = false; + @Input() + @coerceBoolean() + supportsUnitConversion = false; + @Output() axisRemoved = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html index bfa10c68d1..420e834106 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html @@ -41,7 +41,7 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts index dec7148b30..ebc24ddd22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts @@ -69,6 +69,10 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O @coerceBoolean() advanced = false; + @Input() + @coerceBoolean() + supportsUnitConversion = false; + @Output() axisRemoved = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html index 451d3f7282..be569813dc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -44,7 +44,7 @@ required withModules [globalVariables]="functionScopeVariables" - [functionArgs]="['data', 'dsData']" + [functionArgs]="['data', 'dsData', 'ctx']" functionTitle="{{ (patternType === 'label' ? 'widgets.maps.data-layer.label-function' : 'widgets.maps.data-layer.tooltip-function') | translate }}" [helpId]="helpId" [helpPopupStyle]="{width: '900px'}"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html index 6d2dfe2187..5016361604 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html @@ -15,66 +15,66 @@ limitations under the License. --> -
-
- widgets.rpc.common-settings - - widgets.rpc.knob-title - - -
-
- widgets.rpc.value-settings - - widgets.rpc.initial-value - - -
- - widgets.rpc.min-value - + +
+
widgets.knob.behavior
+
+
widgets.knob.initial-value
+ +
+
+
widgets.knob.on-value-change
+ +
+
+
+
widget-config.appearance
+
+
widgets.rpc.knob-title
+ + - - widgets.rpc.max-value - +
+
+
{{ 'widgets.knob.range' | translate }}
+
+
widgets.knob.min
+ + + +
widgets.knob.max
+ + + +
+
+
+
{{ 'widgets.knob.value' | translate }}
+
+ + + +
widget-config.decimals-suffix
+
+
+
+
+
widgets.knob.fallback-initial-value
+ + -
- - widgets.rpc.get-value-method - - - - widgets.rpc.set-value-method - - -
-
- widgets.rpc.rpc-settings - - widgets.rpc.request-timeout - - -
- widgets.rpc.persistent-rpc-settings - - - - - {{ 'widgets.rpc.request-persistent' | translate }} - - - - widget-config.advanced-settings - - - - - widgets.rpc.persistent-polling-interval - - - - -
-
-
+
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts index 5ef0bebc73..26a5d6cd4e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts @@ -15,10 +15,13 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { knobWidgetDefaultSettings, prepareKnobSettings } from '@shared/models/widget/rpc/knob.component.models'; +import { ValueType } from '@shared/models/constants'; +import { deepClone } from '@core/utils'; @Component({ selector: 'tb-knob-control-widget-settings', @@ -27,6 +30,16 @@ import { AppState } from '@core/core.state'; }) export class KnobControlWidgetSettingsComponent extends WidgetSettingsComponent { + get targetDevice(): TargetDevice { + return this.widgetConfig?.config?.targetDevice; + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + + valueType = ValueType; + knobControlWidgetSettingsForm: UntypedFormGroup; constructor(protected store: Store, @@ -39,17 +52,25 @@ export class KnobControlWidgetSettingsComponent extends WidgetSettingsComponent } protected defaultSettings(): WidgetSettings { - return { - title: '', - minValue: 0, - maxValue: 100, - initialValue: 50, - getValueMethod: 'getValue', - setValueMethod: 'setValue', - requestTimeout: 500, - requestPersistent: false, - persistentPollingInterval: 5000 - }; + return knobWidgetDefaultSettings; + } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + const knobSettings = prepareKnobSettings(deepClone(settings) as any) as WidgetSettings; + knobSettings.valueDecimals = this.widgetConfig?.config?.decimals; + knobSettings.valueUnits = deepClone(this.widgetConfig?.config?.units); + return super.prepareInputSettings(knobSettings); + } + + protected prepareOutputSettings(settings: any): WidgetSettings { + const newSettings = deepClone(settings); + if (this.widgetConfig?.config) { + this.widgetConfig.config.units = settings.valueUnits; + this.widgetConfig.config.decimals = settings.valueDecimals; + } + delete newSettings.valueUnits; + delete newSettings.valueDecimals; + return super.prepareOutputSettings(newSettings); } protected onSettingsSet(settings: WidgetSettings) { @@ -61,35 +82,16 @@ export class KnobControlWidgetSettingsComponent extends WidgetSettingsComponent // Value settings - initialValue: [settings.initialValue, []], + initialState: [settings.initialState, []], + valueChange: [settings.valueChange, []], + minValue: [settings.minValue, [Validators.required]], maxValue: [settings.maxValue, [Validators.required]], - getValueMethod: [settings.getValueMethod, [Validators.required]], - setValueMethod: [settings.setValueMethod, [Validators.required]], - - // RPC settings - - requestTimeout: [settings.requestTimeout, [Validators.min(0), Validators.required]], - - // --> Persistent RPC settings + valueUnits: [settings.valueUnits, []], + valueDecimals: [settings.valueDecimals, []], + initialValue: [settings.initialValue, []], - requestPersistent: [settings.requestPersistent, []], - persistentPollingInterval: [settings.persistentPollingInterval, [Validators.min(1000)]] }); } - - protected validatorTriggers(): string[] { - return ['requestPersistent']; - } - - protected updateValidators(emitEvent: boolean): void { - const requestPersistent: boolean = this.knobControlWidgetSettingsForm.get('requestPersistent').value; - if (requestPersistent) { - this.knobControlWidgetSettingsForm.get('persistentPollingInterval').enable({emitEvent}); - } else { - this.knobControlWidgetSettingsForm.get('persistentPollingInterval').disable({emitEvent}); - } - this.knobControlWidgetSettingsForm.get('persistentPollingInterval').updateValueAndValidity({emitEvent: false}); - } } 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 7276c0e27d..1f3ae36089 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 @@ -37,7 +37,7 @@ import { digitalGaugeLayoutTranslations, DigitalGaugeType } from '@home/components/widget/lib/digital-gauge.models'; -import { formatValue } from '@core/utils'; +import { formatValue, isDefined } from '@core/utils'; import { ColorSettings, ColorType, @@ -247,6 +247,10 @@ export class DigitalGaugeWidgetSettingsComponent extends WidgetSettingsComponent settings.titleFont.color = this.digitalGaugeWidgetSettingsForm.get('titleColor').value; settings.labelFont.color = this.digitalGaugeWidgetSettingsForm.get('labelColor').value; + if (isDefined(settings.decimals)) { + delete settings.decimals; + } + return settings; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index ca57a9e26a..7e91a2f043 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -107,7 +107,7 @@ export interface CellContentFunctionInfo { export interface CellContentInfo { contentFunction: Observable; - valueFormat?: ValueFormatProcessor + valueFormat: ValueFormatProcessor } export type CellStyleFunction = (...args: any[]) => any; diff --git a/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts b/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts index 44a687ef35..168c56cef7 100644 --- a/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts +++ b/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts @@ -15,7 +15,7 @@ /// import { PageLink } from '@shared/models/page/page-link'; -import { BehaviorSubject, Observable, of, ReplaySubject } from 'rxjs'; +import { BehaviorSubject, Observable, of, ReplaySubject, Subscription } from 'rxjs'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { BaseData, HasId } from '@shared/models/base-data'; import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; @@ -28,6 +28,7 @@ export class EntitiesDataSource, P extends PageLink = private entitiesSubject = new BehaviorSubject([]); private pageDataSubject = new BehaviorSubject>(emptyPageData()); + private currentLoadSubscription: Subscription = null; public pageData$ = this.pageDataSubject.asObservable(); @@ -58,9 +59,12 @@ export class EntitiesDataSource, P extends PageLink = } loadEntities(pageLink: P): Observable> { + if (this.currentLoadSubscription) { + this.currentLoadSubscription.unsubscribe(); + } this.dataLoading = true; const result = new ReplaySubject>(); - this.fetchFunction(pageLink).pipe( + this.currentLoadSubscription = this.fetchFunction(pageLink).pipe( tap(() => { this.selection.clear(); }), diff --git a/ui-ngx/src/app/modules/home/models/services.map.ts b/ui-ngx/src/app/modules/home/models/services.map.ts index e4217b726b..ebccba900c 100644 --- a/ui-ngx/src/app/modules/home/models/services.map.ts +++ b/ui-ngx/src/app/modules/home/models/services.map.ts @@ -54,6 +54,7 @@ import { EventService } from '@core/http/event.service'; import { UnitService } from '@core/services/unit.service'; import { AuditLogService } from '@core/http/audit-log.service'; import { TrendzSettingsService } from '@core/http/trendz-settings.service'; +import { AiModelService } from '@core/http/ai-model.service'; export const ServicesMap = new Map>( [ @@ -95,6 +96,7 @@ export const ServicesMap = new Map>( ['eventService', EventService], ['unitService', UnitService], ['auditLogService', AuditLogService], - ['trendzSettingsService', TrendzSettingsService] + ['trendzSettingsService', TrendzSettingsService], + ['aiModelService', AiModelService] ] ); 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 8d2622ebea..4a22129ba8 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 @@ -118,6 +118,7 @@ import { CompiledTbFunction } from '@shared/models/js-function.models'; import { FormProperty } from '@shared/models/dynamic-form.models'; import { ExportableEntity } from '@shared/models/base-data'; import { TbUnit } from '@shared/models/unit.models'; +import { UnitService } from '@core/services/unit.service'; export interface IWidgetAction { name: string; @@ -225,6 +226,7 @@ export class WidgetContext { userSettingsService: UserSettingsService; utilsService: UtilsService; telemetryWsService: TelemetryWebsocketService; + unitService: UnitService; telemetrySubscribers?: Array; date: DatePipe; imagePipe: ImagePipe; 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 2836224a9a..2831197a4d 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 @@ -47,6 +47,7 @@ import { MenuId } from '@core/services/menu.models'; import { catchError } from 'rxjs/operators'; import { JsLibraryTableConfigResolver } from '@home/pages/admin/resource/js-library-table-config.resolver'; import { TrendzSettingsComponent } from '@home/pages/admin/trendz-settings.component'; +import { aiModelRoutes } from '@home/pages/ai-model/ai-model-routing.module'; export const scadaSymbolResolver: ResolveFn = (route: ActivatedRouteSnapshot, @@ -362,6 +363,7 @@ const routes: Routes = [ } } }, + ...aiModelRoutes, { path: 'security-settings', redirectTo: '/security-settings/general' diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index a5f18122fd..10721ade5a 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -38,6 +38,7 @@ import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-lib import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component'; import { NgxFlowModule } from '@flowjs/ngx-flow'; import { TrendzSettingsComponent } from '@home/pages/admin/trendz-settings.component'; +import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; @NgModule({ declarations: @@ -50,6 +51,7 @@ import { TrendzSettingsComponent } from '@home/pages/admin/trendz-settings.compo HomeSettingsComponent, ResourcesLibraryComponent, ResourceTabsComponent, + ResourceLibraryTabsComponent, ResourcesTableHeaderComponent, JsResourceComponent, JsLibraryTableHeaderComponent, diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.html new file mode 100644 index 0000000000..4effdaad53 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.html @@ -0,0 +1,23 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.ts new file mode 100644 index 0000000000..a85bf48db3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityTabsComponent } from '@home/components/entity/entity-tabs.component'; +import { Resource } from '@shared/models/resource.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +@Component({ + selector: 'tb-resource-library-tabs', + templateUrl: './resource-library-tabs.component.html', + styleUrls: [] +}) +export class ResourceLibraryTabsComponent extends EntityTabsComponent { + + readonly NULL_UUID = NULL_UUID; + + constructor(protected store: Store) { + super(store); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index 91327b7465..f39ed9ff7b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -37,6 +37,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { map } from 'rxjs/operators'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; +import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; @Injectable() export class ResourcesLibraryTableConfigResolver { @@ -55,6 +56,7 @@ export class ResourcesLibraryTableConfigResolver { this.config.entityTranslations = entityTypeTranslations.get(EntityType.TB_RESOURCE); this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE); this.config.headerComponent = ResourcesTableHeaderComponent; + this.config.entityTabsComponent = ResourceLibraryTabsComponent; this.config.entityTitle = (resource) => resource ? resource.title : ''; diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts new file mode 100644 index 0000000000..82fff06270 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts @@ -0,0 +1,49 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { RouterModule, Routes } from '@angular/router'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { MenuId } from '@core/services/menu.models'; +import { AiModelsTableConfigResolver } from '@home/pages/ai-model/ai-model-table-config.resolve'; +import { NgModule } from '@angular/core'; + +export const aiModelRoutes: Routes = [ + { + path: 'ai-models', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'ai-models.ai-models', + breadcrumb: { + menuId: MenuId.ai_models + } + }, + resolve: { + entitiesTableConfig: AiModelsTableConfigResolver + } + } +]; + +@NgModule({ + providers: [ + AiModelsTableConfigResolver + ], + imports: [RouterModule.forChild(aiModelRoutes)], + exports: [RouterModule], +}) +export class AiModelRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts new file mode 100644 index 0000000000..21a5f50a2f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -0,0 +1,118 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { + CellActionDescriptor, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { DatePipe } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { AiModel, AiProviderTranslations } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; +import { AIModelDialogComponent, AIModelDialogData } from '@home/components/ai-model/ai-model-dialog.component'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class AiModelsTableConfigResolver { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor( + private datePipe: DatePipe, + private aiModelService: AiModelService, + private translate : TranslateService, + private dialog: MatDialog + ) { + this.config.selectionEnabled = true; + this.config.entityType = EntityType.AI_MODEL; + this.config.addAsTextButton = true; + this.config.rowPointer = true; + this.config.detailsPanelEnabled = false; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL); + this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL); + + this.config.headerComponent = AiModelTableHeaderComponent; + this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'}; + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.addEntity = () => this.addModel(null, true); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('name', 'ai-models.name', '33%'), + new EntityTableColumn('provider', 'ai-models.provider', '33%', + entity => this.translate.instant(AiProviderTranslations.get(entity.configuration.provider)) + ), + new EntityTableColumn('modelId', 'ai-models.model', '33%', entity => entity.configuration.modelId) + ) + + this.config.deleteEntityTitle = model => this.translate.instant('ai-models.delete-model-title', {modelName: model.name}); + this.config.deleteEntityContent = () => this.translate.instant('ai-models.delete-model-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('ai-models.delete-models-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('ai-models.delete-models-text'); + + this.config.deleteEntity = id => this.aiModelService.deleteAiModel(id.id); + + this.config.entitiesFetchFunction = pageLink => this.aiModelService.getAiModels(pageLink); + + this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.handleRowClick = ($event, model) => { + this.editModel($event, model); + return true; + }; + } + + resolve(_route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return [ + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.editModel($event, entity) + } + ]; + } + + private editModel($event, AIModel: AiModel): void { + $event?.stopPropagation(); + this.addModel(AIModel, false).subscribe(res => res ? this.config.updateData() : null); + } + + private addModel(AIModel: AiModel, isAdd = false): Observable { + return this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + AIModel + } + }).afterClosed(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html new file mode 100644 index 0000000000..43f1dbf0db --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html @@ -0,0 +1,21 @@ + +
+
ai-models.ai-models
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts new file mode 100644 index 0000000000..48889dc877 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AiModel } from '@shared/models/ai-model.models'; + +@Component({ + selector: 'tb-ai-model-table-header', + templateUrl: './ai-model-table-header.component.html', + styleUrls: [] +}) +export class AiModelTableHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts new file mode 100644 index 0000000000..bd20612e8a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AiModelRoutingModule } from '@home/pages/ai-model/ai-model-routing.module'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; + +@NgModule({ + declarations: [ + AiModelTableHeaderComponent + ], + imports: [ + CommonModule, + SharedModule, + AiModelRoutingModule + ] +}) +export class AiModelModule { } diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts index c7d1ddc9d0..1e4b735ff7 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; @@ -31,7 +31,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; templateUrl: './device-profile-tabs.component.html', styleUrls: [] }) -export class DeviceProfileTabsComponent extends EntityTabsComponent { +export class DeviceProfileTabsComponent extends EntityTabsComponent implements OnInit { deviceTransportTypes = Object.values(DeviceTransportType); @@ -55,4 +55,9 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent entity.pkgName, type: CellActionDescriptorType.COPY_BUTTON }), + new EntityTableColumn('title', 'mobile.mobile-package-title', '20%'), new EntityTableColumn('appSecret', 'mobile.application-secret', '15%', (entity) => this.truncatePipe.transform(entity.appSecret, true, 10, '…'), () => ({}), true, () => ({}), () => undefined, false, diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html index daad184b9e..bbafb83e52 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
mobile.mobile-package @@ -38,6 +38,14 @@ {{ 'mobile.mobile-package-pattern' | translate }} + + mobile.mobile-package-title + + + + {{ 'mobile.mobile-package-title-max-length' | translate }} + + mobile.platform-type diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss index 1925568b81..2462c55555 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss @@ -14,5 +14,5 @@ * limitations under the License. */ :host { - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-disabled-trailing-icon-color: rgba(0, 0, 0, 0.56); } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts index eceda5ac9a..141c274422 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts @@ -63,9 +63,10 @@ export class MobileAppComponent extends EntityComponent { buildForm(entity: MobileApp): FormGroup { const form = this.fb.group({ - pkgName: [entity?.pkgName ? entity.pkgName : '', [Validators.required, Validators.maxLength(255), + pkgName: [entity?.pkgName ?? '', [Validators.required, Validators.maxLength(255), Validators.pattern(/^[a-zA-Z][a-zA-Z\d_]*(?:\.[a-zA-Z][a-zA-Z\d_]*)+$/)]], - platformType: [entity?.platformType ? entity.platformType : PlatformType.ANDROID], + title: [entity?.title ?? '', [Validators.maxLength(255)]], + platformType: [entity?.platformType ?? PlatformType.ANDROID], appSecret: [entity?.appSecret ? entity.appSecret : btoa(randomAlphanumeric(64)), [Validators.required, this.base64Format]], status: [entity?.status ? entity.status : MobileAppStatus.DRAFT], versionInfo: this.fb.group({ diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html index 3c11be8930..afaa0da3c1 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html @@ -43,23 +43,14 @@ [data]=createMarkDownCommand(gitRepositoryLink)>
-
mobile.configuration-step.configure-api-title
-
mobile.configuration-step.configure-api-text
- -
mobile.configuration-step.configure-api-hint
- -
-
-
mobile.configuration-step.configure-package-title
-
mobile.configuration-step.configure-package-text
-
mobile.configuration-step.configure-package-text-install
- -
mobile.configuration-step.configure-package-run-commands
- +
mobile.configuration-step.configure-app-settings-title
+
+
mobile.configuration-step.configure-app-settings-text
+ +
mobile.configuration-step.run-app-title
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts index 9168f14245..5644620b27 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts @@ -21,12 +21,15 @@ import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { ActionPreferencesPutUserSettings } from '@core/auth/auth.actions'; -import { MobileApp } from '@shared/models/mobile-app.models'; +import { MobileApp, MobileAppBundleInfo } from '@shared/models/mobile-app.models'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { isNotEmptyStr } from '@core/utils'; export interface MobileAppConfigurationDialogData { afterAdd: boolean; androidApp: MobileApp; iosApp: MobileApp; + bundle: MobileAppBundleInfo; } @Component({ @@ -36,53 +39,22 @@ export interface MobileAppConfigurationDialogData { }) export class MobileAppConfigurationDialogComponent extends DialogComponent { - notShowAgain = false; - setApplication = false; + private fileName = 'configs'; + notShowAgain = false; showDontShowAgain: boolean; gitRepositoryLink = 'git clone -b master https://github.com/thingsboard/flutter_thingsboard_app.git'; - pathToConstants = 'lib/constants/app_constants.dart'; - flutterRunCommand = 'flutter run'; - flutterInstallRenameCommand = 'flutter pub global activate rename'; - - configureApi: string; - - renameCommands: string[] = []; + flutterRunCommand = `flutter run --dart-define-from-file ${this.fileName}.json`; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) private data: MobileAppConfigurationDialogData, protected dialogRef: MatDialogRef, + private importExportService: ImportExportService, ) { super(store, router, dialogRef); - this.showDontShowAgain = this.data.afterAdd; - - this.setApplication = !!this.data.androidApp || !!this.data.iosApp; - - this.configureApi = `static const thingsBoardApiEndpoint = '${window.location.origin}';`; - if (this.setApplication) { - this.configureApi += '\n'; - if (!!this.data.androidApp) { - this.configureApi += `\nstatic const thingsboardAndroidAppSecret = '${this.data.androidApp.appSecret}';`; - } - if (!!this.data.iosApp) { - this.configureApi += `\nstatic const thingsboardIOSAppSecret = '${this.data.iosApp.appSecret}';`; - } - } - if (this.setApplication) { - if (this.data.androidApp?.pkgName === this.data.iosApp?.pkgName) { - this.renameCommands.push(`rename setBundleId --targets android, ios --value "${this.data.androidApp.pkgName}"`); - } else { - if (!!this.data.androidApp) { - this.renameCommands.push(`rename setBundleId --targets android --value "${this.data.androidApp.pkgName}"`); - } - if (!!this.data.iosApp) { - this.renameCommands.push(`rename setBundleId --targets ios --value "${this.data.iosApp.pkgName}"`); - } - } - } } close(): void { @@ -94,14 +66,28 @@ export class MobileAppConfigurationDialogComponent extends DialogComponent = []; - commands.forEach(command => formatCommands.push(this.createMarkDownSingleCommand(command))); - return formatCommands.join(`\n
\n\n`); - } else { - return this.createMarkDownSingleCommand(commands); + createMarkDownCommand(commands: string): string { + return this.createMarkDownSingleCommand(commands); + } + + downloadSettings(): void { + const settings: any = { + thingsboardApiEndpoint: window.location.origin, + appLinksUrlHost: window.location.host, + appLinksUrlScheme: window.location.protocol.slice(0, -1), + }; + if (!!this.data.androidApp) { + settings.androidApplicationId = this.data.androidApp.pkgName; + settings.androidApplicationName = isNotEmptyStr(this.data.androidApp.title) ? this.data.androidApp.title : this.data.bundle.title; + settings.thingsboardOAuth2CallbackUrlScheme = this.data.androidApp.pkgName + '.auth'; + settings.thingsboardAndroidAppSecret = this.data.androidApp.appSecret; + } + if (!!this.data.iosApp) { + settings.iosApplicationId = this.data.iosApp.pkgName; + settings.iosApplicationName = isNotEmptyStr(this.data.iosApp.title) ? this.data.iosApp.title : this.data.bundle.title; + settings.thingsboardIosAppSecret = this.data.iosApp.appSecret; } + this.importExportService.exportJson(settings, this.fileName); } private createMarkDownSingleCommand(command: string): string { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts index 6a0dc25d70..0b8d1efc21 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts @@ -182,7 +182,8 @@ export class MobileBundleTableConfigResolver { data: { afterAdd, androidApp: data.androidApp, - iosApp: data.iosApp + iosApp: data.iosApp, + bundle: entity } }).afterClosed() .subscribe(); diff --git a/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts index 3c4981f816..0b5c93ff59 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts @@ -47,10 +47,10 @@ export class EditorPanelComponent implements OnInit { tinyMceOptions: Partial = { base_url: '/assets/tinymce', suffix: '.min', - plugins: ['link', 'table', 'image', 'imagetools', 'lists'], + plugins: ['link', 'table', 'image', 'imagetools', 'lists', 'fullscreen'], menubar: 'edit insert view format', toolbar: ['fontfamily fontsize | bold italic underline strikethrough forecolor backcolor', - 'alignleft aligncenter alignright alignjustify | bullist | link table image'], + 'alignleft aligncenter alignright alignjustify | bullist | link table image | fullscreen'], toolbar_mode: 'sliding', height: 400, autofocus: false, diff --git a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html index 951aac77a2..f88244add1 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html @@ -39,10 +39,9 @@
{{ 'mobile.bundle' | translate }}
diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html index b13943067b..3dbbea4277 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html @@ -480,17 +480,18 @@ ignoreAuthorityFilter [allowedEntityTypes]="allowEntityTypeForEntitiesLimit"> -
- +
+ notification.threshold
- - + + - - + + max="100"/> + %
@@ -591,6 +592,69 @@ + + + {{ 'notification.resources-shortage-trigger-settings' | translate }} +
+
+ notification.filter +
+ notification.cpu-threshold +
+ + + + + + % + +
+
+
+ notification.ram-threshold +
+ + + + + + % + +
+
+
+ notification.storage-threshold +
+ + + + + + % + +
+
+
+
+
+
+ + notification.description + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss index 5e495655f1..9f1221cad5 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss @@ -81,7 +81,7 @@ min-width: 364px; } .limit-slider-container { - > label { + > span { margin-right: 16px; width: min-content; max-width: 40%; diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts index 5d1a8b77ac..e0ddefcbe8 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts @@ -102,6 +102,7 @@ export class RuleNotificationDialogComponent extends edgeCommunicationFailureTemplateForm: FormGroup; edgeConnectionTemplateForm: FormGroup; taskProcessingFailureTemplateForm: FormGroup; + resourceUsageShortageTemplateForm: FormGroup; triggerType = TriggerType; triggerTypes: TriggerType[]; @@ -315,7 +316,7 @@ export class RuleNotificationDialogComponent extends this.entitiesLimitTemplateForm = this.fb.group({ triggerConfig: this.fb.group({ entityTypes: [], - threshold: [.8, [Validators.min(0), Validators.max(1)]] + threshold: [80, [Validators.min(0), Validators.max(100)]] }) }); @@ -344,6 +345,14 @@ export class RuleNotificationDialogComponent extends }) }); + this.resourceUsageShortageTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + cpuThreshold: [80, [Validators.min(0), Validators.max(100)]], + ramThreshold: [80, [Validators.min(0), Validators.max(100)]], + storageThreshold: [80, [Validators.min(0), Validators.max(100)]] + }) + }); + this.triggerTypeFormsMap = new Map([ [TriggerType.ALARM, this.alarmTemplateForm], [TriggerType.ALARM_COMMENT, this.alarmCommentTemplateForm], @@ -357,7 +366,8 @@ export class RuleNotificationDialogComponent extends [TriggerType.RATE_LIMITS, this.rateLimitsTemplateForm], [TriggerType.EDGE_COMMUNICATION_FAILURE, this.edgeCommunicationFailureTemplateForm], [TriggerType.EDGE_CONNECTION, this.edgeConnectionTemplateForm], - [TriggerType.TASK_PROCESSING_FAILURE, this.taskProcessingFailureTemplateForm] + [TriggerType.TASK_PROCESSING_FAILURE, this.taskProcessingFailureTemplateForm], + [TriggerType.RESOURCES_SHORTAGE, this.resourceUsageShortageTemplateForm] ]); if (data.isAdd || data.isCopy) { @@ -380,6 +390,14 @@ export class RuleNotificationDialogComponent extends this.deviceInactivityTemplateForm.get('triggerConfig.filterByDevice') .patchValue(!!this.ruleNotification.triggerConfig.devices, {onlySelf: true}); } + if (this.ruleNotification.triggerType === TriggerType.ENTITIES_LIMIT) { + this.entitiesLimitTemplateForm.get('triggerConfig.threshold').patchValue(this.ruleNotification.triggerConfig.threshold * 100, {emitEvent: false}); + } + if (this.ruleNotification.triggerType === TriggerType.RESOURCES_SHORTAGE) { + this.resourceUsageShortageTemplateForm.get('triggerConfig.cpuThreshold').patchValue(this.ruleNotification.triggerConfig.cpuThreshold * 100, {emitEvent: false}); + this.resourceUsageShortageTemplateForm.get('triggerConfig.ramThreshold').patchValue(this.ruleNotification.triggerConfig.ramThreshold * 100, {emitEvent: false}); + this.resourceUsageShortageTemplateForm.get('triggerConfig.storageThreshold').patchValue(this.ruleNotification.triggerConfig.storageThreshold * 100, {emitEvent: false}); + } } } @@ -428,6 +446,14 @@ export class RuleNotificationDialogComponent extends if (triggerType === TriggerType.DEVICE_ACTIVITY) { delete formValue.triggerConfig.filterByDevice; } + if (triggerType === TriggerType.ENTITIES_LIMIT) { + formValue.triggerConfig.threshold = formValue.triggerConfig.threshold / 100; + } + if (triggerType === TriggerType.RESOURCES_SHORTAGE) { + formValue.triggerConfig.cpuThreshold = formValue.triggerConfig.cpuThreshold / 100; + formValue.triggerConfig.ramThreshold = formValue.triggerConfig.ramThreshold / 100; + formValue.triggerConfig.storageThreshold = formValue.triggerConfig.storageThreshold / 100; + } formValue.recipientsConfig.triggerType = triggerType; formValue.triggerConfig.triggerType = triggerType; if (this.ruleNotification && !this.data.isCopy) { @@ -483,8 +509,7 @@ export class RuleNotificationDialogComponent extends } formatLabel(value: number): string { - const formatValue = (value * 100).toFixed(); - return `${formatValue}%`; + return `${value}%`; } private isSysAdmin(): boolean { @@ -497,7 +522,8 @@ export class RuleNotificationDialogComponent extends TriggerType.API_USAGE_LIMIT, TriggerType.NEW_PLATFORM_VERSION, TriggerType.RATE_LIMITS, - TriggerType.TASK_PROCESSING_FAILURE + TriggerType.TASK_PROCESSING_FAILURE, + TriggerType.RESOURCES_SHORTAGE ]); if (this.isSysAdmin()) { diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html index bb15320c4d..3eb330bb50 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html @@ -62,6 +62,12 @@ *ngIf="actionButtonConfigForm.get('link').hasError('required')"> {{ 'notification.link-required' | translate }} + + {{ 'notification.link-max-length' | translate : + {length: actionButtonConfigForm.get('link').getError('maxlength').requiredLength} + }} + {{ 'notification.subject-required' | translate }} + + {{'notification.subject-max-length' | translate : + {length: templateConfigurationForm.get('WEB.subject').getError('maxlength').requiredLength} + }} + notification.message @@ -56,6 +61,11 @@ {{ 'notification.message-required' | translate }} + + {{ 'notification.message-max-length' | translate : + {length: templateConfigurationForm.get('WEB.body').getError('maxlength').requiredLength} + }} +
@@ -194,6 +204,11 @@ {{ 'notification.subject-required' | translate }} + + {{'notification.subject-max-length' | translate : + {length: templateConfigurationForm.get('EMAIL.subject').getError('maxlength').requiredLength} + }} + , private otaPackageService: OtaPackageService, private router: Router, private fileSize: FileSizePipe) { this.config.entityType = EntityType.OTA_PACKAGE; this.config.entityComponent = OtaUpdateComponent; + this.config.entityTabsComponent = OtaUpdateTabsComponent; this.config.entityTranslations = entityTypeTranslations.get(EntityType.OTA_PACKAGE); this.config.entityResources = entityTypeResources.get(EntityType.OTA_PACKAGE); @@ -68,7 +67,9 @@ export class OtaUpdateTableConfigResolve { }), new EntityTableColumn('url', 'ota-update.direct-url', '20%', entity => { return entity.url ? (entity.url.length > 20 ? `${entity.url.slice(0, 20)}…` : entity.url) : ''; - }, () => ({}), true, () => ({}), () => undefined, false, + }, () => ({ + 'text-wrap': 'nowrap' + }), true, () => ({}), () => undefined, false, { name: this.translate.instant('ota-update.copy-direct-url'), icon: 'content_paste', @@ -78,7 +79,7 @@ export class OtaUpdateTableConfigResolve { color: 'rgba(0,0,0,.87)' }, isEnabled: (otaPackage) => !!otaPackage.url, - onAction: ($event, entity) => entity.url, + onAction: (_$event, entity) => entity.url, type: CellActionDescriptorType.COPY_BUTTON }), new EntityTableColumn('fileName', 'ota-update.file-name', '20%'), @@ -86,20 +87,22 @@ export class OtaUpdateTableConfigResolve { return entity.dataSize ? this.fileSize.transform(entity.dataSize) : ''; }), new EntityTableColumn('checksum', 'ota-update.checksum', '220px', entity => { - return entity.checksum ? this.checksumText(entity) : ''; - }, () => ({}), true, () => ({}), () => undefined, false, - { - name: this.translate.instant('ota-update.copy-checksum'), - icon: 'content_paste', - style: { - padding: '4px', - 'font-size': '16px', - color: 'rgba(0,0,0,.87)' - }, - isEnabled: (otaPackage) => !!otaPackage.checksum, - onAction: ($event, entity) => entity.checksum, - type: CellActionDescriptorType.COPY_BUTTON - }) + return entity.checksum ? this.checksumText(entity) : ''; + }, () => ({ + 'text-wrap': 'nowrap' + }), true, () => ({}), () => undefined, false, + { + name: this.translate.instant('ota-update.copy-checksum'), + icon: 'content_paste', + style: { + padding: '4px', + 'font-size': '16px', + color: 'rgba(0,0,0,.87)' + }, + isEnabled: (otaPackage) => !!otaPackage.checksum, + onAction: (_$event, entity) => entity.checksum, + type: CellActionDescriptorType.COPY_BUTTON + }) ); this.config.cellActionDescriptors.push( @@ -149,7 +152,7 @@ export class OtaUpdateTableConfigResolve { } } - checksumText(entity): string { + checksumText(entity: OtaPackageInfo): string { let text = `${ChecksumAlgorithmTranslationMap.get(entity.checksumAlgorithm)}: ${entity.checksum}`; if (text.length > 20) { text = `${text.slice(0, 20)}…`; diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.html b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.html new file mode 100644 index 0000000000..a8cdae4256 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.html @@ -0,0 +1,23 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.ts new file mode 100644 index 0000000000..44d17f3c11 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.ts @@ -0,0 +1,40 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { OtaPackage } from '@shared/models/ota-package.models'; + +@Component({ + selector: 'tb-ota-update-tabs', + templateUrl: './ota-update-tabs.component.html', + styleUrls: [] +}) +export class OtaUpdateTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + isTenantOtaUpdate() { + return this.entity && this.entity.tenantId.id !== NULL_UUID; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html index e028a2714a..ee8e34a4a2 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html @@ -63,7 +63,7 @@
-
+
ota-update.title diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts index 3adf439346..401c660f52 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts @@ -185,9 +185,11 @@ export class OtaUpdateComponent extends EntityComponent implements O })); } - prepareFormValue(formValue: any): any { + prepareFormValue(formValue: OtaPackage & {generateChecksum?: boolean}): any { if (formValue.isURL) { delete formValue.file; + delete formValue.checksumAlgorithm; + delete formValue.checksum; } else { delete formValue.url; } diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts index 139591f14f..fe24da31b2 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts @@ -20,10 +20,12 @@ import { SharedModule } from '@shared/shared.module'; import { HomeComponentsModule } from '@home/components/home-components.module'; import { OtaUpdateRoutingModule } from '@home/pages/ota-update/ota-update-routing.module'; import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component'; +import { OtaUpdateTabsComponent } from '@home/pages/ota-update/ota-update-tabs.component'; @NgModule({ declarations: [ - OtaUpdateComponent + OtaUpdateComponent, + OtaUpdateTabsComponent ], imports: [ CommonModule, 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 index 56d2801670..e7a4380c4a 100644 --- 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 @@ -1304,7 +1304,7 @@ export const scadaSymbolContextCompletion = (metadata: ScadaSymbolMetadata, tags }, formatValue: { meta: 'function', - description: 'Formats numeric value according to specified decimals and units', + description: 'Formats a numeric value according to specified settings or individual parameters for decimals and units, using a ValueFormatProcessor instance.', args: [ { name: 'value', @@ -1312,27 +1312,27 @@ export const scadaSymbolContextCompletion = (metadata: ScadaSymbolMetadata, tags type: 'any' }, { - name: 'dec', - description: 'Number of decimal digits', - type: 'number', + name: 'settingsOrDec', + description: 'Either a ValueFormatSettings object containing formatting settings or the number of decimal digits. ValueFormatSettings includes: decimals (number of decimal digits, optional), units (unit specification as string or TbUnitMapping, optional), showZeroDecimals (whether to keep zero decimal digits, optional), ignoreUnitSymbol (whether to exclude unit symbol from output, optional).', + type: 'ValueFormatSettings | number', optional: true }, { name: 'units', - description: 'Units to append to the formatted value', + description: 'Units to append to the formatted value, applied only if settingsOrDec is a number', type: 'string', optional: true }, { name: 'showZeroDecimals', - description: 'Whether to keep zero decimal digits', + description: 'Whether to keep zero decimal digits, applied only if settingsOrDec is a number', type: 'boolean', optional: true } ], return: { - type: 'string', - description: 'Formatted value' + description: 'The formatted value as a string. Returns undefined if the value cannot be formatted and settingsOrDec is not an object.', + type: 'string | undefined' } }, text: { @@ -1479,6 +1479,47 @@ export const scadaSymbolContextCompletion = (metadata: ScadaSymbolMetadata, tags type: 'any' } ] + }, + unitSymbol: { + meta: 'function', + description: 'Retrieves the target unit symbol based on the current unit system or the provided unit.', + args: [ + { + name: 'unit', + description: 'Unit specification, either a string or a TbUnitMapping object defining unit mappings for different systems.', + type: 'TbUnit' + } + ], + return: { + description: 'The target unit symbol as a string, or the \'from\' unit if no mapping is found for the current unit system.', + type: 'string' + } + }, + convertUnitValue: { + meta: 'function', + description: 'Converts a numeric value from one unit to another, either using a TbUnit mapping or explicit from/to units. Returns the original value on conversion failure.', + args: [ + { + name: 'value', + description: 'Numeric value to be converted', + type: 'number' + }, + { + name: 'unit', + description: 'Unit specification, either a string representing the source unit or a TbUnitMapping object for system-based conversion', + type: 'TbUnit' + }, + { + name: 'to', + description: 'Optional target unit to convert to. If not provided, the target unit is derived from the TbUnitMapping and current unit system.', + type: 'string', + optional: true + } + ], + return: { + description: 'The converted numeric value. Returns the original value if conversion fails or no conversion is needed.', + type: 'number' + } } } }, diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index ac8054283a..8cb785c6f1 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -14,16 +14,7 @@ /// limitations under the License. /// -import { - Component, - ElementRef, - EventEmitter, - forwardRef, - Input, - OnInit, - Output, - ViewChild -} from '@angular/core'; +import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { firstValueFrom, merge, Observable, of, Subject } from 'rxjs'; @@ -300,6 +291,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit this.entityRequiredText = 'notification.notification-recipient-required'; this.notFoundEntities = 'notification.no-recipients-text'; break; + case EntityType.AI_MODEL: + this.entityText = 'ai-models.ai-model'; + this.noEntitiesMatchingText = 'ai-models.no-model-matching'; + this.entityRequiredText = 'ai-models.model-required'; + this.notFoundEntities = 'ai-models.no-model-text'; + break; case AliasEntityType.CURRENT_CUSTOMER: this.entityText = 'customer.default-customer'; this.noEntitiesMatchingText = 'customer.no-customers-matching'; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index 1701e8d754..f3983111bf 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -17,16 +17,21 @@ -->
{ }; + private propagateChange = (_v: any) => { }; - constructor(private store: Store, - private entityService: EntityService, - public translate: TranslateService, + constructor(private entityService: EntityService, private fb: UntypedFormBuilder, private destroyRef: DestroyRef) { @@ -96,7 +97,7 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit, this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } ngOnInit() { @@ -114,9 +115,9 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit, this.updateView(this.modelValue.entityType, values); } ); - } - - ngAfterViewInit(): void { + if (isDefinedAndNotNull(this.predefinedEntityType)) { + this.defaultEntityType = this.predefinedEntityType; + } } setDisabledState(isDisabled: boolean): void { @@ -145,7 +146,7 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit, this.entityListSelectFormGroup.get('entityIds').patchValue([...this.modelValue.ids], {emitEvent: true}); } - updateView(entityType: EntityType | AliasEntityType | null, entityIds: Array | null) { + private updateView(entityType: EntityType | AliasEntityType | null, entityIds: Array | null) { if (this.modelValue.entityType !== entityType || !this.compareIds(this.modelValue.ids, entityIds)) { this.modelValue = { @@ -156,7 +157,7 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit, } } - compareIds(ids1: Array | null, ids2: Array | null): boolean { + private compareIds(ids1: Array | null, ids2: Array | null): boolean { if (ids1 !== null && ids2 !== null) { return JSON.stringify(ids1) === JSON.stringify(ids2); } else { @@ -164,7 +165,7 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit, } } - toEntityIds(modelValue: EntityListSelectModel): Array { + private toEntityIds(modelValue: EntityListSelectModel): Array { if (modelValue !== null && modelValue.entityType && modelValue.ids && modelValue.ids.length > 0) { const entityType = modelValue.entityType; return modelValue.ids.map(id => ({entityType, id})); diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index fc0cfa9586..9510f2a952 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -15,8 +15,13 @@ limitations under the License. --> - - {{ labelText }} + + {{ labelText }} - {{ translate.get('entity.no-entities-matching', {entity: searchText}) | async }} + {{ 'entity.no-entities-matching' | translate: {entity: searchText} }}
- + {{ hint }} - + {{ requiredText }}
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 5cd19156a5..552c4f1f71 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 @@ -14,17 +14,7 @@ /// limitations under the License. /// -import { - AfterViewInit, - Component, - ElementRef, - forwardRef, - Input, - OnChanges, - OnInit, - SimpleChanges, - ViewChild -} from '@angular/core'; +import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { ControlValueAccessor, NG_VALIDATORS, @@ -65,7 +55,7 @@ import { isArray } from 'lodash'; } ] }) -export class EntityListComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { +export class EntityListComponent implements ControlValueAccessor, OnInit, OnChanges { entityListFormGroup: UntypedFormGroup; @@ -115,6 +105,10 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV @coerceBoolean() syncIdsWithDB = false; + @Input() + @coerceBoolean() + inlineField: boolean; + @ViewChild('entityInput') entityInput: ElementRef; @ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete; @ViewChild('chipList', {static: true}) chipList: MatChipGrid; @@ -126,9 +120,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV private dirty = false; - private propagateChange = (v: any) => { }; + private propagateChange = (_v: any) => { }; - constructor(public translate: TranslateService, + constructor(private translate: TranslateService, private entityService: EntityService, private fb: UntypedFormBuilder) { this.entityListFormGroup = this.fb.group({ @@ -146,7 +140,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } ngOnInit() { @@ -178,9 +172,6 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV } } - ngAfterViewInit(): void { - } - setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (isDisabled) { diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-select.component.html index 50d3abc435..2b07af9e08 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.html @@ -15,9 +15,11 @@ limitations under the License. --> -
+
diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts index a85589fae4..01b1f03388 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -25,6 +25,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-entity-select', @@ -58,6 +59,9 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte @Input() additionEntityTypes: {[entityType in string]: string} = {}; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + displayEntityTypeSelect: boolean; AliasEntityType = AliasEntityType; diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html index b21734cfdd..6506d233b0 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -15,14 +15,16 @@ limitations under the License. --> - - {{ 'entity.type' | translate }} + + {{ label }} {{ displayEntityTypeFn(type) }} - + {{ 'entity.type-required' | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts index ce599a0c08..f8a4f49a82 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -52,6 +52,9 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @coerceBoolean() showLabel: boolean; + @Input() + label = this.translate.instant('entity.type'); + @Input() @coerceBoolean() required: boolean; @@ -65,12 +68,16 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + @coerceBoolean() + inlineField: boolean; + entityTypes: Array; - private propagateChange = (v: any) => { }; + private propagateChange = (_v: any) => { }; constructor(private entityService: EntityService, - public translate: TranslateService, + private translate: TranslateService, private fb: UntypedFormBuilder, private destroyRef: DestroyRef) { this.entityTypeFormGroup = this.fb.group({ @@ -82,7 +89,7 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } ngOnInit() { @@ -97,7 +104,7 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, takeUntilDestroyed(this.destroyRef) ).subscribe( (value) => { - let modelValue; + let modelValue: EntityType | AliasEntityType; if (!value || value === '') { modelValue = null; } else { diff --git a/ui-ngx/src/app/shared/components/help-popup.component.html b/ui-ngx/src/app/shared/components/help-popup.component.html index c24b5234b9..8bd3dbcfa9 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.html +++ b/ui-ngx/src/app/shared/components/help-popup.component.html @@ -17,9 +17,9 @@ -->
-
+
+ - + [panelWidth]="panelWidth"> diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.ts b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts index 2f1ea5db14..70b1115b5b 100644 --- a/ui-ngx/src/app/shared/components/string-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts @@ -76,6 +76,9 @@ export class StringAutocompleteComponent implements ControlValueAccessor, OnInit @Input() label: string; + @Input() + panelWidth: string = 'fit-content'; + @Input() tooltipClass = 'tb-error-tooltip'; diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html index 042d94087c..fbb3c1d2f6 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html @@ -27,7 +27,7 @@
-
+
- -
+ +
- - -
{{ computedTimewindowStyle.icon }}
+ diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.ts b/ui-ngx/src/app/shared/components/time/timewindow.component.ts index ea3f49cf9f..a52e3eb091 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.ts @@ -17,6 +17,7 @@ import { ChangeDetectorRef, Component, + DestroyRef, ElementRef, forwardRef, HostBinding, @@ -26,6 +27,7 @@ import { OnInit, SimpleChanges, StaticProvider, + ViewChild, ViewContainerRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @@ -63,6 +65,7 @@ import { } from '@shared/models/widget-settings.models'; import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models'; import { fromEvent } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; // @dynamic @Component({ @@ -79,6 +82,8 @@ import { fromEvent } from 'rxjs'; }) export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChanges { + @ViewChild('panelContainer', { read: ViewContainerRef, static: true }) panelContainer: ViewContainerRef; + historyOnlyValue = false; @Input() @@ -180,6 +185,10 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan @coerceBoolean() disabled: boolean; + @Input() + @coerceBoolean() + panelMode = true; + innerValue: Timewindow; timewindowDisabled: boolean; @@ -197,7 +206,8 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan private datePipe: DatePipe, private cd: ChangeDetectorRef, private nativeElement: ElementRef, - public viewContainerRef: ViewContainerRef) { + private viewContainerRef: ViewContainerRef, + private destroyRef: DestroyRef) { } ngOnInit() { @@ -249,7 +259,8 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan quickIntervalOnly: this.quickIntervalOnly, aggregation: this.aggregation, timezone: this.timezone, - isEdit: this.isEdit + isEdit: this.isEdit, + panelMode: this.panelMode, } as TimewindowPanelData }, { @@ -317,6 +328,9 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan } else { this.updateDisplayValue(); } + if (!this.panelMode) { + this.createPanel(); + } } notifyChanged() { @@ -328,6 +342,9 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan } updateDisplayValue() { + if (!this.panelMode) { + return + } if (this.innerValue.selectedTab === TimewindowType.REALTIME && !this.historyOnly) { this.innerValue.displayValue = this.displayTypePrefix ? (this.translate.instant('timewindow.realtime') + ' - ') : ''; if (this.innerValue.realtime.realtimeType === RealtimeWindowType.INTERVAL) { @@ -373,4 +390,29 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan ))); } + private createPanel() { + this.panelContainer.clear(); + const panelData = { + timewindow: deepClone(this.innerValue), + historyOnly: this.historyOnly, + forAllTimeEnabled: this.forAllTimeEnabled, + quickIntervalOnly: this.quickIntervalOnly, + aggregation: this.aggregation, + timezone: this.timezone, + isEdit: this.isEdit, + panelMode: this.panelMode, + } + const injector = Injector.create({ + providers: [{ provide: TIMEWINDOW_PANEL_DATA, useValue: panelData }], + parent: this.viewContainerRef.injector + }); + const componentRef = this.panelContainer.createComponent(TimewindowPanelComponent, {index: 0, injector}); + componentRef.instance.changeTimewindow.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + this.innerValue = value; + this.timewindowDisabled = this.isTimewindowDisabled(); + this.notifyChanged(); + }) + } } diff --git a/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts b/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts index f378ec59f0..9d32abd46e 100644 --- a/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts +++ b/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts @@ -139,7 +139,13 @@ export class UnitSettingsPanelComponent implements OnInit { } clearUnit() { - this.unitSettingsApplied.emit(null); + this.unitSettingForm.reset({ + convertUnit: false + }); + if (this.required) { + this.unitSettingForm.markAllAsTouched(); + } + this.unitSettingForm.markAsDirty(); } cancel() { diff --git a/ui-ngx/src/app/shared/import-export/import-export.models.ts b/ui-ngx/src/app/shared/import-export/import-export.models.ts index 2f20a421c3..60df84c737 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.models.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.models.ts @@ -160,7 +160,7 @@ export const ZIP_TYPE: FileType = { }; export const CSV_TYPE: FileType = { - mimeType: 'attachament/csv', + mimeType: 'text/csv', extension: 'csv' }; 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 2ad60f0139..94960cb838 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 @@ -1188,7 +1188,7 @@ export class ImportExportService { this.exportJson(data, filename); } - private exportJson(data: any, filename: string) { + public exportJson(data: any, filename: string) { if (isObject(data)) { data = JSON.stringify(data, null, 2); } diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts new file mode 100644 index 0000000000..f3161263b7 --- /dev/null +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -0,0 +1,230 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { HasTenantId } from '@shared/models/entity.models'; +import { AiModelId } from '@shared/models/id/ai-model-id'; + +export interface AiModel extends Omit, 'label'>, HasTenantId, ExportableEntity { + modelType: ModelType; + configuration: { + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + fileName?: string; + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + }; + modelId: string; + temperature?: number; + topP?: number; + topK?: number; + frequencyPenalty?: number; + presencePenalty?: number; + maxOutputTokens?: number; + } +} + +export enum ModelType { + CHAT = 'CHAT' +} + +export enum AiProvider { + OPENAI = 'OPENAI', + AZURE_OPENAI = 'AZURE_OPENAI', + GOOGLE_AI_GEMINI = 'GOOGLE_AI_GEMINI', + GOOGLE_VERTEX_AI_GEMINI = 'GOOGLE_VERTEX_AI_GEMINI', + MISTRAL_AI = 'MISTRAL_AI', + ANTHROPIC = 'ANTHROPIC', + AMAZON_BEDROCK = 'AMAZON_BEDROCK', + GITHUB_MODELS = 'GITHUB_MODELS' +} + +export const AiProviderTranslations = new Map( + [ + [AiProvider.OPENAI , 'ai-models.ai-providers.openai'], + [AiProvider.AZURE_OPENAI , 'ai-models.ai-providers.azure-openai'], + [AiProvider.GOOGLE_AI_GEMINI , 'ai-models.ai-providers.google-ai-gemini'], + [AiProvider.GOOGLE_VERTEX_AI_GEMINI , 'ai-models.ai-providers.google-vertex-ai-gemini'], + [AiProvider.MISTRAL_AI , 'ai-models.ai-providers.mistral-ai'], + [AiProvider.ANTHROPIC , 'ai-models.ai-providers.anthropic'], + [AiProvider.AMAZON_BEDROCK , 'ai-models.ai-providers.amazon-bedrock'], + [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'] + ] +); + +export const ProviderFieldsAllList = [ + 'apiKey', + 'personalAccessToken', + 'projectId', + 'location', + 'serviceAccountKey', + 'fileName', + 'endpoint', + 'serviceVersion', + 'region', + 'accessKeyId', + 'secretAccessKey' +]; + +export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; + +export const AiModelMap = new Map([ + [ + AiProvider.OPENAI, + { + modelList: [ + 'o4-mini', + 'o3-pro', + 'o3', + 'o3-mini', + 'o1', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AZURE_OPENAI, + { + modelList: [], + providerFieldsList: ['apiKey', 'endpoint', 'serviceVersion'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_VERTEX_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['projectId', 'location', 'serviceAccountKey', 'fileName'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.MISTRAL_AI, + { + modelList: [ + 'magistral-medium-latest', + 'magistral-small-latest', + 'mistral-large-latest', + 'mistral-medium-latest', + 'mistral-small-latest', + 'pixtral-large-latest', + 'ministral-8b-latest', + 'ministral-3b-latest', + 'open-mistral-nemo', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.ANTHROPIC, + { + modelList: [ + 'claude-opus-4-0', + 'claude-sonnet-4-0', + 'claude-3-7-sonnet-latest', + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AMAZON_BEDROCK, + { + modelList: [], + providerFieldsList: ['region', 'accessKeyId', 'secretAccessKey'], + modelFieldsList: ['temperature', 'topP', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GITHUB_MODELS, + { + modelList: [], + providerFieldsList: ['personalAccessToken'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], +]); + +export const AiRuleNodeResponseFormatTypeOnlyText: AiProvider[] = [AiProvider.AMAZON_BEDROCK, AiProvider.ANTHROPIC, AiProvider.GITHUB_MODELS]; + +export enum ResponseFormat { + TEXT = 'TEXT', + JSON = 'JSON', + JSON_SCHEMA = 'JSON_SCHEMA' +} + +export interface AiModelWithUserMsg { + userMessage: { + contents: Array<{contentType: string; text: string}>; + } + chatModelConfig: { + modelType: string; + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + fileName?: string + }; + modelId: string; + maxRetries: number; + timeoutSeconds: number; + } +} + +export interface CheckConnectivityResult { + status: string; + errorDetails: string; +} diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 6fade1d193..76b775b2fd 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -526,10 +526,10 @@ export const getCalculatedFieldArgumentsEditorCompleter = (argumentsObj: Record< description: 'Calculated field context arguments.', children: {} }, - msgTs: { + latestTs: { meta: 'constant', type: 'number', - description: 'Timestamp (ms) of the telemetry message that triggered the calculated field execution.' + description: 'Latest timestamp (ms) of the arguments telemetry.' } } } @@ -582,8 +582,8 @@ const calculatedFieldArgumentsContextValueHighlightRules: AceHighlightRules = { next: 'calculatedFieldCtxArgs' }, { - token: 'tb.calculated-field-msgTs', - regex: /msgTs/, + token: 'tb.calculated-field-latestTs', + regex: /latestTs/, next: 'no_regex' }, endGroupHighlightRule diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index fbb3e73216..1617e46b58 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -126,8 +126,11 @@ export const HelpLinks = { ruleNodeCalculatedFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#calculated-fields-node`, ruleNodeClearAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#clear-alarm-node`, ruleNodeCreateAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-alarm-node`, + ruleNodeCopyToView: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#copy-to-view-node`, ruleNodeCreateRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-relation-node`, ruleNodeDeleteRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-relation-node`, + ruleNodeDeviceState: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-state-node`, + ruleNodeMessageCount: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#message-count-node`, ruleNodeMsgDelay: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delay-node-deprecated`, ruleNodeMsgGenerator: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#generator-node`, ruleNodeGpsGeofencingEvents: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#gps-geofencing-events-node`, @@ -135,10 +138,12 @@ export const HelpLinks = { ruleNodeRpcCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-reply-node`, ruleNodeRpcCallRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-request-node`, ruleNodeSaveAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-attributes-node`, + ruleNodeDeleteAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-attributes-node`, ruleNodeSaveTimeseries: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-timeseries-node`, - ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table`, + ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table-node`, 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`, + ruleNodeAiRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#ai-request-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`, @@ -154,6 +159,7 @@ export const HelpLinks = { ruleNodeRestCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rest-call-reply-node`, ruleNodePushToCloud: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-cloud`, ruleNodePushToEdge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-edge`, + ruleNodeDeviceProfile: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-profile-node`, ruleNodeAcknowledge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#acknowledge-node`, ruleNodeCheckpoint: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#checkpoint-node`, ruleNodeSendNotification: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-notification-node`, @@ -200,6 +206,7 @@ export const HelpLinks = { mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`, + aiModels: `${helpBaseUrl}/docs${docPlatformPrefix}/ai-models`, timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, trendzSettings: `${helpBaseUrl}/docs/trendz/` } diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 48cad42e7b..6e7ba24578 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -51,6 +51,7 @@ export enum EntityType { MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE', MOBILE_APP = 'MOBILE_APP', CALCULATED_FIELD = 'CALCULATED_FIELD', + AI_MODEL = 'AI_MODEL', } export enum AliasEntityType { @@ -346,6 +347,8 @@ export const entityTypeTranslations = new Map( [LimitedApi.REST_REQUESTS_PER_TENANT, 'api-limit.rest-api-requests'], [LimitedApi.REST_REQUESTS_PER_CUSTOMER, 'api-limit.rest-api-requests-per-customer'], [LimitedApi.WS_UPDATES_PER_SESSION, 'api-limit.ws-updates-per-session'], - [LimitedApi.CASSANDRA_QUERIES, 'api-limit.cassandra-queries'], + [LimitedApi.CASSANDRA_WRITE_QUERIES_CORE, 'api-limit.cassandra-write-queries-core'], + [LimitedApi.CASSANDRA_READ_QUERIES_CORE, 'api-limit.cassandra-read-queries-core'], + [LimitedApi.CASSANDRA_WRITE_QUERIES_RULE_ENGINE, 'api-limit.cassandra-write-queries-rule-engine'], + [LimitedApi.CASSANDRA_READ_QUERIES_RULE_ENGINE, 'api-limit.cassandra-read-queries-rule-engine'], + [LimitedApi.CASSANDRA_WRITE_QUERIES_MONOLITH, 'api-limit.cassandra-write-queries-monolith'], + [LimitedApi.CASSANDRA_READ_QUERIES_MONOLITH, 'api-limit.cassandra-read-queries-monolith'], [LimitedApi.TRANSPORT_MESSAGES_PER_TENANT, 'api-limit.transport-messages'], [LimitedApi.TRANSPORT_MESSAGES_PER_DEVICE, 'api-limit.transport-messages-per-device'], [LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY, 'api-limit.transport-messages-per-gateway'], diff --git a/ui-ngx/src/app/shared/models/mobile-app.models.ts b/ui-ngx/src/app/shared/models/mobile-app.models.ts index d8c4c8e8b1..cba079492b 100644 --- a/ui-ngx/src/app/shared/models/mobile-app.models.ts +++ b/ui-ngx/src/app/shared/models/mobile-app.models.ts @@ -85,6 +85,7 @@ export interface StoreInfo { export interface MobileApp extends BaseData, HasTenantId { pkgName: string; + title?: string; appSecret: string; platformType: PlatformType; status: MobileAppStatus; diff --git a/ui-ngx/src/app/shared/models/notification.models.ts b/ui-ngx/src/app/shared/models/notification.models.ts index fad4611b45..63d7edd2a2 100644 --- a/ui-ngx/src/app/shared/models/notification.models.ts +++ b/ui-ngx/src/app/shared/models/notification.models.ts @@ -129,7 +129,7 @@ export interface NotificationRule extends Omit, 'la export type NotificationRuleTriggerConfig = Partial; + ApiUsageLimitNotificationRuleTriggerConfig & RateLimitsNotificationRuleTriggerConfig & ResourceUsageShortageNotificationRuleTriggerConfig>; export interface AlarmNotificationRuleTriggerConfig { alarmTypes?: Array; @@ -183,6 +183,12 @@ export interface EntitiesLimitNotificationRuleTriggerConfig { threshold: number; } +export interface ResourceUsageShortageNotificationRuleTriggerConfig { + cpuThreshold: number; + ramThreshold: number; + storageThreshold: number; +} + export interface ApiUsageLimitNotificationRuleTriggerConfig { apiFeatures: ApiFeature[]; notifyOn: ApiUsageStateValue[]; @@ -526,7 +532,8 @@ export enum NotificationType { RATE_LIMITS = 'RATE_LIMITS', EDGE_CONNECTION = 'EDGE_CONNECTION', EDGE_COMMUNICATION_FAILURE = 'EDGE_COMMUNICATION_FAILURE', - TASK_PROCESSING_FAILURE = 'TASK_PROCESSING_FAILURE' + TASK_PROCESSING_FAILURE = 'TASK_PROCESSING_FAILURE', + RESOURCES_SHORTAGE = 'RESOURCES_SHORTAGE' } export const NotificationTypeIcons = new Map([ @@ -538,7 +545,8 @@ export const NotificationTypeIcons = new Map([ [NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, 'settings_ethernet'], [NotificationType.ENTITIES_LIMIT, 'data_thresholding'], [NotificationType.API_USAGE_LIMIT, 'insert_chart'], - [NotificationType.TASK_PROCESSING_FAILURE, 'warning'] + [NotificationType.TASK_PROCESSING_FAILURE, 'warning'], + [NotificationType.RESOURCES_SHORTAGE, 'warning'] ]); export const AlarmSeverityNotificationColors = new Map( @@ -657,6 +665,12 @@ export const NotificationTemplateTypeTranslateMap = new Map([ @@ -688,7 +703,8 @@ export const TriggerTypeTranslationMap = new Map([ [TriggerType.RATE_LIMITS, 'notification.trigger.rate-limits'], [TriggerType.EDGE_CONNECTION, 'notification.trigger.edge-connection'], [TriggerType.EDGE_COMMUNICATION_FAILURE, 'notification.trigger.edge-communication-failure'], - [TriggerType.TASK_PROCESSING_FAILURE, 'notification.trigger.task-processing-failure'] + [TriggerType.TASK_PROCESSING_FAILURE, 'notification.trigger.task-processing-failure'], + [TriggerType.RESOURCES_SHORTAGE, 'notification.trigger.resources-shortage'] ]); export interface NotificationUserSettings { diff --git a/ui-ngx/src/app/shared/models/ota-package.models.ts b/ui-ngx/src/app/shared/models/ota-package.models.ts index e40eb15dea..bba7ea3d04 100644 --- a/ui-ngx/src/app/shared/models/ota-package.models.ts +++ b/ui-ngx/src/app/shared/models/ota-package.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { BaseData } from '@shared/models/base-data'; +import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { TenantId } from '@shared/models/id/tenant-id'; import { OtaPackageId } from '@shared/models/id/ota-package-id'; import { DeviceProfileId } from '@shared/models/id/device-profile-id'; @@ -86,7 +86,7 @@ export interface OtaPagesIds { softwareId?: OtaPackageId; } -export interface OtaPackageInfo extends BaseData, HasTenantId { +export interface OtaPackageInfo extends Omit, 'label'>, HasTenantId, ExportableEntity { tenantId?: TenantId; type: OtaUpdateType; deviceProfileId?: DeviceProfileId; diff --git a/ui-ngx/src/app/shared/models/public-api.ts b/ui-ngx/src/app/shared/models/public-api.ts index d128eaec43..91e1a2b033 100644 --- a/ui-ngx/src/app/shared/models/public-api.ts +++ b/ui-ngx/src/app/shared/models/public-api.ts @@ -63,4 +63,5 @@ export * from './window-message.model'; export * from './usage.models'; export * from './query/query.models'; export * from './regex.constants'; -export * from './trendz-settings.models' +export * from './trendz-settings.models'; +export * from './ai-model.models'; 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 dff6e53654..8225fbf18f 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -483,8 +483,11 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.telemetry.TbCalculatedFieldsNode': 'ruleNodeCalculatedFields', 'org.thingsboard.rule.engine.action.TbClearAlarmNode': 'ruleNodeClearAlarm', 'org.thingsboard.rule.engine.action.TbCreateAlarmNode': 'ruleNodeCreateAlarm', + 'org.thingsboard.rule.engine.action.TbCopyAttributesToEntityViewNode': 'ruleNodeCopyToView', 'org.thingsboard.rule.engine.action.TbCreateRelationNode': 'ruleNodeCreateRelation', 'org.thingsboard.rule.engine.action.TbDeleteRelationNode': 'ruleNodeDeleteRelation', + 'org.thingsboard.rule.engine.action.TbDeviceStateNode': 'ruleNodeDeviceState', + 'org.thingsboard.rule.engine.action.TbMsgCountNode': 'ruleNodeMessageCount', 'org.thingsboard.rule.engine.delay.TbMsgDelayNode': 'ruleNodeMsgDelay', 'org.thingsboard.rule.engine.debug.TbMsgGeneratorNode': 'ruleNodeMsgGenerator', 'org.thingsboard.rule.engine.geo.TbGpsGeofencingActionNode': 'ruleNodeGpsGeofencingEvents', @@ -492,9 +495,11 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.rpc.TbSendRPCReplyNode': 'ruleNodeRpcCallReply', 'org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode': 'ruleNodeRpcCallRequest', 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode': 'ruleNodeSaveAttributes', + 'org.thingsboard.rule.engine.telemetry.TbMsgDeleteAttributesNode': 'ruleNodeDeleteAttributes', 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode': 'ruleNodeSaveTimeseries', 'org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode': 'ruleNodeSaveToCustomTable', 'org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNode': 'ruleNodeAwsLambda', + 'org.thingsboard.rule.engine.ai.TbAiNode': 'ruleNodeAiRequest', 'org.thingsboard.rule.engine.aws.sns.TbSnsNode': 'ruleNodeAwsSns', 'org.thingsboard.rule.engine.aws.sqs.TbSqsNode': 'ruleNodeAwsSqs', 'org.thingsboard.rule.engine.kafka.TbKafkaNode': 'ruleNodeKafka', @@ -506,6 +511,7 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.sms.TbSendSmsNode': 'ruleNodeSendSms', 'org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode': 'ruleNodePushToCloud', 'org.thingsboard.rule.engine.edge.TbMsgPushToEdgeNode': 'ruleNodePushToEdge', + 'org.thingsboard.rule.engine.profile.TbDeviceProfileNode': 'ruleNodeDeviceProfile', 'org.thingsboard.rule.engine.flow.TbRuleChainInputNode': 'ruleNodeRuleChain', 'org.thingsboard.rule.engine.flow.TbRuleChainOutputNode': 'ruleNodeOutputNode', 'org.thingsboard.rule.engine.flow.TbAckNode': 'ruleNodeAcknowledge', diff --git a/ui-ngx/src/app/shared/models/tenant.model.ts b/ui-ngx/src/app/shared/models/tenant.model.ts index 8866b54574..059fe39ada 100644 --- a/ui-ngx/src/app/shared/models/tenant.model.ts +++ b/ui-ngx/src/app/shared/models/tenant.model.ts @@ -83,7 +83,10 @@ export interface DefaultTenantProfileConfiguration { maxWsSubscriptionsPerPublicUser: number; wsUpdatesPerSessionRateLimit: string; - cassandraQueryTenantRateLimitsConfiguration: string; + cassandraWriteQueryTenantCoreRateLimits: string; + cassandraReadQueryTenantCoreRateLimits: string; + cassandraWriteQueryTenantRuleEngineRateLimits: string; + cassandraReadQueryTenantRuleEngineRateLimits: string; edgeEventRateLimits?: string; edgeEventRateLimitsPerEdge?: string; @@ -150,7 +153,10 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan maxWsSubscriptionsPerRegularUser: 0, maxWsSubscriptionsPerPublicUser: 0, wsUpdatesPerSessionRateLimit: '', - cassandraQueryTenantRateLimitsConfiguration: '', + cassandraWriteQueryTenantCoreRateLimits: '', + cassandraReadQueryTenantCoreRateLimits: '', + cassandraWriteQueryTenantRuleEngineRateLimits: '', + cassandraReadQueryTenantRuleEngineRateLimits: '', defaultStorageTtlDays: 0, alarmsTtlDays: 0, rpcTtlDays: 0, diff --git a/ui-ngx/src/app/shared/models/time/time.models.ts b/ui-ngx/src/app/shared/models/time/time.models.ts index c63bd8da52..c204cb6867 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -431,7 +431,7 @@ export const initModelFromDefaultTimewindow = (value: Timewindow, quickIntervalO if (isDefined(value.history.quickInterval)) { model.history.quickInterval = value.history.quickInterval; } - if (isDefined(value.history.fixedTimewindow)) { + if (isDefinedAndNotNull(value.history.fixedTimewindow)) { if (isDefined(value.history.fixedTimewindow.startTimeMs)) { model.history.fixedTimewindow.startTimeMs = value.history.fixedTimewindow.startTimeMs; } @@ -1406,3 +1406,28 @@ export const calculateInterval = (startTime: number, endTime: number, export const getCurrentTimeForComparison = (timeForComparison: moment_.unitOfTime.DurationConstructor, tz?: string): moment_.Moment => getCurrentTime(tz).subtract(1, timeForComparison); + +export const getTimePageLinkInterval = (timewindow: Timewindow): {startTime?: number; endTime?: number} => { + const interval: {startTime?: number; endTime?: number} = {}; + switch (timewindow.history.historyType) { + case HistoryWindowType.LAST_INTERVAL: + const currentTime = Date.now(); + interval.startTime = currentTime - timewindow.history.timewindowMs; + interval.endTime = currentTime; + break; + case HistoryWindowType.FIXED: + interval.startTime = timewindow.history.fixedTimewindow.startTimeMs; + interval.endTime = timewindow.history.fixedTimewindow.endTimeMs; + break; + case HistoryWindowType.INTERVAL: + const startEndTime = calculateIntervalStartEndTime(timewindow.history.quickInterval); + interval.startTime = startEndTime[0]; + interval.endTime = startEndTime[1]; + break; + case HistoryWindowType.FOR_ALL_TIME: + interval.startTime = null; + interval.endTime = null; + break; + } + return interval; +} diff --git a/ui-ngx/src/app/shared/models/units/energy.ts b/ui-ngx/src/app/shared/models/units/energy.ts index 8b81101574..aed2d9f903 100644 --- a/ui-ngx/src/app/shared/models/units/energy.ts +++ b/ui-ngx/src/app/shared/models/units/energy.ts @@ -33,7 +33,7 @@ export type EnergyMetricUnits = | 'GJ' | 'eV'; -export type EnergyImperialUnits = 'kcal' | 'cal' | 'Cal' | 'BTU' | 'ft·lb' | 'thm'; +export type EnergyImperialUnits = 'kcal' | 'cal' | 'Cal' | 'BTU' | 'MBtu' | 'MMBtu' | 'ft·lb' | 'thm'; const METRIC: TbMeasureUnits = { ratio: 1 / 4.184, @@ -117,7 +117,17 @@ const IMPERIAL: TbMeasureUnits = { BTU: { name: 'unit.british-thermal-unit', tags: ['heat', 'work done'], - to_anchor: 252.164401, + to_anchor: 252.1644007218, + }, + MBtu: { + name: 'unit.thousand-british-thermal-unit', + tags: ['heat', 'work done'], + to_anchor: 252164.4007218, + }, + MMBtu : { + name: 'unit.million-british-thermal-unit', + tags: ['heat', 'work done'], + to_anchor: 252164400.7218, }, 'ft·lb': { name: 'unit.foot-pound', diff --git a/ui-ngx/src/app/shared/models/units/power.ts b/ui-ngx/src/app/shared/models/units/power.ts index 937ddde675..0ba9ce802b 100644 --- a/ui-ngx/src/app/shared/models/units/power.ts +++ b/ui-ngx/src/app/shared/models/units/power.ts @@ -19,7 +19,18 @@ import { TbMeasure, TbMeasureUnits } from '@shared/models/unit.models'; export type PowerUnits = PowerMetricUnits | PowerImperialUnits; export type PowerMetricUnits = 'W' | 'μW' | 'mW' | 'kW' | 'MW' | 'GW' | 'PS'; -export type PowerImperialUnits = 'BTU/s' | 'ft-lb/s' | 'hp' | 'BTU/h'; +export type PowerImperialUnits = + | 'ft-lb/s' + | 'hp' + | 'BTU/s' + | 'BTU/h' + | 'BTU/d' + | 'MBtu/s' + | 'MBtu/h' + | 'MBtu/d' + | 'MMBtu/s' + | 'MMBtu/h' + | 'MMBtu/d'; const METRIC: TbMeasureUnits = { ratio: 0.737562149, @@ -65,11 +76,6 @@ const METRIC: TbMeasureUnits = { const IMPERIAL: TbMeasureUnits = { ratio: 1 / 0.737562149, units: { - 'BTU/s': { - name: 'unit.btu-per-second', - tags: ['heat transfer', 'thermal energy'], - to_anchor: 778.16937, - }, 'ft-lb/s': { name: 'unit.foot-pound-per-second', tags: ['mechanical power'], @@ -80,11 +86,51 @@ const IMPERIAL: TbMeasureUnits = { tags: ['performance', 'electricity'], to_anchor: 550, }, + 'BTU/s': { + name: 'unit.btu-per-second', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 778.16937, + }, 'BTU/h': { name: 'unit.btu-per-hour', tags: ['heat transfer', 'thermal energy', 'HVAC'], to_anchor: 0.216158, }, + 'BTU/d': { + name: 'unit.btu-per-day', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 0.00900613, + }, + 'MBtu/s': { + name: 'unit.btu-per-second', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 778169.37, + }, + 'MBtu/h': { + name: 'unit.btu-per-hour', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 216.158, + }, + 'MBtu/d': { + name: 'unit.btu-per-day', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 9.00613, + }, + 'MMBtu/s': { + name: 'unit.btu-per-second', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 778169370, + }, + 'MMBtu/h': { + name: 'unit.btu-per-hour', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 216158, + }, + 'MMBtu/d': { + name: 'unit.btu-per-day', + tags: ['heat transfer', 'thermal energy'], + to_anchor: 9006.13, + }, }, }; diff --git a/ui-ngx/src/app/shared/models/units/speed.ts b/ui-ngx/src/app/shared/models/units/speed.ts index b930b0739c..78433c60ae 100644 --- a/ui-ngx/src/app/shared/models/units/speed.ts +++ b/ui-ngx/src/app/shared/models/units/speed.ts @@ -18,8 +18,8 @@ import { TbMeasure, TbMeasureUnits } from '@shared/models/unit.models'; export type SpeedUnits = SpeedMetricUnits | SpeedImperialUnits; -export type SpeedMetricUnits = 'm/s' | 'km/h' | 'mm/min' | 'mm/s'; -export type SpeedImperialUnits = 'mph' | 'kt' | 'ft/s' | 'ft/min' | 'in/h'; +export type SpeedMetricUnits = 'm/s' | 'km/h' | 'mm/min' | 'm/min' | 'mm/s'; +export type SpeedImperialUnits = 'mph' | 'kt' | 'ft/s' | 'ft/min' | 'in/s' | 'in/h'; const METRIC: TbMeasureUnits = { ratio: 1 / 1.609344, @@ -37,6 +37,11 @@ const METRIC: TbMeasureUnits = { 'mm/min': { name: 'unit.millimeters-per-minute', tags: ['feed rate', 'cutting feed rate'], + to_anchor: 0.00006, + }, + 'm/min': { + name: 'unit.meter-per-minute', + tags: ['velocity', 'pace'], to_anchor: 0.06, }, 'mm/s': { @@ -70,6 +75,11 @@ const IMPERIAL: TbMeasureUnits = { tags: ['velocity', 'pace'], to_anchor: 0.0113636, }, + 'in/s': { + name: 'unit.inch-per-second', + tags: ['velocity', 'pace'], + to_anchor: 0.0568182, + }, 'in/h': { name: 'unit.inch-per-hour', tags: ['velocity', 'pace'], diff --git a/ui-ngx/src/app/shared/models/vc.models.ts b/ui-ngx/src/app/shared/models/vc.models.ts index 3795518ffc..9a4a68e005 100644 --- a/ui-ngx/src/app/shared/models/vc.models.ts +++ b/ui-ngx/src/app/shared/models/vc.models.ts @@ -33,16 +33,20 @@ export const exportableEntityTypes: Array = [ EntityType.WIDGET_TYPE, EntityType.WIDGETS_BUNDLE, EntityType.TB_RESOURCE, + EntityType.OTA_PACKAGE, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, - EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_RULE, + EntityType.AI_MODEL, ]; -export const entityTypesWithoutRelatedData: Set = new Set([ +export const entityTypesWithoutRelatedData = new Set([ EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, - EntityType.TB_RESOURCE + EntityType.TB_RESOURCE, + EntityType.OTA_PACKAGE, + EntityType.AI_MODEL, ]); export interface VersionCreateConfig { 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 193a18cf89..a9f3cd2ed8 100644 --- a/ui-ngx/src/app/shared/models/widget-settings.models.ts +++ b/ui-ngx/src/app/shared/models/widget-settings.models.ts @@ -907,8 +907,6 @@ export abstract class ValueFormatProcessor { export class SimpleValueFormatProcessor extends ValueFormatProcessor { - private readonly isDefinedUnit: boolean; - constructor(protected settings: ValueFormatSettings) { super(settings); this.unitSymbol = !settings.ignoreUnitSymbol && isNotEmptyStr(settings.units) ? (settings.units as string) : null; @@ -917,7 +915,7 @@ export class SimpleValueFormatProcessor extends ValueFormatProcessor { } format(value: any): string { - if (isDefinedAndNotNull(value) && isNumeric(value) && (this.isDefinedDecimals || this.isDefinedUnit || Number(value).toString() === value)) { + if (isDefinedAndNotNull(value) && isNumeric(value) && (this.isDefinedDecimals || this.unitSymbol || Number(value).toString() === value)) { return this.formatValue(Number(value)); } return value ?? ''; diff --git a/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts similarity index 81% rename from ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts rename to ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts index 89d5beb963..1dcd6890d8 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts @@ -17,14 +17,15 @@ import { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; import { FilterInfo, Filters, getFilterId } from '@shared/models/query/query.models'; import { Dashboard } from '@shared/models/dashboard.models'; -import { DatasourceType, Widget } from '@shared/models/widget.models'; +import { Datasource, DatasourceType, Widget } from '@shared/models/widget.models'; import { BaseMapSettings, MapDataLayerSettings, MapDataSourceSettings, + mapDataSourceSettingsToDatasource, MapType } from '@shared/models/widget/maps/map.models'; -import { WidgetExportDefinition } from '@shared/models/widget/widget-export.models'; +import { WidgetModelDefinition } from '@shared/models/widget/widget-model.definition'; interface AliasFilterPair { alias?: EntityAliasInfo, @@ -45,7 +46,7 @@ interface MapDatasourcesInfo { additionalDataSources?: ExportDataSourceInfo; } -export const MapExportDefinition: WidgetExportDefinition = { +export const MapModelDefinition: WidgetModelDefinition = { testWidget(widget: Widget): boolean { if (widget?.config?.settings) { const settings = widget.config.settings; @@ -103,6 +104,26 @@ export const MapExportDefinition: WidgetExportDefinition = { if (info?.additionalDataSources) { updateMapDatasourceFromExportInfo(entityAliases, filters, settings.additionalDataSources, info.additionalDataSources); } + }, + datasources(widget: Widget): Datasource[] { + const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; + const datasources: Datasource[] = []; + if (settings.trips?.length) { + datasources.push(...getMapDataLayersDatasources(settings.trips)); + } + if (settings.markers?.length) { + datasources.push(...getMapDataLayersDatasources(settings.markers)); + } + if (settings.polygons?.length) { + datasources.push(...getMapDataLayersDatasources(settings.polygons)); + } + if (settings.circles?.length) { + datasources.push(...getMapDataLayersDatasources(settings.circles)); + } + if (settings.additionalDataSources?.length) { + datasources.push(...getMapDataLayersDatasources(settings.additionalDataSources)); + } + return datasources; } }; @@ -189,3 +210,16 @@ const prepareAliasAndFilterPair = (dashboard: Dashboard, settings: MapDataSource return null; } } + +const getMapDataLayersDatasources = (settings: MapDataLayerSettings[] | MapDataSourceSettings[]): Datasource[] => { + const datasources: Datasource[] = []; + settings.forEach((dsSettings) => { + datasources.push(mapDataSourceSettingsToDatasource(dsSettings)); + if ((dsSettings as MapDataLayerSettings).additionalDataSources?.length) { + (dsSettings as MapDataLayerSettings).additionalDataSources.forEach((ds) => { + datasources.push(mapDataSourceSettingsToDatasource(ds)); + }); + } + }); + return datasources; +}; diff --git a/ui-ngx/src/app/shared/models/widget/rpc/knob.component.models.ts b/ui-ngx/src/app/shared/models/widget/rpc/knob.component.models.ts new file mode 100644 index 0000000000..3ac15bf70c --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/rpc/knob.component.models.ts @@ -0,0 +1,125 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + DataToValueType, + GetValueAction, + GetValueSettings, + SetValueAction, + SetValueSettings, + ValueToDataType +} from '@shared/models/action-widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { isDefinedAndNotNull } from '@core/utils'; + +export interface KnobSettings { + initialState: GetValueSettings; + valueChange: SetValueSettings; + minValue: number; + maxValue: number; + initialValue: number; + title: string; + getValueMethod?: string; //deprecated + setValueMethod?: string; //deprecated + requestTimeout?: number; //deprecated + requestPersistent?: boolean; //deprecated + persistentPollingInterval?: number; //deprecated +} + +export const knobWidgetDefaultSettings: KnobSettings = { + initialState: { + action: GetValueAction.EXECUTE_RPC, + defaultValue: 50, + executeRpc: { + method: 'getValue', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 5000 + }, + getAttribute: { + key: 'value', + scope: null + }, + getTimeSeries: { + key: 'value' + }, + getAlarmStatus: { + severityList: null, + typeList: null + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return double value */\nreturn data;' + } + }, + valueChange: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setValue', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 5000 + }, + setAttribute: { + key: 'value', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'value' + }, + valueToData: { + type: ValueToDataType.VALUE, + constantValue: 0, + valueToDataFunction: '/* Convert input double value to RPC parameters or attribute/time-series value */\nreturn value;' + } + }, + title: '', + minValue: 0, + maxValue: 100, + initialValue: 50 +} + +export const prepareKnobSettings = (settings: KnobSettings): KnobSettings => { + if (isDefinedAndNotNull(settings.getValueMethod)) { + settings.initialState.executeRpc.method = settings.getValueMethod; + delete settings.getValueMethod; + } + + if (isDefinedAndNotNull(settings.setValueMethod)) { + settings.valueChange.executeRpc.method = settings.setValueMethod; + delete settings.setValueMethod; + } + + if (isDefinedAndNotNull(settings.requestPersistent)) { + settings.initialState.executeRpc.requestPersistent = settings.requestPersistent; + settings.valueChange.executeRpc.requestPersistent = settings.requestPersistent; + delete settings.requestPersistent; + } + + if (isDefinedAndNotNull(settings.persistentPollingInterval)) { + settings.initialState.executeRpc.persistentPollingInterval = settings.persistentPollingInterval; + settings.valueChange.executeRpc.persistentPollingInterval = settings.persistentPollingInterval; + delete settings.persistentPollingInterval; + } + + if (isDefinedAndNotNull(settings.requestTimeout)) { + settings.initialState.executeRpc.requestTimeout = settings.requestTimeout; + settings.valueChange.executeRpc.requestTimeout = settings.requestTimeout; + delete settings.requestTimeout; + } + return settings; +} diff --git a/ui-ngx/src/app/shared/models/widget/widget-export.models.ts b/ui-ngx/src/app/shared/models/widget/widget-model.definition.ts similarity index 68% rename from ui-ngx/src/app/shared/models/widget/widget-export.models.ts rename to ui-ngx/src/app/shared/models/widget/widget-model.definition.ts index 5b47010285..1b54240c09 100644 --- a/ui-ngx/src/app/shared/models/widget/widget-export.models.ts +++ b/ui-ngx/src/app/shared/models/widget/widget-model.definition.ts @@ -14,22 +14,23 @@ /// limitations under the License. /// -import { Widget } from '@shared/models/widget.models'; +import { Datasource, Widget } from '@shared/models/widget.models'; import { Dashboard } from '@shared/models/dashboard.models'; import { EntityAliases } from '@shared/models/alias.models'; import { Filters } from '@shared/models/query/query.models'; -import { MapExportDefinition } from '@shared/models/widget/maps/map-export.models'; +import { MapModelDefinition } from '@shared/models/widget/maps/map-model.definition'; -export interface WidgetExportDefinition { +export interface WidgetModelDefinition { testWidget(widget: Widget): boolean; prepareExportInfo(dashboard: Dashboard, widget: Widget): T; updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: T): void; + datasources(widget: Widget): Datasource[]; } -const widgetExportDefinitions: WidgetExportDefinition[] = [ - MapExportDefinition +const widgetModelRegistry: WidgetModelDefinition[] = [ + MapModelDefinition ]; -export const getWidgetExportDefinition = (widget: Widget): WidgetExportDefinition => { - return widgetExportDefinitions.find(def => def.testWidget(widget)); +export const findWidgetModelDefinition = (widget: Widget): WidgetModelDefinition => { + return widgetModelRegistry.find(def => def.testWidget(widget)); } diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index ec7219d78a..9fd6e4a71e 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -276,6 +276,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: { + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', hideSingleSelectionIndicator: true } }, diff --git a/ui-ngx/src/assets/dashboard/api_usage.json b/ui-ngx/src/assets/dashboard/api_usage.json index 5564223512..9738e9ac0f 100644 --- a/ui-ngx/src/assets/dashboard/api_usage.json +++ b/ui-ngx/src/assets/dashboard/api_usage.json @@ -7885,6 +7885,7 @@ }, "tooltipDateColor": "rgba(0, 0, 0, 0.76)", "tooltipDateInterval": true, + "tooltipHideZeroValues": true, "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", "tooltipBackgroundBlur": 4, "animation": { @@ -8293,6 +8294,7 @@ }, "tooltipDateColor": "rgba(0, 0, 0, 0.76)", "tooltipDateInterval": true, + "tooltipHideZeroValues": true, "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", "tooltipBackgroundBlur": 4, "animation": { diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md index 4c54b01499..1d45dea3c8 100644 --- a/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md +++ b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md @@ -1,7 +1,7 @@ ## Calculated Field TBEL Script Function The **calculate()** function is a user-defined script that enables custom calculations using [TBEL](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/tbel/) on telemetry and attribute data. -It receives arguments configured in the calculated field setup, along with an additional `ctx` object that stores `msgTs` and provides access to all arguments. +It receives arguments configured in the calculated field setup, along with an additional `ctx` object that stores `latestTs` and provides access to all arguments. ### Function Signature @@ -216,14 +216,14 @@ The return format depends on the output type configured in the calculated field ### Message timestamp -The `ctx` object also includes property `msgTs`, which represents the timestamp of the incoming telemetry message that triggered the calculated field execution in milliseconds. +The `ctx` object also includes property `latestTs`, which represents the latest timestamp of the arguments telemetry in milliseconds. -You can use `ctx.msgTs` to set the timestamp of the resulting output explicitly when returning a time series object. +You can use `ctx.latestTs` to set the timestamp of the resulting output explicitly when returning a time series object. ```javascript var temperatureC = (temperatureF - 32) / 1.8; return { - ts: ctx.msgTs, + ts: ctx.latestTs, values: { "temperatureC": toFixed(temperatureC, 2) } diff --git a/ui-ngx/src/assets/help/en_US/notification/resources_shortage.md b/ui-ngx/src/assets/help/en_US/notification/resources_shortage.md new file mode 100644 index 0000000000..6ea03a514c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/resources_shortage.md @@ -0,0 +1,43 @@ +#### Resources shortage notification templatization + +
+
+ +Notification subject and message fields support templatization. +The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + +* `resource` - the resource name (e.g., "CPU", "RAM", "STORAGE"); +* `usage` - the current usage value of the resource; +* `serviceId` - the service id (convenient in cluster setup); +* `serviceType` - the service type (convenient in cluster setup); + +Parameter names must be wrapped using `${...}`. For example: `${resource}`. +You may also modify the value of the parameter with one of the suffixes: + +* `upperCase`, for example - `${resource:upperCase}` +* `lowerCase`, for example - `${resource:lowerCase}` +* `capitalize`, for example - `${resource:capitalize}` + +
+ +##### Examples + +Let's assume there is a resource usage shortage and the system is low on free resources (CPU, RAM, or Storage). +The following template: + +```text +Warning: ${resource} is critically high at ${usage}% +{:copy-code} +``` + +will be transformed to: + +```text +Warning: CPU is critically high at 83% +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md b/ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md new file mode 100644 index 0000000000..e2577244c1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md @@ -0,0 +1,75 @@ +#### Example Usage: AI-Powered Alarm Analysis +This example demonstrates how to use the AI node to automatically analyze a new device alarm, generate a human-readable summary, and suggest troubleshooting steps. + +##### Scenario +An IoT freezer unit generates a "High Temperature" alarm. We want the AI to process this alarm data to create a clear summary and a recommended action plan for an operator. + +1. **Incoming message structure** + +Message body (represents an alarm, some alarm fields omitted for brevity): +```json +{ + "type": "High Temperature", + "details": { + "currentTemp_C": -5, + "threshold_C": -18 + } +} +``` + +Message metadata: +```json +{ + "deviceName": "Freezer-B7", + "deviceType": "CommercialFreezer" +} +``` + +2. **Prompt configuration** + +**System prompt** + +Here, we set the AI's role and enforce a strict JSON output format. This ensures the output is always machine-parsable. +``` +You are an expert AI assistant for IoT operations. +Your task is to analyze device data and respond with a single, valid JSON object. +Do not include any text, explanations, or markdown formatting before or after the JSON output. +``` + +**User prompt** + +This prompt defines the specific task, using templates to dynamically insert data from the incoming alarm message. +``` +Analyze the following alarm from a "${deviceType}" unit named "${deviceName}". + +Alarm Data: +$[*] + +Based on the alarm data, generate a JSON object with two keys: +1. "summary": A brief, human-readable summary of the event. +2. "action": A concrete, recommended next step for an operator. +``` + +3. **How it works** + +When the alarm message from "Freezer-B7" is processed by the AI node, the templates are substituted with the actual data: +- `${deviceName}` becomes "Freezer-B7" +- `${deviceType}` becomes "CommercialFreezer" +- `$[*]` is replaced by the entire message body JSON: `{"type":"High Temperature","details":{"currentTemp_C":-5,"threshold_C":-18}}` +> **Note:** You can also use `${*}`. In this case, it will be replaced with the entire message metadata JSON. + +The final instruction sent to the AI is a combination of the system and the substituted user prompt. + +4. **Expected AI output** + +Given the combined instructions, the AI would generate the following structured JSON output, which can then be used in subsequent rule nodes (e.g., to send an enriched email). + +```json +{ + "summary": "Critical high temperature alert on freezer unit Freezer-B7. The current temperature is -5°C, which is significantly above the required threshold of -18°C.", + "action": "Dispatch technician immediately to inspect the unit's cooling system and ensure the door is properly sealed. Investigate for potential power issues." +} +``` + +> **Note:** The scenario above is a hypothetical example designed to illustrate the functionality of the node and its templating capabilities. +> The specific details, such as freezer alarms, are used for demonstration purposes and are not intended to suggest or limit the potential use cases. diff --git a/ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md index c4092455fa..49f0dd0a2b 100644 --- a/ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md +++ b/ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md @@ -91,11 +91,5 @@ return []; {:copy-code} ``` -
- -You can see real life example, how to use this node in this tutorial: - -- [Data function based on telemetry from 2 devices{:target="_blank"}](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/tutorials/function-based-on-telemetry-from-two-devices#delta-temperature-rule-chain) -

diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md index 2461f4cace..77da936c73 100644 --- a/ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md @@ -91,11 +91,5 @@ return []; {:copy-code} ``` -
- -You can see real life example, how to use this node in this tutorial: - -- [Data function based on telemetry from 2 devices{:target="_blank"}](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/tutorials/function-based-on-telemetry-from-two-devices#delta-temperature-rule-chain) -

diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md index 0028d77b82..7d85ffed26 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md @@ -3,7 +3,7 @@

-*function (data, dsData): string* +*function (data, dsData, ctx): string* A JavaScript function used to compute text or HTML code of the marker label. diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md index a627e1867f..3df8c8324a 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md @@ -5,4 +5,6 @@ resolved from configured datasources. Each object represents basic entity properties (ex. entityId, entityName)
and provides access to other entity attributes/timeseries declared in datasources of data layers configuration including additional datasources of the map configuration. - +
  • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
  • 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 61de661ac9..f5bebc2798 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ar_AE.json +++ b/ui-ngx/src/assets/locale/locale.constant-ar_AE.json @@ -5247,7 +5247,6 @@ "add-limit": "إضافة حد", "advanced-settings": "الإعدادات المتقدمة", "edit-limit": "تعديل الحد", - "but-less-than": "ولكن أقل من", "edit-transport-tenant-msg-title": "تعديل حدود معدل رسائل نقل المستأجر", "edit-transport-tenant-telemetry-msg-title": "تعديل حدود معدل رسائل تليمتري نقل المستأجر", "edit-transport-tenant-telemetry-data-points-title": "تعديل حدود معدل نقاط بيانات تليمتري نقل المستأجر", diff --git a/ui-ngx/src/assets/locale/locale.constant-da_DK.json b/ui-ngx/src/assets/locale/locale.constant-da_DK.json index d9a5172664..8bff960467 100644 --- a/ui-ngx/src/assets/locale/locale.constant-da_DK.json +++ b/ui-ngx/src/assets/locale/locale.constant-da_DK.json @@ -4808,7 +4808,7 @@ "use-simple-client-http-factory": "Brug simpel HTTP-klientfabrik", "ignore-request-body": "Uden forespørgselsindhold", "parse-to-plain-text": "Fortolk som almindelig tekst", - "parse-to-plain-text-hint": "Hvis valgt, transformeres JSON-strengen i beskedens indhold til almindelig tekst.", + "parse-to-plain-text-hint": "Hvis valgt, vil meddelelsens anmodningsindhold blive konverteret fra JSON-streng til almindelig tekst, f.eks. vil msg = \"Hello,\\t\"world\"\" blive analyseret til Hello, \"world\"", "read-timeout": "Læs timeout i millisekunder", "read-timeout-hint": "Værdien 0 betyder uendelig timeout", "max-parallel-requests-count": "Maksimalt antal parallelle forespørgsler", @@ -4816,7 +4816,7 @@ "max-response-size": "Maks. svarstørrelse (i KB)", "max-response-size-hint": "Maksimal hukommelse tildelt til buffering ved afkodning/enkodning af HTTP-meddelelser", "headers": "Headers", - "headers-hint": "Brug ${metadataKey} for metadata eller $[messageKey] for beskeddata i header/værdi felter", + "headers-hint": "Brug ${metadataKey} for værdi fra metadata, $[messageKey] for værdi fra meddelelsesindhold i header/værdifelter", "header": "Header", "header-required": "Header er påkrævet", "value": "Værdi", @@ -4884,7 +4884,7 @@ "pubsub-topic-name": "Emnenavn", "pubsub-topic-name-required": "Emnenavn er påkrævet", "message-attributes": "Beskedattributter", - "message-attributes-hint": "Brug ${metadataKey} eller $[messageKey] i navn/værdi felter", + "message-attributes-hint": "Brug ${metadataKey} for værdi fra metadata, $[messageKey] for værdi fra meddelelsesindhold i navn-/værdifelter", "connect-timeout": "Forbindelsestimeout (sek)", "connect-timeout-required": "Forbindelsestimeout er påkrævet.", "connect-timeout-range": "Timeout skal være mellem 1 og 200.", @@ -5073,18 +5073,18 @@ "failure-if-delta-negative": "Rapportér fejl ved negativ delta", "failure-if-delta-negative-tooltip": "Fejl ved negativ forskel mellem værdier.", "use-caching": "Brug caching", - "use-caching-tooltip": "Cacher input-værdi for performance.", - "add-time-difference-between-readings": "Tilføj tidsforskel mellem aflæsninger", - "add-time-difference-between-readings-tooltip": "Tilføjer \"{{periodValueKey}}\" til beskeden.", - "period-value-key": "Tidsforskel nøgle", - "period-value-key-required": "Tidsforskel nøgle er påkrævet.", - "general-pattern-hint": "Brug ${metadataKey} eller $[messageKey]", - "alarm-severity-pattern-hint": "Angiv alarmniveau (CRITICAL, MAJOR, etc.) via metadata eller besked.", - "output-node-name-hint": "Navn på regelnode bruges som relationstype til videresendelse.", - "use-server-ts": "Brug server-tidsstempel", - "use-server-ts-hint": "Sikrer korrekt rækkefølge ved manglende tidsstempel.", - "kv-map-pattern-hint": "Alle felter understøtter templatization.", - "kv-map-single-pattern-hint": "Felt understøtter templatization.", + "use-caching-tooltip": "Regelknuden vil cache værdien af \"{{inputValueKey}}\" fra den indkommende besked for at forbedre ydeevnen. Bemærk, at cachen ikke opdateres, hvis \"{{inputValueKey}}\" ændres et andet sted.", + "add-time-difference-between-readings": "Tilføj tidsforskel mellem aflæsninger af \"{{inputValueKey}}\"", + "add-time-difference-between-readings-tooltip": "Hvis aktiveret, tilføjer regelknuden \"{{periodValueKey}}\" til den udgående besked.", + "period-value-key": "Tidsintervalnøgle", + "period-value-key-required": "Tidsintervalnøgle er påkrævet.", + "general-pattern-hint": "Brug ${metadataKey} for værdi fra metadata, $[messageKey] for værdi fra meddelelsesindhold.", + "alarm-severity-pattern-hint": "Brug ${metadataKey} for værdi fra metadata, $[messageKey] for værdi fra meddelelsesindhold. Alarmens alvorlighed skal være et systemniveau (CRITICAL, MAJOR osv.)", + "output-node-name-hint": "Regelknudens navn svarer til relationstypen for den udgående besked og bruges til at videresende beskeder til andre regelknuder i den kaldende regelkæde.", + "use-server-ts": "Brug serverens tidsstempel", + "use-server-ts-hint": "Brug serverens aktuelle tidsstempel for tidsseriedata, der mangler et eksplicit tidsstempel. Dette hjælper med korrekt rækkefølge, når der behandles beskeder fra flere kilder eller ved uordnet ankomst.", + "kv-map-pattern-hint": "Alle inputfelter understøtter skabelonisering. Brug $[messageKey] for værdi fra beskeden og ${metadataKey} for værdi fra metadata.", + "kv-map-single-pattern-hint": "Inputfelt understøtter skabelonisering. Brug $[messageKey] for værdi fra beskeden og ${metadataKey} for værdi fra metadata.", "shared-scope": "Delt område", "server-scope": "Serverområde", "client-scope": "Klientområde", @@ -5640,7 +5640,6 @@ "add-limit": "Tilføj begrænsning", "advanced-settings": "Avancerede indstillinger", "edit-limit": "Rediger begrænsning", - "but-less-than": "men mindre end", "calculated-field-debug-event-rate-limit": "Fejlfindingshændelser for beregnede felter", "edit-calculated-field-debug-event-rate-limit": "Rediger hastighedsgrænser for fejlfindingshændelser for beregnede felter", "edit-transport-tenant-msg-title": "Rediger grænse for transportlejers beskeder", @@ -9192,33 +9191,6 @@ "items-per-page-separator": "af" }, "language": { - "language": "Language", - "locales": { - "ar_AE": "العربية (الإمارات العربية المتحدة)", - "ca_ES": "català (Espanya)", - "cs_CZ": "čeština (Česko)", - "da_DK": "dansk (Danmark)", - "de_DE": "Deutsch (Deutschland)", - "el_GR": "Ελληνικά (Ελλάδα)", - "en_US": "English (United States)", - "es_ES": "español (España)", - "fa_IR": "فارسی (ایران)", - "fr_FR": "français (France)", - "it_IT": "italiano (Italia)", - "ja_JP": "日本語 (日本)", - "ka_GE": "ქართული (საქართველო)", - "ko_KR": "한국어 (대한민국)", - "lt_LT": "lietuvių (Lietuva)", - "lv_LV": "latviešu (Latvija)", - "nl_BE": "Nederlands (België)", - "pl_PL": "polski (Polska)", - "pt_BR": "português (Brasil)", - "ro_RO": "română (România)", - "sl_SI": "slovenščina (Slovenija)", - "tr_TR": "Türkçe (Türkiye)", - "uk_UA": "українська (Україна)", - "zh_CN": "中文 (中国)", - "zh_TW": "中文 (台灣)" - } + "language": "Language" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index 766cac37f2..f48081bee3 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -4814,7 +4814,7 @@ "max-parallel-requests-count": "Max. Anzahl paralleler Anfragen", "max-parallel-requests-count-hint": "Wert 0 bedeutet unbegrenzte Parallelität", "max-response-size": "Maximale Antwortgröße (in KB)", - "max-response-size-hint": "Maximaler Speicher für das Puffern von Daten beim Kodieren/Decodieren von HTTP-Nachrichten (z. B. JSON, XML)", + "max-response-size-hint": "Die maximale Menge an Speicher, die zum Puffern von Daten beim Dekodieren oder Kodieren von HTTP-Nachrichten wie JSON- oder XML-Nutzdaten reserviert wird", "headers": "Header", "headers-hint": "Verwenden Sie ${metadataKey} für Werte aus den Metadaten und $[messageKey] für Werte aus dem Nachrichtentext in Header-/Wert-Feldern.", "header": "Header", @@ -4964,7 +4964,7 @@ "proxy-scheme": "Proxy-Schema", "numbers-to-template": "Telefonnummern zum Template", "numbers-to-template-required": "Telefonnummern zum Template sind erforderlich", - "numbers-to-template-hint": "Kommagetrennte Telefonnummern. Verwenden Sie ${metadataKey} für Werte aus Metadaten und $[messageKey] für Werte aus dem Nachrichtentext", + "numbers-to-template-hint": "Kommagetrennte Telefonnummern. Verwenden Sie ${metadataKey} für Werte aus Metadaten und $[messageKey] für Werte aus dem Nachrichtentext", "sms-message-template": "SMS-Nachrichtenvorlage", "sms-message-template-required": "SMS-Nachrichtenvorlage ist erforderlich", "use-system-sms-settings": "Systemweite SMS-Anbietereinstellungen verwenden", @@ -5045,7 +5045,7 @@ "tell-failure-if-absent": "Fehler melden", "tell-failure-if-absent-hint": "Wenn mindestens ein ausgewählter Schlüssel nicht vorhanden ist, wird die ausgehende Nachricht mit \"Fehler\" gekennzeichnet.", "get-latest-value-with-ts": "Zeitstempel für letzte Telemetriewerte abrufen", - "get-latest-value-with-ts-hint": "Wenn ausgewählt, enthalten die letzten Telemetriewerte auch einen Zeitstempel, z. B.: \"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", + "get-latest-value-with-ts-hint": "Wenn ausgewählt, enthalten die neuesten Telemetriedaten auch einen Zeitstempel, z.B.: \"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", "ignore-null-strings": "Leere Zeichenfolgen ignorieren", "ignore-null-strings-hint": "Wenn ausgewählt, ignoriert der Regelknoten Entitätsfelder mit leerem Wert.", "add-metadata-key-values-as-kafka-headers": "Metadaten-Schlüssel-Werte-Paare als Kafka-Header hinzufügen", @@ -5079,7 +5079,7 @@ "period-value-key": "Zeitraum-Wert-Schlüssel", "period-value-key-required": "Zeitraum-Wert-Schlüssel ist erforderlich.", "general-pattern-hint": "Verwenden Sie ${metadataKey} für Metadatenwert, $[messageKey] für Nachrichtenwert.", - "alarm-severity-pattern-hint": "Verwenden Sie ${metadataKey} für Metadatenwert, $[messageKey] für Nachrichtenwert. Alarm-Schweregrade sollten systemdefiniert sein (CRITICAL, MAJOR usw.)", + "alarm-severity-pattern-hint": "Verwenden Sie ${metadataKey} für Metadatenwert, $[messageKey] für Nachrichtenwert. Alarm-Schweregrade sollten systemdefiniert sein (CRITICAL, MAJOR usw.)", "output-node-name-hint": "Der Name des Regelknotens entspricht dem Beziehungstyp der Ausgabemeldung und wird verwendet, um Nachrichten an andere Knoten in der Regelkette weiterzuleiten.", "use-server-ts": "Server-Zeitstempel verwenden", "use-server-ts-hint": "Verwenden Sie den aktuellen Zeitstempel des Servers für Zeitreihen ohne expliziten Zeitstempel. Dies hilft, die Reihenfolge bei mehreren Quellen oder verspäteten Nachrichten zu wahren.", @@ -5640,7 +5640,6 @@ "add-limit": "Limit hinzufügen", "advanced-settings": "Erweiterte Einstellungen", "edit-limit": "Limit bearbeiten", - "but-less-than": "aber weniger als", "calculated-field-debug-event-rate-limit": "Berechnete Feld-Debug-Ereignisse", "edit-calculated-field-debug-event-rate-limit": "Limit für berechnete Feld-Debug-Ereignisse bearbeiten", "edit-transport-tenant-msg-title": "Limit für Transport-Mieter-Nachrichten bearbeiten", @@ -9192,33 +9191,6 @@ "items-per-page-separator": "von" }, "language": { - "language": "Sprache", - "locales": { - "ar_AE": "العربية (الإمارات العربية المتحدة)", - "ca_ES": "katalanisch (Spanien)", - "cs_CZ": "tschechisch (Tschechien)", - "da_DK": "dänisch (Dänemark)", - "de_DE": "Deutsch (Deutschland)", - "el_GR": "griechisch (Griechenland)", - "en_US": "Englisch (USA)", - "es_ES": "spanisch (Spanien)", - "fa_IR": "persisch (Iran)", - "fr_FR": "französisch (Frankreich)", - "it_IT": "italienisch (Italien)", - "ja_JP": "japanisch (Japan)", - "ka_GE": "georgisch (Georgien)", - "ko_KR": "koreanisch (Südkorea)", - "lt_LT": "litauisch (Litauen)", - "lv_LV": "lettisch (Lettland)", - "nl_BE": "niederländisch (Belgien)", - "pl_PL": "polnisch (Polen)", - "pt_BR": "portugiesisch (Brasilien)", - "ro_RO": "rumänisch (Rumänien)", - "sl_SI": "slowenisch (Slowenien)", - "tr_TR": "türkisch (Türkei)", - "uk_UA": "ukrainisch (Ukraine)", - "zh_CN": "chinesisch (China)", - "zh_TW": "chinesisch (Taiwan)" - } + "language": "Sprache" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json index 164b5486b9..151dbeb528 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -1,20 +1,25 @@ { "access": { - "unauthorized": "Χωρίς δικαιώματα πρόσβασης", + "unauthorized": "Μη εξουσιοδοτημένο", "unauthorized-access": "Μη εξουσιοδοτημένη πρόσβαση", - "unauthorized-access-text": "Θα πρέπει να συνδεθείτε για να έχετε πρόσβαση σε αυτόν τον πόρο!", - "access-forbidden": "Απαγορεύεται η πρόσβαση!", - "access-forbidden-text": "Δεν έχετε δικαιώματα πρόσβασης σε αυτήν την τοποθεσία!
    Συνδεθείτε με διαφορετικό όνομα χρήστη, αν εξακολουθείτε να θέλετε να έχετε πρόσβαση σε αυτήν την τοποθεσία.", - "refresh-token-expired": "Η περίοδος χρήσης έχει λήξει", - "refresh-token-failed": "Δεν είναι δυνατή η ανανέωση της περιόδου χρήσης", - "permission-denied": "Άρνηση πρόσβασης!", - "permission-denied-text": "Δεν έχετε δικαίωμα εκτέλεσης αυτής της λειτουργίας!" + "unauthorized-access-text": "Πρέπει να συνδεθείτε για να έχετε πρόσβαση σε αυτόν τον πόρο!", + "access-forbidden": "Απαγορευμένη πρόσβαση", + "access-forbidden-text": "Δεν έχετε δικαιώματα πρόσβασης σε αυτήν την τοποθεσία!
    Δοκιμάστε να συνδεθείτε με διαφορετικό χρήστη αν εξακολουθείτε να θέλετε να αποκτήσετε πρόσβαση.", + "refresh-token-expired": "Η συνεδρία έληξε", + "refresh-token-failed": "Αδυναμία ανανέωσης συνεδρίας", + "permission-denied": "Η άδεια απορρίφθηκε", + "permission-denied-text": "Δεν έχετε άδεια να εκτελέσετε αυτήν τη λειτουργία!" + }, + "account": { + "account": "Λογαριασμός", + "notification-settings": "Ρυθμίσεις ειδοποιήσεων" }, "action": { "activate": "Ενεργοποίηση", "suspend": "Αναστολή", "save": "Αποθήκευση", "saveAs": "Αποθήκευση ως", + "move": "Μετακίνηση", "cancel": "Ακύρωση", "ok": "OK", "delete": "Διαγραφή", @@ -22,306 +27,919 @@ "yes": "Ναι", "no": "Όχι", "update": "Ενημέρωση", - "remove": "Διαγραφή", + "remove": "Αφαίρεση", "search": "Αναζήτηση", - "clear-search": "Καθαρισμός Αναζήτησης", + "clear-search": "Καθαρισμός αναζήτησης", "assign": "Ανάθεση", - "unassign": "Ακύρωση Ανάθεσης", - "share": "Διαμοιρασμός", - "make-private": "Ιδιωτικό", + "unassign": "Αποανάθεση", + "share": "Κοινή χρήση", + "make-private": "Ορισμός ως ιδιωτικό", "apply": "Εφαρμογή", - "apply-changes": "Εφααρμογή Αλλαγών", - "edit-mode": "Λειτουργία Επεξεργασίας", - "enter-edit-mode": "Έναρξη Επεξεργασίας", - "decline-changes": "Απόρριψη Αλλαγών", + "apply-changes": "Εφαρμογή αλλαγών", + "edit-mode": "Λειτουργία επεξεργασίας", + "enter-edit-mode": "Είσοδος σε λειτουργία επεξεργασίας", + "decline-changes": "Απόρριψη αλλαγών", + "decline": "Απόρριψη", "close": "Κλείσιμο", "back": "Πίσω", "run": "Εκτέλεση", "sign-in": "Σύνδεση!", "edit": "Επεξεργασία", - "view": "Επισκόπηση", + "view": "Προβολή", "create": "Δημιουργία", - "drag": "Drag", + "drag": "Σύρσιμο", "refresh": "Ανανέωση", "undo": "Αναίρεση", "copy": "Αντιγραφή", "paste": "Επικόλληση", - "copy-reference": "Αντιγραφή παραπομπής", - "paste-reference": "Επικόλληση παραπομπής", + "copy-reference": "Αντιγραφή αναφοράς", + "paste-reference": "Επικόλληση αναφοράς", "import": "Εισαγωγή", "export": "Εξαγωγή", - "share-via": "Διαμοίραση μέσω {{provider}}", - "move": "Μετακίνηση", + "share-via": "Κοινή χρήση μέσω {{provider}}", "select": "Επιλογή", "continue": "Συνέχεια", - "done": "Ολοκληρώθηκε" + "discard-changes": "Απόρριψη αλλαγών", + "download": "Λήψη", + "next": "Επόμενο", + "next-with-label": "Επόμενο: {{label}}", + "read-more": "Διαβάστε περισσότερα", + "hide": "Απόκρυψη", + "test": "Δοκιμή", + "done": "Ολοκληρώθηκε", + "print": "Εκτύπωση", + "restore": "Επαναφορά", + "confirm": "Επιβεβαίωση", + "more": "Περισσότερα", + "less": "Λιγότερα", + "skip": "Παράλειψη", + "send": "Αποστολή", + "reset": "Επαναφορά", + "show-more": "Εμφάνιση περισσότερων", + "dont-show-again": "Να μην εμφανιστεί ξανά", + "see-documentation": "Δείτε την τεκμηρίωση", + "clear": "Καθαρισμός", + "upload": "Μεταφόρτωση", + "delete-anyway": "Διαγραφή ούτως ή άλλως", + "delete-selected": "Διαγραφή επιλεγμένων", + "set": "Ορισμός" }, "aggregation": { - "aggregation": "Συνάθροιση", - "function": "Συνάρτηση συνάθροισης δεδομένων", + "aggregation": "Συσσώρευση", + "function": "Συνάρτηση συσσώρευσης δεδομένων", "limit": "Μέγιστες τιμές", "group-interval": "Διάστημα ομαδοποίησης", - "min": "Min", - "max": "Max", - "avg": "Μέσος Όρος", + "min": "Ελάχιστο", + "max": "Μέγιστο", + "avg": "Μέσος όρος", "sum": "Άθροισμα", - "count": "Καταμέτρηση", - "none": "Κανένα" + "count": "Πλήθος", + "none": "Καμία" }, "admin": { + "settings": "Ρυθμίσεις", "general": "Γενικά", - "general-settings": "Γενικές Ρυθμίσεις", - "outgoing-mail": "Διακομιστής Αλληλογραφίας", - "outgoing-mail-settings": "Διακομιστής Εξερχόμενης Αλληλογραφίας", - "system-settings": "Ρυθμίσεις Συστήματος", - "test-mail-sent": "Το δοκιμαστικό μήνυμα στάλθηκε με επιτυχία!", + "general-settings": "Γενικές ρυθμίσεις", + "home-settings": "Ρυθμίσεις αρχικής σελίδας", + "home": "Αρχική", + "outgoing-mail": "Διακομιστής αλληλογραφίας", + "outgoing-mail-settings": "Ρυθμίσεις εξερχόμενης αλληλογραφίας", + "system-settings": "Ρυθμίσεις συστήματος", + "test-mail-sent": "Το δοκιμαστικό email εστάλη με επιτυχία!", "base-url": "Βασική διεύθυνση URL", - "base-url-required": "Απαιτείται ορισμός Base URL.", - "mail-from": "Αποστολέας", - "mail-from-required": "Απαιτείται βασική διεύθυνση URL.", + "base-url-required": "Η βασική διεύθυνση URL είναι υποχρεωτική.", + "prohibit-different-url": "Απαγόρευση χρήσης ονόματος κεντρικού υπολογιστή από τις κεφαλίδες αιτήσεων του πελάτη", + "prohibit-different-url-hint": "Αυτή η ρύθμιση θα πρέπει να είναι ενεργοποιημένη σε περιβάλλοντα παραγωγής. Ενδέχεται να προκαλέσει προβλήματα ασφαλείας όταν είναι απενεργοποιημένη", + "device-connectivity": { + "device-connectivity": "Συνδεσιμότητα συσκευής", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPs", + "mqtt": "MQTT", + "mqtts": "MQTTs", + "coap": "COAP", + "coaps": "COAPs", + "hint": "Αν τα πεδία κεντρικού υπολογιστή ή θύρας είναι κενά, θα χρησιμοποιηθεί η προεπιλεγμένη τιμή πρωτοκόλλου.", + "host": "Κεντρικός υπολογιστής", + "port": "Θύρα", + "port-pattern": "Η θύρα πρέπει να είναι θετικός ακέραιος αριθμός.", + "port-range": "Η θύρα πρέπει να είναι στην περιοχή από 1 έως 65535." + }, + "mail-from": "Από διεύθυνση email", + "mail-from-required": "Η από διεύθυνση email είναι υποχρεωτική.", "smtp-protocol": "Πρωτόκολλο SMTP", - "smtp-host": "SMTP host", - "smtp-host-required": "Απαιτείται ορισμός SMTP host.", + "smtp-host": "Διακομιστής SMTP", + "smtp-host-required": "Ο διακομιστής SMTP είναι υποχρεωτικός.", "smtp-port": "Θύρα SMTP", - "smtp-port-required": "Πρέπει να εισάγετε SMTP port.", - "smtp-port-invalid": "Αυτή δε φαίνεται να είναι μια έγκυρη SMTP port.", - "timeout-msec": "Timeout (msec)", - "timeout-required": "Απαιτείται τιμή Timeout.", - "timeout-invalid": "Αυτή δε φαίνεται να είναι μια έγκυρη τιμή timeout.", + "smtp-port-required": "Πρέπει να καθορίσετε θύρα SMTP.", + "smtp-port-invalid": "Αυτό δεν φαίνεται να είναι έγκυρη θύρα SMTP.", + "timeout-msec": "Χρονικό όριο (msec)", + "timeout-required": "Το χρονικό όριο είναι υποχρεωτικό.", + "timeout-invalid": "Αυτό δεν φαίνεται να είναι έγκυρο χρονικό όριο.", "enable-tls": "Ενεργοποίηση TLS", "tls-version": "Έκδοση TLS", - "send-test-mail": "Αποστολή δοκιμαστικού μηνύματος", - "use-system-mail-settings": "Χρήση των ρυθμίσεων διακομιστή αλληλογραφίας συστήματος", - "mail-templates": "Πρότυπα αλληλογραφίας", - "mail-template-settings": "Ρυθμίσεις προτύπων αλληλογραφίας", - "use-system-mail-template-settings": "Χρήση προτύπων ηλεκτρονικού ταχυδρομείου συστήματος", - "mail-template": { - "mail-template": "Πρότυπο αλληλογραφίας", - "test": "Δοκιμαστικό μήνυμα ηλεκτρονικού ταχυδρομείου", - "activation": "Μήνυμα ενεργοποίησης λογαριασμού", - "account-activated": "Μήνυμα επιτυχούς ενεργοποίησης λογαριασμού", - "reset-password": "Μήνυμα επαναφοράς κωδικού πρόσβασης", - "password-was-reset": "Μήνυμα επαναφοράς κωδικού πρόσβασης", - "user-activated": "Μήνυμα ενεργοποίησης χρήστη", - "user-registered": "Μήνυμα καταχώρησης χρήστη" - }, - "mail-subject": "Θέμα μηνύματος (subject)", - "mail-body": "Κυρίως μήνυμα (body)" + "enable-proxy": "Ενεργοποίηση μεσολάβησης", + "proxy-host": "Διακομιστής μεσολάβησης", + "proxy-host-required": "Ο διακομιστής μεσολάβησης είναι υποχρεωτικός.", + "proxy-port": "Θύρα μεσολάβησης", + "proxy-port-required": "Η θύρα μεσολάβησης είναι υποχρεωτική.", + "proxy-port-range": "Η θύρα μεσολάβησης πρέπει να είναι μεταξύ 1 και 65535.", + "proxy-user": "Χρήστης μεσολάβησης", + "proxy-password": "Κωδικός μεσολάβησης", + "change-password": "Αλλαγή κωδικού", + "send-test-mail": "Αποστολή δοκιμαστικού email", + "sms-provider": "Πάροχος SMS", + "sms-provider-settings": "Ρυθμίσεις παρόχου SMS", + "sms-provider-type": "Τύπος παρόχου SMS", + "sms-provider-type-required": "Ο τύπος παρόχου SMS είναι υποχρεωτικός.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "Κλειδί πρόσβασης AWS", + "aws-access-key-id-required": "Το κλειδί πρόσβασης AWS είναι υποχρεωτικό", + "aws-secret-access-key": "Μυστικό κλειδί AWS", + "aws-secret-access-key-required": "Το μυστικό κλειδί AWS είναι υποχρεωτικό", + "aws-region": "Περιοχή AWS", + "aws-region-required": "Η περιοχή AWS είναι υποχρεωτική", + "number-from": "Αριθμός αποστολέα", + "number-from-required": "Ο αριθμός αποστολέα είναι υποχρεωτικός.", + "number-to": "Αριθμός παραλήπτη", + "number-to-required": "Ο αριθμός παραλήπτη είναι υποχρεωτικός.", + "phone-number-hint": "Αριθμός σε μορφή E.164, π.χ. +19995550123", + "phone-number-hint-twilio": "Αριθμός σε μορφή E.164/SID αριθμού/Messaging Service SID, π.χ. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Μη έγκυρος αριθμός τηλεφώνου. Πρέπει να είναι σε μορφή E.164, π.χ. +19995550123.", + "phone-number-pattern-twilio": "Μη έγκυρος αριθμός. Πρέπει να είναι σε μορφή E.164/SID αριθμού/Messaging Service SID, π.χ. +19995550123/PNXXX/MGXXX.", + "sms-message": "Μήνυμα SMS", + "sms-message-required": "Το μήνυμα SMS είναι υποχρεωτικό.", + "sms-message-max-length": "Το μήνυμα SMS δεν μπορεί να υπερβαίνει τους 1600 χαρακτήρες", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Το Twilio Account SID είναι υποχρεωτικό", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Το Twilio Account Token είναι υποχρεωτικό", + "send-test-sms": "Αποστολή δοκιμαστικού SMS", + "test-sms-sent": "Το δοκιμαστικό SMS στάλθηκε με επιτυχία!", + "security-settings": "Ρυθμίσεις ασφαλείας", + "password-policy": "Πολιτική κωδικών πρόσβασης", + "minimum-password-length": "Ελάχιστο μήκος κωδικού", + "minimum-password-length-required": "Το ελάχιστο μήκος κωδικού είναι υποχρεωτικό", + "minimum-password-length-range": "Το ελάχιστο μήκος πρέπει να είναι από 6 έως 50 χαρακτήρες", + "maximum-password-length": "Μέγιστο μήκος κωδικού", + "maximum-password-length-min": "Το μέγιστο μήκος πρέπει να είναι τουλάχιστον 6 χαρακτήρες", + "maximum-password-length-less-min": "Το μέγιστο μήκος πρέπει να είναι μεγαλύτερο από το ελάχιστο", + "minimum-uppercase-letters": "Ελάχιστος αριθμός κεφαλαίων γραμμάτων", + "minimum-uppercase-letters-range": "Ο ελάχιστος αριθμός κεφαλαίων δεν μπορεί να είναι αρνητικός", + "minimum-lowercase-letters": "Ελάχιστος αριθμός πεζών γραμμάτων", + "minimum-lowercase-letters-range": "Ο ελάχιστος αριθμός πεζών δεν μπορεί να είναι αρνητικός", + "minimum-digits": "Ελάχιστος αριθμός ψηφίων", + "minimum-digits-range": "Ο ελάχιστος αριθμός ψηφίων δεν μπορεί να είναι αρνητικός", + "minimum-special-characters": "Ελάχιστος αριθμός ειδικών χαρακτήρων", + "minimum-special-characters-range": "Ο ελάχιστος αριθμός ειδικών χαρακτήρων δεν μπορεί να είναι αρνητικός", + "password-expiration-period-days": "Περίοδος λήξης κωδικού σε ημέρες", + "password-expiration-period-days-range": "Η περίοδος λήξης δεν μπορεί να είναι αρνητική", + "password-reuse-frequency-days": "Συχνότητα επαναχρησιμοποίησης κωδικού σε ημέρες", + "password-reuse-frequency-days-range": "Η συχνότητα επαναχρησιμοποίησης δεν μπορεί να είναι αρνητική", + "allow-whitespace": "Να επιτρέπονται κενά", + "force-reset-password-if-no-valid": "Εξαναγκασμός επαναφοράς κωδικού αν δεν είναι έγκυρος", + "force-reset-password-if-no-valid-hint": "Προσοχή κατά την ενεργοποίηση αυτής της δυνατότητας: θα απαιτείται από τους χρήστες με μη έγκυρο κωδικό να επαναφέρουν τον κωδικό τους μέσω email.", + "general-policy": "Γενική πολιτική", + "max-failed-login-attempts": "Μέγιστος αριθμός αποτυχημένων προσπαθειών σύνδεσης πριν από το κλείδωμα λογαριασμού", + "minimum-max-failed-login-attempts-range": "Ο μέγιστος αριθμός αποτυχημένων προσπαθειών δεν μπορεί να είναι αρνητικός", + "user-lockout-notification-email": "Σε περίπτωση κλειδώματος λογαριασμού χρήστη, αποστολή ειδοποίησης μέσω email", + "user-activation-token-ttl": "Χρόνος ζωής συνδέσμου ενεργοποίησης λογαριασμού (σε ώρες)", + "user-activation-token-ttl-range": "Ο χρόνος ζωής πρέπει να είναι από 1 έως 24 ώρες", + "password-reset-token-ttl": "Χρόνος ζωής συνδέσμου επαναφοράς κωδικού (σε ώρες)", + "password-reset-token-ttl-range": "Ο χρόνος ζωής συνδέσμου πρέπει να είναι από 1 έως 24 ώρες", + "mobile-secret-key-length": "Μήκος μυστικού κλειδιού κινητού", + "mobile-secret-key-length-range": "Το μήκος του μυστικού κλειδιού πρέπει να είναι θετικό", + "domain-name": "Όνομα τομέα", + "domain-name-unique": "Το όνομα τομέα και το πρωτόκολλο πρέπει να είναι μοναδικά.", + "domain-name-max-length": "Το όνομα τομέα πρέπει να έχει λιγότερους από 256 χαρακτήρες", + "error-verification-url": "Ένα όνομα τομέα δεν πρέπει να περιέχει τα σύμβολα '/' και ':'. Παράδειγμα: thingsboard.io", + "connection-settings": "Ρυθμίσεις σύνδεσης", + "oauth2": { + "access-token-uri": "URI διακριτικού πρόσβασης", + "access-token-uri-required": "Το URI διακριτικού πρόσβασης είναι υποχρεωτικό.", + "activate-user": "Ενεργοποίηση χρήστη", + "add-domain": "Προσθήκη τομέα", + "delete-domain": "Διαγραφή τομέα", + "add-provider": "Προσθήκη παρόχου", + "delete-provider": "Διαγραφή παρόχου", + "allow-user-creation": "Να επιτρέπεται η δημιουργία χρήστη", + "always-fullscreen": "Πάντα πλήρης οθόνη", + "authorization-uri": "URI εξουσιοδότησης", + "authorization-uri-required": "Το URI εξουσιοδότησης είναι υποχρεωτικό.", + "add-client": "Προσθήκη πελάτη OAuth 2.0", + "client-details": "Λεπτομέρειες πελάτη OAuth 2.0", + "client": "Πελάτης OAuth 2.0", + "clients": "Πελάτες OAuth 2.0", + "no-oauth2-clients": "Δεν βρέθηκαν πελάτες OAuth 2.0", + "search-oauth2-clients": "Αναζήτηση πελατών OAuth 2.0", + "delete-client-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον πελάτη OAuth 2.0 '{{clientName}}';", + "delete-client-text": "Προσοχή, μετά την επιβεβαίωση ο πελάτης και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-mobile-app-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την εφαρμογή κινητού '{{applicationName}}';", + "delete-mobile-app-text": "Προσοχή, μετά την επιβεβαίωση η εφαρμογή κινητού και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "title": "Τίτλος", + "client-title-required": "Ο τίτλος είναι υποχρεωτικός", + "client-title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 100 χαρακτήρες", + "advanced-settings": "Σύνθετες ρυθμίσεις", + "domain-details": "Λεπτομέρειες τομέα", + "no-domains": "Δεν βρέθηκαν τομείς", + "search-domains": "Αναζήτηση τομέων", + "mobile-app-details": "Λεπτομέρειες εφαρμογής κινητού", + "add-mobile-app": "Προσθήκη εφαρμογής κινητού", + "no-mobile-apps": "Δεν βρέθηκαν εφαρμογές κινητού", + "search-mobile-apps": "Αναζήτηση εφαρμογών κινητού", + "send-token": "Αποστολή διακριτικού", + "create-new": "Δημιουργία νέου", + "client-authentication-method": "Μέθοδος ταυτοποίησης πελάτη", + "client-id": "Client ID", + "client-id-required": "Το Client ID είναι υποχρεωτικό.", + "client-id-max-length": "Το Client ID πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "client-secret": "Client secret", + "client-secret-required": "Το Client secret είναι υποχρεωτικό.", + "client-secret-max-length": "Το Client secret πρέπει να είναι μικρότερο από 2049 χαρακτήρες", + "custom-setting": "Προσαρμοσμένες ρυθμίσεις", + "customer-name-pattern": "Μοτίβο ονόματος πελάτη", + "customer-name-pattern-max-length": "Το μοτίβο ονόματος πελάτη πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "default-dashboard-name": "Προεπιλεγμένο όνομα πίνακα ελέγχου", + "default-dashboard-name-max-length": "Το όνομα πίνακα ελέγχου πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "delete-domain-text": "Προσοχή, μετά την επιβεβαίωση ο τομέας και όλα τα δεδομένα παρόχου δεν θα είναι διαθέσιμα.", + "delete-domain-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον τομέα '{{domainName}}';", + "delete-registration-text": "Προσοχή, μετά την επιβεβαίωση τα δεδομένα του παρόχου δεν θα είναι διαθέσιμα.", + "delete-registration-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον πάροχο '{{name}}';", + "email-attribute-key": "Κλειδί γνωρίσματος email", + "email-attribute-key-required": "Το κλειδί γνωρίσματος email είναι υποχρεωτικό.", + "email-attribute-key-max-length": "Το κλειδί email πρέπει να είναι μικρότερο από 32 χαρακτήρες", + "first-name-attribute-key": "Κλειδί γνωρίσματος μικρού ονόματος", + "first-name-attribute-key-max-length": "Το κλειδί μικρού ονόματος πρέπει να είναι μικρότερο από 32 χαρακτήρες", + "general": "Γενικά", + "jwk-set-uri": "URI συνόλου JWK", + "last-name-attribute-key": "Κλειδί γνωρίσματος επωνύμου", + "last-name-attribute-key-max-length": "Το κλειδί επωνύμου πρέπει να είναι μικρότερο από 32 χαρακτήρες", + "login-button-icon": "Εικονίδιο κουμπιού σύνδεσης", + "login-button-label": "Ετικέτα παρόχου", + "login-button-label-placeholder": "Σύνδεση με $(Provider label)", + "login-button-label-required": "Η ετικέτα είναι υποχρεωτική.", + "login-provider": "Πάροχος σύνδεσης", + "mapper": "Χαρτογράφος", + "new-domain": "Νέος τομέας", + "oauth2": "OAuth 2.0", + "password-max-length": "Ο κωδικός πρόσβασης πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "redirect-uri-template": "Πρότυπο URI ανακατεύθυνσης", + "copy-redirect-uri": "Αντιγραφή URI ανακατεύθυνσης", + "registration-id": "Αναγνωριστικό εγγραφής", + "registration-id-required": "Το αναγνωριστικό εγγραφής είναι υποχρεωτικό.", + "registration-id-unique": "Το αναγνωριστικό εγγραφής πρέπει να είναι μοναδικό στο σύστημα.", + "scope": "Πεδίο", + "scope-required": "Το πεδίο είναι υποχρεωτικό.", + "tenant-name-pattern": "Μοτίβο ονόματος ενοικιαστή", + "tenant-name-pattern-required": "Το μοτίβο ονόματος ενοικιαστή είναι υποχρεωτικό.", + "tenant-name-pattern-max-length": "Το μοτίβο ονόματος ενοικιαστή πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "tenant-name-strategy": "Στρατηγική ονοματοδοσίας ενοικιαστή", + "type": "Τύπος χαρτογράφου", + "uri-pattern-error": "Μη έγκυρη μορφή URI.", + "url": "URL", + "url-pattern": "Μη έγκυρη μορφή URL.", + "url-required": "Το URL είναι υποχρεωτικό.", + "url-max-length": "Το URL πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "user-info-uri": "URI πληροφοριών χρήστη", + "user-info-uri-required": "Το URI πληροφοριών χρήστη είναι υποχρεωτικό.", + "username-max-length": "Το όνομα χρήστη πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "user-name-attribute-name": "Κλειδί γνωρίσματος ονόματος χρήστη", + "user-name-attribute-name-required": "Το κλειδί γνωρίσματος ονόματος χρήστη είναι υποχρεωτικό", + "protocol": "Πρωτόκολλο", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Ενεργοποίηση ρυθμίσεων OAuth 2.0", + "disable": "Απενεργοποίηση ρυθμίσεων OAuth 2.0", + "edge": "Διάδοση στο Edge", + "edge-enable": "Ενεργοποίηση διάδοσης στο Edge", + "edge-disable": "Απενεργοποίηση διάδοσης στο Edge", + "domains": "Τομείς", + "mobile-apps": "Εφαρμογές κινητού", + "mobile-package": "Πακέτο εφαρμογής", + "mobile-package-placeholder": "Π.χ.: my.example.app", + "mobile-package-hint": "Για Android: το μοναδικό Application ID. Για iOS: Αναγνωριστικό πακέτου προϊόντος.", + "mobile-package-unique": "Το πακέτο εφαρμογής πρέπει να είναι μοναδικό.", + "mobile-package-required": "Το πακέτο εφαρμογής είναι υποχρεωτικό.", + "mobile-package-max-length": "Το πακέτο εφαρμογής πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "mobile-package-spaces": "Το πακέτο εφαρμογής δεν πρέπει να περιέχει κενά", + "mobile-app-secret": "Μυστικό εφαρμογής", + "mobile-app-secret-hint": "Συμβολοσειρά σε μορφή Base64 που αναπαριστά τουλάχιστον 512 bits δεδομένων.", + "mobile-app-secret-required": "Το μυστικό εφαρμογής είναι υποχρεωτικό.", + "mobile-app-secret-min-length": "Το μυστικό εφαρμογής πρέπει να είναι τουλάχιστον 512 bits.", + "mobile-app-secret-base64": "Το μυστικό εφαρμογής πρέπει να είναι σε μορφή base64.", + "invalid-mobile-app-secret": "Το μυστικό εφαρμογής πρέπει να περιέχει μόνο αλφαριθμητικούς χαρακτήρες και να έχει μήκος μεταξύ 16 και 2048 χαρακτήρων.", + "copy-mobile-app-secret": "Αντιγραφή μυστικού εφαρμογής", + "delete-mobile-app": "Διαγραφή πληροφοριών εφαρμογής", + "providers": "Πάροχοι", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Όλες οι πλατφόρμες", + "smtp-provider": "Πάροχος SMTP", + "allowed-platforms": "Επιτρεπόμενες πλατφόρμες", + "authentication": "Ταυτοποίηση", + "basic": "Βασική", + "provider": "Πάροχος", + "redirect-url": "URI ανακατεύθυνσης", + "domain-name": "Όνομα τομέα", + "domain-name-required": "Το όνομα τομέα είναι υποχρεωτικό", + "redirect-url-template": "Πρότυπο URI ανακατεύθυνσης", + "microsoft-tenant-id": "Αναγνωριστικό καταλόγου (ενοικιαστής)", + "microsoft-tenant-id-required": "Το αναγνωριστικό καταλόγου (ενοικιαστής) είναι υποχρεωτικό", + "token-uri": "URI διακριτικού", + "token-uri-required": "Το URI διακριτικού είναι υποχρεωτικό", + "redirect-uri": "URI ανακατεύθυνσης", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Προσαρμοσμένος", + "generate-access-token": "Δημιουργία διακριτικού πρόσβασης", + "update-access-token": "Ενημέρωση διακριτικού πρόσβασης", + "access-token-status": "Κατάσταση διακριτικού πρόσβασης:", + "token-status-generated": "δημιουργήθηκε", + "token-status-not-generated": "δεν δημιουργήθηκε" + }, + "smpp-provider": { + "smpp-version": "Έκδοση SMPP", + "smpp-host": "Διακομιστής SMPP", + "smpp-host-required": "Ο διακομιστής SMPP είναι υποχρεωτικός", + "smpp-port": "Θύρα SMPP", + "smpp-port-required": "Η θύρα SMPP είναι υποχρεωτική", + "system-id": "Αναγνωριστικό συστήματος", + "system-id-required": "Το αναγνωριστικό συστήματος είναι υποχρεωτικό", + "password": "Κωδικός πρόσβασης", + "password-required": "Ο κωδικός πρόσβασης είναι υποχρεωτικός", + "type-settings": "Ρυθμίσεις τύπου", + "source-settings": "Ρυθμίσεις προέλευσης", + "destination-settings": "Ρυθμίσεις προορισμού", + "additional-settings": "Πρόσθετες ρυθμίσεις", + "system-type": "Τύπος συστήματος", + "bind-type": "Τύπος σύνδεσης (Bind)", + "service-type": "Τύπος υπηρεσίας", + "source-address": "Διεύθυνση προέλευσης", + "source-ton": "TON προέλευσης", + "source-npi": "NPI προέλευσης", + "destination-ton": "TON προορισμού (Τύπος Αριθμού)", + "destination-npi": "NPI προορισμού (Προσδιορισμός Σχεδίου Αρίθμησης)", + "address-range": "Εύρος διευθύνσεων", + "coding-scheme": "Σχέδιο κωδικοποίησης", + "bind-type-tx": "Αποστολέας (Transmitter)", + "bind-type-rx": "Δέκτης (Receiver)", + "bind-type-trx": "Αμφίδρομος (Transceiver)", + "ton-unknown": "Άγνωστο", + "ton-international": "Διεθνές", + "ton-national": "Εθνικό", + "ton-network-specific": "Ειδικό για δίκτυο", + "ton-subscriber-number": "Αριθμός συνδρομητή", + "ton-alphanumeric": "Αλφαριθμητικό", + "ton-abbreviated": "Συντομογραφημένο", + "npi-unknown": "0 - Άγνωστο", + "npi-isdn": "1 - ISDN/σχέδιο τηλεφωνικής αρίθμησης (E163/E164)", + "npi-data-numbering-plan": "3 - Σχέδιο αρίθμησης δεδομένων (X.121)", + "npi-telex-numbering-plan": "4 - Σχέδιο αρίθμησης Telex (F.69)", + "npi-land-mobile": "6 - Κινητή τηλεφωνία (E.212)", + "npi-national-numbering-plan": "8 - Εθνικό σχέδιο αρίθμησης", + "npi-private-numbering-plan": "9 - Ιδιωτικό σχέδιο αρίθμησης", + "npi-ermes-numbering-plan": "10 - Σχέδιο αρίθμησης ERMES (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - Αναγνωριστικό πελάτη WAP (ορίζεται από το WAP Forum)", + "scheme-smsc": "0 - Προεπιλεγμένο αλφάβητο SMSC (ASCII για σύντομο και μακρύ κωδικό και GSM για δωρεάν)", + "scheme-ia5": "1 - IA5 (ASCII για σύντομο και μακρύ κωδικό, Latin 9 για δωρεάν - ISO-8859-9)", + "scheme-octet-unspecified-2": "2 - Octet απροσδιόριστο (8-bit δυαδικό)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octet απροσδιόριστο (8-bit δυαδικό)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Κυριλλικό (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Εβραϊκό (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Κωδικοποίηση εικονογραμμάτων", + "scheme-music-codes": "10 - Μουσικοί Κωδικοί (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Επεκταμένο Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Κορεατικό σετ γραφικών χαρακτήρων (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Επιλέξτε όνομα ουράς", + "queue-name": "Όνομα", + "queue-name-required": "Το όνομα της ουράς είναι υποχρεωτικό!", + "queues": "Ουρές", + "queue-partitions": "Κατατμήσεις", + "queue-submit-strategy": "Στρατηγική υποβολής", + "queue-processing-strategy": "Στρατηγική επεξεργασίας", + "queue-configuration": "Ρύθμιση ουράς", + "repository-settings": "Ρυθμίσεις αποθετηρίου", + "repository": "Αποθετήριο", + "repository-url": "URL αποθετηρίου", + "repository-url-required": "Το URL αποθετηρίου είναι υποχρεωτικό.", + "default-branch": "Όνομα προεπιλεγμένου κλάδου", + "repository-read-only": "Μόνο για ανάγνωση", + "show-merge-commits": "Εμφάνιση συγχωνευμένων commits", + "authentication-settings": "Ρυθμίσεις ταυτοποίησης", + "auth-method": "Μέθοδος ταυτοποίησης", + "auth-method-username-password": "Κωδικός / διακριτικό πρόσβασης", + "auth-method-username-password-hint": "Οι χρήστες GitHub πρέπει να χρησιμοποιούν διακριτικά με δικαιώματα εγγραφής στο αποθετήριο.", + "auth-method-private-key": "Ιδιωτικό κλειδί", + "password-access-token": "Κωδικός / διακριτικό πρόσβασης", + "change-password-access-token": "Αλλαγή κωδικού / διακριτικού πρόσβασης", + "private-key": "Ιδιωτικό κλειδί", + "drop-private-key-file-or": "Σύρετε και αφήστε ένα αρχείο ιδιωτικού κλειδιού ή", + "passphrase": "Φράση πρόσβασης", + "enter-passphrase": "Εισάγετε φράση πρόσβασης", + "change-passphrase": "Αλλαγή φράσης πρόσβασης", + "check-access": "Έλεγχος πρόσβασης", + "check-repository-access-success": "Η πρόσβαση στο αποθετήριο επαληθεύτηκε επιτυχώς!", + "delete-repository-settings-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τις ρυθμίσεις αποθετηρίου;", + "delete-repository-settings-text": "Προσοχή, μετά την επιβεβαίωση οι ρυθμίσεις του αποθετηρίου θα διαγραφούν και η δυνατότητα ελέγχου έκδοσης δεν θα είναι διαθέσιμη.", + "auto-commit-settings": "Ρυθμίσεις αυτόματης δέσμευσης", + "auto-commit": "Αυτόματη δέσμευση", + "auto-commit-entities": "Οντότητες αυτόματης δέσμευσης", + "no-auto-commit-entities-prompt": "Δεν έχουν ρυθμιστεί οντότητες για αυτόματη δέσμευση", + "delete-auto-commit-settings-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τις ρυθμίσεις αυτόματης δέσμευσης;", + "delete-auto-commit-settings-text": "Προσοχή, μετά την επιβεβαίωση οι ρυθμίσεις αυτόματης δέσμευσης θα διαγραφούν και η δυνατότητα θα απενεργοποιηθεί για όλες τις οντότητες.", + "mobile-app": { + "mobile-app": "Εφαρμογή κινητού", + "mobile-app-qr-code-widget-settings": "Ρυθμίσεις widget QR κώδικα για κινητά", + "applications": "Εφαρμογές", + "default": "Προεπιλογή", + "custom": "Προσαρμοσμένο", + "android": "Android", + "ios": "iOS", + "appearance": "Εμφάνιση", + "appearance-on-home-page": "Εμφάνιση στην αρχική σελίδα", + "enabled": "Ενεργοποιημένο", + "disabled": "Απενεργοποιημένο", + "badges": "Σήματα", + "label": "Ετικέτα", + "label-required": "Η ετικέτα είναι υποχρεωτική", + "label-max-length": "Η ετικέτα πρέπει να έχει έως 50 χαρακτήρες", + "right": "Δεξιά", + "left": "Αριστερά", + "set": "Ορισμός", + "preview": "Προεπισκόπηση", + "connect-mobile-app": "Σύνδεση εφαρμογής κινητού", + "use-system-settings": "Χρήση ρυθμίσεων συστήματος" + }, + "2fa": { + "2fa": "Έλεγχος ταυτότητας δύο παραγόντων", + "available-providers": "Διαθέσιμοι πάροχοι", + "issuer-name": "Όνομα εκδότη", + "issuer-name-required": "Το όνομα εκδότη είναι υποχρεωτικό.", + "max-verification-failures-before-user-lockout": "Μέγιστος αριθμός αποτυχημένων ελέγχων πριν από κλείδωμα χρήστη", + "max-verification-failures-before-user-lockout-pattern": "Ο μέγιστος αριθμός αποτυχιών πρέπει να είναι θετικός ακέραιος.", + "number-of-checking-attempts": "Αριθμός προσπαθειών ελέγχου", + "number-of-checking-attempts-pattern": "Ο αριθμός προσπαθειών πρέπει να είναι θετικός ακέραιος.", + "number-of-checking-attempts-required": "Ο αριθμός προσπαθειών είναι υποχρεωτικός.", + "number-of-codes": "Αριθμός κωδικών", + "number-of-codes-pattern": "Ο αριθμός κωδικών πρέπει να είναι θετικός ακέραιος.", + "number-of-codes-required": "Ο αριθμός κωδικών είναι υποχρεωτικός.", + "provider": "Πάροχος", + "retry-verification-code-period": "Χρονικό διάστημα επανάληψης εισαγωγής κωδικού (δευτ.)", + "retry-verification-code-period-pattern": "Ο ελάχιστος χρόνος είναι 5 δευτερόλεπτα", + "retry-verification-code-period-required": "Το χρονικό διάστημα επανάληψης είναι υποχρεωτικό.", + "total-allowed-time-for-verification": "Συνολικός επιτρεπόμενος χρόνος επαλήθευσης (δευτ.)", + "total-allowed-time-for-verification-pattern": "Ο ελάχιστος συνολικός χρόνος είναι 60 δευτερόλεπτα", + "total-allowed-time-for-verification-required": "Ο συνολικός χρόνος είναι υποχρεωτικός.", + "use-system-two-factor-auth-settings": "Χρήση ρυθμίσεων ελέγχου ταυτότητας δύο παραγόντων συστήματος", + "verification-code-check-rate-limit": "Όριο ρυθμού ελέγχου κωδικού επαλήθευσης", + "verification-code-lifetime": "Διάρκεια ζωής κωδικού επαλήθευσης (δευτ.)", + "verification-code-lifetime-pattern": "Η διάρκεια ζωής πρέπει να είναι θετικός ακέραιος.", + "verification-code-lifetime-required": "Η διάρκεια ζωής του κωδικού επαλήθευσης είναι υποχρεωτική.", + "verification-message-template": "Πρότυπο μηνύματος επαλήθευσης", + "verification-limitations": "Περιορισμοί επαλήθευσης", + "verification-message-template-pattern": "Το μήνυμα πρέπει να περιέχει το πρότυπο: ${code}", + "verification-message-template-required": "Το πρότυπο μηνύματος επαλήθευσης είναι υποχρεωτικό.", + "within-time": "Εντός χρόνου (δευτ.)", + "within-time-pattern": "Ο χρόνος πρέπει να είναι θετικός ακέραιος.", + "within-time-required": "Ο χρόνος είναι υποχρεωτικός." + }, + "jwt": { + "security-settings": "Ρυθμίσεις ασφαλείας JWT", + "issuer-name": "Όνομα εκδότη", + "issuer-name-required": "Το όνομα εκδότη είναι υποχρεωτικό.", + "signings-key": "Κλειδί υπογραφής", + "signings-key-hint": "Συμβολοσειρά σε μορφή Base64 που αναπαριστά τουλάχιστον 512 bits δεδομένων.", + "signings-key-required": "Το κλειδί υπογραφής είναι υποχρεωτικό.", + "signings-key-min-length": "Το κλειδί υπογραφής πρέπει να είναι τουλάχιστον 512 bits.", + "signings-key-base64": "Το κλειδί υπογραφής πρέπει να είναι σε μορφή base64.", + "expiration-time": "Χρόνος λήξης διακριτικού (δευτ.)", + "expiration-time-required": "Ο χρόνος λήξης διακριτικού είναι υποχρεωτικός.", + "expiration-time-max": "Μέγιστος επιτρεπόμενος χρόνος είναι 2147483647 δευτερόλεπτα (68 έτη).", + "expiration-time-min": "Ελάχιστος χρόνος είναι 60 δευτερόλεπτα (1 λεπτό).", + "refresh-expiration-time": "Χρόνος λήξης διακριτικού ανανέωσης (δευτ.)", + "refresh-expiration-time-required": "Ο χρόνος λήξης διακριτικού ανανέωσης είναι υποχρεωτικός.", + "refresh-expiration-time-max": "Μέγιστος επιτρεπόμενος χρόνος είναι 2147483647 δευτερόλεπτα (68 έτη).", + "refresh-expiration-time-min": "Ελάχιστος χρόνος είναι 900 δευτερόλεπτα (15 λεπτά).", + "refresh-expiration-time-less-token": "Ο χρόνος του διακριτικού ανανέωσης πρέπει να είναι μεγαλύτερος από τον χρόνο του αρχικού διακριτικού.", + "generate-key": "Δημιουργία κλειδιού", + "info-header": "Όλοι οι χρήστες θα πρέπει να επανασυνδεθούν", + "info-message": "Η αλλαγή του κλειδιού υπογραφής JWT θα καταστήσει όλα τα εκδοθέντα διακριτικά άκυρα. Όλοι οι χρήστες θα πρέπει να επανασυνδεθούν. Αυτό θα επηρεάσει επίσης scripts που χρησιμοποιούν Rest API/Websockets." + }, + "resources": "Πόροι", + "notifications": "Ειδοποιήσεις", + "notifications-settings": "Ρυθμίσεις ειδοποιήσεων", + "slack-api-token": "Slack API διακριτικό", + "slack": "Slack", + "slack-settings": "Ρυθμίσεις Slack", + "mobile-settings": "Ρυθμίσεις κινητού", + "firebase-service-account-file": "Αρχείο διαπιστευτηρίων λογαριασμού υπηρεσίας Firebase (JSON)", + "select-firebase-service-account-file": "Σύρετε και αποθέστε το αρχείο διαπιστευτηρίων Firebase ή " }, "alarm": { - "alarm": "Alarm", - "alarms": "Alarms", - "select-alarm": "Επιλογή alarm", - "no-alarms-matching": "Δεν βρέθηκαν alarms σχετικά με '{{entity}}'.", - "alarm-required": "Απαιτείται Alarm", - "alarm-status": "Κατάσταση Alarm", + "alarm": "Συναγερμός", + "alarms": "Συναγερμοί", + "all-alarms": "Όλοι οι συναγερμοί", + "select-alarm": "Επιλογή συναγερμού", + "no-alarms-matching": "Δεν βρέθηκαν συναγερμοί που να ταιριάζουν με '{{entity}}'.", + "alarm-required": "Ο συναγερμός είναι υποχρεωτικός", + "alarm-filter": "Φίλτρο συναγερμών", + "filter": "Φίλτρο", + "alarm-status": "Κατάσταση συναγερμού", + "alarm-status-list": "Λίστα κατάστασης συναγερμών", + "any-status": "Οποιαδήποτε κατάσταση", "search-status": { - "ANY": "'Ολες", - "ACTIVE": "Ενεργό", - "CLEARED": "Εκκαθαρίστηκε", - "ACK": "Επιβεβαιώθηκε", - "UNACK": "Χωρίς επιβεβαίωση" + "ANY": "Οποιαδήποτε", + "ACTIVE": "Ενεργός", + "CLEARED": "Εκκαθαρισμένος", + "ACK": "Αναγνωρισμένος", + "UNACK": "Μη αναγνωρισμένος" }, "display-status": { - "ACTIVE_UNACK": "Ενεργό χωρίς επιβεβαίωση", - "ACTIVE_ACK": "Ενεργό επιβεβαιωμένο", - "CLEARED_UNACK": "Εκκαθαρίστηκε χωρίς επιβεβαίωση", - "CLEARED_ACK": "Εκκαθαρίστηκε επιβεβαιωμένο" + "ACTIVE_UNACK": "Ενεργός μη αναγνωρισμένος", + "ACTIVE_ACK": "Ενεργός αναγνωρισμένος", + "CLEARED_UNACK": "Εκκαθαρισμένος μη αναγνωρισμένος", + "CLEARED_ACK": "Εκκαθαρισμένος αναγνωρισμένος" }, - "no-alarms-prompt": "Δεν βρέθηκαν alarm", - "created-time": "Ώρα δημιουργίας", + "no-alarms-prompt": "Δεν βρέθηκαν συναγερμοί", + "created-time": "Χρόνος δημιουργίας", "type": "Τύπος", - "severity": "Βαρύτητα", - "originator": "Δημιουργός", - "originator-type": "Τύπος δημιουργού", + "severity": "Σοβαρότητα", + "originator": "Προέλευση", + "originator-type": "Τύπος προέλευσης", "details": "Λεπτομέρειες", + "originator-label": "Ετικέτα προέλευσης", + "assign": "Ανάθεση", + "assignments": "Αναθέσεις", + "assignee": "Ανατεθειμένος", + "assignee-id": "ID ανατεθειμένου", + "assignee-first-name": "Όνομα ανατεθειμένου", + "assignee-last-name": "Επώνυμο ανατεθειμένου", + "assignee-email": "Email ανατεθειμένου", + "unassigned": "Μη ανατεθειμένος", + "user-deleted": "Ο χρήστης διαγράφηκε", + "assignee-not-set": "Όλοι", "status": "Κατάσταση", - "alarm-details": "Λεπτομέρειες Alarm", - "start-time": "Ώρα έναρξης", - "end-time": "Ώρα λήξης", - "ack-time": "Ώρα επιβεβαίωσης", - "clear-time": "Ώρα εκκαθάρισης", - "severity-critical": "Critical", - "severity-major": "Κρίσιμο", - "severity-minor": "Ασήμαντο", + "alarm-details": "Λεπτομέρειες συναγερμού", + "start-time": "Χρόνος έναρξης", + "assign-time": "Χρόνος ανάθεσης", + "end-time": "Χρόνος λήξης", + "ack-time": "Χρόνος αναγνώρισης", + "clear-time": "Χρόνος εκκαθάρισης", + "duration": "Διάρκεια", + "alarm-severity": "Σοβαρότητα συναγερμού", + "alarm-severity-list": "Λίστα σοβαρότητας συναγερμού", + "any-severity": "Οποιαδήποτε σοβαρότητα", + "severity-critical": "Κρίσιμη", + "severity-major": "Σημαντική", + "severity-minor": "Δευτερεύουσα", "severity-warning": "Προειδοποίηση", - "severity-indeterminate": "Απροσδιόριστο", - "acknowledge": "Επιβεβαίωση", + "severity-indeterminate": "Απροσδιόριστη", + "acknowledge": "Αναγνώριση", "clear": "Εκκαθάριση", - "search": "Αναζήτηση alarm", - "selected-alarms": "{ count, plural, =1 {1 alarm} other {# alarms} } επιλέχθηκαν", - "no-data": "Δεν υπάρχουν δεδομένα για εμφάνιση", - "polling-interval": "Διάστημα δειγματοληψίας alarm(sec)", - "polling-interval-required": "Απαιτείται ορισμός διαστήματος δειγματοληψίας alarm.", - "min-polling-interval-message": "Η ελάχιστη επιτρεπόμενη τιμή διαστήματος δειγματοληψίας alarm είναι 1 sec.", - "aknowledge-alarms-title": "Επιβεβαίωση { count, plural, =1 {1 alarm} other {# alarms} }", - "aknowledge-alarms-text": "Είστε σίγουρος ότι θέλετε να επιβεβαιώσετε { count, plural, =1 {1 alarm} other {# alarms} };", - "aknowledge-alarm-title": "Επιβεβαίωση Alarm", - "aknowledge-alarm-text": "Είστε σίγουρος ότι θέλετε να επιβεβαιώσετε το Alarm?", - "clear-alarms-title": "Εκκαθάριση { count, plural, =1 {1 alarm} other {# alarms} }", - "clear-alarms-text": "Είστε σίγουρος ότι θέλετε να εκκαθαρίσετε { count, plural, =1 {1 alarm} other {# alarms} }?", - "clear-alarm-title": "Εκκαθάριση Alarm", - "clear-alarm-text": "Είστε σίγουρος ότι θέλετε να εκκαθαρίσετε το Alarm?", - "alarm-status-filter": "Φίλτρο κατάστασης Alarm" + "delete": "Διαγραφή", + "search": "Αναζήτηση συναγερμών", + "selected-alarms": "{ count, plural, =1 {1 συναγερμός} other {# συναγερμοί} } επιλεγμένοι", + "no-data": "Δεν υπάρχουν δεδομένα για προβολή", + "polling-interval": "Διάστημα δειγματοληψίας συναγερμών (δευτ.)", + "polling-interval-required": "Απαιτείται το διάστημα δειγματοληψίας συναγερμών.", + "min-polling-interval-message": "Το ελάχιστο επιτρεπόμενο διάστημα είναι 1 δευτερόλεπτο.", + "aknowledge-alarms-title": "Αναγνώριση { count, plural, =1 {1 συναγερμού} other {# συναγερμών} }", + "aknowledge-alarms-text": "Είστε βέβαιοι ότι θέλετε να αναγνωρίσετε { count, plural, =1 {1 συναγερμό} other {# συναγερμούς} };", + "aknowledge-alarm-title": "Αναγνώριση Συναγερμού", + "aknowledge-alarm-text": "Είστε βέβαιοι ότι θέλετε να αναγνωρίσετε τον συναγερμό;", + "selected-alarms-are-acknowledged": "Οι επιλεγμένοι συναγερμοί έχουν ήδη αναγνωριστεί", + "clear-alarms-title": "Εκκαθάριση { count, plural, =1 {1 συναγερμού} other {# συναγερμών} }", + "clear-alarms-text": "Είστε βέβαιοι ότι θέλετε να εκκαθαρίσετε { count, plural, =1 {1 συναγερμό} other {# συναγερμούς} };", + "clear-alarm-title": "Εκκαθάριση Συναγερμού", + "clear-alarm-text": "Είστε βέβαιοι ότι θέλετε να εκκαθαρίσετε τον συναγερμό;", + "delete-alarms-title": "Διαγραφή { count, plural, =1 {1 συναγερμού} other {# συναγερμών} }", + "delete-alarms-text": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 συναγερμό} other {# συναγερμούς} };", + "selected-alarms-are-cleared": "Οι επιλεγμένοι συναγερμοί έχουν ήδη εκκαθαριστεί", + "alarm-status-filter": "Φίλτρο κατάστασης συναγερμού", + "alarm-filter-title": "Φίλτρο συναγερμού", + "assigned": "Ανατεθειμένος", + "filter-title": "Φίλτρο", + "max-count-load": "Μέγιστος αριθμός συναγερμών για φόρτωση (0 - απεριόριστο)", + "max-count-load-required": "Ο μέγιστος αριθμός συναγερμών είναι υποχρεωτικός.", + "max-count-load-error-min": "Ελάχιστη τιμή είναι 0.", + "fetch-size": "Μέγεθος φόρτωσης", + "fetch-size-required": "Το μέγεθος φόρτωσης είναι υποχρεωτικό.", + "fetch-size-error-min": "Η ελάχιστη τιμή είναι 10.", + "alarm-types": "Τύποι συναγερμών", + "alarm-type-list": "Λίστα τύπων συναγερμών", + "any-type": "Οποιοσδήποτε τύπος", + "assigned-to-current-user": "Ανατέθηκε στον τρέχοντα χρήστη", + "assigned-to-me": "Ανατέθηκε σε εμένα", + "search-propagated-alarms": "Αναζήτηση διαδομένων συναγερμών", + "comments": "Σχόλια συναγερμού", + "show-more": "Προβολή περισσότερων", + "additional-info": "Πρόσθετες πληροφορίες", + "alarm-type": "Τύπος συναγερμού", + "enter-alarm-type": "Εισαγωγή τύπου συναγερμού", + "no-alarm-types-matching": "Δεν βρέθηκαν τύποι συναγερμού που να ταιριάζουν με '{{entitySubtype}}'.", + "alarm-type-list-empty": "Δεν έχουν επιλεγεί τύποι συναγερμού." + }, + "alarm-activity": { + "add": "Προσθήκη σχολίου...", + "alarm-comment": "Σχόλιο συναγερμού", + "comments": "Σχόλια", + "delete-alarm-comment": "Θέλετε να διαγράψετε αυτό το σχόλιο;", + "refresh": "Ανανέωση", + "oldest-first": "Παλαιότερα πρώτα", + "newest-first": "Νεότερα πρώτα", + "activity": "Δραστηριότητα", + "export": "Εξαγωγή σε CSV", + "author": "Συντάκτης", + "created-date": "Ημερομηνία δημιουργίας", + "edited-date": "Ημερομηνία επεξεργασίας", + "text": "Κείμενο", + "system": "Σύστημα" }, "alias": { "add": "Προσθήκη ψευδωνύμου", "edit": "Επεξεργασία ψευδωνύμου", - "name": "Ψευδώνυμο", - "name-required": "Απαιτείται Ψευδώνυμο", - "duplicate-alias": "Το ψευδώνυμο υπάρχει ήδη.", - "filter-type-single-entity": "Απλή Οντότητα", - "filter-type-entity-group": "Ομάδα Οντοτήτων", - "filter-type-entity-list": "Λίστα Οντοτήτων", - "filter-type-entity-name": "Όνομα Οντότητας", - "filter-type-entity-group-list": "Λίστα ομάδας Οντοτήτων", - "filter-type-entity-group-name": "Όνομα ομάδας Οντοτήτων", - "filter-type-state-entity": "Οντότητα από την κατάσταση του dashboard", - "filter-type-state-entity-description": "Οντότητα που λαμβάνεται από τις παραμέτρους κατάστασης του dashboard", - "filter-type-asset-type": "Τύπος Asset", - "filter-type-asset-type-description": "Assets του τύπου '{{assetTypes}}'", - "filter-type-asset-type-and-name-description": "Assets του τύπου '{{assetTypes}}' με όνομα που αρχίζει από '{{prefix}}'", - "filter-type-device-type": "Τύπος Συσκευής", - "filter-type-device-type-description": "Συσκευές του τύπου '{{deviceTypes}}'", - "filter-type-device-type-and-name-description": "Συσκευές του τύπου '{{deviceTypes}}' με όνομα που αρχίζει από '{{prefix}}'", - "filter-type-entity-view-type": "Τύπος προβολής Οντοτήτων", - "filter-type-entity-view-type-description": "Τύπος προβολής Οντοτήτων '{{entityViewTypes}}'", - "filter-type-entity-view-type-and-name-description": "Τύπος προβολής Οντοτήτων '{{entityViewTypes}}' με όνομα που αρχίζει από '{{prefix}}'", - "filter-type-relations-query": "Ερώτημα Σχέσεων", + "name": "Όνομα ψευδωνύμου", + "name-required": "Το όνομα ψευδωνύμου είναι υποχρεωτικό", + "duplicate-alias": "Υπάρχει ήδη ψευδώνυμο με το ίδιο όνομα.", + "filter-type-single-entity": "Μοναδική οντότητα", + "filter-type-entity-list": "Λίστα οντοτήτων", + "filter-type-entity-name": "Όνομα οντότητας", + "filter-type-entity-type": "Τύπος οντότητας", + "filter-type-state-entity": "Οντότητα από την κατάσταση του πίνακα ελέγχου", + "filter-type-state-entity-description": "Οντότητα από παραμέτρους κατάστασης πίνακα ελέγχου", + "filter-type-asset-type": "Τύπος περιουσιακού στοιχείου", + "filter-type-asset-type-description": "Περιουσιακά στοιχεία τύπου '{{assetTypes}}'", + "filter-type-asset-type-and-name-description": "Περιουσιακά στοιχεία τύπου '{{assetTypes}}' και με όνομα που ξεκινά με '{{prefix}}'", + "filter-type-device-type": "Τύπος συσκευής", + "filter-type-device-type-description": "Συσκευές τύπου '{{deviceTypes}}'", + "filter-type-device-type-and-name-description": "Συσκευές τύπου '{{deviceTypes}}' και με όνομα που ξεκινά με '{{prefix}}'", + "filter-type-entity-view-type": "Τύπος προβολής οντότητας", + "filter-type-entity-view-type-description": "Προβολές οντοτήτων τύπου '{{entityViewTypes}}'", + "filter-type-entity-view-type-and-name-description": "Προβολές οντοτήτων τύπου '{{entityViewTypes}}' και με όνομα που ξεκινά με '{{prefix}}'", + "filter-type-edge-type": "Τύπος Edge", + "filter-type-edge-type-description": "Edges τύπου '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Edges τύπου '{{edgeTypes}}' και με όνομα που ξεκινά με '{{prefix}}'", + "filter-type-relations-query": "Ερώτημα σχέσεων", "filter-type-relations-query-description": "{{entities}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", - "filter-type-asset-search-query": "Ερώτημα αναζήτησης Asset", - "filter-type-asset-search-query-description": "Asset με τύπο {{assetTypes}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", - "filter-type-device-search-query": "Ερώτημα αναζήτησης Συσκευών", - "filter-type-device-search-query-description": "Συσκευές με τύπο {{deviceTypes}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", - "filter-type-entity-view-search-query": "Ερώτημα αναζήτησης Όψεων Οντοτήτων", - "filter-type-entity-view-search-query-description": "Όψεις Οντοτήτων με τύπο {{entityViewTypes}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", - "entity-filter": "Φίλτρο Οντοτήτων", - "resolve-multiple": "Διακανονισμός ως πολλαπλές οντότητες", - "filter-type": "Είδος Φίλτρου", - "filter-type-required": "Απαιτείται καθορισμός του Είδους Φίλτρου.", - "entity-filter-no-entity-matched": "Δεν βρέθηκαν οντότητες που ταιριάζουν με το συγκεκριμένο φίλτρο.", - "no-entity-filter-specified": "Δεν έχει οριστεί Φίλτρο Οντοτήτων", - "root-state-entity": "Χρήση της οντότητας κατάστασης του dashboard ως ρίζα", - "group-state-entity": "Χρήση της οντότητας κατάστασης του dashboard ως ομάδας οντοτήτων", - "root-entity": "Οντότητα ρίζας", + "filter-type-edge-search-query": "Ερώτημα αναζήτησης Edge", + "filter-type-edge-search-query-description": "Edges τύπου {{edgeTypes}} με σχέση {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Ερώτημα αναζήτησης περιουσιακών στοιχείων", + "filter-type-asset-search-query-description": "Περιουσιακά στοιχεία τύπου {{assetTypes}} με σχέση {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Ερώτημα αναζήτησης συσκευών", + "filter-type-device-search-query-description": "Συσκευές τύπου {{deviceTypes}} με σχέση {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Ερώτημα αναζήτησης προβολής οντοτήτων", + "filter-type-entity-view-search-query-description": "Προβολές οντοτήτων τύπου {{entityViewTypes}} με σχέση {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Κατάσταση χρήσης API", + "entity-filter": "Φίλτρο οντοτήτων", + "resolve-multiple": "Επίλυση ως πολλαπλές οντότητες", + "resolve-multiple-hint": "Ενεργοποιήστε για ταυτόχρονη εμφάνιση δεδομένων από όλες τις φιλτραρισμένες οντότητες.\nΑν απενεργοποιηθεί, το widget θα εμφανίζει δεδομένα μόνο από την επιλεγμένη οντότητα.", + "filter-type": "Τύπος φίλτρου", + "filter-type-required": "Ο τύπος φίλτρου είναι υποχρεωτικός.", + "entity-filter-no-entity-matched": "Δεν βρέθηκαν οντότητες που να ταιριάζουν με το καθορισμένο φίλτρο.", + "no-entity-filter-specified": "Δεν έχει καθοριστεί φίλτρο οντοτήτων", + "root-state-entity": "Χρήση οντότητας κατάστασης πίνακα ελέγχου ως ριζική", + "last-level-relation": "Ανάκτηση μόνο σχέσης τελευταίου επιπέδου", + "root-entity": "Ριζική οντότητα", "state-entity-parameter-name": "Όνομα παραμέτρου οντότητας κατάστασης", "default-state-entity": "Προεπιλεγμένη οντότητα κατάστασης", - "default-state-entity-group": "Προεπιλεγμένη ομάδα καταστάσεων οντοτήτων", - "default-entity-parameter-name": "Εξ ορισμού", - "max-relation-level": "Μέγιστος βαθμός συσχέτισης", - "unlimited-level": "Απεριόριστος βαθμός", - "state-entity": "Οντότητα κατάστασης του Dashboard", - "entities-of-group-state-entity": "Οντότητες από την ομάδα οντοτήτων κατάστασης του dashboard", - "all-entities": "Όλες οι Οντότητες", - "any-relation": "Οποιαδήποτε" + "default-entity-parameter-name": "Προεπιλογή", + "max-relation-level": "Μέγιστο επίπεδο σχέσης", + "unlimited-level": "Απεριόριστο επίπεδο", + "state-entity": "Οντότητα κατάστασης πίνακα ελέγχου", + "all-entities": "Όλες οι οντότητες", + "any-relation": "οποιαδήποτε" }, "asset": { - "asset": "Asset", - "assets": "Assets", - "management": "Διαχείριση Asset", - "view-assets": "Προβολή Asset", - "add": "Προσθήκη Asset", - "assign-to-customer": "Ανάθεση σε Πελάτη", - "assign-asset-to-customer": "Ανάθεση Asset(s) σε Πελάτη", - "assign-asset-to-customer-text": "Επιλέξτε τα Asset που θα αναθέσετε στον πελάτη", - "no-assets-text": "Δεν βρέθηκαν Asset", - "assign-to-customer-text": "Παρακαλώ επιλέξτε πελάτη για να αναθέσετε το Asset(s)", + "asset": "Περιουσιακό στοιχείο", + "assets": "Περιουσιακά στοιχεία", + "management": "Διαχείριση περιουσιακών στοιχείων", + "view-assets": "Προβολή περιουσιακών στοιχείων", + "add": "Προσθήκη περιουσιακού στοιχείου", + "asset-type-max-length": "Ο τύπος περιουσιακού στοιχείου πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "assign-to-customer": "Ανάθεση σε πελάτη", + "assign-asset-to-customer": "Ανάθεση περιουσιακού στοιχείου(ων) σε πελάτη", + "assign-asset-to-customer-text": "Επιλέξτε τα περιουσιακά στοιχεία για ανάθεση στον πελάτη", + "no-assets-text": "Δεν βρέθηκαν περιουσιακά στοιχεία", + "assign-to-customer-text": "Επιλέξτε πελάτη για την ανάθεση του(των) περιουσιακού(ών) στοιχείου(ων)", "public": "Δημόσιο", - "assignedToCustomer": "Συνδεδεμένο με πελάτη", - "make-public": "Κάνε το Asset δημόσιο", - "make-private": "Κάνε το Asset ιδιωτικό", - "unassign-from-customer": "Αποσύνδεση από τον πελάτη", - "delete": "Διαγραφή Αsset", - "asset-public": "Το Asset είναι δημόσιο", - "asset-type": "Τύπος Asset", - "asset-type-required": "Απαιτείται ορισμός είδους Asset.", - "select-asset-type": "Επιλογή τύπου Αsset", - "enter-asset-type": "Εισαγωγή τύπου Αsset", - "any-asset": "Οποιοδήποτε Αsset", - "no-asset-types-matching": "Δεν βρέθηκαν τύποι Asset που να τιαιριάζουν με '{{entitySubtype}}'.", - "asset-type-list-empty": "Δεν επιλέχθηκε τύπος Asset.", - "asset-types": "Τύποι Asset", + "assignedToCustomer": "Ανατέθηκε σε πελάτη", + "make-public": "Δημοσιοποίηση περιουσιακού στοιχείου", + "make-private": "Ιδιωτικοποίηση περιουσιακού στοιχείου", + "unassign-from-customer": "Αφαίρεση από πελάτη", + "delete": "Διαγραφή περιουσιακού στοιχείου", + "asset-public": "Το περιουσιακό στοιχείο είναι δημόσιο", + "asset-type": "Τύπος περιουσιακού στοιχείου", + "asset-type-required": "Ο τύπος περιουσιακού στοιχείου είναι υποχρεωτικός.", + "select-asset-type": "Επιλέξτε τύπο περιουσιακού στοιχείου", + "enter-asset-type": "Εισαγωγή προφίλ περιουσιακού στοιχείου", + "any-asset": "Οποιοδήποτε περιουσιακό στοιχείο", + "no-asset-types-matching": "Δεν βρέθηκαν τύποι περιουσιακού στοιχείου που να ταιριάζουν με '{{entitySubtype}}'.", + "asset-type-list-empty": "Δεν επιλέχθηκαν τύποι περιουσιακών στοιχείων.", + "asset-types": "Τύποι περιουσιακών στοιχείων", "name": "Όνομα", - "name-required": "Απαιτείται Όνομα.", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "label-max-length": "Η ετικέτα πρέπει να είναι μικρότερη από 256 χαρακτήρες", "description": "Περιγραφή", "type": "Τύπος", - "type-required": "Απαιτείται Τύπος.", + "type-required": "Ο τύπος είναι υποχρεωτικός.", "details": "Λεπτομέρειες", - "events": "Γεγονότα", - "add-asset-text": "Προσθήκη νέου Asset", - "asset-details": "Λεπτομέρειες Asset", - "assign-assets": "Ανάθεση Assets", - "assign-assets-text": "Ανάθεση { count, plural, =1 {1 asset} other {# assets} } σε πελάτη", - "delete-assets": "Διαγραφή Assets", - "unassign-assets": "Αποσύνδεση Assets", - "unassign-assets-action-title": "Αποσύνδεση { count, plural, =1 {1 asset} other {# assets} } από πελάτη", - "assign-new-asset": "Ανάθεση νέου Asset", - "delete-asset-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το Asset '{{assetName}}'?", - "delete-asset-text": "Προσοχή!, Μετά την επιβεβαίωση, το Asset και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-assets-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 asset} other {# assets} };", - "delete-assets-action-title": "Διαγραφή { count, plural, =1 {1 asset} other {# assets} }", - "delete-assets-text": "Προσοχή! Μετά την επιβεβαίωση, όλα τα επιλεγμένα Asset και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "make-public-asset-title": "Είστε βέβαιοι ότι θέλετε να κάνετε το Asset '{{assetName}}' δημόσιο;", - "make-public-asset-text": "Μετά την επιβεβαίωση, το Asset και όλα τα στοιχεία του θα είναι προσβάσιμα από άλλους.", - "make-private-asset-title": "Είστε βέβαιοι ότι θέλετε να κάνετε το Asset '{{assetName}}' ιδιωτικό;", - "make-private-asset-text": "Μετά την επιβεβαίωση, το Asset και όλα τα στοιχεία του θα είναι ιδιωτικά και μη προσβάσιμα από άλλους.", - "unassign-asset-title": "Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε το Asset '{{assetName}}';", - "unassign-asset-text": "Μετά την επιβεβαίωση, το Asset θα αποσυνδεθεί και δεν θα είναι προσβάσιμο από τον πελάτη.", - "unassign-asset": "Αποσύνδεση Asset", - "unassign-assets-title": "Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε { count, plural, =1 {1 asset} other {# assets} };", - "unassign-assets-text": "Μετά την επιβεβαίωση, όλα τα επιλεγμένα Asset θα αποσυνδεθούν και δεν θα είναι προσβάσιμα από τον πελάτη.", - "copyId": "Αντιγραφή Asset Id", - "idCopiedMessage": "Το Asset Id αντιγράφηκε στο πρόχειρο", - "select-asset": "Επιλογή Asset", - "no-assets-matching": "Δεν βρέθηκαν Asset που να ταιριάζουν με '{{entity}}'.", - "asset-required": "Απαιτείται Asset", - "name-starts-with": "Όνομα Asset που ξεκινάει με", - "selected-assets": "{ count, plural, =1 {1 asset} other {# assets} } επιλέχθηκαν", - "search": "Αναζήτηση Asset", - "select-group-to-add": "Επιλέξτε ομάδα στόχο για να προσθέσετε επιλεγμένα Asset", - "select-group-to-move": "Επιλέξτε ομάδα στόχο για να μετακινήσετε επιλεγμένα assets", - "remove-assets-from-group": "Είστε βέβαιοι ότι θέλετε να καταργήσετε { count, plural, =1 {1 asset} other {# assets} } από την ομάδα '{entityGroup}';", - "group": "Ομάδα Asset", - "list-of-groups": "{ count, plural, =1 {One asset group} other {List of # asset groups} }", - "group-name-starts-with": "Ομάδες Asset των οποίων τα ονόματα ξεκινούν με '{{prefix}}'", - "import": "Εισαγωγή Asset", - "asset-file": "Αρχείο Asset" + "events": "Συμβάντα", + "add-asset-text": "Προσθήκη νέου περιουσιακού στοιχείου", + "asset-details": "Λεπτομέρειες περιουσιακού στοιχείου", + "assign-assets": "Ανάθεση περιουσιακών στοιχείων", + "assign-assets-text": "Ανάθεση { count, plural, =1 {1 περιουσιακού στοιχείου} other {# περιουσιακών στοιχείων} } σε πελάτη", + "assign-asset-to-edge-title": "Ανάθεση περιουσιακού στοιχείου(ων) σε Edge", + "assign-asset-to-edge-text": "Επιλέξτε τα περιουσιακά στοιχεία για ανάθεση στο Edge", + "delete-assets": "Διαγραφή περιουσιακών στοιχείων", + "unassign-assets": "Αφαίρεση ανάθεσης περιουσιακών στοιχείων", + "unassign-assets-action-title": "Αφαίρεση ανάθεσης { count, plural, =1 {1 περιουσιακού στοιχείου} other {# περιουσιακών στοιχείων} } από πελάτη", + "assign-new-asset": "Ανάθεση νέου περιουσιακού στοιχείου", + "delete-asset-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το περιουσιακό στοιχείο '{{assetName}}';", + "delete-asset-text": "Προσοχή, μετά την επιβεβαίωση το περιουσιακό στοιχείο και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-assets-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 περιουσιακό στοιχείο} other {# περιουσιακά στοιχεία} };", + "delete-assets-action-title": "Διαγραφή { count, plural, =1 {1 περιουσιακού στοιχείου} other {# περιουσιακών στοιχείων} }", + "delete-assets-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα περιουσιακά στοιχεία θα διαγραφούν και όλα τα σχετικά δεδομένα θα χαθούν οριστικά.", + "make-public-asset-title": "Είστε βέβαιοι ότι θέλετε να δημοσιοποιήσετε το περιουσιακό στοιχείο '{{assetName}}';", + "make-public-asset-text": "Μετά την επιβεβαίωση, το περιουσιακό στοιχείο και όλα τα δεδομένα του θα γίνουν δημόσια και θα είναι προσβάσιμα από άλλους.", + "make-private-asset-title": "Είστε βέβαιοι ότι θέλετε να καταστήσετε ιδιωτικό το περιουσιακό στοιχείο '{{assetName}}';", + "make-private-asset-text": "Μετά την επιβεβαίωση, το περιουσιακό στοιχείο και όλα τα δεδομένα του θα γίνουν ιδιωτικά και δεν θα είναι προσβάσιμα από άλλους.", + "unassign-asset-title": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε την ανάθεση του περιουσιακού στοιχείου '{{assetName}}';", + "unassign-asset-text": "Μετά την επιβεβαίωση, το περιουσιακό στοιχείο θα αποανατεθεί και δεν θα είναι προσβάσιμο από τον πελάτη.", + "unassign-asset": "Αποανάθεση περιουσιακού στοιχείου", + "unassign-assets-title": "Είστε βέβαιοι ότι θέλετε να αποαναθέσετε { count, plural, =1 {1 περιουσιακό στοιχείο} other {# περιουσιακά στοιχεία} };", + "unassign-assets-text": "Μετά την επιβεβαίωση, όλα τα επιλεγμένα περιουσιακά στοιχεία θα αποανατεθούν και δεν θα είναι προσβάσιμα από τον πελάτη.", + "copyId": "Αντιγραφή αναγνωριστικού περιουσιακού στοιχείου", + "idCopiedMessage": "Το αναγνωριστικό του περιουσιακού στοιχείου αντιγράφηκε στο πρόχειρο", + "select-asset": "Επιλογή περιουσιακού στοιχείου", + "no-assets-matching": "Δεν βρέθηκαν περιουσιακά στοιχεία που να ταιριάζουν με '{{entity}}'.", + "asset-required": "Απαιτείται περιουσιακό στοιχείο", + "name-starts-with": "Έκφραση ονόματος περιουσιακού στοιχείου", + "help-text": "Χρησιμοποιήστε το '%' κατά περίπτωση: '%περιουσιακό_όνομα%', '%περιουσιακό_όνομα_λήγει', 'περιουσιακό_ξεκινά_με'.", + "search": "Αναζήτηση περιουσιακών στοιχείων", + "import": "Εισαγωγή περιουσιακών στοιχείων", + "asset-file": "Αρχείο περιουσιακών στοιχείων", + "label": "Ετικέτα", + "assign-asset-to-edge": "Ανάθεση περιουσιακού στοιχείου(ων) σε Edge", + "unassign-asset-from-edge": "Αφαίρεση από Edge", + "unassign-asset-from-edge-title": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε το περιουσιακό στοιχείο '{{assetName}}' από το Edge;", + "unassign-asset-from-edge-text": "Μετά την επιβεβαίωση, το περιουσιακό στοιχείο θα αποανατεθεί και δεν θα είναι προσβάσιμο από το Edge.", + "unassign-assets-from-edge-title": "Είστε βέβαιοι ότι θέλετε να αποαναθέσετε { count, plural, =1 {1 περιουσιακό στοιχείο} other {# περιουσιακά στοιχεία} };", + "unassign-assets-from-edge-text": "Μετά την επιβεβαίωση, όλα τα επιλεγμένα περιουσιακά στοιχεία θα αποανατεθούν και δεν θα είναι προσβάσιμα από το Edge.", + "selected-assets": "{ count, plural, =1 {1 περιουσιακό στοιχείο} other {# περιουσιακά στοιχεία} } επιλεγμένα" }, "attribute": { "attributes": "Χαρακτηριστικά", - "latest-telemetry": "Τελευταία τηλεμετρία", - "attributes-scope": "Πεδίο εφαρμογής Χαρακτηριστικών Οντότητας", + "latest-telemetry": "Πρόσφατο τηλεμετρικό", + "no-latest-telemetry": "Δεν υπάρχει πρόσφατο τηλεμετρικό", + "attributes-scope": "Πεδίο χαρακτηριστικών οντότητας", "scope-telemetry": "Τηλεμετρία", - "scope-latest-telemetry": "Τελευταία τηλεμετρία", - "scope-client": "Χαρακτηριστικά Client", - "scope-server": "Χαρακτηριστικά Server", - "scope-shared": "Κοινόχρηστα Χαρακτηριστικά", - "add": "Προσθήκη Χαρακτηριστικού", - "add-attribute-prompt": "Προσθέστε Χαρακτηριστικό", - "key": "Όνομα", - "last-update-time": "Ώρα τελευταίας ενημέρωσης", - "key-required": "Απαιτείται μεταβλητή χαρακτηριστικού.", + "scope-latest-telemetry": "Πρόσφατο τηλεμετρικό", + "scope-client": "Χαρακτηριστικά πελάτη", + "scope-server": "Χαρακτηριστικά διακομιστή", + "scope-shared": "Κοινόχρηστα χαρακτηριστικά", + "scope-client-short": "Πελάτης", + "scope-server-short": "Διακομιστής", + "scope-shared-short": "Κοινόχρηστο", + "scope-latest-short": "Πρόσφατο", + "scope-any": "Οποιοδήποτε", + "add": "Προσθήκη χαρακτηριστικού", + "key": "Κλειδί", + "key-max-length": "Το κλειδί πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "last-update-time": "Χρόνος τελευταίας ενημέρωσης", + "key-required": "Το κλειδί χαρακτηριστικού είναι υποχρεωτικό.", "value": "Τιμή", - "value-required": "Απαιτείται ορισμός τιμής χαρακτηριστικού.", - "delete-attributes-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 attribute} other {# attributes} };", - "delete-attributes-text": "Προσοχή! Μετά την επιβεβαίωση θα αφαιρεθούν όλα τα επιλεγμένα χαρακτηριστικά.", + "value-required": "Η τιμή χαρακτηριστικού είναι υποχρεωτική.", + "telemetry-key-required": "Το κλειδί τηλεμετρίας είναι υποχρεωτικό", + "telemetry-value-required": "Η τιμή τηλεμετρίας είναι υποχρεωτική", + "delete-attributes-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 χαρακτηριστικό} other {# χαρακτηριστικά} };", + "delete-attributes-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα χαρακτηριστικά θα διαγραφούν.", "delete-attributes": "Διαγραφή χαρακτηριστικών", - "enter-attribute-value": "Καταχωρίστε την τιμή του χαρακτηριστικού", - "show-on-widget": "Εμφάνιση σε widget", - "widget-mode": "Είδος λειτουργίας widget", + "enter-attribute-value": "Εισαγωγή τιμής χαρακτηριστικού", + "show-on-widget": "Εμφάνιση στο widget", + "widget-mode": "Λειτουργία widget", "next-widget": "Επόμενο widget", "prev-widget": "Προηγούμενο widget", - "add-to-dashboard": "Προσθήκη σε dashboard", - "add-widget-to-dashboard": "Προσθήκη widget στο dashboard", - "selected-attributes": "{ count, plural, =1 {1 attribute} other {# attributes} } επιλέχθηκαν", - "selected-telemetry": "{ count, plural, =1 {1 telemetry unit} other {# telemetry units} } επιλέχθηκαν" + "add-to-dashboard": "Προσθήκη στον πίνακα ελέγχου", + "add-widget-to-dashboard": "Προσθήκη widget στον πίνακα ελέγχου", + "selected-attributes": "{ count, plural, =1 {1 χαρακτηριστικό} other {# χαρακτηριστικά} } επιλεγμένα", + "selected-telemetry": "{ count, plural, =1 {1 μονάδα τηλεμετρίας} other {# μονάδες τηλεμετρίας} } επιλεγμένες", + "no-attributes-text": "Δεν βρέθηκαν χαρακτηριστικά", + "no-telemetry-text": "Δεν βρέθηκε τηλεμετρία", + "copy-key": "Αντιγραφή κλειδιού", + "add-telemetry": "Προσθήκη τηλεμετρίας", + "copy-value": "Αντιγραφή τιμής", + "delete-timeseries": { + "start-time": "Χρόνος έναρξης", + "ends-on": "Λήγει στις", + "strategy": "Στρατηγική", + "delete-strategy": "Στρατηγική διαγραφής", + "all-data": "Διαγραφή όλων των δεδομένων", + "all-data-except-latest-value": "Διαγραφή όλων των δεδομένων εκτός από την πιο πρόσφατη τιμή", + "latest-value": "Διαγραφή πιο πρόσφατης τιμής", + "all-data-for-time-period": "Διαγραφή όλων των δεδομένων για την επιλεγμένη χρονική περίοδο", + "rewrite-latest-value": "Επανεγγραφή πιο πρόσφατης τιμής" + } + }, + "api-usage": { + "api-features": "Λειτουργίες API", + "api-usage": "Χρήση API", + "alarm": "Συναγερμός", + "alarms-created": "Δημιουργήθηκαν συναγερμοί", + "queue-stats": "Στατιστικά ουράς", + "processing-failures-and-timeouts": "Αποτυχίες επεξεργασίας και χρονικά όρια", + "exceptions": "Εξαιρέσεις", + "alarms-created-daily-activity": "Ημερήσια δραστηριότητα δημιουργίας συναγερμών", + "alarms-created-hourly-activity": "Ωριαία δραστηριότητα δημιουργίας συναγερμών", + "alarms-created-monthly-activity": "Μηνιαία δραστηριότητα δημιουργίας συναγερμών", + "data-points": "Σημεία δεδομένων", + "data-points-storage-days": "Ημέρες αποθήκευσης σημείων δεδομένων", + "device-api": "API συσκευής", + "email": "Email", + "email-messages": "Μηνύματα Email", + "email-messages-daily-activity": "Ημερήσια δραστηριότητα Email", + "email-messages-monthly-activity": "Μηνιαία δραστηριότητα Email", + "executions": "Εκτελέσεις", + "scripts": "Σενάρια", + "scripts-hourly-activity": "Ωριαία δραστηριότητα σεναρίων", + "scripts-daily-activity": "Ημερήσια δραστηριότητα σεναρίων", + "scripts-monthly-activity": "Μηνιαία δραστηριότητα σεναρίων", + "javascript": "JavaScript", + "javascript-executions": "Εκτελέσεις JavaScript", + "tbel": "TBEL", + "tbel-executions": "Εκτελέσεις TBEL", + "latest-error": "Πρόσφατο σφάλμα", + "messages": "Μηνύματα", + "notifications": "Ειδοποιήσεις", + "notifications-email-sms": "Ειδοποιήσεις (Email/SMS)", + "notifications-hourly-activity": "Ωριαία δραστηριότητα ειδοποιήσεων", + "permanent-failures": "Μόνιμες αποτυχίες ${entityName}", + "permanent-timeouts": "Μόνιμα χρονικά όρια ${entityName}", + "processing-failures": "Αποτυχίες επεξεργασίας ${entityName}", + "processing-timeouts": "Χρονικά όρια επεξεργασίας ${entityName}", + "rule-chain": "Αλυσίδα κανόνων", + "rule-engine": "Μηχανή κανόνων", + "rule-engine-daily-activity": "Ημερήσια δραστηριότητα μηχανής κανόνων", + "rule-engine-executions": "Εκτελέσεις μηχανής κανόνων", + "rule-engine-hourly-activity": "Ωριαία δραστηριότητα μηχανής κανόνων", + "rule-engine-monthly-activity": "Μηνιαία δραστηριότητα μηχανής κανόνων", + "rule-engine-statistics": "Στατιστικά μηχανής κανόνων", + "rule-node": "Κόμβος κανόνα", + "sms": "SMS", + "sms-messages": "Μηνύματα SMS", + "sms-messages-daily-activity": "Ημερήσια δραστηριότητα SMS", + "sms-messages-monthly-activity": "Μηνιαία δραστηριότητα SMS", + "successful": "Επιτυχίες ${entityName}", + "telemetry": "Τηλεμετρία", + "telemetry-persistence": "Διατήρηση τηλεμετρίας", + "telemetry-persistence-daily-activity": "Ημερήσια δραστηριότητα διατήρησης τηλεμετρίας", + "telemetry-persistence-hourly-activity": "Ωριαία δραστηριότητα διατήρησης τηλεμετρίας", + "telemetry-persistence-monthly-activity": "Μηνιαία δραστηριότητα διατήρησης τηλεμετρίας", + "transport": "Μεταφορά", + "transport-daily-activity": "Ημερήσια δραστηριότητα μεταφοράς", + "transport-data-points": "Σημεία δεδομένων μεταφοράς", + "transport-hourly-activity": "Ωριαία δραστηριότητα μεταφοράς", + "transport-messages": "Μηνύματα μεταφοράς", + "transport-monthly-activity": "Μηνιαία δραστηριότητα μεταφοράς", + "view-details": "Προβολή λεπτομερειών", + "view-statistics": "Προβολή στατιστικών" + }, + "api-limit": { + "cassandra-queries": "Ερωτήματα Cassandra", + "entity-version-creation": "Δημιουργία έκδοσης οντότητας", + "entity-version-load": "Φόρτωση έκδοσης οντότητας", + "notification-requests": "Αιτήματα ειδοποίησης", + "notification-requests-per-rule": "Αιτήματα ειδοποίησης ανά κανόνα", + "rest-api-requests": "Αιτήματα REST API", + "rest-api-requests-per-customer": "Αιτήματα REST API ανά πελάτη", + "transport-messages": "Μηνύματα μεταφοράς", + "transport-messages-per-device": "Μηνύματα μεταφοράς ανά συσκευή", + "transport-messages-per-gateway": "Μηνύματα μεταφοράς ανά πύλη", + "transport-messages-per-gateway-device": "Μηνύματα μεταφοράς ανά συσκευή πύλης", + "ws-updates-per-session": "Ενημερώσεις WS ανά συνεδρία", + "edge-events": "Γεγονότα Edge", + "edge-events-per-edge": "Γεγονότα Edge ανά Edge", + "edge-uplink-messages": "Μηνύματα uplink Edge", + "edge-uplink-messages-per-edge": "Μηνύματα uplink ανά Edge" }, "audit-log": { - "audit": "Καταγραφή", - "audit-logs": "Ημερολόγια Καταγραφής", - "timestamp": "Ώρα", - "entity-type": "Είδος Οντότητας", - "entity-name": "Όνομα Οντότητας", + "audit": "Έλεγχος", + "audit-logs": "Αρχεία καταγραφής ελέγχου", + "timestamp": "Χρονική σήμανση", + "entity-type": "Τύπος οντότητας", + "entity-name": "Όνομα οντότητας", "user": "Χρήστης", "type": "Τύπος", "status": "Κατάσταση", @@ -329,2182 +947,6268 @@ "type-added": "Προστέθηκε", "type-deleted": "Διαγράφηκε", "type-updated": "Ενημερώθηκε", - "type-attributes-updated": "Τα χαρακτηριστικά ενημερώθηκαν", - "type-attributes-deleted": "Τα χαρακτηριστικά διαγράφηκαν", + "type-attributes-updated": "Ενημερώθηκαν χαρακτηριστικά", + "type-attributes-deleted": "Διαγράφηκαν χαρακτηριστικά", "type-rpc-call": "Κλήση RPC", - "type-credentials-updated": "Τα διαπιστευτήρια ενημερώθηκαν", - "type-assigned-to-customer": "Ανατέθηκε σε Πελάτη", - "type-unassigned-from-customer": "Αποσυνδέθηκε από Πελάτη", + "type-credentials-updated": "Ενημερώθηκαν διαπιστευτήρια", + "type-assigned-to-customer": "Ανατέθηκε σε πελάτη", + "type-unassigned-from-customer": "Αποανατέθηκε από πελάτη", + "type-assigned-to-edge": "Ανατέθηκε σε Edge", + "type-unassigned-from-edge": "Αποανατέθηκε από Edge", "type-activated": "Ενεργοποιήθηκε", - "type-suspended": "Μπήκε σε αναστολή", - "type-credentials-read": "Τα διαπιστευτήρια διαβάστηκαν", - "type-attributes-read": "Τα χαρακτηριστικά διαβάστηκαν", - "type-added-to-entity-group": "Προστέθηκε στην ομάδα", - "type-removed-from-entity-group": "Αφαιρέθηκε από την ομάδα", - "type-relation-add-or-update": "Relation updated", - "type-relation-delete": "Η συσχέτιση ενημερώθηκε", - "type-relations-delete": "Όλες οι συσχετίσεις διαγράφηκαν", - "type-alarm-ack": "Επιβεβαιώθηκε", - "type-alarm-clear": "Εκκαθαρίστηκε", - "type-rest-api-rule-engine-call": "Κλήση Rule engine REST API", - "type-made-public": "Έγινε δημόσιο", - "type-made-private": "Έγινε ιδιωτικό", + "type-suspended": "Ανεστάλη", + "type-credentials-read": "Αναγνώστηκαν διαπιστευτήρια", + "type-attributes-read": "Αναγνώστηκαν χαρακτηριστικά", + "type-relation-add-or-update": "Η σχέση ενημερώθηκε", + "type-relation-delete": "Η σχέση διαγράφηκε", + "type-relations-delete": "Όλες οι σχέσεις διαγράφηκαν", + "type-alarm-ack": "Ο συναγερμός αναγνωρίστηκε", + "type-alarm-clear": "Ο συναγερμός εκκαθαρίστηκε", + "type-alarm-delete": "Ο συναγερμός διαγράφηκε", + "type-alarm-assign": "Ο συναγερμός ανατέθηκε", + "type-alarm-unassign": "Ο συναγερμός αποανατέθηκε", + "type-added-comment": "Προστέθηκε σχόλιο", + "type-updated-comment": "Ενημερώθηκε σχόλιο", + "type-deleted-comment": "Διαγράφηκε σχόλιο", + "type-login": "Είσοδος", + "type-logout": "Έξοδος", + "type-lockout": "Κλείδωμα", "status-success": "Επιτυχία", "status-failure": "Αποτυχία", - "audit-log-details": "΄Λεπτομέρειες καταγραφής", - "no-audit-logs-prompt": "Δεν βρέθηκαν αρχεία καταγραφής", - "action-data": "Δεδομένα ενεργειών", + "audit-log-details": "Λεπτομέρειες αρχείου καταγραφής ελέγχου", + "no-audit-logs-prompt": "Δεν βρέθηκαν καταγραφές", + "action-data": "Δεδομένα ενέργειας", "failure-details": "Λεπτομέρειες αποτυχίας", - "search": "Αναζήτηση αρχείων καταγραφής", - "clear-search": "΄Καθαρισμός αναζήτησης" + "search": "Αναζήτηση καταγραφών ελέγχου", + "clear-search": "Καθαρισμός αναζήτησης", + "type-assigned-from-tenant": "Ανατέθηκε από ενοικιαστή", + "type-assigned-to-tenant": "Ανατέθηκε σε ενοικιαστή", + "type-provision-success": "Η συσκευή εγκαταστάθηκε", + "type-provision-failure": "Η εγκατάσταση της συσκευής απέτυχε", + "type-timeseries-updated": "Ενημερώθηκε τηλεμετρία", + "type-timeseries-deleted": "Διαγράφηκε τηλεμετρία", + "type-sms-sent": "SMS στάλθηκε" + }, + "debug-settings": { + "label": "Ρυθμίσεις εντοπισμού σφαλμάτων", + "on-failure": "Μόνο αποτυχίες (24/7)", + "all-messages": "Όλα τα μηνύματα ({{time}})", + "failures": "Αποτυχίες", + "entity": "οντότητα", + "hint": { + "main-limited": "Δεν θα καταγραφούν περισσότερα από {{msg}} μηνύματα εντοπισμού για την {{entity}} ανά {{time}}.", + "on-failure": "Καταγραφή μόνο μηνυμάτων σφάλματος.", + "all-messages": "Καταγραφή όλων των μηνυμάτων εντοπισμού." + } + }, + "calculated-fields": { + "expression": "Έκφραση", + "no-found": "Δεν βρέθηκαν υπολογιζόμενα πεδία", + "list": "{ count, plural, =1 {Ένα υπολογιζόμενο πεδίο} other {Λίστα με # υπολογιζόμενα πεδία} }", + "selected-fields": "{ count, plural, =1 {1 υπολογιζόμενο πεδίο} other {# υπολογιζόμενα πεδία} } επιλεγμένα", + "type": { + "simple": "Απλό", + "script": "Script" + }, + "arguments": "Ορίσματα", + "decimals-by-default": "Αριθμοί μετά την υποδιαστολή (προεπιλογή)", + "debugging": "Εντοπισμός σφαλμάτων υπολογιζόμενου πεδίου", + "argument-name": "Όνομα ορίσματος", + "datasource": "Πηγή δεδομένων", + "add-argument": "Προσθήκη ορίσματος", + "test-script-function": "Δοκιμή συνάρτησης script", + "no-arguments": "Δεν έχουν οριστεί ορίσματα", + "argument-settings": "Ρυθμίσεις ορίσματος", + "argument-current": "Τρέχουσα οντότητα", + "argument-current-tenant": "Τρέχων ενοικιαστής", + "argument-device": "Συσκευή", + "argument-asset": "Περιουσιακό στοιχείο", + "argument-customer": "Πελάτης", + "argument-tenant": "Τρέχων ενοικιαστής", + "argument-type": "Τύπος ορίσματος", + "see-debug-events": "Προβολή γεγονότων εντοπισμού σφαλμάτων", + "attribute": "Χαρακτηριστικό", + "copy-argument-name": "Αντιγραφή ονόματος ορίσματος", + "timeseries-key": "Κλειδί χρονοσειράς", + "device-name": "Όνομα συσκευής", + "latest-telemetry": "Πρόσφατο τηλεμετρικό", + "rolling": "Συσσώρευση χρονοσειράς", + "attribute-scope": "Πεδίο χαρακτηριστικού", + "server-attributes": "Χαρακτηριστικά διακομιστή", + "client-attributes": "Χαρακτηριστικά πελάτη", + "shared-attributes": "Κοινόχρηστα χαρακτηριστικά", + "attribute-key": "Κλειδί χαρακτηριστικού", + "default-value": "Προεπιλεγμένη τιμή", + "limit": "Μέγιστες τιμές", + "time-window": "Παράθυρο χρόνου", + "customer-name": "Όνομα πελάτη", + "asset-name": "Όνομα περιουσιακού στοιχείου", + "timeseries": "Χρονοσειρά", + "output": "Έξοδος", + "create": "Δημιουργία νέου υπολογιζόμενου πεδίου", + "file": "Αρχείο υπολογιζόμενου πεδίου", + "invalid-file-error": "Μη έγκυρη μορφή αρχείου. Βεβαιωθείτε ότι είναι έγκυρο αρχείο JSON.", + "import": "Εισαγωγή υπολογιζόμενου πεδίου", + "export": "Εξαγωγή υπολογιζόμενου πεδίου", + "export-failed-error": "Αποτυχία εξαγωγής υπολογιζόμενου πεδίου: {{error}}", + "output-type": "Τύπος εξόδου", + "delete-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το υπολογιζόμενο πεδίο '{{title}}';", + "delete-text": "Προσοχή, μετά την επιβεβαίωση το υπολογιζόμενο πεδίο και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-multiple-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 υπολογιζόμενο πεδίο} other {# υπολογιζόμενα πεδία} };", + "delete-multiple-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα υπολογιζόμενα πεδία θα διαγραφούν και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "test-with-this-message": "Δοκιμή με αυτό το μήνυμα", + "hint": { + "arguments-simple-with-rolling": "Το απλού τύπου υπολογιζόμενο πεδίο δεν πρέπει να περιλαμβάνει κλειδιά με τύπο συσσώρευσης χρονοσειράς.", + "arguments-empty": "Τα ορίσματα δεν πρέπει να είναι κενά.", + "expression-required": "Η έκφραση είναι υποχρεωτική.", + "expression-invalid": "Η έκφραση δεν είναι έγκυρη", + "expression-max-length": "Το μήκος της έκφρασης πρέπει να είναι μικρότερο από 255 χαρακτήρες.", + "argument-name-required": "Το όνομα ορίσματος είναι υποχρεωτικό.", + "argument-name-pattern": "Το όνομα ορίσματος δεν είναι έγκυρο.", + "argument-name-duplicate": "Υπάρχει ήδη όρισμα με αυτό το όνομα.", + "argument-name-max-length": "Το όνομα ορίσματος πρέπει να είναι μικρότερο από 256 χαρακτήρες.", + "argument-name-forbidden": "Το όνομα ορίσματος είναι δεσμευμένο και δεν μπορεί να χρησιμοποιηθεί.", + "argument-type-required": "Ο τύπος ορίσματος είναι υποχρεωτικός.", + "max-args": "Έχει επιτευχθεί ο μέγιστος αριθμός ορισμάτων.", + "decimals-range": "Οι δεκαδικοί στην προεπιλογή πρέπει να είναι αριθμός από 0 έως 15.", + "expression": "Η προεπιλεγμένη έκφραση δείχνει πώς να μετατραπεί η θερμοκρασία από Φαρενάιτ σε Κελσίου.", + "arguments-entity-not-found": "Η οντότητα-στόχος του ορίσματος δεν βρέθηκε." + } }, "confirm-on-exit": { - "message": "Έχετε μη αποθηκευμένες αλλαγές. Είστε βέβαιοι ότι θέλετε να φύγετε από τη σελίδα;", - "html-message": "Έχετε μη αποθηκευμένες αλλαγές.
    Είστε βέβαιοι ότι θέλετε να φύγετε από τη σελίδα;", + "message": "Έχετε μη αποθηκευμένες αλλαγές. Είστε βέβαιοι ότι θέλετε να φύγετε από αυτήν τη σελίδα;", + "html-message": "Έχετε μη αποθηκευμένες αλλαγές.
    Είστε βέβαιοι ότι θέλετε να φύγετε από αυτήν τη σελίδα;", "title": "Μη αποθηκευμένες αλλαγές" }, "contact": { "country": "Χώρα", + "country-required": "Η χώρα είναι υποχρεωτική.", "city": "Πόλη", - "state": "Περιφέρεια", + "state": "Πολιτεία / Περιφέρεια", "postal-code": "Ταχυδρομικός Κώδικας", "postal-code-invalid": "Μη έγκυρη μορφή ταχυδρομικού κώδικα.", - "address": "Διεύθυνση (γραμμή 1)", - "address2": "Διεύθυνση (γραμμή 2)", + "address": "Διεύθυνση", + "address2": "Διεύθυνση 2", "phone": "Τηλέφωνο", "email": "Email", - "no-address": "Χωρίς διεύθυνση" + "no-address": "Δεν υπάρχει διεύθυνση", + "no-country-found": "Δεν βρέθηκαν χώρες.", + "no-country-matching": "Δεν βρέθηκε χώρα που να ταιριάζει με '{{country}}'.", + "state-max-length": "Το μήκος της πολιτείας πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "phone-max-length": "Ο αριθμός τηλεφώνου πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "city-max-length": "Η πόλη πρέπει να είναι μικρότερη από 256 χαρακτήρες" }, "common": { + "name": "Όνομα", + "type": "Τύπος", + "general": "Γενικά", "username": "Όνομα χρήστη", "password": "Κωδικός πρόσβασης", - "enter-username": "Εισάγετε Όνομα χρήστη", - "enter-password": "Εισάγετε Κωδικό πρόσβασης", - "enter-search": "Αναζήτηση", - "created-time": "Δημιουργήθηκε" - }, - "converter": { - "converter": "Μετατροπέας δεδομένων", - "converters": "Μετατροπείς δεδομένων", - "select-converter": "Επιλογή μετατροπέα δεδομένων", - "no-converters-matching": "Δεν βρέθηκαν μετατροπείς δεδομένων για '{{entity}}'.", - "converter-required": "Απαιτείται ορισμός μετατροπέα δεδομένων", - "delete": "Διαγραφή μετατροπέα", - "management": "Διαχείριση μετατροπέων δεδομένων", - "add-converter-text": "Προσθήκη νέου μετατροπέα δεδομένων", - "no-converters-text": "Δεν βρέθηκαν μετατροπείς δεδομένων", - "selected-converters": "{ count, plural, =1 {1 data converter} other {# data converters} } επιλέχθηκαν", - "delete-converter-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον μετατροπέα δεδομένων '{{converterName}}'?", - "delete-converter-text": "Προσέξτε, μετά την επιβεβαίωση, ο μετατροπέας δεδομένων και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-converters-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 data converter} other {# data converters} };", - "delete-converters-action-title": "Διαγραφή { count, plural, =1 {1 data converter} other {# data converters} }", - "delete-converters-text": "Προσοχή! Μετά την επιβεβαίωση, όλοι οι επιλεγμένοι μετατροπείς δεδομένων και όλα τα σχετικά δεδομένα θα καταστούν μη ανακτήσιμα.", - "events": "Γεγονότα", - "add": "Προσθήκη μετατροπέα δεδομένων", - "converter-details": "Λεπτομέρειες μετατροπέα δεδομένων", - "details": "Λεπτομέρειες", - "copyId": "Αντιγραφή ID μετατροπέα δεδομένων", - "idCopiedMessage": "Το ID του μετατροπέα δεδομένων αντιγράφηκε στο πρόχειρο.", - "debug-mode": "Λειτουργία εντοπισμού σφαλμάτων", - "name": "Όνομα", - "name-required": "Απαιτείται Όνομα.", - "description": "Περιγραφή", - "decoder": "Αποκωδικοποιητής", - "encoder": "Kωδικοποιητής", - "test-decoder-fuction": "Δοκιμή λειτουργίας αποκωδικοποιητή", - "test-encoder-fuction": "Δοκιμή λειτουργίας κωδικοποιητή", - "decoder-input-params": "Είσοδο παραμέτρων αποκωδικοποιητή", - "encoder-input-params": "Είσοδο παραμέτρων κωδικοποιητή", - "payload": "Φορτίο", - "payload-content-type": "Τύπος περιεχόμενου φορτίου", - "payload-content": "Περιεχόμενο φορτίο", - "message": "Μήνυμα", - "message-type": "Τύπος μηνύματος", - "message-type-required": "Απαιτείται τύπος μηνύματος", - "test": "Δοκιμή", - "metadata": "Μεταδεδομένα", - "metadata-required": "Οι καταχωρίσεις μεταδεδομένων δεν μπορούν να είναι κενές.", - "integration-metadata": "Μεταδεδομένα ενσωμάτωσης", - "integration-metadata-required": "Οι καταχωρήσεις μεταδεδομένων ενσωμάτωσης δεν μπορούν να είναι κενές.", + "data": "Δεδομένα", + "timestamp": "Χρονική σήμανση", + "enter-username": "Εισάγετε όνομα χρήστη", + "enter-password": "Εισάγετε κωδικό πρόσβασης", + "enter-search": "Εισάγετε αναζήτηση", + "created-time": "Χρόνος δημιουργίας", + "disabled": "Απενεργοποιημένο", + "loading": "Φόρτωση...", + "proceed": "Συνέχεια", + "open-details-page": "Άνοιγμα σελίδας λεπτομερειών", + "not-found": "Δεν βρέθηκε", + "value": "Τιμή", + "documentation": "Τεκμηρίωση", + "time-left": "Απομένει {{time}}", "output": "Έξοδος", - "import": "Εισαγωγή μετατροπέα", - "export": "Εξαγωγή μετατροπέα", - "export-failed-error": "Δεν είναι δυνατή η εξαγωγή του μετατροπέα: {{error}}", - "create-new-converter": "Δημιουργία νέου μετατροπέα", - "converter-file": "Αρχείο μετατροπέα", - "invalid-converter-file-error": "Δεν είναι δυνατή η εισαγωγή του μετατροπέα: Μη έγκυρη δομή δεδομένων μετατροπέα.", - "type": "Τύπος", - "type-required": "Απαιτείται τύπος.", - "type-uplink": "Uplink", - "type-downlink": "Downlink" + "suffix": { + "s": "δ", + "ms": "ms" + }, + "hint": { + "name-required": "Το όνομα είναι υποχρεωτικό.", + "name-pattern": "Το όνομα δεν είναι έγκυρο.", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες.", + "title-required": "Ο τίτλος είναι υποχρεωτικός.", + "title-pattern": "Ο τίτλος δεν είναι έγκυρος.", + "title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 256 χαρακτήρες.", + "key-required": "Το κλειδί είναι υποχρεωτικό.", + "key-pattern": "Το κλειδί δεν είναι έγκυρο.", + "key-max-length": "Το κλειδί πρέπει να είναι μικρότερο από 256 χαρακτήρες." + }, + "required-fields": "Λείπουν υποχρεωτικά πεδία" }, "content-type": { "json": "Json", - "text": "Text", - "binary": "Binary (Base64)" + "text": "Κείμενο", + "binary": "Δυαδικό (Base64)" + }, + "color": { + "color": "Χρώμα" }, "customer": { "customer": "Πελάτης", "customers": "Πελάτες", - "management": "Διαχείριση Πελατών", - "dashboard": "Dashboard Πελάτη", - "dashboards": "Dashboards Πελάτη", - "devices": "Συσκευές Πελάτη", - "entity-views": "Προβολές Οντοτήτων ΠελάτηCustomer Entity Views", - "assets": "Assets Πελάτη", - "public-dashboards": "Δημόσια Dashboards", - "public-devices": "Δημόσιες Devices", - "public-assets": "Δημόσια Assets", - "public-entity-views": "Δημόσιες Προβολές Οντοτήτων", - "add": "Προσθήκη Πελάτη", + "management": "Διαχείριση πελατών", + "dashboard": "Πίνακας ελέγχου πελάτη", + "dashboards": "Πίνακες ελέγχου πελάτη", + "devices": "Συσκευές πελάτη", + "entity-views": "Προβολές οντοτήτων πελάτη", + "assets": "Περιουσιακά στοιχεία πελάτη", + "public-dashboards": "Δημόσιοι πίνακες ελέγχου", + "public-devices": "Δημόσιες συσκευές", + "public-assets": "Δημόσια περιουσιακά στοιχεία", + "public-entity-views": "Δημόσιες προβολές οντοτήτων", + "add": "Προσθήκη πελάτη", "delete": "Διαγραφή πελάτη", - "manage-customer-user-groups": "Διαχείριση ομάδων χρηστών πελατών", - "manage-customer-groups": "Διαχείριση ομάδων πελατών", - "manage-customer-device-groups": "Διαχείριση ομάδων συσκευών πελατών", - "manage-customer-asset-groups": "Διαχείριση ομάδων Asset πελατών", - "manage-customer-entity-view-groups": "Διαχείριση ομάδων προβολής οντότητας πελατών", - "manage-customer-dashboard-groups": "Διαχείριση ομάδων dashboard πελατών", - "manage-customer-users": "Διαχείριση χρηστών πελατών", - "manage-customers": "Διαχείριση πελατών", - "manage-customer-devices": "Διαχείρηση συσκευών πελατών", - "manage-customer-entity-views": "Διαχείριση προβολής οντότητας πελατών", - "manage-customer-dashboards": "Διαχείριση dashboards πελατών", + "manage-customer-users": "Διαχείριση χρηστών πελάτη", + "manage-customer-devices": "Διαχείριση συσκευών πελάτη", + "manage-customer-dashboards": "Διαχείριση πινάκων ελέγχου πελάτη", "manage-public-devices": "Διαχείριση δημόσιων συσκευών", - "manage-public-dashboards": "Διαχείριση δημόσιων συσκευών", - "manage-customer-assets": "Διαχείριση οντοτήτων πελατών", - "manage-public-assets": "Διαχείριση δημόσιων οντοτήτων", + "manage-public-dashboards": "Διαχείριση δημόσιων πινάκων ελέγχου", + "manage-customer-assets": "Διαχείριση περιουσιακών στοιχείων πελάτη", + "manage-customer-edges": "Διαχείριση Edge πελάτη", + "manage-public-assets": "Διαχείριση δημόσιων περιουσιακών στοιχείων", "add-customer-text": "Προσθήκη νέου πελάτη", "no-customers-text": "Δεν βρέθηκαν πελάτες", "customer-details": "Λεπτομέρειες πελάτη", - "delete-customer-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τον πελάτη '{{customerTitle}}'?", - "delete-customer-text": "Προσέξτε, μετά την επιβεβαίωση, ο πελάτης και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-customers-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 customer} other {# customers} }?", - "delete-customers-action-title": "Διαγραφή { count, plural, =1 {1 customer} other {# customers} }", - "delete-customers-text": "Προσέξτε, μετά την επιβεβαίωση, οι πελάτες και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "manage-user-groups": "Διαχείρηση ομαδών χρηστών", - "manage-asset-groups": "Διαχείρηση ομαδών οντοτήτων", - "manage-device-groups": "Διαχείρηση ομαδών συσκευών", - "manage-dashboard-groups": "Διαχείρηση ομαδών dashboards", - "manage-entity-view-groups": "Διαχείρηση ομαδών προβολής οντοτήτων", - "manage-users": "Διαχείρηση χρηστών", - "manage-assets": "Διαχείρηση οντοτήτων", - "manage-devices": "Διαχείρηση συσκευών", - "manage-dashboards": "Διαχείρηση dashboards", + "delete-customer-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον πελάτη '{{customerTitle}}';", + "delete-customer-text": "Προσοχή, μετά την επιβεβαίωση ο πελάτης και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-customers-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 πελάτη} other {# πελάτες} };", + "delete-customers-action-title": "Διαγραφή { count, plural, =1 {1 πελάτη} other {# πελατών} }", + "delete-customers-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι πελάτες θα διαγραφούν και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "manage-users": "Διαχείριση χρηστών", + "manage-assets": "Διαχείριση περιουσιακών στοιχείων", + "manage-devices": "Διαχείριση συσκευών", + "manage-dashboards": "Διαχείριση πινάκων ελέγχου", "title": "Τίτλος", - "title-required": "Απαιτείται ένας τίτλος.", + "title-required": "Ο τίτλος είναι υποχρεωτικός.", + "title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 256 χαρακτήρες", "description": "Περιγραφή", "details": "Λεπτομέρειες", - "events": "Γεγονότα", - "copyId": "Αντιγραφή ID πελάτη", - "idCopiedMessage": "Το ID του πελάτη έχει αντιγραφεί στο πρόχειρο", + "events": "Συμβάντα", + "copyId": "Αντιγραφή αναγνωριστικού πελάτη", + "idCopiedMessage": "Το αναγνωριστικό πελάτη αντιγράφηκε στο πρόχειρο", "select-customer": "Επιλέξτε πελάτη", - "no-customers-matching": "Δεν βρέθηκαν πελάτες που να αντιστοιχούν σε '{{entity}}'.", + "no-customers-matching": "Δεν βρέθηκαν πελάτες που να ταιριάζουν με '{{entity}}'.", "customer-required": "Απαιτείται πελάτης", - "selected-customers": "{ count, plural, =1 {1 customer} other {# customers} } επιλέχθηκαν", - "search": "Αναζήτηση πελατών", - "select-group-to-add": "Επιλέξτε ομάδα για να προσθέσετε τους επιλεγμένους πελάτες", - "select-group-to-move": "Επιλέξτε ομάδα για να μετακινήσετε τους επιλεγμένους πελάτες", - "remove-customers-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, =1 {1 customer} other {# customers} } από την ομάδα '{entityGroup}'?", - "group": "Ομάδα πελατών", - "list-of-groups": "{ count, plural, =1 {One customer group} other {List of # customer groups} }", - "group-name-starts-with": "Πελάτες των οποίων το όνομα αρχίζει από '{{prefix}}'", - "select-default-customer": "Επιλογή προεπιλεγμένου πελάτη", + "select-default-customer": "Επιλέξτε προεπιλεγμένο πελάτη", "default-customer": "Προεπιλεγμένος πελάτης", - "default-customer-required": "Ο προεπιλεγμένος πελάτης είναι υποχρεωτικός για να είναι δυνατή η απασφαλμάτωση του dashboard από τον Tenant", - "allow-white-labeling": "Επιτρέψτε το White Labeling" - }, - "customers-hierarchy": { - "customers-hierarchy": "Ιεραρχία Πελατών", - "open-nav-tree": "Άνοιγμα διακλάδωσης", - "return-to-top-level": "Επιστροφή στο αρχικό επίπεδο" + "default-customer-required": "Απαιτείται προεπιλεγμένος πελάτης για εντοπισμό σφαλμάτων στον πίνακα ελέγχου επιπέδου ενοικιαστή", + "search": "Αναζήτηση πελατών", + "selected-customers": "{ count, plural, =1 {1 πελάτης} other {# πελάτες} } επιλεγμένοι", + "edges": "Παραδείγματα Edge πελάτη", + "manage-edges": "Διαχείριση Edge" }, - "custom-menu": { - "custom-menu": "Προσαρμοσμένο Μενού", - "custom-menu-hint": "Ορίστε το προσαρμοσμένο μενού (JSON) παρακάτω. Αυτό το JSON περιέχει μια λίστα με προσαρμοσμένα στοιχεία μενού." + "css-size": { + "size-value-required": "Η τιμή μεγέθους είναι υποχρεωτική", + "invalid-size-value": "Μη έγκυρη τιμή μεγέθους" }, - "custom-translation": { - "custom-translation": "Μεταφράσεις", - "translation-map": "Χάρτης μετάφρασης", - "key": "Κλειδί", - "import": "Εισαγωγή μετάφρασης", - "export": "Εξαγωγή μετάφρασης", - "export-data": "Εξαγωγή δεδομένων μετάφρασης", - "import-data": "Εισαγωγή δεδομένων μετάφρασης", - "translation-file": "Αρχείο μετάφρασης", - "invalid-translation-file-error": "Δεν είναι δυνατή η εισαγωγή αρχείου μετάφρασης: Μη έγκυρη δομή δεδομένων μετάφρασης.", - "custom-translation-hint": "Καθορίστε την προσαρμοσμένη μετάφραση (JSON) παρακάτω. Αυτό το JSON θα αντικαταστήσει την προεπιλεγμένη μετάφραση. Κάνετε Λήψη αρχείου γλώσσας για να λάβετε την υπάρχουσα μετάφραση. Μπορείτε επίσης να χρησιμοποιήσετε το ληφθέν αρχείο ως αναφορά στα διαθέσιμα ζεύγη κλειδιών-τιμών μετάφρασης.", - "download-locale-file": "Λήψη αρχείου γλώσσας" + "date": { + "last-update-n-ago": "Τελευταία ενημέρωση πριν από Ν", + "last-update-n-ago-text": "Τελευταία ενημέρωση πριν από {{ agoText }}", + "custom-date": "Προσαρμοσμένη ημερομηνία", + "format": "Μορφή", + "preview": "Προεπισκόπηση", + "auto": "Αυτόματο", + "time-granularity-formats": "Μορφές χρονικής ακρίβειας", + "unit-year": "Χρόνια", + "unit-month": "Μήνες", + "unit-day": "Ημέρες", + "unit-hour": "Ώρες", + "unit-minute": "Λεπτά", + "unit-second": "Δευτερόλεπτα", + "unit-millisecond": "Χιλιοστά του δευτερολέπτου" }, "datetime": { - "date-from": "Ημ/νία από", + "date-from": "Ημερομηνία από", "time-from": "Ώρα από", - "date-to": "Ημ/νία έως", - "time-to": "Ώρα έως" + "date-to": "Ημερομηνία έως", + "time-to": "Ώρα έως", + "from": "Από", + "to": "Έως" }, "dashboard": { "dashboard": "Dashboard", "dashboards": "Dashboards", - "management": "Διαχείριση Dashboard", - "view-dashboards": "Προβολή Dashboards", - "add": "Προσθήκη Dashboard", - "assign-dashboard-to-customer": "Ανάθεση Dashboard(s) Σε Πελάτη", - "assign-dashboard-to-customer-text": "Παρακαλώ επιλέξτε τα dashboards που θέλετε να αναθέσετε σε πελάτη", - "assign-to-customer-text": "Παρακαλώ επιλέξτε τον πελάτη στον οποίο θέλετε να αναθέσετε το/τα dashboard(s)", - "assign-to-customer": "Ανάθεση σε πελάτη", - "unassign-from-customer": "Αφαίρεση από πελάτη", - "make-public": "Δημοσιοποιήση Dashboard", - "make-private": "Ιδιωτικοποίηση Dashboard", - "manage-assigned-customers": "Διαχείρηση ανατεθειμένων πελατών", - "assigned-customers": "Ανατεθειμένοι πελάτες", - "assign-to-customers": "Ανάθεση Dashboard(s) Σε Πελάτες", - "assign-to-customers-text": "Παρακαλώ επιλέξτε τους πελάτες στους οποίους θέλετε να αναθέσετε το/τα dashboard(s)", - "unassign-from-customers": "Μη Ανατεθειμένα Dashboard(s) Από Πελάτες", - "unassign-from-customers-text": "Παρακαλώ επιλέξτε τους πελάτες τους οποίους θέλετε να αφαιρέσετε από το/τα dashboard(s)", - "no-dashboards-text": "Δεν βρέθηκαν dashboards", - "no-widgets": "Δεν έχουν ρυθμιστεί widgets", - "add-widget": "Προσθέστε νέο widget", - "title": "Τίτλος", - "select-widget-title": "Επιλογή widget", - "select-widget-subtitle": "Λίστα με τους διαθέσιμους τύπους widget", - "delete": "Διαγραφή dashboard", - "title-required": "Απαιτείται ένας τίτλος.", - "description": "Περιγραφή", - "details": "Λεπτομέρειες", - "dashboard-details": "Λεπτομέρειες Dashboard", - "add-dashboard-text": "Προσθέστε νέο dashboard", - "assign-dashboards": "Αναθέστε dashboards", - "assign-new-dashboard": "Ανάθεση νέου dashboard", - "assign-dashboards-text": "Ανάθεση { count, plural, =1 {1 dashboard} other {# dashboards} } σε πελάτες", - "unassign-dashboards-action-text": "Μη ανατεθειμένο/α { count, plural, =1 {1 dashboard} other {# dashboards} } από πελάτες", - "delete-dashboards": "Διαγραφή dashboards", - "unassign-dashboards": "Αφαίρεση dashboards", - "unassign-dashboards-action-title": "Αφαίρεση { count, plural, =1 {1 dashboard} other {# dashboards} } από πελάτη", - "delete-dashboard-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το dashboard '{{dashboardTitle}}'?", - "delete-dashboard-text": "Προσοχή, μετά την επιβεβαίωση, το dashboard και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-dashboards-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 dashboard} other {# dashboards} }?", - "delete-dashboards-action-title": "Διααγραφή { count, plural, =1 {1 dashboard} other {# dashboards} }", - "delete-dashboards-text": "Προσοχή, μετά την επιβεβαίωση, όλα τα επιλεγμένα dashboards και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "unassign-dashboard-title": "Είστε σίγουρου ότι θέλετε να αφαιρέσετε το dashboard '{{dashboardTitle}}'?", - "unassign-dashboard-text": "Μετά την επιβεβαίωση το dashboard θα αφαιρεθεί και δεν θα είναι διαθέσιμο στον πελάτη.", - "unassign-dashboard": "Αφαίρεση dashboard", - "unassign-dashboards-title": "Είστε σίγουρου ότι θέλετε να αφαιρέσετε { count, plural, =1 {1 dashboard} other {# dashboards} }?", - "unassign-dashboards-text": "Μετά την επιβεβαίωση όλα τα επιλεγμένα dashboards θα αφαιρεθούν και δεν θα είναι διαθέσιμα στον πελάτη.", - "public-dashboard-title": "Το dashboard είναι δημόσιο", - "public-dashboard-text": "Το dashboard {{dashboardTitle}} είναι δημόσιο και διαθέσιμο μέσω του παρακάτω συνδέσμου:", - "public-dashboard-notice": "Σημείωση: Μην ξεχνάτε να κάνετε δημόσιες τις συσκευές που συσχετίζονται ώστε να είναι δυνατή η πρόσβαση στα δεδομένα τους.", - "public-dashboard-link": "Σύνδεσμος δημόσιου dashboard", - "public-dashboard-link-text": "Το δημόσιο dashboard {{dashboardTitle}} είναι πλέον διαθέσιμο μέσω μέσω του παρακάτω συνδέσμου:", - "public-dashboard-link-notice": "Σημείωση: Μην ξεχνάτε να κάνετε δημόσιες τις συσκευές, τα assets και την προβολή οντοτήτων που συσχετίζονται ώστε να είναι δυνατή η πρόσβαση στα δεδομένα τους.", - "make-private-dashboard-title": "Είστε σίγουροι ότι θέλετε να κάνετε το dashboard '{{dashboardTitle}}' ιδιωτικό", - "make-private-dashboard-text": "Μετά την επιβεβαίωση το dashboard θα γίνουν ιδιωτικά και δεν θα είναι διαθέσιμα από τρίτους.", - "make-private-dashboard": "Κάνε το dashboard ιδιωτικό", + "management": "Dashboard management", + "view-dashboards": "View Dashboards", + "add": "Add dashboard", + "assign-dashboard-to-customer": "Assign Dashboard(s) To Customer", + "assign-dashboard-to-customer-text": "Please select the dashboards to assign to the customer", + "assign-to-customer-text": "Please select the customer to assign the dashboard(s)", + "assign-to-customer": "Assign to customer", + "unassign-from-customer": "Unassign from customer", + "make-public": "Make dashboard public", + "make-private": "Make dashboard private", + "manage-assigned-customers": "Manage assigned customers", + "assigned-customers": "Assigned customers", + "assign-to-customers": "Assign Dashboard(s) To Customers", + "assign-to-customers-text": "Please select the customers to assign the dashboard(s)", + "unassign-from-customers": "Unassign Dashboard(s) From Customers", + "unassign-from-customers-text": "Please select the customers to unassign from the dashboard(s)", + "no-dashboards-text": "No dashboards found", + "no-widgets": "No widgets configured", + "add-widget": "Add new widget", + "add-widget-button-text": "Add widget", + "title": "Title", + "image": "Dashboard image", + "mobile-app-settings": "Mobile application settings", + "mobile-order": "Dashboard order in mobile application", + "mobile-hide": "Hide dashboard in mobile application", + "update-image": "Update dashboard image", + "take-screenshot": "Take screenshot", + "select-widget-title": "Select widget", + "select-widget-value": "{{title}}: select widget", + "select-widget-subtitle": "List of available widget types", + "delete": "Delete dashboard", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256", + "description": "Description", + "details": "Details", + "dashboard-details": "Dashboard details", + "add-dashboard-text": "Add new dashboard", + "assign-dashboards": "Assign dashboards", + "assign-new-dashboard": "Assign new dashboard", + "assign-dashboards-text": "Assign { count, plural, =1 {1 dashboard} other {# dashboards} } to customers", + "unassign-dashboards-action-text": "Unassign { count, plural, =1 {1 dashboard} other {# dashboards} } from customers", + "delete-dashboards": "Delete dashboards", + "unassign-dashboards": "Unassign dashboards", + "unassign-dashboards-action-title": "Unassign { count, plural, =1 {1 dashboard} other {# dashboards} } from customer", + "delete-dashboard-title": "Are you sure you want to delete the dashboard '{{dashboardTitle}}'?", + "delete-dashboard-text": "Be careful, after the confirmation the dashboard and all related data will become unrecoverable.", + "delete-dashboards-title": "Are you sure you want to delete { count, plural, =1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "Delete { count, plural, =1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "Be careful, after the confirmation all selected dashboards will be removed and all related data will become unrecoverable.", + "unassign-dashboard-title": "Are you sure you want to unassign the dashboard '{{dashboardTitle}}'?", + "unassign-dashboard-text": "After the confirmation the dashboard will be unassigned and won't be accessible by the customer.", + "unassign-dashboard": "Unassign dashboard", + "unassign-dashboards-title": "Are you sure you want to unassign { count, plural, =1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "After the confirmation all selected dashboards will be unassigned and won't be accessible by the customer.", + "public-dashboard-title": "Dashboard is now public", + "public-dashboard-text": "Your dashboard {{dashboardTitle}} is now public and accessible via next public link:", + "public-dashboard-notice": "Note: Do not forget to make related devices public in order to access their data.", + "make-private-dashboard-title": "Are you sure you want to make the dashboard '{{dashboardTitle}}' private?", + "make-private-dashboard-text": "After the confirmation the dashboard will be made private and won't be accessible by others.", + "make-private-dashboard": "Make dashboard private", "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", - "select-dashboard": "Επιλογή dashboard", - "no-dashboards-matching": "Δεν βρέθηκαν dashboards που να αντιστοιχούν σε '{{entity}}'.", - "dashboard-required": "Απαιτείται dashboard.", - "select-existing": "Επιλέξτε ένα υπάρχον dashboard", - "create-new": "Δημιουργία νέου dashboard", - "new-dashboard-title": "Τίτλος νέου dashboard", - "open-dashboard": "Άνοιγμα dashboard", - "set-background": "Ορίστε φόντο", - "background-color": "χρώμα φόντου", - "background-image": "εικόνα φόντου", - "background-size-mode": "μέγεθος φόντου", - "no-image": "Δεν επιλέχθηκε εικόνα", - "drop-image": "Ρίξτε εδώ μια εικόνα ή κάνετε κλικ για να επιλέξετε ένα αρχείο για να ανεβεί.", - "settings": "Ρυθμίσεις", - "columns-count": "Αρίθμιση στηλών", - "columns-count-required": "Απαιτείται η αρίθμιση στηλών.", - "min-columns-count-message": "Ελάχιστος αριθμός στηλών είναι 10.", - "max-columns-count-message": "Μέγιστος αριθμός στηλών είναι 1000.", + "select-dashboard": "Select dashboard", + "no-dashboards-matching": "No dashboards matching '{{entity}}' were found.", + "dashboard-required": "Dashboard is required.", + "select-existing": "Select existing dashboard", + "create-new": "Create new dashboard", + "new-dashboard-title": "New dashboard title", + "open-dashboard": "Open dashboard", + "set-background": "Set background", + "background-color": "Background color", + "background-image": "Background image", + "background-size-mode": "Background size mode", + "no-image": "No image selected", + "empty-image": "No image", + "drop-image": "Drop an image or click to select a file to upload.", + "maximum-upload-file-size": "Maximum upload file size: {{ size }}", + "cannot-upload-file": "Cannot upload file", + "settings": "Settings", + "move-all-widgets": "Move all widgets", + "move-by": "Move by", + "cols": "cols", + "rows": "rows", + "layout": "Διάταξη", + "layout-type-default": "Προεπιλογή", + "layout-type-scada": "SCADA", + "layout-type-divider": "Διαχωριστικό", + "layout-settings-type": "Ρυθμίσεις διάταξης: {{ type }} σημείο αλλαγής", + "columns-count": "Αριθμός στηλών", + "columns-count-required": "Ο αριθμός στηλών είναι υποχρεωτικός.", + "min-columns-count-message": "Επιτρέπονται τουλάχιστον 10 στήλες.", + "max-columns-count-message": "Επιτρέπονται έως 1000 στήλες.", + "min-layout-width": "Ελάχιστο πλάτος διάταξης", + "columns-suffix": "στήλες", "widgets-margins": "Περιθώριο μεταξύ widgets", + "margin-required": "Απαιτείται τιμή περιθωρίου.", + "min-margin-message": "Το ελάχιστο περιθώριο είναι 0.", + "max-margin-message": "Το μέγιστο περιθώριο είναι 50.", "horizontal-margin": "Οριζόντιο περιθώριο", - "horizontal-margin-required": "Απαιτείται τιμή για το οριζόντιο περιθώριο.", - "min-horizontal-margin-message": "Ελάχιστη τιμή οριζόντιου περιθωρίου είναι 0.", - "max-horizontal-margin-message": "Μέγιστη τιμή οριζόντιου περιθωρίου είναι 50.", - "vertical-margin": "Κάθετο περιθώριο", - "vertical-margin-required": "Απαιτείται τιμή για το κάθετο περιθώριο", - "min-vertical-margin-message": "Ελάχιστη τιμή καθέτου περιθωρίου είναι 0.", - "max-vertical-margin-message": "Μέγιστη τιμή καθέτου περιθωρίου είναι 50.", + "horizontal-margin-required": "Απαιτείται τιμή οριζόντιου περιθωρίου.", + "min-horizontal-margin-message": "Το ελάχιστο οριζόντιο περιθώριο είναι 0.", + "max-horizontal-margin-message": "Το μέγιστο οριζόντιο περιθώριο είναι 50.", + "vertical-margin": "Κατακόρυφο περιθώριο", + "vertical-margin-required": "Απαιτείται τιμή κατακόρυφου περιθωρίου.", + "min-vertical-margin-message": "Το ελάχιστο κατακόρυφο περιθώριο είναι 0.", + "max-vertical-margin-message": "Το μέγιστο κατακόρυφο περιθώριο είναι 50.", + "apply-outer-margin": "Εφαρμογή περιθωρίου στις άκρες της διάταξης", "autofill-height": "Αυτόματη συμπλήρωση ύψους διάταξης", - "mobile-layout": "Ρυθμίσεις διάταξης mobile", - "mobile-row-height": "ύψος γραμμής mobile, σε px", - "mobile-row-height-required": "Απαιτείται ύψος γραμμής mobile.", - "min-mobile-row-height-message": "Ελάχιστη τιμή ύψους γραμμής mobile είναι 5.", - "max-mobile-row-height-message": "Μέγιστη τιμή ύψους γραμμής mobile είναι 200.", - "display-title": "Τίτλος εμφάνισης dashboard", - "toolbar-always-open": "Γραμμή εργαλείων πάντα ανοιχτή", + "mobile-layout": "Ρυθμίσεις διάταξης για κινητά", + "mobile-row-height": "Ύψος γραμμής σε κινητό", + "mobile-row-height-required": "Απαιτείται τιμή ύψους γραμμής κινητού.", + "min-mobile-row-height-message": "Ελάχιστο ύψος γραμμής για κινητό είναι 5 pixels.", + "max-mobile-row-height-message": "Μέγιστο ύψος γραμμής για κινητό είναι 200 pixels.", + "row-height": "Ύψος γραμμής", + "row-height-required": "Απαιτείται τιμή ύψους γραμμής.", + "min-row-height-message": "Ελάχιστο ύψος γραμμής είναι 5 pixels.", + "max-row-height-message": "Μέγιστο ύψος γραμμής είναι 200 pixels.", + "display-first-in-mobile-view": "Εμφάνιση πρώτα στην προβολή κινητού", + "title-settings": "Ρυθμίσεις τίτλου", + "display-title": "Εμφάνιση τίτλου πίνακα", "title-color": "Χρώμα τίτλου", - "display-dashboards-selection": "Εμφάνηση επιλογής dashboards", + "toolbar-settings": "Ρυθμίσεις γραμμής εργαλείων", + "hide-toolbar": "Απόκρυψη γραμμής εργαλείων", + "toolbar-always-open": "Διατήρηση γραμμής εργαλείων ανοιχτής", + "display-dashboards-selection": "Εμφάνιση επιλογής πινάκων", "display-entities-selection": "Εμφάνιση επιλογής οντοτήτων", - "display-dashboard-timewindow": "Εμφάνιση χρονικού πλαισίου", + "display-filters": "Εμφάνιση φίλτρων", + "display-dashboard-timewindow": "Εμφάνιση χρονικού παραθύρου", "display-dashboard-export": "Εμφάνιση εξαγωγής", - "import": "Εισαγωγή dashboard", - "export": "Εξαγωγή dashboard", - "export-failed-error": "Δεν ήταν δυνατή η εξαγωγή dashboard: {{error}}", - "export-pdf": "Εξαγωγή ως PDF", - "export-png": "Εξαγωγή ως PNG", - "export-jpg": "Εξαγωγή ως JPEG", - "export-json-config": "Εξαγωγή ρυθμίσεις JSON", - "download-dashboard-progress": "Δημιουργία dashboard {{reportType}} ...", - "create-new-dashboard": "Δημιουργία νέου dashboard", - "dashboard-file": "Αρχείο dashboard", - "invalid-dashboard-file-error": "Δεν ήταν δυνατή η εισαγωγή dashboard: Μη έγκυρη δομή δεδομένων dashboard.", - "dashboard-import-missing-aliases-title": "Διαμορφώστε τα ψευδώνυμα που χρησιμοποιούνται από το dashboard που εισαγάγατε", + "display-update-dashboard-image": "Εμφάνιση ενημέρωσης εικόνας πίνακα", + "dashboard-logo-settings": "Ρυθμίσεις λογότυπου πίνακα", + "display-dashboard-logo": "Εμφάνιση λογότυπου σε πλήρη οθόνη", + "dashboard-logo-image": "Εικόνα λογότυπου πίνακα", + "advanced-settings": "Προχωρημένες ρυθμίσεις", + "dashboard-css": "CSS πίνακα", + "import": "Εισαγωγή πίνακα", + "export": "Εξαγωγή πίνακα", + "export-failed-error": "Αδυναμία εξαγωγής πίνακα: {{error}}", + "export-prompt": "Ενσωμάτωση εικόνων και πόρων πίνακα", + "create-new-dashboard": "Δημιουργία νέου πίνακα", + "dashboard-file": "Αρχείο πίνακα", + "invalid-dashboard-file-error": "Αδυναμία εισαγωγής πίνακα: Μη έγκυρη δομή δεδομένων πίνακα.", + "dashboard-import-missing-aliases-title": "Διαμόρφωση ψευδωνύμων που χρησιμοποιούνται στον εισαγόμενο πίνακα", "create-new-widget": "Δημιουργία νέου widget", "import-widget": "Εισαγωγή widget", "widget-file": "Αρχείο widget", - "invalid-widget-file-error": "Δεν ήταν δυνατή η εισαγωγή widget: Μη έγκυρη δομή δεδομένων dashboard.", - "widget-import-missing-aliases-title": "Διαμορφώστε τα ψευδώνυμα που χρησιμοποιούνται από το widget που εισαγάγατε", - "open-toolbar": "Άνοιγμα γραμμής εργαλείων dashboard", + "invalid-widget-file-error": "Αδυναμία εισαγωγής widget: Μη έγκυρη δομή δεδομένων widget.", + "widget-import-missing-aliases-title": "Διαμόρφωση ψευδωνύμων που χρησιμοποιούνται στο εισαγόμενο widget", + "open-toolbar": "Άνοιγμα γραμμής εργαλείων", "close-toolbar": "Κλείσιμο γραμμής εργαλείων", - "configuration-error": "Σφάλμα ρυθμίσεων", - "alias-resolution-error-title": "Σφάλμα ρυθμίσεων ψευδωνύμων dashboard", - "invalid-aliases-config": "Δεν βρέθηκε καμία συσκευή που να ταιριάζει με κάποιο από τα φίλτρα.
    Παρακαλούμε επικοινωνήστε με τον Διαχειριστή για την επίλυση του ζητήματος.", - "select-devices": "Επιλογή συσκευών", + "configuration-error": "Σφάλμα διαμόρφωσης", + "alias-resolution-error-title": "Σφάλμα διαμόρφωσης ψευδωνύμων πίνακα", + "invalid-aliases-config": "Αδυναμία εύρεσης συσκευών που να αντιστοιχούν σε ορισμένα φίλτρα ψευδωνύμων.
    Παρακαλώ επικοινωνήστε με τον διαχειριστή σας για επίλυση.", + "select-devices": "Επιλέξτε συσκευές", "assignedToCustomer": "Ανατεθειμένο σε πελάτη", "assignedToCustomers": "Ανατεθειμένο σε πελάτες", "public": "Δημόσιο", + "copyId": "Αντιγραφή αναγνωριστικού πίνακα", + "idCopiedMessage": "Το αναγνωριστικό πίνακα αντιγράφηκε στο πρόχειρο", "public-link": "Δημόσιος σύνδεσμος", "copy-public-link": "Αντιγραφή δημόσιου συνδέσμου", - "public-link-copied-message": "Ο δημόσιος σύνδεσμος έχει αντιγραφεί στο πρόχειρο", - "manage-states": "Διαχείρηση κατάστασης dashboard", - "states": "Κατάσταση dashboard", - "search-states": "Αναζήτηση σε κατάσταση dashboard", - "selected-states": "{ count, plural, =1 {1 dashboard state} other {# dashboard states} } επιλεγμένα", - "edit-state": "Επεξεργασία κατάστασης dashboard", - "delete-state": "Διαγραφή κατάστασης dashboard", - "add-state": "Προσθήκη κατάστασης dashboard", - "state": "Κατάσταση Dashboard", + "public-link-copied-message": "Ο δημόσιος σύνδεσμος αντιγράφηκε στο πρόχειρο", + "manage-states": "Διαχείριση καταστάσεων πίνακα", + "states": "Καταστάσεις πίνακα", + "states-short": "Καταστάσεις", + "search-states": "Αναζήτηση καταστάσεων πίνακα", + "selected-states": "{ count, plural, =1 {1 κατάσταση πίνακα} other {# καταστάσεις πίνακα} } επιλεγμένες", + "edit-state": "Επεξεργασία κατάστασης πίνακα", + "delete-state": "Διαγραφή κατάστασης πίνακα", + "add-state": "Προσθήκη κατάστασης πίνακα", + "no-states-text": "Δεν βρέθηκαν καταστάσεις", + "state": "Κατάσταση πίνακα", "state-name": "Όνομα", - "state-name-required": "Απαιτείται όνομα κατάστασης dashboard.", - "state-id": "ID Κατάστασης", - "state-id-required": "Απαιτείται ID κατάστασης dashboard.", - "state-id-exists": "Υπάρχει ήδη κατάσταση dashboard με το ίδιο ID.", - "is-root-state": "Root state", - "delete-state-title": "Διαγραφή κατάστασης dashboard", - "delete-state-text": "Είστε σίγουροι ότι θέλετε να διαγράψετε την κατάσταση dashboard με όνομα '{{stateName}}'?", + "state-name-required": "Το όνομα της κατάστασης του πίνακα είναι υποχρεωτικό.", + "state-id": "Αναγνωριστικό κατάστασης", + "state-id-required": "Το αναγνωριστικό κατάστασης του πίνακα είναι υποχρεωτικό.", + "state-id-exists": "Υπάρχει ήδη κατάσταση πίνακα με το ίδιο αναγνωριστικό.", + "is-root-state": "Βασική κατάσταση", + "delete-state-title": "Διαγραφή κατάστασης πίνακα", + "delete-state-text": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την κατάσταση πίνακα με όνομα '{{stateName}}';", "show-details": "Εμφάνιση λεπτομερειών", "hide-details": "Απόκρυψη λεπτομερειών", - "select-state": "Επιλογή κατάστασης", - "state-controller": "Έλεγχος κατάστασης", - "selected-dashboards": "{ count, plural, =1 {1 dashboard} other {# dashboards} } επιλεγμένα", - "search": "Αναζήτηση dashboards", - "select-group-to-add": "Επιλογή ομάδας για να προστεθεί στα επιλεγμένα dashboards", - "select-group-to-move": "Επιλογή ομάδας για να μετακινηθεί στα επιλεγμένα dashboards", - "remove-dashboards-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, =1 {1 dashboard} other {# dashboards} } from group '{entityGroup}'?", - "group": "Ομάδα dashboards", - "list-of-groups": "{ count, plural, =1 {One dashboard group} other {List of # dashboard groups} }", - "group-name-starts-with": "Ομάδες dashboard των οποίων το όνομα αρχίζει από '{{prefix}}'" + "select-state": "Επιλέξτε κατάσταση στόχου", + "state-controller": "Ελεγκτής κατάστασης", + "state-controller-default": "στατικό (παρωχημένο)", + "search": "Αναζήτηση πινάκων", + "selected-dashboards": "{ count, plural, =1 {1 πίνακας} other {# πίνακες} } επιλεγμένοι", + "home-dashboard": "Αρχικός πίνακας", + "home-dashboard-hide-toolbar": "Απόκρυψη γραμμής εργαλείων στον αρχικό πίνακα", + "unassign-dashboard-from-edge-text": "Μετά την επιβεβαίωση ο πίνακας θα αποανατεθεί και δεν θα είναι προσβάσιμος από το Edge.", + "unassign-dashboards-from-edge-title": "Είστε βέβαιοι ότι θέλετε να αποαναθέσετε { count, plural, =1 {1 πίνακα} other {# πίνακες} };", + "unassign-dashboards-from-edge-text": "Μετά την επιβεβαίωση όλοι οι επιλεγμένοι πίνακες θα αποανατεθούν και δεν θα είναι προσβάσιμοι από το Edge.", + "assign-dashboard-to-edge": "Ανάθεση πίνακα(ων) σε Edge", + "assign-dashboard-to-edge-text": "Παρακαλώ επιλέξτε τους πίνακες για ανάθεση στο Edge", + "non-existent-dashboard-state-error": "Η κατάσταση πίνακα με id \"{{ stateId }}\" δεν βρέθηκε", + "edit-mode": "Λειτουργία επεξεργασίας", + "duplicate-state-action": "Αντιγραφή κατάστασης", + "breakpoint-value": "Breakpoint ({{ value }})", + "breakpoints-id": { + "default": "Προεπιλογή", + "xs": "Κινητό (xs)", + "sm": "Tablet (sm)", + "md": "Laptop (md)", + "lg": "Desktop (lg)", + "xl": "Desktop (xl)" + }, + "view-format-type-grid": "Πλέγμα", + "view-format-type-list": "Λίστα", + "view-format": "Μορφή προβολής" }, "datakey": { "settings": "Ρυθμίσεις", - "advanced": "Προχωρημένες", + "general": "Γενικά", + "advanced": "Προχωρημένα", + "key": "Κλειδί", + "keys": "Κλειδιά", "label": "Ετικέτα", "color": "Χρώμα", - "units": "Ειδικό σύμβολο που εμφανίζεται δίπλα από την τιμή", + "units": "Ειδικό σύμβολο που εμφανίζεται δίπλα στην τιμή", "decimals": "Αριθμός ψηφίων μετά την υποδιαστολή", - "data-generation-func": "Λειτουργία δημιουργίας δεδομένων", - "use-data-post-processing-func": "Χρησιμοποιήστε τη λειτουργία μετα-επεξεργασίας δεδομένων", - "configuration": "Ρυθμίσεις ονομάτων δεδομένων", - "timeseries": "Χρονική σειρά", - "attributes": "Ιδιώτητες", - "alarm": "Πεδία alarm", - "timeseries-required": "Απαιτείται χρονική σειρά οντοτήτων.", - "timeseries-or-attributes-required": "Απαιτείται χρονική σειρά/ιδιώτητες οντοτήτων.", - "maximum-timeseries-or-attributes": "Μέγιστο { count, plural, =1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", - "alarm-fields-required": "Απαιτούνται πεδία alarm.", - "function-types": "Τύποι λειτουργιών", - "function-types-required": "Απαιτούνται τύποι λειτουργιών.", - "maximum-function-types": "Μέγιστο { count, plural, =1 {1 function type is allowed.} other {# function types are allowed} }", - "time-description": "χρονικό σημείο της συγκεκριμένης τιμής", - "value-description": "η συγκεκριμένη τιμή", - "prev-value-description": "αποτέλεσμα της προηγούμενης λειτουργίας", - "time-prev-description": "χρονσικό σημείο της προηγούμενης τιμής", - "prev-orig-value-description": "αρχική προηγούμενη τιμή" + "data-generation-func": "Συνάρτηση δημιουργίας δεδομένων", + "use-data-post-processing-func": "Χρήση συνάρτησης μετα-επεξεργασίας δεδομένων", + "configuration": "Διαμόρφωση κλειδιού δεδομένων", + "timeseries": "Χρονικές σειρές", + "attributes": "Χαρακτηριστικά", + "entity-field": "Πεδίο οντότητας", + "alarm": "Πεδία ειδοποίησης", + "timeseries-required": "Απαιτούνται χρονικές σειρές οντότητας.", + "timeseries-or-attributes-required": "Απαιτούνται χρονικές σειρές/χαρακτηριστικά οντότητας.", + "alarm-fields-timeseries-or-attributes-required": "Απαιτούνται πεδία ειδοποίησης ή χρονικές σειρές/χαρακτηριστικά οντότητας.", + "maximum-timeseries-or-attributes": "Μέγιστο { count, plural, =1 {1 χρονική σειρά/χαρακτηριστικό επιτρέπεται.} other {# χρονικές σειρές/χαρακτηριστικά επιτρέπονται} }", + "alarm-fields-required": "Απαιτούνται πεδία ειδοποίησης.", + "function-types": "Τύποι συναρτήσεων", + "function-type": "Τύπος συνάρτησης", + "function-types-required": "Απαιτούνται τύποι συναρτήσεων.", + "data-keys": "Κλειδιά δεδομένων", + "data-key": "Κλειδί δεδομένων", + "data-keys-required": "Απαιτούνται κλειδιά δεδομένων.", + "data-key-required": "Απαιτείται κλειδί δεδομένων.", + "alarm-keys": "Κλειδιά δεδομένων ειδοποίησης", + "alarm-key": "Κλειδί δεδομένων ειδοποίησης", + "alarm-key-functions": "Συναρτήσεις κλειδιών ειδοποίησης", + "alarm-key-function": "Συνάρτηση κλειδιού ειδοποίησης", + "latest-keys": "Τελευταία κλειδιά δεδομένων", + "latest-key": "Τελευταίο κλειδί δεδομένων", + "latest-key-functions": "Συναρτήσεις τελευταίων κλειδιών", + "latest-key-function": "Συνάρτηση τελευταίου κλειδιού", + "timeseries-keys": "Κλειδιά χρονικών σειρών", + "timeseries-key": "Κλειδί χρονικής σειράς", + "timeseries-key-functions": "Συναρτήσεις κλειδιών χρονικών σειρών", + "timeseries-key-function": "Συνάρτηση κλειδιού χρονικής σειράς", + "maximum-function-types": "Μέγιστο { count, plural, =1 {1 τύπος συνάρτησης επιτρέπεται.} other {# τύποι συναρτήσεων επιτρέπονται} }", + "time-description": "χρονοσφραγίδα της τρέχουσας τιμής;", + "value-description": "η τρέχουσα τιμή;", + "prev-value-description": "αποτέλεσμα της προηγούμενης κλήσης συνάρτησης;", + "time-prev-description": "χρονοσφραγίδα της προηγούμενης τιμής;", + "prev-orig-value-description": "αρχική προηγούμενη τιμή;", + "aggregation": "Συσσώρευση", + "aggregation-type-hint-common": "Για λόγους απόδοσης, η συσσωρευμένη τιμή είναι διαθέσιμη μόνο για σταθερά χρονικά διαστήματα όπως \"τρέχουσα ημέρα\", \"τρέχων μήνας\" κ.λπ. και δεν είναι διαθέσιμη για κινούμενα χρονικά παράθυρα όπως 'τελευταία 30 λεπτά' ή 'τελευταίες 24 ώρες'.", + "aggregation-type-none-hint": "Λήψη τελευταίας τιμής.", + "aggregation-type-min-hint": "Εύρεση ελάχιστης τιμής μεταξύ των σημείων δεδομένων σε επιλεγμένο χρονικό παράθυρο.", + "aggregation-type-max-hint": "Εύρεση μέγιστης τιμής μεταξύ των σημείων δεδομένων σε επιλεγμένο χρονικό παράθυρο.", + "aggregation-type-avg-hint": "Υπολογισμός μέσου όρου μεταξύ των σημείων δεδομένων σε επιλεγμένο χρονικό παράθυρο.", + "aggregation-type-sum-hint": "Άθροιση όλων των τιμών των σημείων δεδομένων σε επιλεγμένο χρονικό παράθυρο.", + "aggregation-type-count-hint": "Συνολικός αριθμός σημείων δεδομένων σε επιλεγμένο χρονικό παράθυρο.", + "delta-calculation": "Υπολογισμός διαφοράς", + "enable-delta-calculation": "Ενεργοποίηση υπολογισμού διαφοράς", + "enable-delta-calculation-hint": "Όταν είναι ενεργοποιημένο, η τιμή του κλειδιού δεδομένων υπολογίζεται με βάση τις συσσωρευμένες τιμές για ένα επιλεγμένο χρονικό παράθυρο και μια καθορισμένη περίοδο σύγκρισης. Για λόγους απόδοσης, ο υπολογισμός διαφοράς είναι διαθέσιμος μόνο για ιστορικά χρονικά παράθυρα και όχι για τιμές σε πραγματικό χρόνο.", + "delta-calculation-result": "Αποτέλεσμα υπολογισμού διαφοράς", + "delta-calculation-result-previous-value": "Προηγούμενη τιμή", + "delta-calculation-result-delta-absolute": "Διαφορά (απόλυτη)", + "delta-calculation-result-delta-percent": "Διαφορά (ποσοστιαία)", + "source": "Πηγή", + "latest": "Τελευταίο", + "latest-value": "Τελευταία τιμή", + "delta": "διαφορά", + "percent": "ποσοστό", + "absolute": "απόλυτη" }, "datasource": { "type": "Τύπος πηγής δεδομένων", "name": "Όνομα", - "add-datasource-prompt": "Παρακαλούμε προσθέστε πηγή δεδομένων" + "label": "Ετικέτα", + "add-datasource-prompt": "Παρακαλώ προσθέστε πηγή δεδομένων" }, "details": { "details": "Λεπτομέρειες", - "edit-mode": "Λειτουργία Επεξεργασίας", + "edit-mode": "Λειτουργία επεξεργασίας", + "edit-json": "Επεξεργασία JSON", "toggle-edit-mode": "Εναλλαγή λειτουργίας επεξεργασίας" }, "device": { "device": "Συσκευή", - "device-required": "Απαιτείται ορισμός συσκευής.", + "device-required": "Απαιτείται συσκευή.", "devices": "Συσκευές", - "management": "Διαχείριση Συσκευών", - "view-devices": "Προβολή Συσκευών", + "management": "Διαχείριση συσκευών", + "view-devices": "Προβολή συσκευών", "device-alias": "Ψευδώνυμο συσκευής", + "device-type-max-length": "Ο τύπος συσκευής πρέπει να είναι μικρότερος από 256 χαρακτήρες", "aliases": "Ψευδώνυμα συσκευής", - "no-alias-matching": "Δεν βρέθηκε '{{alias}}'.", + "no-alias-matching": "'{{alias}}' δεν βρέθηκε.", "no-aliases-found": "Δεν βρέθηκαν ψευδώνυμα.", - "no-key-matching": "Δεν βρέθηκε '{{key}}'.", - "no-keys-found": "Δεν βρέθηκαν ονόματα", - "create-new-alias": "Δημιουργήστε ένα νέο ψευδώνυμο!", - "create-new-key": "Δημιουργήστε ένα νέο!", - "duplicate-alias-error": "Βρέθηκε διπλότυπο το ψευδώνυμο '{{alias}}'.
    Τα ψευδώνυμα συσκευών πρέπει να είναι μοναδικά σε ένα dashboard.", - "configure-alias": "Ρύθμιση ψευδώνυμου '{{alias}}'", - "no-devices-matching": "Δεν βρέθηκε συσκευή η οποία να αντιστοιχεί σε '{{entity}}'.", + "no-key-matching": "'{{key}}' δεν βρέθηκε.", + "no-keys-found": "Δεν βρέθηκαν κλειδιά.", + "create-new-alias": "Δημιουργία νέου!", + "create-new-key": "Δημιουργία νέου!", + "duplicate-alias-error": "Βρέθηκε διπλό ψευδώνυμο '{{alias}}'.
    Τα ψευδώνυμα συσκευής πρέπει να είναι μοναδικά στον πίνακα.", + "configure-alias": "Διαμόρφωση ψευδωνύμου '{{alias}}'", + "no-devices-matching": "Δεν βρέθηκαν συσκευές που να ταιριάζουν με '{{entity}}'.", "alias": "Ψευδώνυμο", "alias-required": "Απαιτείται ψευδώνυμο συσκευής.", - "remove-alias": "Αφαίρεση ψευδώνυμου συσκευής", - "add-alias": "Προσθήκη ψευδώνυμου συσκευής", - "name-starts-with": "Το όνομα συσκευής αρχίζει με", + "remove-alias": "Αφαίρεση ψευδωνύμου συσκευής", + "add-alias": "Προσθήκη ψευδωνύμου συσκευής", + "name-starts-with": "Έκφραση ονόματος συσκευής", + "help-text": "Χρησιμοποιήστε '%' όπως απαιτείται: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", "device-list": "Λίστα συσκευών", "use-device-name-filter": "Χρήση φίλτρου", - "device-list-empty": "Δεν επιλέχθηκαν συσκευές.", - "device-name-filter-required": "Απαιτείται φίλτρο ονόματος συσκυεής.", - "device-name-filter-no-device-matched": "Δεν βρέθηκαν συσκευές οι οποίες να αρχίζουν από '{{device}}'.", + "device-list-empty": "Δεν έχουν επιλεγεί συσκευές.", + "device-name-filter-required": "Απαιτείται φίλτρο ονόματος συσκευής.", + "device-name-filter-no-device-matched": "Δεν βρέθηκαν συσκευές που ξεκινούν με '{{device}}'.", "add": "Προσθήκη συσκευής", "assign-to-customer": "Ανάθεση σε πελάτη", - "assign-device-to-customer": "Ανάθεση Συσκευών Σε Πελάτη", - "assign-device-to-customer-text": "Παρακαλούμε επιλέξτε συσκευές προς ανάθεση σε πελάτη", - "make-public": "Δημοσιοποίηση συσκευών", - "make-private": "Ιδιωτικοποίηση συσκευών", + "assign-device-to-customer": "Ανάθεση συσκευής/ών σε πελάτη", + "assign-device-to-customer-text": "Παρακαλώ επιλέξτε τις συσκευές για ανάθεση στον πελάτη", + "make-public": "Κάντε τη συσκευή δημόσια", + "make-private": "Κάντε τη συσκευή ιδιωτική", "no-devices-text": "Δεν βρέθηκαν συσκευές", - "assign-to-customer-text": "Παρακαλώ επιλέξτε πελάτη για να αναθέσετε τις συσκευές", - "device-details": "Λεπτομέρειες συσκευές", + "assign-to-customer-text": "Παρακαλώ επιλέξτε τον πελάτη για την ανάθεση της συσκευής/ών", + "device-details": "Λεπτομέρειες συσκευής", "add-device-text": "Προσθήκη νέας συσκευής", "credentials": "Διαπιστευτήρια", - "manage-credentials": "Διαχείρηση διαπιστευτηρίων", + "manage-credentials": "Διαχείριση διαπιστευτηρίων", "delete": "Διαγραφή συσκευής", "assign-devices": "Ανάθεση συσκευών", - "assign-devices-text": "Ανάθεση { count, plural, =1 {1 device} other {# devices} } σε πελάτη", + "assign-devices-text": "Ανάθεση { count, plural, =1 {1 συσκευής} other {# συσκευών} } σε πελάτη", "delete-devices": "Διαγραφή συσκευών", "unassign-from-customer": "Αφαίρεση από πελάτη", "unassign-devices": "Αφαίρεση συσκευών", - "unassign-devices-action-title": "Αφαίρεση { count, plural, =1 {1 device} other {# devices} } από πελάτη", - "assign-new-device": "Ανάθεση νέων συσκευών", - "make-public-device-title": "Είστε σίγουροι ότι θέλετε να κάνετε τη συσκευή '{{deviceName}}' δημόσια;", - "make-public-device-text": "Μετά από την επιβεβαίωσή σας η συσκευή και όλα τα δεδομένα της θα είναι δημόσια και διαθέσιμα σε τρίτους.", - "make-private-device-title": "Είστε σίγουροι ότι θέλετε να κάνετε τη συσκευή '{{deviceName}}' ιδιωτική;", - "make-private-device-text": "Μετά από την επιβεβαίωσή σας η συσκευή και όλα τα δεδομένα της θα είναι ιδιωτικά και δεν θα είναι διαθέσιμα σε τριτους.", + "unassign-devices-action-title": "Αφαίρεση { count, plural, =1 {1 συσκευής} other {# συσκευών} } από πελάτη", + "unassign-device-from-edge-title": "Είστε βέβαιοι ότι θέλετε να αποαναθέσετε τη συσκευή '{{deviceName}}';", + "unassign-device-from-edge-text": "Μετά την επιβεβαίωση η συσκευή δεν θα είναι πλέον προσβάσιμη από το Edge.", + "unassign-devices-from-edge": "Αποαναθέτηση συσκευών από το Edge", + "assign-new-device": "Ανάθεση νέας συσκευής", + "make-public-device-title": "Είστε βέβαιοι ότι θέλετε να κάνετε τη συσκευή '{{deviceName}}' δημόσια;", + "make-public-device-text": "Μετά την επιβεβαίωση η συσκευή και όλα τα δεδομένα της θα είναι δημόσια και προσβάσιμα από όλους.", + "make-private-device-title": "Είστε βέβαιοι ότι θέλετε να κάνετε τη συσκευή '{{deviceName}}' ιδιωτική;", + "make-private-device-text": "Μετά την επιβεβαίωση η συσκευή και όλα τα δεδομένα της θα είναι ιδιωτικά και δεν θα είναι προσβάσιμα από άλλους.", "view-credentials": "Προβολή διαπιστευτηρίων", - "delete-device-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την συσκευή '{{deviceName}}';", - "delete-device-text": "Προσοχή, μετά την επιβεβαίωση, η συσκευή και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-devices-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 device} other {# devices} };", - "delete-devices-action-title": "Διαγραφή { count, plural, =1 {1 device} other {# devices} }", - "delete-devices-text": "Προσοχή, μετά την επιβεβαίωση, όλες οι επιλεγμένες συσκευές θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "unassign-device-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε την συσκευή '{{deviceName}}'?", - "unassign-device-text": "Μετά την επιβεβαίωση, η συσκευή θα αφαιρεθεί και δεν θα είναι προσβάσιμες από τον πελάτη.", - "unassign-device": "Αφαίρεση συσκευής", - "unassign-devices-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, =1 {1 device} other {# devices} }?", - "unassign-devices-text": "Μετά την επιβεβαίωση, όλες οι επιλεγμένες συσκευές θα καταργηθούν και δεν θα είναι προσβάσιμες από τον πελάτη.", - "device-credentials": "Πιστοποιητικά συσκευής", + "delete-device-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη συσκευή '{{deviceName}}';", + "delete-device-text": "Προσοχή, μετά την επιβεβαίωση η συσκευή και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-devices-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 συσκευή} other {# συσκευές} };", + "delete-devices-action-title": "Διαγραφή { count, plural, =1 {1 συσκευής} other {# συσκευών} }", + "delete-devices-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες συσκευές θα διαγραφούν και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "unassign-device-title": "Είστε βέβαιοι ότι θέλετε να αποαναθέσετε τη συσκευή '{{deviceName}}';", + "unassign-device-text": "Μετά την επιβεβαίωση η συσκευή δεν θα είναι πλέον προσβάσιμη από τον πελάτη.", + "unassign-device": "Αποαναθέτηση συσκευής", + "unassign-devices-title": "Είστε βέβαιοι ότι θέλετε να αποαναθέσετε { count, plural, =1 {1 συσκευή} other {# συσκευές} };", + "unassign-devices-text": "Μετά την επιβεβαίωση όλες οι επιλεγμένες συσκευές θα αποανατεθούν και δεν θα είναι προσβάσιμες από τον πελάτη.", + "device-credentials": "Διαπιστευτήρια συσκευής", + "loading-device-credentials": "Φόρτωση διαπιστευτηρίων συσκευής...", "credentials-type": "Τύπος διαπιστευτηρίων", - "access-token": "Διακριτικό πρόσβασης", - "access-token-required": "Απαιτείται διακριτικό πρόσβασης.", - "access-token-invalid": "Access token length must be from 1 to 32 characters.", - "secret": "Secret", - "secret-required": "Απαιτείται secret.", - "device-type": "Τύπος συσκευής", - "device-type-required": "Απαιτείται τύπος συσκεύης.", - "select-device-type": "Επιλογή τύπου συσκευής", - "enter-device-type": "Είσοδο τύπου συσκευής", + "access-token": "Access token", + "access-token-required": "Το access token είναι υποχρεωτικό.", + "access-token-invalid": "Το μήκος του access token πρέπει να είναι από 1 έως 32 χαρακτήρες.", + "certificate-pem-format": "Πιστοποιητικό σε μορφή PEM", + "certificate-pem-format-required": "Το πιστοποιητικό είναι υποχρεωτικό.", + "copy-access-token": "Αντιγραφή Access token", + "copy-certificate": "Αντιγραφή πιστοποιητικού", + "copy-client-id": "Αντιγραφή Client ID", + "copy-user-name": "Αντιγραφή ονόματος χρήστη", + "copy-password": "Αντιγραφή κωδικού πρόσβασης", + "generate-client-id": "Δημιουργία Client ID", + "generate-user-name": "Δημιουργία ονόματος χρήστη", + "generate-password": "Δημιουργία κωδικού πρόσβασης", + "generate-access-token": "Δημιουργία Access Token", + "lwm2m-security-config": { + "identity": "Ταυτότητα πελάτη", + "identity-required": "Απαιτείται η ταυτότητα πελάτη.", + "identity-tooltip": "Το αναγνωριστικό PSK είναι ένα αυθαίρετο αναγνωριστικό PSK έως 128 byte, όπως περιγράφεται στο πρότυπο [RFC7925].\nΤο αναγνωριστικό PSK ΠΡΕΠΕΙ να μετατραπεί πρώτα σε συμβολοσειρά χαρακτήρων και στη συνέχεια να κωδικοποιηθεί σε οκτάδες χρησιμοποιώντας UTF-8.", + "client-key": "Κλειδί πελάτη", + "client-key-required": "Απαιτείται το κλειδί πελάτη.", + "client-key-tooltip-prk": "Το δημόσιο κλειδί RPK ή id πρέπει να είναι σύμφωνα με το πρότυπο [RFC7250] και να είναι κωδικοποιημένο σε μορφή Base64!", + "client-key-tooltip-psk": "Το κλειδί PSK πρέπει να είναι σύμφωνα με το πρότυπο [RFC4279] και σε δεκαεξαδική μορφή: 32, 64, 128 χαρακτήρες!", + "endpoint": "Όνομα πελάτη τελικού σημείου", + "endpoint-required": "Απαιτείται όνομα πελάτη τελικού σημείου.", + "client-public-key": "Δημόσιο κλειδί πελάτη", + "client-public-key-hint": "Αν δεν έχει οριστεί δημόσιο κλειδί πελάτη, θα χρησιμοποιηθεί το αξιόπιστο πιστοποιητικό", + "client-public-key-tooltip": "Το δημόσιο κλειδί X509 πρέπει να είναι σε μορφή DER-κωδικοποιημένου X509v3 και να υποστηρίζει αποκλειστικά τον αλγόριθμο EC και να είναι κωδικοποιημένο σε μορφή Base64!", + "mode": "Λειτουργία διαμόρφωσης ασφάλειας", + "client-tab": "Διαμόρφωση ασφάλειας πελάτη", + "client-certificate": "Πιστοποιητικό πελάτη", + "bootstrap-tab": "Bootstrap πελάτη", + "bootstrap-server": "Διακομιστής Bootstrap", + "lwm2m-server": "Διακομιστής LwM2M", + "client-publicKey-or-id": "Δημόσιο κλειδί ή Id πελάτη", + "client-publicKey-or-id-required": "Απαιτείται Δημόσιο κλειδί ή Id πελάτη.", + "client-publicKey-or-id-tooltip-psk": "Το αναγνωριστικό PSK είναι ένα αυθαίρετο αναγνωριστικό PSK έως 128 byte, όπως περιγράφεται στο πρότυπο [RFC7925].\nΤο αναγνωριστικό PSK ΠΡΕΠΕΙ να μετατραπεί πρώτα σε συμβολοσειρά χαρακτήρων και στη συνέχεια να κωδικοποιηθεί σε οκτάδες χρησιμοποιώντας UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "Το δημόσιο κλειδί RPK ή id πρέπει να είναι σύμφωνα με το πρότυπο [RFC7250] και να είναι κωδικοποιημένο σε μορφή Base64!", + "client-publicKey-or-id-tooltip-x509": "Το δημόσιο κλειδί X509 πρέπει να είναι σε μορφή DER-κωδικοποιημένου X509v3 και να υποστηρίζει αποκλειστικά τον αλγόριθμο EC και να είναι κωδικοποιημένο σε μορφή Base64", + "client-secret-key": "Μυστικό κλειδί πελάτη", + "client-secret-key-required": "Απαιτείται το μυστικό κλειδί πελάτη.", + "client-secret-key-tooltip-psk": "Το κλειδί PSK πρέπει να είναι σύμφωνα με το πρότυπο [RFC4279] και σε δεκαεξαδική μορφή: 32, 64, 128 χαρακτήρες!", + "client-secret-key-tooltip-prk": "Το μυστικό κλειδί RPK πρέπει να είναι σε μορφή PKCS_8 (κωδικοποίηση DER, πρότυπο [RFC5958]) και να είναι κωδικοποιημένο σε μορφή Base64!", + "client-secret-key-tooltip-x509": "Το μυστικό κλειδί X509 πρέπει να είναι σε μορφή PKCS_8 (κωδικοποίηση DER, πρότυπο [RFC5958]) και να είναι κωδικοποιημένο σε μορφή Base64!" + }, + "client-id": "Client ID", + "client-id-pattern": "Περιέχει μη έγκυρο χαρακτήρα.", + "user-name": "Όνομα χρήστη", + "user-name-required": "Το όνομα χρήστη είναι υποχρεωτικό.", + "client-id-or-user-name-necessary": "Απαιτείται Client ID ή/και Όνομα χρήστη", + "password": "Κωδικός πρόσβασης", + "secret": "Μυστικό", + "secret-required": "Απαιτείται μυστικό.", + "device-type": "Προφίλ συσκευής", + "device-type-required": "Ο τύπος συσκευής είναι υποχρεωτικός.", + "select-device-type": "Επιλέξτε τύπο συσκευής", + "enter-device-type": "Εισαγωγή προφίλ συσκευής", "any-device": "Οποιαδήποτε συσκευή", - "no-device-types-matching": "Δεν βρέθηκε συσκευή η οποία να αντιστοιχεί σε '{{entitySubtype}}'.", - "device-type-list-empty": "Δεν έχουν επιλεχθεί τύποι συσκευής.", - "device-types": "Τύποι συσκευής", + "no-device-types-matching": "Δεν βρέθηκαν προφίλ συσκευής που να ταιριάζουν με '{{entitySubtype}}'.", + "device-type-list-empty": "Δεν έχει επιλεγεί προφίλ συσκευής!", + "device-profile-type-list-empty": "Πρέπει να επιλεγεί τουλάχιστον ένα προφίλ συσκευής.", + "device-types": "Τύποι συσκευών", "name": "Όνομα", - "name-required": "Απαιτείται όνομα.", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "label-max-length": "Η ετικέτα πρέπει να είναι μικρότερη από 256 χαρακτήρες", "description": "Περιγραφή", "label": "Ετικέτα", - "events": "Γεγονότα", + "events": "Συμβάντα", "details": "Λεπτομέρειες", - "copyId": "Αντιγραφή ID συσκευής", - "copyAccessToken": "Αντιγραφή διακριτικού διαπιστευτηρίου", - "idCopiedMessage": "Το ID της συσκευής έχει αντιγραφεί στο πρόχειρο", - "accessTokenCopiedMessage": "Το διακριτικό διαπιστευτήριο έχει αντιγραφεί στο πρόχειρο", - "assignedToCustomer": "Ανάθεση σε πελάτη", - "unable-delete-device-alias-title": "Αδύνατο να διαγραφεί το ψευδώνυμο συσκευής", - "unable-delete-device-alias-text": "Το ψευδώνυμο συσκευής '{{deviceAlias}}' δεν μπορεί να διαγραφεί όσο εξακωλουθεί να χρησιμοποιείται από τα ακόλουθα widget(s):
    {{widgetsList}}", - "is-gateway": "είναι gateway", - "public": "Δημόσιο", + "copyId": "Αντιγραφή Id συσκευής", + "copyAccessToken": "Αντιγραφή access token", + "copy-mqtt-authentication": "Αντιγραφή διαπιστευτηρίων MQTT", + "idCopiedMessage": "Το Id της συσκευής αντιγράφηκε στο πρόχειρο", + "accessTokenCopiedMessage": "Το access token της συσκευής αντιγράφηκε στο πρόχειρο", + "mqtt-authentication-copied-message": "Η αυθεντικοποίηση MQTT της συσκευής αντιγράφηκε στο πρόχειρο", + "assignedToCustomer": "Ανατεθειμένη σε πελάτη", + "unable-delete-device-alias-title": "Αδυναμία διαγραφής ψευδωνύμου συσκευής", + "unable-delete-device-alias-text": "Το ψευδώνυμο συσκευής '{{deviceAlias}}' δεν μπορεί να διαγραφεί επειδή χρησιμοποιείται από τα παρακάτω widget(s):
    {{widgetsList}}", + "is-gateway": "Είναι gateway", + "overwrite-activity-time": "Αντικατάσταση χρόνου δραστηριότητας για συνδεδεμένη συσκευή", + "device-filter": "Φίλτρο συσκευής", + "device-filter-title": "Φίλτρο συσκευής", + "filter-title": "Φίλτρο", + "device-state": "Κατάσταση συσκευής", + "state": "Κατάσταση", + "any": "Οποιαδήποτε", + "active": "Ενεργή", + "inactive": "Ανενεργή", + "public": "Δημόσια", "device-public": "Η συσκευή είναι δημόσια", - "select-device": "Επιλογή συσκευής", - "selected-devices": "{ count, plural, =1 {1 device} other {# devices} } επιλέχθηκαν", - "search": "Αναζήτηση συσκευών", - "select-group-to-add": "Επιλέξτε την ομάδα στην οποία θα προστεθουν οι επιλεγμένες συσκευές", - "select-group-to-move": "Επιλέξτε την ομάδα στην οποία θα μετακινηθούν οι επιλεγμένες συσκευές", - "remove-devices-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, =1 {1 device} other {# devices} } from group '{entityGroup}'?", - "group": "Ομάδα Συσκευών", - "list-of-groups": "{ count, plural, =1 {One device group} other {List of # device groups} }", - "group-name-starts-with": "Ομάδες συσκευών των οποίων το όνομα αρχίζει από '{{prefix}}'", + "select-device": "Επιλέξτε συσκευή", "import": "Εισαγωγή συσκευής", - "device-file": "Αρχείο συσκευής" + "device-file": "Αρχείο συσκευής", + "search": "Αναζήτηση συσκευών", + "selected-devices": "{ count, plural, =1 {1 συσκευή} other {# συσκευές} } επιλεγμένες", + "device-configuration": "Ρύθμιση συσκευής", + "transport-configuration": "Ρύθμιση μεταφοράς", + "wizard": { + "device-details": "Λεπτομέρειες συσκευής" + }, + "unassign-devices-from-edge-title": "Είστε βέβαιοι ότι θέλετε να αποαναθέσετε { count, plural, =1 {1 συσκευή} other {# συσκευές} };", + "unassign-devices-from-edge-text": "Μετά την επιβεβαίωση όλες οι επιλεγμένες συσκευές θα αποανατεθούν και δεν θα είναι προσβάσιμες από το Edge.", + "time": "Χρόνος", + "connectivity": { + "check-connectivity": "Έλεγχος συνδεσιμότητας", + "device-created-check-connectivity": "Η συσκευή δημιουργήθηκε. Ας ελέγξουμε τη συνδεσιμότητα!", + "loading-check-connectivity-command": "Φόρτωση εντολών ελέγχου συνδεσιμότητας...", + "use-following-instructions": "Χρησιμοποιήστε τις παρακάτω οδηγίες για την αποστολή τηλεμετρίας εκ μέρους της συσκευής μέσω shell", + "execute-following-command": "Εκτελέστε την παρακάτω εντολή", + "install-curl-windows": "Από τα Windows 10 b17063 και μετά, το cURL είναι διαθέσιμο από προεπιλογή", + "install-curl-macos": "Από το Mac OS X 10.2 6C115 (Jaguar), το cURL είναι διαθέσιμο από προεπιλογή", + "install-mqtt-windows": "Χρησιμοποιήστε τις οδηγίες για λήψη, εγκατάσταση, ρύθμιση και εκτέλεση του mosquitto_pub", + "install-coap-client": "Χρησιμοποιήστε τις οδηγίες για λήψη, εγκατάσταση, ρύθμιση και εκτέλεση του coap-client", + "install-necessary-client-tools": "Εγκαταστήστε τα απαραίτητα εργαλεία πελάτη", + "mqtts-x509-command": "Χρησιμοποιήστε την παρακάτω τεκμηρίωση για να συνδέσετε τη συσκευή μέσω MQTT με εξουσιοδότηση X509", + "coaps-x509-command": "Χρησιμοποιήστε την παρακάτω τεκμηρίωση για να συνδέσετε τη συσκευή μέσω CoAP over DTLS με εξουσιοδότηση X509", + "snmp-command": "Χρησιμοποιήστε την παρακάτω τεκμηρίωση για να συνδέσετε τη συσκευή μέσω SNMP.", + "sparkplug-command": "Χρησιμοποιήστε την παρακάτω τεκμηρίωση για να συνδέσετε τη συσκευή μέσω MQTT Sparkplug.", + "lwm2m-command": "Χρησιμοποιήστε την παρακάτω τεκμηρίωση για να συνδέσετε τη συσκευή μέσω LWM2M." + } + }, + "dynamic-form": { + "property": { + "properties": "Ιδιότητες", + "property": "Ιδιότητα", + "id": "Id", + "name": "Όνομα", + "type": "Τύπος", + "type-text": "Κείμενο", + "type-password": "Κωδικός πρόσβασης", + "type-textarea": "Περιοχή κειμένου", + "type-number": "Αριθμός", + "type-switch": "Διακόπτης", + "type-select": "Επιλογή", + "type-radios": "Κουμπιά επιλογής", + "type-datetime": "Ημερομηνία/Ώρα", + "type-image": "Εικόνα", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "Markdown", + "type-color": "Χρώμα", + "type-color-settings": "Ρυθμίσεις χρώματος", + "type-font": "Γραμματοσειρά", + "type-units": "Μονάδες", + "type-icon": "Εικονίδιο", + "type-fieldset": "Ομάδα πεδίων", + "type-array": "Πίνακας", + "type-html-section": "Ενότητα HTML", + "group-title": "Τίτλος ομάδας", + "no-properties": "Δεν έχουν διαμορφωθεί ιδιότητες", + "add-property": "Προσθήκη ιδιότητας", + "property-settings": "Ρυθμίσεις ιδιότητας", + "remove-property": "Αφαίρεση ιδιότητας", + "default-value": "Προεπιλεγμένη τιμή", + "value-required": "Απαιτείται τιμή", + "number-settings": "Ρυθμίσεις αριθμού", + "min": "Ελάχιστο", + "max": "Μέγιστο", + "step": "Βήμα", + "selected-options-limit": "Όριο επιλεγμένων επιλογών", + "advanced-ui-settings": "Προηγμένες ρυθμίσεις UI", + "disable-on-property": "Απενεργοποίηση με βάση ιδιότητα", + "display-condition-function": "Συνάρτηση συνθήκης εμφάνισης", + "sub-label": "Υποετικέτα", + "vertical-divider-after": "Κατακόρυφος διαχωριστής μετά", + "input-field-suffix": "Κατάληξη πεδίου εισόδου", + "property-row-classes": "Κλάσεις γραμμής ιδιότητας", + "property-field-classes": "Κλάσεις πεδίου ιδιότητας", + "not-unique-property-ids-error": "Τα Id των ιδιοτήτων πρέπει να είναι μοναδικά!", + "enable-multiple-select": "Ενεργοποίηση πολλαπλής επιλογής", + "allow-empty-select-option": "Επιτρέπεται κενή επιλογή", + "select-options": "Επιλογές λίστας", + "not-unique-select-option-value-error": "Οι τιμές επιλογών πρέπει να είναι μοναδικές!", + "value": "Τιμή", + "label": "Ετικέτα", + "add-option": "Προσθήκη επιλογής", + "no-options": "Δεν έχουν διαμορφωθεί επιλογές", + "remove-option": "Αφαίρεση επιλογής", + "textarea-rows": "Γραμμές περιοχής κειμένου", + "help-id": "Id βοήθειας", + "buttons-direction": "Κατεύθυνση κουμπιών", + "direction-row": "Γραμμή", + "direction-column": "Στήλη", + "radio-button-options": "Επιλογές κουμπιών επιλογής", + "datetime-type": "Τύπος πεδίου Ημερομηνία/Ώρα", + "datetime-type-date": "Ημερομηνία", + "datetime-type-time": "Ώρα", + "datetime-type-datetime": "Ημερομηνία/Ώρα", + "enable-clear-button": "Ενεργοποίηση κουμπιού εκκαθάρισης", + "html-section-settings": "Ρυθμίσεις ενότητας HTML", + "html-section-classes": "Κλάσεις ενότητας HTML", + "html-section-content": "Περιεχόμενο ενότητας HTML", + "array-item": "Στοιχείο πίνακα", + "item-type": "Τύπος στοιχείου", + "item-name": "Όνομα στοιχείου", + "no-items": "Δεν υπάρχουν στοιχεία" + }, + "clear-form": "Εκκαθάριση φόρμας", + "clear-form-prompt": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε όλες τις ιδιότητες της φόρμας;", + "import-form": "Εισαγωγή φόρμας από JSON", + "export-form": "Εξαγωγή φόρμας σε JSON", + "json-file": "Αρχείο JSON", + "json-content": "Περιεχόμενο JSON", + "invalid-form-json-file-error": "Αδυναμία εισαγωγής φόρμας από JSON: Μη έγκυρη δομή δεδομένων φόρμας JSON." + }, + "asset-profile": { + "asset-profile": "Προφίλ περιουσιακού στοιχείου", + "asset-profiles": "Προφίλ περιουσιακών στοιχείων", + "all-asset-profiles": "Όλα", + "add": "Προσθήκη προφίλ περιουσιακού στοιχείου", + "edit": "Επεξεργασία προφίλ περιουσιακού στοιχείου", + "asset-profile-details": "Λεπτομέρειες προφίλ περιουσιακού στοιχείου", + "no-asset-profiles-text": "Δεν βρέθηκαν προφίλ περιουσιακών στοιχείων", + "search": "Αναζήτηση προφίλ περιουσιακών στοιχείων", + "selected-asset-profiles": "{ count, plural, =1 {1 προφίλ περιουσιακού στοιχείου} other {# προφίλ περιουσιακών στοιχείων} } επιλεγμένα", + "no-asset-profiles-matching": "Δεν βρέθηκε προφίλ περιουσιακού στοιχείου που να ταιριάζει με '{{entity}}'.", + "asset-profile-required": "Απαιτείται προφίλ περιουσιακού στοιχείου", + "idCopiedMessage": "Το Id του προφίλ περιουσιακού στοιχείου αντιγράφηκε στο πρόχειρο", + "set-default": "Ορισμός ως προεπιλεγμένο", + "delete": "Διαγραφή προφίλ περιουσιακού στοιχείου", + "copyId": "Αντιγραφή Id προφίλ περιουσιακού στοιχείου", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "new-device-profile-name": "Όνομα προφίλ περιουσιακού στοιχείου", + "new-device-profile-name-required": "Απαιτείται όνομα προφίλ περιουσιακού στοιχείου.", + "name": "Όνομα", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "image": "Εικόνα προφίλ περιουσιακού στοιχείου", + "description": "Περιγραφή", + "default": "Προεπιλογή", + "default-rule-chain": "Προεπιλεγμένη αλυσίδα κανόνων", + "default-edge-rule-chain": "Προεπιλεγμένη αλυσίδα κανόνων Edge", + "default-edge-rule-chain-hint": "Χρησιμοποιείται στο Edge ως αλυσίδα κανόνων για την επεξεργασία εισερχόμενων δεδομένων για περιουσιακά στοιχεία αυτού του προφίλ", + "mobile-dashboard": "Πίνακας ελέγχου για κινητό", + "mobile-dashboard-hint": "Χρησιμοποιείται από την εφαρμογή για κινητό ως πίνακας λεπτομερειών περιουσιακού στοιχείου", + "select-queue-hint": "Επιλέξτε από αναπτυσσόμενη λίστα.", + "delete-asset-profile-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το προφίλ περιουσιακού στοιχείου '{{assetProfileName}}';", + "delete-asset-profile-text": "Προσοχή, μετά την επιβεβαίωση το προφίλ και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-asset-profiles-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 προφίλ περιουσιακού στοιχείου} other {# προφίλ περιουσιακών στοιχείων} };", + "delete-asset-profiles-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα προφίλ θα διαγραφούν και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "set-default-asset-profile-title": "Είστε βέβαιοι ότι θέλετε να ορίσετε το προφίλ '{{assetProfileName}}' ως προεπιλεγμένο;", + "set-default-asset-profile-text": "Μετά την επιβεβαίωση, το προφίλ θα οριστεί ως προεπιλογή και θα χρησιμοποιείται για νέα περιουσιακά στοιχεία χωρίς καθορισμένο προφίλ.", + "no-asset-profiles-found": "Δεν βρέθηκαν προφίλ περιουσιακών στοιχείων.", + "create-new-asset-profile": "Δημιουργία νέου!", + "create-asset-profile": "Δημιουργία νέου προφίλ περιουσιακού στοιχείου", + "import": "Εισαγωγή προφίλ περιουσιακού στοιχείου", + "export": "Εξαγωγή προφίλ περιουσιακού στοιχείου", + "export-failed-error": "Αδυναμία εξαγωγής προφίλ: {{error}}", + "asset-profile-file": "Αρχείο προφίλ περιουσιακού στοιχείου", + "invalid-asset-profile-file-error": "Αδυναμία εισαγωγής προφίλ: Μη έγκυρη δομή δεδομένων προφίλ περιουσιακού στοιχείου." + }, + "device-profile": { + "device-profile": "Προφίλ συσκευής", + "device-profiles": "Προφίλ συσκευών", + "all-device-profiles": "Όλα", + "add": "Προσθήκη προφίλ συσκευής", + "edit": "Επεξεργασία προφίλ συσκευής", + "device-profile-details": "Λεπτομέρειες προφίλ συσκευής", + "no-device-profiles-text": "Δεν βρέθηκαν προφίλ συσκευών", + "search": "Αναζήτηση προφίλ συσκευών", + "selected-device-profiles": "{ count, plural, =1 {1 προφίλ συσκευής} other {# προφίλ συσκευών} } επιλεγμένα", + "no-device-profiles-matching": "Δεν βρέθηκε προφίλ συσκευής που να ταιριάζει με '{{entity}}'.", + "device-profile-required": "Απαιτείται προφίλ συσκευής", + "idCopiedMessage": "Το Id του προφίλ συσκευής αντιγράφηκε στο πρόχειρο", + "set-default": "Ορισμός ως προεπιλεγμένο", + "delete": "Διαγραφή προφίλ συσκευής", + "copyId": "Αντιγραφή Id προφίλ συσκευής", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "name": "Όνομα", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "type": "Τύπος προφίλ", + "type-required": "Απαιτείται τύπος προφίλ.", + "type-default": "Προεπιλεγμένο", + "image": "Εικόνα προφίλ συσκευής", + "transport-type": "Τύπος μεταφοράς", + "transport-type-required": "Απαιτείται τύπος μεταφοράς.", + "transport-type-default": "Προεπιλεγμένο", + "transport-type-default-hint": "Υποστηρίζει βασικό μεταφορέα MQTT, HTTP και CoAP", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Ενεργοποιεί σύνθετες ρυθμίσεις μεταφοράς MQTT", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Ενεργοποιεί σύνθετες ρυθμίσεις μεταφοράς CoAP", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "Τύπος μεταφοράς LWM2M", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Καθορίστε ρυθμίσεις μεταφοράς SNMP", + "transport-type-http": "HTTP", + "description": "Περιγραφή", + "default": "Προεπιλογή", + "profile-configuration": "Ρυθμίσεις προφίλ", + "transport-configuration": "Ρυθμίσεις μεταφοράς", + "default-rule-chain": "Προεπιλεγμένη αλυσίδα κανόνων", + "default-edge-rule-chain": "Προεπιλεγμένη αλυσίδα κανόνων Edge", + "default-edge-rule-chain-hint": "Χρησιμοποιείται στο Edge για την επεξεργασία εισερχόμενων δεδομένων συσκευών αυτού του προφίλ", + "mobile-dashboard": "Πίνακας ελέγχου κινητού", + "mobile-dashboard-hint": "Χρησιμοποιείται από την εφαρμογή κινητού ως πίνακας λεπτομερειών συσκευής", + "select-queue-hint": "Επιλέξτε από αναπτυσσόμενη λίστα.", + "delete-device-profile-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το προφίλ συσκευής '{{deviceProfileName}}';", + "delete-device-profile-text": "Προσοχή, μετά την επιβεβαίωση το προφίλ και όλα τα σχετικά δεδομένα, περιλαμβανομένων των OTA ενημερώσεων, δεν θα είναι ανακτήσιμα.", + "delete-device-profiles-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 προφίλ συσκευής} other {# προφίλ συσκευών} };", + "delete-device-profiles-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα προφίλ θα διαγραφούν και όλα τα σχετικά δεδομένα, περιλαμβανομένων των OTA ενημερώσεων, δεν θα είναι ανακτήσιμα.", + "set-default-device-profile-title": "Είστε βέβαιοι ότι θέλετε να ορίσετε το προφίλ συσκευής '{{deviceProfileName}}' ως προεπιλεγμένο;", + "set-default-device-profile-text": "Μετά την επιβεβαίωση, το προφίλ θα οριστεί ως προεπιλογή και θα χρησιμοποιείται για νέες συσκευές χωρίς καθορισμένο προφίλ.", + "no-device-profiles-found": "Δεν βρέθηκαν προφίλ συσκευών.", + "create-new-device-profile": "Δημιουργία νέου!", + "mqtt-device-topic-filters": "Φίλτρα θεμάτων συσκευής MQTT", + "mqtt-device-topic-filters-unique": "Τα φίλτρα θεμάτων MQTT πρέπει να είναι μοναδικά.", + "mqtt-device-topic-filters-spark-plug": "Κόμβος Sparkplug B Edge of Network (EoN).", + "mqtt-device-topic-filters-spark-plug-hint": "Επιτρέπει συνδέσεις από κόμβους EoN με payload και μορφή θεμάτων Sparkplug B.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "Μετρικές SparkPlug προς αποθήκευση ως χαρακτηριστικά.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "Ονόματα μετρικών SparkPlug που θα αποθηκευτούν ως χαρακτηριστικά συσκευής. Όλες οι υπόλοιπες θα αποθηκευτούν ως τηλεμετρία.", + "mqtt-device-payload-type": "Payload συσκευής MQTT", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Ενεργοποίηση συμβατότητας με άλλες μορφές payload", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Όταν είναι ενεργοποιημένο, χρησιμοποιείται Protobuf ως προεπιλογή και γίνεται fallback σε JSON αν αποτύχει η ανάλυση. Χρήσιμο για υποστήριξη συσκευών κατά τη διάρκεια αναβάθμισης firmware.", + "mqtt-use-json-format-for-default-downlink-topics": "Χρήση μορφής JSON για τα προεπιλεγμένα θέματα downlink", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Όταν ενεργοποιηθεί, χρησιμοποιείται JSON payload για αποστολή attributes και RPC μέσω θεμάτων v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. Δεν επηρεάζει νέα θέματα (v2): v2/a/res/$request_id κ.λπ.", + "mqtt-send-ack-on-validation-exception": "Αποστολή PUBACK σε αποτυχία επικύρωσης PUBLISH μηνύματος", + "mqtt-send-ack-on-validation-exception-hint": "Αντί για τερματισμό της συνεδρίας, η πλατφόρμα θα στείλει PUBACK αν είναι ενεργοποιημένο.", + "snmp-add-mapping": "Προσθήκη SNMP αντιστοίχισης", + "snmp-mapping-not-configured": "Δεν έχει διαμορφωθεί αντιστοίχιση OID σε χρονική σειρά/χαρακτηριστικό", + "snmp-timseries-or-attribute-name": "Όνομα για αντιστοίχιση χρονικής σειράς/χαρακτηριστικού", + "snmp-timseries-or-attribute-type": "Τύπος για αντιστοίχιση χρονικής σειράς/χαρακτηριστικού", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Απαιτείται τύπος payload.", + "coap-device-type": "Τύπος συσκευής CoAP", + "coap-device-payload-type": "Payload συσκευής CoAP", + "coap-device-type-required": "Απαιτείται τύπος συσκευής CoAP.", + "coap-device-type-default": "Προεπιλεγμένος", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Υποστηρίζονται μονοεπίπεδοι [+] και πολυεπίπεδοι [#] χαρακτήρες υποκατάστασης.", + "telemetry-topic-filter": "Φίλτρο θεμάτων τηλεμετρίας", + "telemetry-topic-filter-required": "Απαιτείται φίλτρο θεμάτων τηλεμετρίας.", + "attributes-topic-filter": "Φίλτρο θεμάτων δημοσίευσης χαρακτηριστικών", + "attributes-subscribe-topic-filter": "Φίλτρο θεμάτων εγγραφής χαρακτηριστικών", + "attributes-topic-filter-required": "Απαιτείται φίλτρο θεμάτων δημοσίευσης χαρακτηριστικών.", + "attributes-subscribe-topic-filter-required": "Απαιτείται φίλτρο θεμάτων εγγραφής χαρακτηριστικών.", + "telemetry-proto-schema": "Σχήμα proto τηλεμετρίας", + "telemetry-proto-schema-required": "Απαιτείται σχήμα proto τηλεμετρίας.", + "attributes-proto-schema": "Σχήμα proto χαρακτηριστικών", + "attributes-proto-schema-required": "Απαιτείται σχήμα proto χαρακτηριστικών.", + "rpc-response-proto-schema": "Σχήμα proto απόκρισης RPC", + "rpc-response-proto-schema-required": "Απαιτείται σχήμα proto απόκρισης RPC.", + "rpc-response-topic-filter": "Φίλτρο θεμάτων απόκρισης RPC", + "rpc-response-topic-filter-required": "Απαιτείται φίλτρο θεμάτων απόκρισης RPC.", + "rpc-request-proto-schema": "Σχήμα proto αιτήματος RPC", + "rpc-request-proto-schema-required": "Απαιτείται σχήμα proto αιτήματος RPC.", + "rpc-request-proto-schema-hint": "Το μήνυμα RPC πρέπει να περιέχει πάντα: string method = 1; int32 requestId = 2; και params = 3 οποιουδήποτε τύπου δεδομένων.", + "not-valid-pattern-topic-filter": "Μη έγκυρο μοτίβο φίλτρου θεμάτων", + "not-valid-single-character": "Μη έγκυρη χρήση μονοεπίπεδου χαρακτήρα υποκατάστασης", + "not-valid-multi-character": "Μη έγκυρη χρήση πολυεπίπεδου χαρακτήρα υποκατάστασης", + "single-level-wildcards-hint": "[+] είναι κατάλληλο για οποιοδήποτε επίπεδο στο φίλτρο θεμάτων. Π.χ.: v1/devices/+/telemetry ή +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] μπορεί να αντικαταστήσει ολόκληρο το φίλτρο και πρέπει να είναι το τελευταίο σύμβολο. Π.χ.: # ή v1/devices/me/#.", + "alarm-rules": "Κανόνες συναγερμού", + "alarm-rules-with-count": "Κανόνες συναγερμού ({{count}})", + "no-alarm-rules": "Δεν έχουν διαμορφωθεί κανόνες συναγερμού", + "add-alarm-rule": "Προσθήκη κανόνα συναγερμού", + "edit-alarm-rule": "Επεξεργασία κανόνα συναγερμού", + "alarm-type": "Τύπος συναγερμού", + "alarm-type-required": "Απαιτείται τύπος συναγερμού.", + "alarm-type-unique": "Ο τύπος συναγερμού πρέπει να είναι μοναδικός στο προφίλ.", + "alarm-type-max-length": "Ο τύπος συναγερμού πρέπει να είναι μικρότερος από 256 χαρακτήρες.", + "create-alarm-pattern": "Δημιουργία συναγερμού {{alarmType}}", + "create-alarm-rules": "Δημιουργία κανόνων συναγερμού", + "no-create-alarm-rules": "Δεν έχουν διαμορφωθεί συνθήκες δημιουργίας", + "add-create-alarm-rule-prompt": "Προσθέστε κανόνα δημιουργίας συναγερμού", + "clear-alarm-rule": "Εκκαθάριση κανόνα συναγερμού", + "no-clear-alarm-rule": "Δεν έχει διαμορφωθεί συνθήκη εκκαθάρισης", + "add-create-alarm-rule": "Προσθήκη συνθήκης δημιουργίας", + "add-clear-alarm-rule": "Προσθήκη συνθήκης εκκαθάρισης", + "select-alarm-severity": "Επιλέξτε σοβαρότητα συναγερμού", + "alarm-severity-required": "Απαιτείται σοβαρότητα συναγερμού.", + "condition-duration": "Διάρκεια συνθήκης", + "condition-duration-value": "Τιμή διάρκειας", + "condition-duration-time-unit": "Μονάδα χρόνου", + "condition-duration-value-range": "Η διάρκεια πρέπει να είναι από 1 έως 2147483647.", + "condition-duration-value-pattern": "Η διάρκεια πρέπει να είναι ακέραιος αριθμός.", + "condition-duration-value-required": "Απαιτείται τιμή διάρκειας.", + "condition-duration-time-unit-required": "Απαιτείται μονάδα χρόνου.", + "advanced-settings": "Προηγμένες ρυθμίσεις", + "alarm-rule-additional-info": "Επιπλέον πληροφορίες", + "edit-alarm-rule-additional-info": "Επεξεργασία επιπλέον πληροφοριών", + "alarm-rule-additional-info-placeholder": "Προσθέστε σχόλια ή ρυθμίσεις που θα εμφανίζονται στις λεπτομέρειες του συναγερμού", + "alarm-rule-additional-info-hint": "Υπόδειξη: χρησιμοποιήστε ${keyName} για την υποκατάσταση τιμών από χαρακτηριστικά ή τηλεμετρία.", + "alarm-rule-mobile-dashboard": "Πίνακας ελέγχου για κινητό", + "alarm-rule-mobile-dashboard-hint": "Χρησιμοποιείται ως ταμπλό λεπτομερειών συναγερμού στην εφαρμογή κινητού", + "alarm-rule-no-mobile-dashboard": "Δεν επιλέχθηκε ταμπλό", + "propagate-alarm": "Διάδοση συναγερμού σε σχετιζόμενες οντότητες", + "alarm-rule-relation-types-list": "Τύποι σχέσεων", + "alarm-rule-relation-types-list-hint": "Καθορίζει τους τύπους σχέσεων για το φιλτράρισμα σχετικών οντοτήτων. Αν δεν οριστεί, ο συναγερμός διαδίδεται σε όλες.", + "propagate-alarm-to-owner": "Διάδοση στον κάτοχο της οντότητας (Πελάτης ή Μισθωτής)", + "propagate-alarm-to-tenant": "Διάδοση στον Μισθωτή", + "alarm-rule-condition": "Συνθήκη κανόνα συναγερμού", + "enter-alarm-rule-condition-prompt": "Προσθέστε συνθήκη κανόνα συναγερμού", + "edit-alarm-rule-condition": "Επεξεργασία συνθήκης κανόνα συναγερμού", + "device-provisioning": "Διαμόρφωση συσκευής", + "provision-strategy": "Στρατηγική διαμόρφωσης", + "provision-strategy-required": "Απαιτείται στρατηγική διαμόρφωσης.", + "provision-strategy-disabled": "Απενεργοποιημένη", + "provision-strategy-created-new": "Επιτρέπεται δημιουργία νέων συσκευών", + "provision-strategy-check-pre-provisioned": "Έλεγχος για προκαθορισμένες συσκευές", + "provision-device-key": "Κλειδί διαμόρφωσης", + "provision-device-key-required": "Απαιτείται κλειδί διαμόρφωσης.", + "copy-provision-key": "Αντιγραφή κλειδιού διαμόρφωσης", + "provision-key-copied-message": "Το κλειδί διαμόρφωσης αντιγράφηκε στο πρόχειρο", + "provision-device-secret": "Μυστικό διαμόρφωσης", + "provision-device-secret-required": "Απαιτείται μυστικό διαμόρφωσης.", + "copy-provision-secret": "Αντιγραφή μυστικού διαμόρφωσης", + "provision-secret-copied-message": "Το μυστικό διαμόρφωσης αντιγράφηκε στο πρόχειρο", + "provision-strategy-x509": { + "certificate-chain": "Αλυσίδα Πιστοποιητικών X509", + "certificate-chain-hint": "Η στρατηγική X.509 χρησιμοποιείται για διαμόρφωση μέσω πιστοποιητικών πελάτη σε διπλής κατεύθυνσης TLS.", + "allow-create-new-devices": "Δημιουργία νέων συσκευών", + "allow-create-new-devices-hint": "Αν επιλεγεί, θα δημιουργούνται νέες συσκευές και το πιστοποιητικό θα χρησιμοποιείται ως διαπιστευτήρια.", + "certificate-value": "Πιστοποιητικό σε μορφή PEM", + "certificate-value-required": "Απαιτείται πιστοποιητικό σε μορφή PEM", + "cn-regex-variable": "Μεταβλητή CN με Regular Expression", + "cn-regex-variable-required": "Απαιτείται μεταβλητή CN με Regular Expression", + "cn-regex-variable-hint": "Απαραίτητη για την εξαγωγή ονόματος συσκευής από το κοινό όνομα του πιστοποιητικού X509." + }, + "condition": "Συνθήκη", + "condition-type": "Τύπος συνθήκης", + "condition-type-simple": "Απλή", + "condition-type-duration": "Διάρκεια", + "condition-during": "Κατά τη διάρκεια {{during}}", + "condition-during-dynamic": "Κατά τη διάρκεια \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Επανάληψη", + "condition-type-required": "Απαιτείται τύπος συνθήκης.", + "condition-repeating-value": "Αριθμός γεγονότων", + "condition-repeating-value-range": "Ο αριθμός γεγονότων πρέπει να είναι μεταξύ 1 και 2147483647.", + "condition-repeating-value-pattern": "Ο αριθμός γεγονότων πρέπει να είναι ακέραιος αριθμός.", + "condition-repeating-value-required": "Απαιτείται αριθμός γεγονότων.", + "condition-repeat-times": "Επαναλαμβάνεται { count, plural, =1 {1 φορά} other {# φορές} }", + "condition-repeat-times-dynamic": "Επαναλαμβάνεται \"{ attribute }\" ({ count, plural, =1 {1 φορά} other {# φορές} })", + "schedule-type": "Τύπος προγραμματιστή", + "schedule-type-required": "Απαιτείται τύπος προγραμματιστή.", + "schedule": "Πρόγραμμα", + "edit-schedule": "Επεξεργασία προγράμματος συναγερμού", + "schedule-any-time": "Ενεργό ανά πάσα στιγμή", + "schedule-specific-time": "Ενεργό σε συγκεκριμένο χρόνο", + "schedule-custom": "Προσαρμοσμένο", + "schedule-day": { + "monday": "Δευτέρα", + "tuesday": "Τρίτη", + "wednesday": "Τετάρτη", + "thursday": "Πέμπτη", + "friday": "Παρασκευή", + "saturday": "Σάββατο", + "sunday": "Κυριακή" + }, + "schedule-days": "Ημέρες", + "schedule-time": "Ώρα", + "schedule-time-from": "Από", + "schedule-time-to": "Έως", + "schedule-days-of-week-required": "Πρέπει να επιλεγεί τουλάχιστον μία ημέρα της εβδομάδας.", + "create-device-profile": "Δημιουργία νέου προφίλ συσκευής", + "import": "Εισαγωγή προφίλ συσκευής", + "export": "Εξαγωγή προφίλ συσκευής", + "export-failed-error": "Αποτυχία εξαγωγής προφίλ συσκευής: {{error}}", + "device-profile-file": "Αρχείο προφίλ συσκευής", + "invalid-device-profile-file-error": "Αποτυχία εισαγωγής προφίλ συσκευής: Μη έγκυρη δομή δεδομένων.", + "power-saving-mode": "Λειτουργία εξοικονόμησης ενέργειας", + "power-saving-mode-type": { + "default": "Χρήση λειτουργίας εξοικονόμησης από το προφίλ", + "psm": "Λειτουργία εξοικονόμησης ενέργειας (PSM)", + "drx": "Διακοπτόμενη λήψη (DRX)", + "edrx": "Εκτεταμένη διακοπτόμενη λήψη (eDRX)" + }, + "edrx-cycle": "Κύκλος eDRX", + "edrx-cycle-required": "Απαιτείται κύκλος eDRX.", + "edrx-cycle-pattern": "Ο κύκλος eDRX πρέπει να είναι θετικός ακέραιος.", + "edrx-cycle-min": "Ελάχιστος αριθμός κύκλου eDRX είναι {{ min }} δευτερόλεπτα.", + "paging-transmission-window": "Παράθυρο μετάδοσης σελίδας", + "paging-transmission-window-required": "Απαιτείται παράθυρο μετάδοσης σελίδας.", + "paging-transmission-window-pattern": "Το παράθυρο μετάδοσης σελίδας πρέπει να είναι θετικός ακέραιος.", + "paging-transmission-window-min": "Ελάχιστος αριθμός παραθύρου μετάδοσης σελίδας είναι {{ min }} δευτερόλεπτα.", + "psm-activity-timer": "Χρονοδιακόπτης δραστηριότητας PSM", + "psm-activity-timer-required": "Απαιτείται χρονοδιακόπτης δραστηριότητας PSM.", + "psm-activity-timer-pattern": "Ο χρονοδιακόπτης δραστηριότητας PSM πρέπει να είναι θετικός ακέραιος.", + "psm-activity-timer-min": "Ελάχιστος χρόνος δραστηριότητας PSM είναι {{ min }} δευτερόλεπτα.", + "lwm2m": { + "object-list": "Λίστα αντικειμένων", + "object-list-empty": "Δεν έχουν επιλεγεί αντικείμενα.", + "no-objects-found": "Δεν βρέθηκαν αντικείμενα.", + "no-objects-matching": "Δεν βρέθηκαν αντικείμενα που να ταιριάζουν με '{{object}}'.", + "model-tab": "Μοντέλο LWM2M", + "add-new-instances": "Προσθήκη νέων στιγμιοτύπων", + "instances-list": "Λίστα στιγμιοτύπων", + "instances-list-required": "Απαιτείται λίστα στιγμιοτύπων.", + "instance-id-pattern": "Το ID του στιγμιοτύπου πρέπει να είναι θετικός ακέραιος.", + "instance-id-max": "Μέγιστη τιμή ID στιγμιοτύπου {{max}}.", + "instance": "Στιγμιότυπο", + "resource-label": "#ID Όνομα πόρου", + "observe-label": "Παρακολούθηση", + "attribute-label": "Χαρακτηριστικό", + "telemetry-label": "Τηλεμετρία", + "edit-observe-select": "Για επεξεργασία παρακολούθησης επιλέξτε τηλεμετρία ή χαρακτηριστικό", + "edit-attributes-select": "Για επεξεργασία χαρακτηριστικών επιλέξτε τηλεμετρία ή χαρακτηριστικό", + "no-attributes-set": "Δεν έχουν οριστεί χαρακτηριστικά", + "key-name": "Όνομα κλειδιού", + "key-name-required": "Απαιτείται όνομα κλειδιού", + "attribute-name": "Όνομα χαρακτηριστικού", + "attribute-name-required": "Απαιτείται όνομα χαρακτηριστικού.", + "attribute-value": "Τιμή χαρακτηριστικού", + "attribute-value-required": "Απαιτείται τιμή χαρακτηριστικού.", + "attribute-value-pattern": "Η τιμή χαρακτηριστικού πρέπει να είναι θετικός ακέραιος.", + "edit-attributes": "Επεξεργασία χαρακτηριστικών: {{ name }}", + "view-attributes": "Προβολή χαρακτηριστικών: {{ name }}", + "add-attribute": "Προσθήκη χαρακτηριστικού", + "edit-attribute": "Επεξεργασία χαρακτηριστικού", + "view-attribute": "Προβολή χαρακτηριστικού", + "remove-attribute": "Αφαίρεση χαρακτηριστικού", + "delete-server-text": "Προσοχή, μετά την επιβεβαίωση οι ρυθμίσεις του διακομιστή δεν θα είναι ανακτήσιμες.", + "delete-server-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τον διακομιστή;", + "mode": "Λειτουργία ρυθμίσεων ασφαλείας", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap διακομιστής (ShortId...)", + "lwm2m-server-legend": "LwM2M διακομιστής (ShortId...)", + "server": "Διακομιστής", + "short-id": "Σύντομο ID διακομιστή", + "short-id-tooltip": "Σύντομο Id διακομιστή. Χρησιμοποιείται για σύνδεση με στιγμιότυπο αντικειμένου.\nΠρέπει να οριστεί όταν το πεδίο Bootstrap-Server έχει τιμή 'false'.\nΔεν επιτρέπεται η χρήση των ID:0 και ID:65535.", + "short-id-tooltip-bootstrap": "Χρησιμοποιείται για σύνδεση με στιγμιότυπο αντικειμένου διακομιστή.\nΠρέπει να οριστεί όταν το πεδίο Bootstrap-Server έχει τιμή 'false'.", + "short-id-required": "Απαιτείται σύντομο ID διακομιστή.", + "short-id-range": "Το σύντομο ID διακομιστή πρέπει να είναι μεταξύ {{ min }} και {{ max }}.", + "short-id-pattern": "Το σύντομο ID διακομιστή πρέπει να είναι θετικός ακέραιος.", + "lifetime": "Διάρκεια εγγραφής πελάτη", + "lifetime-required": "Απαιτείται διάρκεια εγγραφής πελάτη.", + "lifetime-pattern": "Η διάρκεια πρέπει να είναι θετικός ακέραιος.", + "default-min-period": "Ελάχιστη περίοδος μεταξύ ειδοποιήσεων (δ)", + "default-min-period-tooltip": "Χρησιμοποιείται αν δεν περιλαμβάνεται η παράμετρος στην παρατήρηση.", + "default-min-period-required": "Απαιτείται ελάχιστη περίοδος.", + "default-min-period-pattern": "Η ελάχιστη περίοδος πρέπει να είναι θετικός ακέραιος.", + "notification-storing": "Αποθήκευση ειδοποιήσεων όταν είναι απενεργοποιημένο ή εκτός σύνδεσης", + "binding": "Δεσμευτικό", + "binding-type": { + "u": "U: Το client είναι προσβάσιμο μέσω UDP ανά πάσα στιγμή.", + "m": "M: Το client είναι προσβάσιμο μέσω MQTT ανά πάσα στιγμή.", + "h": "H: Το client είναι προσβάσιμο μέσω HTTP ανά πάσα στιγμή.", + "t": "T: Το client είναι προσβάσιμο μέσω TCP ανά πάσα στιγμή.", + "s": "S: Το client είναι προσβάσιμο μέσω SMS ανά πάσα στιγμή.", + "n": "N: Το client πρέπει να απαντήσει μέσω Non-IP (υποστηρίζεται από LWM2M 1.1).", + "uq": "UQ: UDP σε queue mode (δεν υποστηρίζεται από LWM2M 1.1)", + "uqs": "UQS: UDP + SMS ενεργά· UDP σε queue mode, SMS σε standard (δεν υποστηρίζεται από LWM2M 1.1)", + "tq": "TQ: TCP σε queue mode (δεν υποστηρίζεται από LWM2M 1.1)", + "tqs": "TQS: TCP + SMS ενεργά· TCP σε queue mode, SMS σε standard (δεν υποστηρίζεται από LWM2M 1.1)", + "sq": "SQ: SMS σε queue mode (δεν υποστηρίζεται από LWM2M 1.1)" + }, + "binding-tooltip": "Ορίζει τα υποστηριζόμενα binding modes για το LwM2M Client. Πρέπει να είναι ίδιο με το \"Supported Binding and Modes\" στο αντικείμενο Συσκευής. Υποστηρίζεται ένα binding ανά συνεδρία μεταφοράς.", + "bootstrap-server": "Bootstrap διακομιστής", + "lwm2m-server": "LwM2M διακομιστής", + "include-bootstrap-server": "Συμπερίληψη ενημερώσεων Bootstrap Server", + "bootstrap-update-title": "Ο Bootstrap Server έχει ήδη διαμορφωθεί. Θέλετε σίγουρα να εξαιρεθεί;", + "bootstrap-update-text": "Προσοχή, τα δεδομένα διαμόρφωσης θα χαθούν μετά την επιβεβαίωση.", + "server-host": "Διεύθυνση υποδοχής", + "server-host-required": "Απαιτείται διεύθυνση υποδοχής.", + "server-port": "Θύρα", + "server-port-required": "Απαιτείται θύρα.", + "server-port-pattern": "Η θύρα πρέπει να είναι θετικός ακέραιος.", + "server-port-range": "Η θύρα πρέπει να είναι από 1 έως 65535.", + "server-public-key": "Δημόσιο κλειδί διακομιστή", + "server-public-key-required": "Απαιτείται δημόσιο κλειδί διακομιστή.", + "client-hold-off-time": "Χρόνος αναμονής πελάτη", + "client-hold-off-time-required": "Απαιτείται χρόνος αναμονής πελάτη.", + "client-hold-off-time-pattern": "Ο χρόνος αναμονής πρέπει να είναι θετικός ακέραιος.", + "client-hold-off-time-tooltip": "Για χρήση μόνο με Bootstrap-Server", + "account-after-timeout": "Λογαριασμός μετά το χρονικό όριο", + "account-after-timeout-required": "Απαιτείται ρύθμιση λογαριασμού μετά το χρονικό όριο.", + "account-after-timeout-pattern": "Η τιμή πρέπει να είναι θετικός ακέραιος.", + "account-after-timeout-tooltip": "Ρύθμιση Bootstrap-Server για χρονικό όριο", + "server-type": "Τύπος διακομιστή", + "add-new-server-title": "Προσθήκη νέας ρύθμισης διακομιστή", + "add-server-config": "Προσθήκη ρύθμισης διακομιστή", + "add-lwm2m-server-config": "Προσθήκη διακομιστή LwM2M", + "no-config-servers": "Δεν έχουν διαμορφωθεί διακομιστές", + "others-tab": "Άλλες ρυθμίσεις", + "client-strategy": "Στρατηγική πελάτη κατά τη σύνδεση", + "client-strategy-label": "Στρατηγική", + "client-strategy-only-observe": "Μόνο αίτημα παρακολούθησης μετά την αρχική σύνδεση", + "client-strategy-read-all": "Ανάγνωση όλων των πόρων και αίτημα παρακολούθησης μετά την εγγραφή", + "fw-update": "Αναβάθμιση firmware", + "fw-update-strategy": "Στρατηγική αναβάθμισης firmware", + "fw-update-strategy-data": "Αποστολή του αρχείου firmware ως δυαδικό αρχείο χρησιμοποιώντας Object 19 και Resource 0 (Data)", + "fw-update-strategy-package": "Αποστολή του αρχείου firmware ως δυαδικό αρχείο χρησιμοποιώντας Object 5 και Resource 0 (Package)", + "fw-update-strategy-package-uri": "Αυτόματη δημιουργία μοναδικού CoAP URL για λήψη και αποστολή αναβάθμισης firmware μέσω Object 5 και Resource 1 (Package URI)", + "sw-update": "Αναβάθμιση λογισμικού", + "sw-update-strategy": "Στρατηγική αναβάθμισης λογισμικού", + "sw-update-strategy-package": "Αποστολή του αρχείου λογισμικού χρησιμοποιώντας Object 9 και Resource 2 (Package)", + "sw-update-strategy-package-uri": "Αυτόματη δημιουργία μοναδικού CoAP URL για λήψη και αποστολή αναβάθμισης λογισμικού μέσω Object 9 και Resource 3 (Package URI)", + "fw-update-resource": "Πόρος CoAP αναβάθμισης firmware", + "fw-update-resource-required": "Απαιτείται πόρος CoAP αναβάθμισης firmware.", + "sw-update-resource": "Πόρος CoAP αναβάθμισης λογισμικού", + "sw-update-resource-required": "Απαιτείται πόρος CoAP αναβάθμισης λογισμικού.", + "config-json-tab": "Json διαμόρφωση προφίλ συσκευής", + "attributes-name": { + "min-period": "Ελάχιστη περίοδος", + "max-period": "Μέγιστη περίοδος", + "greater-than": "Μεγαλύτερο από", + "less-than": "Μικρότερο από", + "step": "Βήμα", + "min-evaluation-period": "Ελάχιστη περίοδος αξιολόγησης", + "max-evaluation-period": "Μέγιστη περίοδος αξιολόγησης" + }, + "default-object-id": "Προεπιλεγμένη έκδοση αντικειμένου (χαρακτηριστικό)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1" + } + }, + "snmp": { + "add-communication-config": "Προσθήκη ρύθμισης επικοινωνίας", + "add-mapping": "Προσθήκη αντιστοίχισης", + "authentication-passphrase": "Κωδικός ελέγχου ταυτότητας", + "authentication-passphrase-required": "Απαιτείται κωδικός ελέγχου ταυτότητας.", + "authentication-protocol": "Πρωτόκολλο ελέγχου ταυτότητας", + "authentication-protocol-required": "Απαιτείται πρωτόκολλο ελέγχου ταυτότητας.", + "communication-configs": "Ρυθμίσεις επικοινωνίας", + "community": "Community string", + "community-required": "Απαιτείται το community string.", + "context-name": "Όνομα πλαισίου (Context name)", + "data-key": "Κλειδί δεδομένων", + "data-key-required": "Απαιτείται κλειδί δεδομένων.", + "data-type": "Τύπος δεδομένων", + "data-type-required": "Απαιτείται τύπος δεδομένων.", + "engine-id": "ID μηχανής", + "host": "Διακομιστής", + "host-required": "Απαιτείται διακομιστής.", + "oid": "OID", + "oid-pattern": "Μη έγκυρη μορφή OID.", + "oid-required": "Απαιτείται OID.", + "please-add-communication-config": "Προσθέστε ρύθμιση επικοινωνίας", + "please-add-mapping-config": "Προσθέστε ρύθμιση αντιστοίχισης", + "port": "Θύρα", + "port-format": "Μη έγκυρη μορφή θύρας.", + "port-required": "Απαιτείται θύρα.", + "privacy-passphrase": "Κωδικός απορρήτου", + "privacy-passphrase-required": "Απαιτείται κωδικός απορρήτου.", + "privacy-protocol": "Πρωτόκολλο απορρήτου", + "privacy-protocol-required": "Απαιτείται πρωτόκολλο απορρήτου.", + "protocol-version": "Έκδοση πρωτοκόλλου", + "protocol-version-required": "Απαιτείται έκδοση πρωτοκόλλου.", + "querying-frequency": "Συχνότητα ερωτήσεων, ms", + "querying-frequency-invalid-format": "Η συχνότητα πρέπει να είναι θετικός ακέραιος.", + "querying-frequency-required": "Απαιτείται συχνότητα ερωτήσεων.", + "retries": "Επαναλήψεις", + "retries-invalid-format": "Οι επαναλήψεις πρέπει να είναι θετικός ακέραιος.", + "retries-required": "Απαιτούνται επαναλήψεις.", + "scope": "Εμβέλεια", + "scope-required": "Απαιτείται εμβέλεια.", + "security-name": "Όνομα ασφαλείας", + "security-name-required": "Απαιτείται όνομα ασφαλείας.", + "timeout-ms": "Χρονικό όριο, ms", + "timeout-ms-invalid-format": "Το χρονικό όριο πρέπει να είναι θετικός ακέραιος.", + "timeout-ms-required": "Απαιτείται χρονικό όριο.", + "user-name": "Όνομα χρήστη", + "user-name-required": "Απαιτείται όνομα χρήστη." + } }, "dialog": { - "close": "Κλείσιμο διαλόγου" + "close": "Κλείσιμο παραθύρου", + "error-message-title": "Μήνυμα σφάλματος:", + "error-details-title": "Λεπτομέρειες σφάλματος" }, "direction": { "column": "Στήλη", "row": "Γραμμή" }, + "edge": { + "edge": "Edge", + "edge-instances": "Παραδείγματα Edge", + "instances": "Παραδείγματα", + "edge-file": "Αρχείο Edge", + "name-max-length": "Το όνομα πρέπει να έχει λιγότερους από 256 χαρακτήρες", + "label-max-length": "Η ετικέτα πρέπει να έχει λιγότερους από 256 χαρακτήρες", + "type-max-length": "Ο τύπος πρέπει να έχει λιγότερους από 256 χαρακτήρες", + "management": "Διαχείριση Edge", + "no-edges-matching": "Δεν βρέθηκαν εγκαταστάσεις Edge που να ταιριάζουν με '{{entity}}'.", + "add": "Προσθήκη Edge", + "no-edges-text": "Δεν βρέθηκαν εγκαταστάσεις Edge", + "edge-details": "Λεπτομέρειες Edge", + "add-edge-text": "Προσθήκη νέας εγκατάστασης Edge", + "delete": "Διαγραφή Edge", + "delete-edge-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την εγκατάσταση Edge '{{edgeName}}';", + "delete-edge-text": "Προσοχή, μετά την επιβεβαίωση η εγκατάσταση και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-edges-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 εγκατάσταση Edge} other {# εγκαταστάσεις Edge} };", + "delete-edges-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες εγκαταστάσεις Edge και τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "name": "Όνομα", + "name-starts-with": "Το όνομα Edge ξεκινά με", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "description": "Περιγραφή", + "details": "Λεπτομέρειες", + "events": "Συμβάντα", + "copy-id": "Αντιγραφή Id Edge", + "id-copied-message": "Το Id Edge αντιγράφηκε στο πρόχειρο", + "sync": "Συγχρονισμός Edge", + "edge-required": "Η εγκατάσταση Edge είναι υποχρεωτική", + "edge-type": "Τύπος Edge", + "edge-type-required": "Ο τύπος Edge είναι υποχρεωτικός.", + "event-action": "Ενέργεια συμβάντος", + "entity-id": "ID οντότητας", + "select-edge-type": "Επιλογή τύπου Edge", + "assign-to-customer": "Ανάθεση σε πελάτη", + "assign-to-customer-text": "Επιλέξτε τον πελάτη στον οποίο θα ανατεθεί η εγκατάσταση Edge", + "assign-edge-to-customer": "Ανάθεση Edge σε πελάτη", + "assign-edge-to-customer-text": "Επιλέξτε τις εγκαταστάσεις Edge που θα ανατεθούν στον πελάτη", + "assignedToCustomer": "Ανατέθηκε σε πελάτη", + "edge-public": "Η εγκατάσταση Edge είναι δημόσια", + "assigned-to-customer": "Ανατέθηκε στον: {{customerTitle}}", + "unassign-from-customer": "Αποκαθήλωση από πελάτη", + "unassign-edge-title": "Είστε βέβαιοι ότι θέλετε να αποκαθηλώσετε την εγκατάσταση Edge '{{edgeName}}';", + "unassign-edge-text": "Μετά την επιβεβαίωση, η εγκατάσταση Edge δεν θα είναι πλέον προσβάσιμη από τον πελάτη.", + "unassign-edges-title": "Είστε βέβαιοι ότι θέλετε να αποκαθηλώσετε { count, plural, =1 {1 εγκατάσταση Edge} other {# εγκαταστάσεις Edge} };", + "unassign-edges-text": "Μετά την επιβεβαίωση, οι επιλεγμένες εγκαταστάσεις Edge δεν θα είναι πλέον προσβάσιμες από τον πελάτη.", + "make-public": "Δημοσιοποίηση Edge", + "make-public-edge-title": "Είστε βέβαιοι ότι θέλετε να κάνετε την εγκατάσταση Edge '{{edgeName}}' δημόσια;", + "make-public-edge-text": "Μετά την επιβεβαίωση, η εγκατάσταση και όλα τα δεδομένα της θα είναι δημόσια και προσβάσιμα από τρίτους.", + "make-private": "Ιδιωτικοποίηση Edge", + "public": "Δημόσιο", + "make-private-edge-title": "Είστε βέβαιοι ότι θέλετε να κάνετε την εγκατάσταση Edge '{{edgeName}}' ιδιωτική;", + "make-private-edge-text": "Μετά την επιβεβαίωση, η εγκατάσταση και όλα τα δεδομένα της θα είναι ιδιωτικά και δεν θα είναι προσβάσιμα από τρίτους.", + "import": "Εισαγωγή Edge", + "install-connect-instructions": "Οδηγίες εγκατάστασης και σύνδεσης", + "install-connect-instructions-edge-created": "Η εγκατάσταση Edge δημιουργήθηκε! Ελέγξτε τις Οδηγίες Εγκατάστασης και Σύνδεσης", + "loading-edge-instructions": "Φόρτωση οδηγιών Edge...", + "label": "Ετικέτα", + "load-entity-error": "Αποτυχία φόρτωσης δεδομένων. Η οντότητα έχει διαγραφεί.", + "assign-new-edge": "Ανάθεση νέου Edge", + "unassign-from-edge": "Αποκαθήλωση από Edge", + "edge-key": "Κλειδί Edge", + "copy-edge-key": "Αντιγραφή κλειδιού Edge", + "edge-key-copied-message": "Το κλειδί Edge αντιγράφηκε στο πρόχειρο", + "edge-secret": "Μυστικό Edge", + "copy-edge-secret": "Αντιγραφή μυστικού Edge", + "edge-secret-copied-message": "Το μυστικό Edge αντιγράφηκε στο πρόχειρο", + "manage-assets": "Διαχείριση assets", + "manage-devices": "Διαχείριση συσκευών", + "manage-entity-views": "Διαχείριση προβολών οντοτήτων", + "manage-dashboards": "Διαχείριση dashboards", + "manage-rulechains": "Διαχείριση αλυσίδων κανόνων", + "assets": "Assets Edge", + "devices": "Συσκευές Edge", + "entity-views": "Προβολές οντοτήτων Edge", + "dashboard": "Dashboard Edge", + "dashboards": "Dashboards Edge", + "rulechain-templates": "Πρότυπα rule chain", + "edge-rulechain-templates": "Πρότυπα rule chain Edge", + "rulechains": "Rule chains Edge", + "search": "Αναζήτηση Edge", + "selected-edges": "{ count, plural, =1 {1 εγκατάσταση Edge} other {# εγκαταστάσεις Edge} } επιλεγμένες", + "any-edge": "Οποιοδήποτε Edge", + "no-edge-types-matching": "Δεν βρέθηκαν τύποι Edge που να ταιριάζουν με '{{entitySubtype}}'.", + "edge-type-list-empty": "Δεν έχουν επιλεγεί τύποι Edge.", + "edge-types": "Τύποι Edge", + "enter-edge-type": "Εισάγετε τύπο Edge", + "deployed": "Αναπτυγμένο", + "pending": "Εκκρεμεί", + "downlinks": "Downlinks", + "no-downlinks-prompt": "Δεν βρέθηκαν downlinks", + "sync-process-started-successfully": "Η διαδικασία συγχρονισμού ξεκίνησε με επιτυχία!", + "missing-related-rule-chains-title": "Το Edge έχει ελλείποντα σχετικά rule chains", + "missing-related-rule-chains-text": "Τα ανατεθειμένα rule chains χρησιμοποιούν rule nodes που προωθούν μηνύματα σε άλλα rule chains που δεν είναι ανατεθειμένα σε αυτό το Edge.

    Λίστα ελλειπόντων rule chains:
    {{missingRuleChains}}", + "widget-datasource-error": "Αυτό το widget υποστηρίζει μόνο datasource τύπου EDGE entity", + "upgrade-instructions": "Οδηγίες αναβάθμισης", + "connected": "Συνδεδεμένο", + "disconnected": "Αποσυνδεδεμένο" + }, + "edge-event": { + "type-dashboard": "Πίνακας ελέγχου", + "type-asset": "Πόρος", + "type-device": "Συσκευή", + "type-device-profile": "Προφίλ συσκευής", + "type-asset-profile": "Προφίλ πόρου", + "type-entity-view": "Προβολή οντότητας", + "type-alarm": "Συναγερμός", + "type-rule-chain": "Αλυσίδα κανόνων", + "type-rule-chain-metadata": "Μεταδεδομένα αλυσίδας κανόνων", + "type-edge": "Edge", + "type-user": "Χρήστης", + "type-tenant": "Ενοικιαστής", + "type-tenant-profile": "Προφίλ ενοικιαστή", + "type-customer": "Πελάτης", + "type-relation": "Σχέση", + "type-widgets-bundle": "Πακέτο γραφικών", + "type-widgets-type": "Τύπος γραφικού", + "type-admin-settings": "Ρυθμίσεις διαχειριστή", + "type-ota-package": "Πακέτο OTA", + "type-queue": "Ουρά", + "action-type-added": "Προστέθηκε", + "action-type-deleted": "Διαγράφηκε", + "action-type-updated": "Ενημερώθηκε", + "action-type-post-attributes": "Αποστολή χαρακτηριστικών", + "action-type-attributes-updated": "Τα χαρακτηριστικά ενημερώθηκαν", + "action-type-attributes-deleted": "Τα χαρακτηριστικά διαγράφηκαν", + "action-type-timeseries-updated": "Οι χρονοσειρές ενημερώθηκαν", + "action-type-credentials-updated": "Τα διαπιστευτήρια ενημερώθηκαν", + "action-type-assigned-to-customer": "Ανατέθηκε σε πελάτη", + "action-type-unassigned-from-customer": "Αποκαθηλώθηκε από πελάτη", + "action-type-relation-add-or-update": "Προσθήκη ή ενημέρωση σχέσης", + "action-type-relation-deleted": "Η σχέση διαγράφηκε", + "action-type-rpc-call": "Κλήση RPC", + "action-type-alarm-ack": "Αναγνώριση συναγερμού", + "action-type-alarm-clear": "Εκκαθάριση συναγερμού", + "action-type-alarm-assigned": "Ο συναγερμός ανατέθηκε", + "action-type-alarm-unassigned": "Ο συναγερμός αποκαθηλώθηκε", + "action-type-assigned-to-edge": "Ανατέθηκε σε Edge", + "action-type-unassigned-from-edge": "Αποκαθηλώθηκε από Edge", + "action-type-credentials-request": "Αίτηση διαπιστευτηρίων", + "action-type-entity-merge-request": "Αίτηση συγχώνευσης οντοτήτων" + }, "error": { - "unable-to-connect": "Αδύνατον να συνδεθεί στον διακομιστή! Παρακαλούμε ελέγξτε την σύνδεσή σας στο ίντερνετ.", - "unhandled-error-code": "Μη διορθωμένος κωδικός σφάλματος: {{errorCode}}", + "unable-to-connect": "Αδυναμία σύνδεσης με τον διακομιστή! Ελέγξτε τη σύνδεσή σας στο διαδίκτυο.", + "unhandled-error-code": "Μη διαχειριζόμενος κωδικός σφάλματος: {{errorCode}}", "unknown-error": "Άγνωστο σφάλμα" }, "entity": { "entity": "Οντότητα", "entities": "Οντότητες", - "aliases": "Ψευδώνυμα οντότητας", + "entities-count": "Αριθμός οντοτήτων", + "alarms-count": "Αριθμός συναγερμών", + "aliases": "Ψευδώνυμα οντοτήτων", + "aliases-short": "Ψευδώνυμα", "entity-alias": "Ψευδώνυμο οντότητας", - "unable-delete-entity-alias-title": "Αδύνατο να διαγραφεί το ψευδώνυμο οντότητας", - "unable-delete-entity-alias-text": "Το ψευδώνυμο οντότητας '{{entityAlias}}' δεν μπορεί να διαγραφεί όσο εξακολουθεί να χρησιμοποιείται από τα παρακάτω widget(s):
    {{widgetsList}}", - "duplicate-alias-error": "Βρέθηκε διπλότυπο το ψευδώνυμο '{{alias}}'.
    Τα ψευδώνυμα οντοτήτων πρέπει να είναι μοναδικά σε ένα dashboard.", - "missing-entity-filter-error": "Λείπει φίλτρο από το ψευδώνυμο '{{alias}}'.", - "configure-alias": "Ρύθμιση ψευδώνυμου '{{alias}}'", + "unable-delete-entity-alias-title": "Αδυναμία διαγραφής ψευδωνύμου οντότητας", + "unable-delete-entity-alias-text": "Το ψευδώνυμο οντότητας '{{entityAlias}}' δεν μπορεί να διαγραφεί καθώς χρησιμοποιείται από τα ακόλουθα γραφικά:
    {{widgetsList}}", + "duplicate-alias-error": "Βρέθηκε διπλό ψευδώνυμο '{{alias}}'.
    Τα ψευδώνυμα οντοτήτων πρέπει να είναι μοναδικά εντός του πίνακα ελέγχου.", + "missing-entity-filter-error": "Λείπει το φίλτρο για το ψευδώνυμο '{{alias}}'.", + "configure-alias": "Ρύθμιση του ψευδωνύμου '{{alias}}'", "alias": "Ψευδώνυμο", "alias-required": "Απαιτείται ψευδώνυμο οντότητας.", - "remove-alias": "Αφαίρεση ψευδώνυμου οντότητας", - "add-alias": "Προσθήκη ψευδώνυμου οντότητας", + "remove-alias": "Αφαίρεση ψευδωνύμου οντότητας", + "add-alias": "Προσθήκη ψευδωνύμου οντότητας", + "edit-alias": "Επεξεργασία ψευδωνύμου οντότητας", "entity-list": "Λίστα οντοτήτων", - "entity-type": "Τύπος οντοτήτων", + "entity-type": "Τύπος οντότητας", "entity-types": "Τύποι οντοτήτων", "entity-type-list": "Λίστα τύπων οντοτήτων", "any-entity": "Οποιαδήποτε οντότητα", + "add-entity-type": "Προσθήκη τύπου οντότητας", "enter-entity-type": "Εισαγωγή τύπου οντότητας", - "no-entities-matching": "Δεν βρέθηκαν οντότητες ο οποίες να σχετίζονται με '{{entity}}'.", - "no-entity-types-matching": "Δεν βρέθηκαν τύποι οντότητας ο οποίοι να σχετίζονται με '{{entityType}}'.", - "name-starts-with": "το όνομα αρχίζει από", - "use-entity-name-filter": "χρήση φίλτρου", - "entity-list-empty": "Δεν έχουν επιλεγεί οντότητες.", + "no-entities-matching": "Δεν βρέθηκαν οντότητες που να ταιριάζουν με '{{entity}}'.", + "no-entities-text": "Δεν βρέθηκαν οντότητες", + "no-entity-types-matching": "Δεν βρέθηκαν τύποι οντοτήτων που να ταιριάζουν με '{{entityType}}'.", + "name-starts-with": "Έκφραση ονόματος", + "help-text": "Χρησιμοποιήστε '%' ανάλογα με την ανάγκη: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "use-entity-name-filter": "Χρήση φίλτρου", + "entity-list-empty": "Δεν επιλέχθηκαν οντότητες.", + "entity-type-list-required": "Πρέπει να επιλεγεί τουλάχιστον ένας τύπος οντότητας.", "entity-name-filter-required": "Απαιτείται φίλτρο ονόματος οντότητας.", - "entity-name-filter-no-entity-matched": "Δεν βρέθηκαν οντότητες οι οποίες αρχίζουν από '{{entity}}'.", - "all-subtypes": "Όλοι", + "entity-name-filter-no-entity-matched": "Δεν βρέθηκαν οντότητες που να ξεκινούν με '{{entity}}'.", + "all-subtypes": "Όλα", "select-entities": "Επιλογή οντοτήτων", "no-aliases-found": "Δεν βρέθηκαν ψευδώνυμα.", - "no-alias-matching": "Δεν βρέθηκε '{{alias}}'.", - "create-new-alias": "Δημιουργείστε ένα νέο!", + "no-alias-matching": "'{{alias}}' δεν βρέθηκε.", + "create-new-alias": "Δημιουργία νέου!", + "create-new": "Δημιουργία νέου", "key": "Κλειδί", - "key-name": "όνομα κλειδιού", - "no-keys-found": "δεν βρέθηκαν κλειδιά.", - "no-key-matching": "Δεν βρέθηκε '{{key}}'.", - "create-new-key": "Δημιουργείστε ένα νέο!", + "key-name": "Όνομα κλειδιού", + "no-keys-found": "Δεν βρέθηκαν κλειδιά.", + "no-key-matching": "'{{key}}' δεν βρέθηκε.", + "create-new-key": "Δημιουργία νέου!", "type": "Τύπος", "type-required": "Απαιτείται τύπος οντότητας.", "type-device": "Συσκευή", "type-devices": "Συσκευές", - "list-of-devices": "{ count, plural, =1 {One device} other {List of # devices} }", - "device-name-starts-with": "Συσκευές των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-asset": "Asset", - "type-assets": "Assets", - "list-of-assets": "{ count, plural, =1 {One asset} other {List of # assets} }", - "asset-name-starts-with": "Assets των οποίων το όνομα αρχίζει από '{{prefix}}'", + "list-of-devices": "{ count, plural, =1 {Μία συσκευή} other {Λίστα με # συσκευές} }", + "device-name-starts-with": "Συσκευές με ονόματα που ξεκινούν με '{{prefix}}'", + "type-device-profile": "Προφίλ συσκευής", + "type-device-profiles": "Προφίλ συσκευών", + "clear-selected-profiles": "Καθαρισμός επιλεγμένων προφίλ", + "list-of-device-profiles": "{ count, plural, =1 {Ένα προφίλ συσκευής} other {Λίστα με # προφίλ συσκευών} }", + "device-profile-name-starts-with": "Προφίλ συσκευών με ονόματα που ξεκινούν με '{{prefix}}'", + "type-asset-profile": "Προφίλ πόρου", + "type-asset-profiles": "Προφίλ πόρων", + "list-of-asset-profiles": "{ count, plural, =1 {Ένα προφίλ πόρου} other {Λίστα με # προφίλ πόρων} }", + "asset-profile-name-starts-with": "Προφίλ πόρων με ονόματα που ξεκινούν με '{{prefix}}'", + "type-asset": "Πόρος", + "type-assets": "Πόροι", + "list-of-assets": "{ count, plural, =1 {Ένας πόρος} other {Λίστα με # πόρους} }", + "asset-name-starts-with": "Πόροι με ονόματα που ξεκινούν με '{{prefix}}'", "type-entity-view": "Προβολή οντότητας", - "type-entity-views": "Προβολές οντότητας", - "list-of-entity-views": "{ count, plural, =1 {One entity view} other {List of # entity views} }", - "entity-view-name-starts-with": "Προβολές οντότητας των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-entity-views": "Προβολές οντοτήτων", + "list-of-entity-views": "{ count, plural, =1 {Μία προβολή οντότητας} other {Λίστα με # προβολές οντοτήτων} }", + "entity-view-name-starts-with": "Προβολές οντοτήτων με ονόματα που ξεκινούν με '{{prefix}}'", "type-rule": "Κανόνας", "type-rules": "Κανόνες", - "list-of-rules": "{ count, plural, =1 {One rule} other {List of # rules} }", - "rule-name-starts-with": "Κανόνες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "list-of-rules": "{ count, plural, =1 {Ένας κανόνας} other {Λίστα με # κανόνες} }", + "rule-name-starts-with": "Κανόνες με ονόματα που ξεκινούν με '{{prefix}}'", "type-plugin": "Πρόσθετο", - "type-plugins": "Προσθετα", - "list-of-plugins": "{ count, plural, =1 {One plugin} other {List of # plugins} }", - "plugin-name-starts-with": "Πρόσθετα των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-tenant": "Μισθωτής", - "type-tenants": "Μισθωτές", - "list-of-tenants": "{ count, plural, =1 {One tenant} other {List of # tenants} }", - "tenant-name-starts-with": "Μισθωτές των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-plugins": "Πρόσθετα", + "list-of-plugins": "{ count, plural, =1 {Ένα πρόσθετο} other {Λίστα με # πρόσθετα} }", + "plugin-name-starts-with": "Πρόσθετα με ονόματα που ξεκινούν με '{{prefix}}'", + "type-tenant": "Ενοικιαστής", + "type-tenants": "Ενοικιαστές", + "list-of-tenants": "{ count, plural, =1 {Ένας ενοικιαστής} other {Λίστα με # ενοικιαστές} }", + "tenant-name-starts-with": "Ενοικιαστές με ονόματα που ξεκινούν με '{{prefix}}'", + "type-tenant-profile": "Προφίλ ενοικιαστή", + "type-tenant-profiles": "Προφίλ ενοικιαστών", + "list-of-tenant-profiles": "{ count, plural, =1 {Ένα προφίλ ενοικιαστή} other {Λίστα με # προφίλ ενοικιαστών} }", + "tenant-profile-name-starts-with": "Προφίλ ενοικιαστών με ονόματα που ξεκινούν με '{{prefix}}'", "type-customer": "Πελάτης", "type-customers": "Πελάτες", - "list-of-customers": "{ count, plural, =1 {One customer} other {List of # customers} }", - "customer-name-starts-with": "Πελάτες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "list-of-customers": "{ count, plural, =1 {Ένας πελάτης} other {Λίστα με # πελάτες} }", + "customer-name-starts-with": "Πελάτες με ονόματα που ξεκινούν με '{{prefix}}'", "type-user": "Χρήστης", "type-users": "Χρήστες", - "list-of-users": "{ count, plural, =1 {One user} other {List of # users} }", - "user-name-starts-with": "Χρήστες των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-dashboard": "Dashboard", - "type-dashboards": "Dashboards", - "list-of-dashboards": "{ count, plural, =1 {One dashboard} other {List of # dashboards} }", - "dashboard-name-starts-with": "Dashboards των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-alarm": "Alarm", - "type-alarms": "Alarms", - "list-of-alarms": "{ count, plural, =1 {One alarms} other {List of # alarms} }", - "alarm-name-starts-with": "Alarms των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-rulechain": "Αλυσίδα Κανόνων", - "type-rulechains": "Αλυσίδες Κανόνων", - "list-of-rulechains": "{ count, plural, =1 {One rule chain} other {List of # rule chains} }", - "rulechain-name-starts-with": "Αλυσίδες Κανόνων των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-scheduler-event": "Προγραμματιστής", - "type-scheduler-events": "Προγραμματιστής", - "list-of-scheduler-events": "{ count, plural, =1 {One scheduler event} other {List of # scheduler events} }", - "scheduler-event-name-starts-with": "Προγραμματιστές γεγονότων των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-blob-entity": "Ογκώδη οντότητα", - "type-blob-entities": "Ογκώδεις οντότητες", - "list-of-blob-entities": "{ count, plural, =1 {One blob entity} other {List of # blob entities} }", - "blob-entity-name-starts-with": "Ογκώδεις οντότητες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "list-of-users": "{ count, plural, =1 {Ένας χρήστης} other {Λίστα με # χρήστες} }", + "user-name-starts-with": "Χρήστες με ονόματα που ξεκινούν με '{{prefix}}'", + "type-dashboard": "Πίνακας ελέγχου", + "type-dashboards": "Πίνακες ελέγχου", + "list-of-dashboards": "{ count, plural, =1 {Ένας πίνακας ελέγχου} other {Λίστα με # πίνακες ελέγχου} }", + "dashboard-name-starts-with": "Πίνακες ελέγχου με ονόματα που ξεκινούν με '{{prefix}}'", + "type-alarm": "Συναγερμός", + "type-alarms": "Συναγερμοί", + "list-of-alarms": "{ count, plural, =1 {Ένας συναγερμός} other {Λίστα με # συναγερμούς} }", + "alarm-name-starts-with": "Συναγερμοί με ονόματα που ξεκινούν με '{{prefix}}'", + "type-rulechain": "Αλυσίδα κανόνων", + "type-rulechains": "Αλυσίδες κανόνων", + "list-of-rulechains": "{ count, plural, =1 {Μία αλυσίδα κανόνων} other {Λίστα με # αλυσίδες κανόνων} }", + "rulechain-name-starts-with": "Αλυσίδες κανόνων με ονόματα που ξεκινούν με '{{prefix}}'", "type-rulenode": "Κόμβος κανόνα", - "type-rulenodes": "Κόμβοι κανόνων", - "list-of-rulenodes": "{ count, plural, =1 {One rule node} other {List of # rule nodes} }", - "rulenode-name-starts-with": "Κόμβοι κανόνων των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-current-customer": "Τρέχον Πελάτης", - "search": "Αναζήτηση Οντότητες", - "selected-entities": "{ count, plural, =1 {1 entity} other {# entities} } επιλεγμένα", + "type-rulenodes": "Κόμβοι κανόνα", + "list-of-rulenodes": "{ count, plural, =1 {Ένας κόμβος κανόνα} other {Λίστα με # κόμβους κανόνα} }", + "rulenode-name-starts-with": "Κόμβοι κανόνα με ονόματα που ξεκινούν με '{{prefix}}'", + "type-current-customer": "Τρέχων πελάτης", + "type-current-tenant": "Τρέχων ενοικιαστής", + "type-current-user": "Τρέχων χρήστης", + "type-current-user-owner": "Ιδιοκτήτης τρέχοντος χρήστη", + "type-calculated-field": "Υπολογιζόμενο πεδίο", + "type-calculated-fields": "Υπολογιζόμενα πεδία", + "type-widgets-bundle": "Πακέτο γραφικών", + "type-widgets-bundles": "Πακέτα γραφικών", + "list-of-widgets-bundles": "{ count, plural, =1 {Ένα πακέτο γραφικών} other {Λίστα με # πακέτα γραφικών} }", + "type-widget": "Γραφικό", + "type-widgets": "Γραφικά", + "list-of-widgets": "{ count, plural, =1 {Ένα γραφικό} other {Λίστα με # γραφικά} }", + "search": "Αναζήτηση οντοτήτων", + "selected-entities": "{ count, plural, =1 {1 οντότητα} other {# οντότητες} } επιλεγμένες", "entity-name": "Όνομα οντότητας", + "entity-label": "Ετικέτα οντότητας", "details": "Λεπτομέρειες οντότητας", "no-entities-prompt": "Δεν βρέθηκαν οντότητες", - "no-data": "Δεν υπάρχουν δεδομένα προς προβολή", - "columns-to-display": "στήλες που προβάλονται", - "type-entity-group": "Ομάδα Οντότητας", - "type-converter": "Μετατροπέας Δεδομένων", - "type-converters": "Μετατροπείς Δεδομένων", - "list-of-converters": "{ count, plural, =1 {One data converter} other {List of # data converters} }", - "converter-name-starts-with": "Μετατροπείς δεδομένων των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-integration": "Ενσωμάτωση", - "type-integrations": "Ενσωματώσεις", - "list-of-integrations": "{ count, plural, =1 {One integration} other {List of # integrations} }", - "integration-name-starts-with": "Ενσωματώσεις των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-role": "Ρόλος", - "type-roles": "Ρόλοι", - "list-of-roles": "{ count, plural, =1 {One role} other {List of # roles} }", - "role-name-starts-with": "Ρόλοι των οποίων το όνομα αρχίζει από '{{prefix}}'", - "type-group-permission": "Άδεια Ομάδας" - }, - "entity-group": { - "entity-group": "Ομάδα Οντότητας", - "details": "Λεπτομέρειες", - "columns": "Στήλες", - "add-column": "Προσθήκη στήλης", - "column-value": "Τιμή", - "column-value-required": "Απαιτείται τιμή στήλης.", - "column-title": "Τίτλος", - "default-sort-order": "Προεπιλογή ταξινόμισης", - "default-sort-order-required": "Απαιτείται προεπιλογή ταξινόμισης στήλης.", - "hide-in-mobile-view": "κρυφό σε mobile", - "use-cell-style-function": "Λειτουργία χρήσης στυλ κελιού", - "use-cell-content-function": "Λειτουργία χρήσης περιεχομένου κελιού", - "edit-column": "Επεξεργασία στήλης", - "column-details": "Λεπτομέρειες στήλης", - "actions": "Ενέργιες", - "settings": "Ρυθμίσεις", - "delete": "Διαγραφή ομάδας οντοτήτων", - "name": "Όνομα", - "name-required": "Απαιτείται όνομα.", - "description": "Περιγραφή", - "add": "Προσθήκη ομάδας οντοτήτων", - "add-entity-group-text": "Προσθήκη νέας ομάδας οντοτήτων", - "no-entity-groups-text": "Δεν βρέθηκαν ομάδες οντοτήτων", - "entity-group-details": "Λεπτομέρειες ομαδών οντοτήτων", - "delete-entity-groups": "Διαγραφή ομαδών οντοτήτων", - "delete-entity-group-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την ομάδα οντοτήτων '{{entityGroupName}}'?", - "delete-entity-group-text": "Προσοχή, μετά την επιβεβαίωση, η ομάδα οντοτήτων και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-entity-groups-title": "Είστε σίγουροι ότι θέλετε να διαγραφούν { count, plural, =1 {1 entity group} other {# entity groups} }?", - "delete-entity-groups-action-title": "Διαγραφή { count, plural, =1 {1 entity group} other {# entity groups} }", - "delete-entity-groups-text": "Προσοχή, αφού ολοκληρωθεί η επιβεβαίωση, όλες οι επιλεγμένες ομάδες οντοτήτων θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "device-groups": "Ομάδες Συσκευών", - "asset-groups": "Ομάδες Asset", - "customer-groups": "Ομάδες Πελατών", - "device-group": "Ομάδα Πελατών", - "asset-group": "Ομάδα Asset", - "user-group": "Ομάδα Χρηστών", - "user-groups": "Ομάδες Χρηστών", - "customer-group": "Ομάδα Χρηστών", - "entity-view-groups": "Ομάδες Όψεων", - "entity-view-group": "Προβολή ομαδών οντοτήτων", - "dashboard-groups": "Ομάδες Dashboard", - "dashboard-group": "Ομάδα Dashboard", - "fetch-more": "Περισσότερα", - "column-type": { - "column-type": "Τύπος στήλης", - "client-attribute": "Χαρακτηριστικά Client", - "shared-attribute": "Κοινόχρηστα Χαρακτηριστικά", - "server-attribute": "Χαρακτηριστικό Server", - "timeseries": "Χρονική σειρά", - "entity-field": "Πεδίο οντότητας" - }, - "column-type-required": "Απαιτείται τύπος στήλης.", - "entity-field": { - "created-time": "Δημιουργήθηκε", - "name": "Όνομα", - "type": "Τύπος", - "assigned_customer": "Ανατεθειμένος πελάτης", - "authority": "Εξουσιοδότηση", - "first_name": "Όνομα", - "last_name": "Επίθετο", - "email": "Email", - "title": "Τίτλος", - "country": "Χώρα", - "state": "Νομός", - "city": "Πόλη", - "address": "Διεύθυνση", - "address2": "Διεύθυνση 2", - "zip": "Τ.Κ.", - "phone": "Τηλέφωνο" - }, - "sort-order": { - "asc": "Αύξουσα", - "desc": "Φθίνουσα", - "none": "Καμία" - }, - "details-mode": { - "on-row-click": "στο κλικ στη γραμμη", - "on-action-button-click": "στο κλικ στο κουμπί λεπτομέρειες", - "disabled": "μη διαθέσιμο" - }, - "change-owner": "Αλλαγή ιδιοκτήτη", - "select-target-owner": "Επιλογή ιδιοκτήτη", - "no-owners-matching": "Δεν βρέθηκε ιδιοκτήτης που να αντιστοιχεί σε '{{owner}}'.", - "target-owner-required": "Απαιτείται επιλεγμένος ιδιοκτήτης.", - "confirm-change-owner-title": "Είστε σίγουροι ότι θέλετε να αλλάξετε ιδιοκτήτη για { count, plural, =1 {1 selected entity} other {# selected entities} }?", - "confirm-change-owner-text": "Προσοχή, μετά την επιβεβαίωση, όλες οι επιλεγμένες οντότητες θα αφαιρεθούν από τον τρέχοντα ιδιοκτήτη και θα τοποθετηθούν στην ομάδα 'Όλα' του επιλεγμένου ιδιοκτήτη.", - "add-to-group": "Προσθήκη σε ομάδα", - "move-to-group": "Μετακίνηση σε ομάδα", - "select-entity-group": "Επιλογή ομάδας οντοτήτων", - "no-entity-groups-matching": "Δεν βρέθηκαν ομάδες οντοτήτων που να αντιστοιχούν με '{{entityGroup}}'.", - "target-entity-group-required": "Απαιτείται επιλεγμένη ομάδα οντοτήτων.", - "select-user-group": "Επιλογή ομάδας χρηστών", - "no-user-groups-matching": "Δεν βρέθηκαν ομάδες χρηστών οι οποίες να αντιστοιχούν σε '{{entityGroup}}'.", - "target-user-group-required": "Απαιτείται επιλεγμένη ομάδα χρηστών.", - "remove-from-group": "Αφαίρεση από ομάδα", - "group-table-title": "Τίτλος ομάδας πίνακα", - "enable-search": "Ενεργοποίηση αναζήτησης οντοτήτων", - "enable-add": "Ενεργοποίηση προσθήκης οντοτήτων", - "enable-delete": "Ενεργοποίηση διαγραφής οντοτήτων", - "enable-selection": "Ενεργοποίηση επιλογής οντοτήτων", - "enable-group-transfer": "Ενεργοποίηση μεταφοράς ομάδων", - "display-pagination": "Προβολή σελιδοποίησης", - "default-page-size": "Προεπιλεγμένο μέγεθος σελίδας", - "enable-assignment-actions": "Ενεργοποίηση ανάθεσης", - "enable-credentials-management": "Ενεργοποίηση διαχείρησης διαπιστευτηρίων", - "enable-login-as-user": "Ενεργοποίηση εισόδου ως χρήστη", - "enable-users-management": "Ενεργοποίηση διαχείρισης χρήστών", - "enable-customers-management": "Ενεργοποίηση διαχείρησης πελατών", - "enable-assets-management": "Ενεργοποίηση διαχείρησης assets", - "enable-devices-management": "Ενεργοποίηση διαχείρησης συσκευών", - "enable-entity-views-management": "Ενεργοποίηση διαχείρησης προβολής οντοτήτων", - "enable-dashboards-management": "Ενεργοποίηση διαχείρησης dashboard", - "open-details-on": "Άνοιγμα λεπτομερειών οντότητας σε", - "select-existing": "Ε[ιλογή υπάρχον ομάδας οντοτήτων", - "create-new": "Δημιουργία νέας ομάδας οντοτήτων", - "new-entity-group-name": "Νέο όνομα ομάδας οντοτήτων", - "entity-group-list": "Λίστα ομάδων οντοτήτων", - "entity-group-list-empty": "Δεν έχουν επιλεχθεί ομάδες οντοτήτων.", - "name-starts-with": "Το όνομα της ομάδας οντοτήτων αρχίζει από", - "entity-group-name-filter-required": "Απαιτείται φίλτρο ονόματος ομάδας οντοτήτων.", - "roles": "Ρόλοι", - "permissions": "Δικαιώματα", - "public": "Δημόσιο", - "entity-group-public": "Η ομάδα οντοτήτων είναι δημόσια", - "make-public": "Δημοσιοποίηση ομάδας οντοτήτων", - "make-private": "Ιδιωτικοποίηση ομάδας οντοτήτων", - "make-public-entity-group-title": "Είστε σίγουροι ότι θέλετε να κάνετε την ομάδα οντοτήτων '{{entityGroupName}}' δημόσια;", - "make-public-entity-group-text": "Μετά την επιβεβαίωση, η ομάδα οντοτήτων και όλες οι οντότητές της θα δημοσιοποιηθούν και θα είναι προσβάσιμες από τρίτους.", - "make-private-entity-group-title": "Είστε σίγουροι ότι θέλετε να κάνετε την ομάδα οντοτήτων '{{entityGroupName}}' ιδιωτική?", - "make-private-entity-group-text": "Μετά την επιβεβαίωση, η ομάδα οντοτήτων και όλες οι οντότητές της θα γίνουν ιδιωτικές και δεν θα είναι προσβάσιμες από τρίτους.", - "copyId": "Αντιγραφή ID ομάδας οντοτήτων", - "idCopiedMessage": "Το ID της ομάδας οντοτήτων έχει αντιγραφεί στο πρόχειρο" + "no-data": "Δεν υπάρχουν δεδομένα για εμφάνιση", + "columns-to-display": "Στήλες προς εμφάνιση", + "type-api-usage-state": "Κατάσταση Χρήσης API", + "type-edge": "Edge", + "type-edges": "Edges", + "list-of-edges": "{ count, plural, =1 {Ένα Edge} other {Λίστα με # Edges} }", + "edge-name-starts-with": "Edges με ονόματα που ξεκινούν με '{{prefix}}'", + "version-conflict": { + "message": "Θέλετε να αντικαταστήσετε την υπάρχουσα έκδοση ή να απορρίψετε τις αλλαγές και να φορτώσετε την τελευταία έκδοση;", + "link": "Μπορείτε να κατεβάσετε τη δική σας έκδοση του {{entityType}} χρησιμοποιώντας αυτό", + "overwrite": "Αντικατάσταση έκδοσης", + "discard": "Απόρριψη αλλαγών" + }, + "type-tb-resource": "Πόρος", + "type-tb-resources": "Πόροι", + "list-of-tb-resources": "{ count, plural, =1 {Ένας πόρος} other {Λίστα με # πόρους} }", + "type-ota-package": "Πακέτο OTA", + "type-rpc": "RPC", + "type-queue": "Ουρά", + "type-queue-stats": "Στατιστικά ουράς", + "type-queues-stats": "Στατιστικά ουρών", + "type-notification": "Ειδοποίηση", + "type-notification-rule": "Κανόνας ειδοποίησης", + "type-notification-rules": "Κανόνες ειδοποιήσεων", + "list-of-notification-rules": "{ count, plural, =1 {Ένας κανόνας ειδοποίησης} other {Λίστα με # κανόνες ειδοποίησης} }", + "type-notification-target": "Παραλήπτης ειδοποίησης", + "type-notification-targets": "Παραλήπτες ειδοποιήσεων", + "list-of-notification-targets": "{ count, plural, =1 {Ένας παραλήπτης ειδοποίησης} other {Λίστα με # παραλήπτες ειδοποίησης} }", + "type-notification-request": "Αίτημα ειδοποίησης", + "type-notification-template": "Πρότυπο ειδοποίησης", + "type-notification-templates": "Πρότυπα ειδοποιήσεων", + "list-of-notification-templates": "{ count, plural, =1 {Ένα πρότυπο ειδοποίησης} other {Λίστα με # πρότυπα ειδοποιήσεων} }", + "link": "σύνδεσμος", + "type-oauth2-client": "Πελάτης OAuth 2.0", + "type-oauth2-clients": "Πελάτες OAuth 2.0", + "list-of-oauth2-clients": "{ count, plural, =1 {Ένας πελάτης OAuth 2.0} other {Λίστα με # πελάτες OAuth 2.0} }", + "type-domain": "Τομέας", + "type-domains": "Τομείς", + "list-of-domains": "{ count, plural, =1 {Ένας τομέας} other {Λίστα με # τομείς} }", + "type-mobile-app": "Εφαρμογή κινητού", + "type-mobile-apps": "Εφαρμογές κινητού", + "list-of-mobile-apps": "{ count, plural, =1 {Μία εφαρμογή κινητού} other {Λίστα με # εφαρμογές κινητού} }", + "type-mobile-app-bundle": "Πακέτο εφαρμογής κινητού", + "type-mobile-app-bundles": "Πακέτα εφαρμογής κινητού", + "list-of-mobile-app-bundles": "{ count, plural, =1 {Ένα πακέτο κινητού} other {Λίστα με # πακέτα κινητού} }" }, "entity-field": { - "created-time": "Δημιουργήθηκε", + "created-time": "Χρόνος δημιουργίας", "name": "Όνομα", "type": "Τύπος", "first-name": "Όνομα", - "last-name": "Επίθετο", + "last-name": "Επώνυμο", "email": "Email", "title": "Τίτλος", "country": "Χώρα", - "state": "Νομός", + "state": "Πολιτεία", "city": "Πόλη", "address": "Διεύθυνση", "address2": "Διεύθυνση 2", - "zip": "Τ.Κ.", - "phone": "Τηλέφωνο" + "zip": "Ταχ. Κώδικας", + "phone": "Τηλέφωνο", + "label": "Ετικέτα", + "queue-name": "Όνομα ουράς", + "service-id": "Service Id", + "owner-name": "Όνομα ιδιοκτήτη", + "owner-type": "Τύπος ιδιοκτήτη" }, "entity-view": { - "entity-view": "Όψη Οντότητας", + "entity-view": "Προβολή οντότητας", "entity-view-required": "Απαιτείται προβολή οντότητας.", - "entity-views": "Όψεις Οντοτήτων", - "management": "Διαχείριση Όψεων Οντοτήτων", - "view-entity-views": "Προβολή οντοτήτων", + "entity-views": "Προβολές οντοτήτων", + "management": "Διαχείριση προβολών οντοτήτων", + "view-entity-views": "Προβολή προβολών οντοτήτων", "entity-view-alias": "Ψευδώνυμο προβολής οντότητας", - "aliases": "Ψευδώνυμα προβολής οντότητας", - "no-alias-matching": "'Δεν βρέθηκαν {{alias}}'.", + "aliases": "Ψευδώνυμα προβολής οντοτήτων", + "no-alias-matching": "'{{alias}}' δεν βρέθηκε.", "no-aliases-found": "Δεν βρέθηκαν ψευδώνυμα.", - "no-key-matching": "'Δεν βρέθηκαν {{key}}'.", - "no-keys-found": "Δεν βρέθηκαν ονόματα.", - "create-new-alias": "Δημιουργήστε ένα νέο!", - "create-new-key": "Δημιουργήστε ένα νέο!", - "duplicate-alias-error": "Βρέθηκε διπλότυπο '{{alias}}'.
    Τα ψευδώνυμα προβολής οντότητας πρέπει να είναι μοναδικά εντός του dashboard.", - "configure-alias": "Ρύθμιση ψευδώνυμου '{{alias}}'", - "no-entity-views-matching": "Δεν βρέθηκαν προβολές οντοτήτων οι οποίες να αντιστοιχούν σε '{{entity}}'.", + "no-key-matching": "'{{key}}' δεν βρέθηκε.", + "no-keys-found": "Δεν βρέθηκαν κλειδιά.", + "create-new-alias": "Δημιουργία νέου!", + "create-new-key": "Δημιουργία νέου!", + "duplicate-alias-error": "Βρέθηκε διπλό ψευδώνυμο '{{alias}}'.
    Τα ψευδώνυμα προβολής οντοτήτων πρέπει να είναι μοναδικά στον πίνακα εργαλείων.", + "configure-alias": "Διαμόρφωση ψευδωνύμου '{{alias}}'", + "no-entity-views-matching": "Δεν βρέθηκαν προβολές οντοτήτων που να αντιστοιχούν στο '{{entity}}'.", + "public": "Δημόσιο", "alias": "Ψευδώνυμο", - "alias-required": "Απαιτείται ψευδώνυμο προβολής οντοτήτων.", - "remove-alias": "Αφαίρεση ψευδωνύμου προβολής οντοτήτων", - "add-alias": "Προβολή ψευδωνύμου προβολής οντοτήτων", - "name-starts-with": "Το όνομα προβολής οντότητας αρχίζει από", - "entity-view-list": "Λίστα προβολής οντότητας", + "alias-required": "Απαιτείται ψευδώνυμο προβολής οντότητας.", + "remove-alias": "Αφαίρεση ψευδωνύμου προβολής οντότητας", + "add-alias": "Προσθήκη ψευδωνύμου προβολής οντότητας", + "name-starts-with": "Έκφραση ονόματος προβολής οντότητας", + "help-text": "Χρησιμοποιήστε '%' όπως απαιτείται: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "Λίστα προβολών οντοτήτων", "use-entity-view-name-filter": "Χρήση φίλτρου", - "entity-view-list-empty": "Δεν έχουν επιλεχθεί προβολές οντότητας.", + "entity-view-list-empty": "Δεν έχουν επιλεγεί προβολές οντοτήτων.", "entity-view-name-filter-required": "Απαιτείται φίλτρο ονόματος προβολής οντότητας.", - "entity-view-name-filter-no-entity-view-matched": "Δεν βρέθηκαν προβολές οντότητας οι οποίες να αρχίζουν από '{{entityView}}'.", - "add": "Προσθήκη Προβολής Οντότητας", + "entity-view-name-filter-no-entity-view-matched": "Δεν βρέθηκαν προβολές οντοτήτων που να ξεκινούν με '{{entityView}}'.", + "add": "Προσθήκη προβολής οντότητας", + "entity-view-public": "Η προβολή οντότητας είναι δημόσια", "assign-to-customer": "Ανάθεση σε πελάτη", - "assign-entity-view-to-customer": "Ανάθεση προβολής/ών οντότητας σε πελάτη", - "assign-entity-view-to-customer-text": "Παρακαλώ επιλέξτε τις προβολές οντότητας για να αναθέσετε σε πελάτη", - "no-entity-views-text": "Δεν βρέθηκαν προβολές οντότητας", - "assign-to-customer-text": "Παρακαλώ επιλέξτε πελάτη για να ανάθεση προβολών οντότητας", + "assign-entity-view-to-customer": "Ανάθεση προβολών οντοτήτων σε πελάτη", + "assign-entity-view-to-customer-text": "Επιλέξτε τις προβολές οντοτήτων για ανάθεση στον πελάτη", + "no-entity-views-text": "Δεν βρέθηκαν προβολές οντοτήτων", + "assign-to-customer-text": "Επιλέξτε τον πελάτη για να του αναθέσετε τις προβολές οντοτήτων", "entity-view-details": "Λεπτομέρειες προβολής οντότητας", "add-entity-view-text": "Προσθήκη νέας προβολής οντότητας", "delete": "Διαγραφή προβολής οντότητας", - "assign-entity-views": "Ανάθεση προβολών οντότητας", - "assign-entity-views-text": "Ανάθεση { count, plural, =1 {1 entityView} other {# entityViews} } σε πελάτη", - "delete-entity-views": "Διαγραφή προβολών οντότητας", - "make-public": "Δημοσιοποίηση προβολής οντότητας", - "make-private": "Ιδιωτικοποίηση προβολής οντότητας", - "unassign-from-customer": "Αφαίρεση από πελάτη", - "unassign-entity-views": "Αφαίρεση προβολών οντότητας", - "unassign-entity-views-action-title": "Αφαίρεση { count, plural, =1 {1 entityView} other {# entityViews} } από πελάτη", + "assign-entity-views": "Ανάθεση προβολών οντοτήτων", + "assign-entity-views-text": "Ανάθεση { count, plural, =1 {1 προβολή οντότητας} other {# προβολές οντοτήτων} } σε πελάτη", + "delete-entity-views": "Διαγραφή προβολών οντοτήτων", + "make-public": "Κάντε την προβολή δημόσια", + "make-private": "Κάντε την προβολή ιδιωτική", + "unassign-from-customer": "Από-ανάθεση από πελάτη", + "unassign-entity-views": "Από-ανάθεση προβολών οντοτήτων", + "unassign-entity-views-action-title": "Από-ανάθεση { count, plural, =1 {1 προβολή οντότητας} other {# προβολές οντοτήτων} } από τον πελάτη", "assign-new-entity-view": "Ανάθεση νέας προβολής οντότητας", - "delete-entity-view-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την προβολή οντότητας '{{entityViewName}}'?", - "delete-entity-view-text": "Προσοχή, μετά την επιβεβαίωση, η προβολή της οντότητας και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-entity-views-title": "Είστε σίγουροι ότι θέλετε να προβάλετε την οντότητα { count, plural, =1 {1 entityView} other {# entityViews} };", - "delete-entity-views-action-title": "Διαγραφή { count, plural, =1 {1 entityView} other {# entityViews} }", - "delete-entity-views-text": "Προσοχή, μετά την επιβεβαίωση, όλες οι επιλεγμένες προβολές της οντότητας και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "make-public-entity-view-title": "Είστε σίγουροι ότι θέλετε να κάνετε την προβολή οντότητας '{{entityViewName}}' δημόσια?", - "make-public-entity-view-text": "Μετά την επιβεβαίωση, η προβολή της οντότητας και όλα τα δεδομένα της θα δημοσιοποιηθούν και θα είναι προσβάσιμα από τρίτους.", - "make-private-entity-view-title": "Είστε σίγουροι ότι θέλετε να κάνετε την προβολή οντότητας '{{entityViewName}}' ιδιωτική;", - "make-private-entity-view-text": "Μετά την επιβεβαίωση, η προβολή της οντότητας και όλα τα δεδομένα της θα γίνουν ιδιωτικά και θα δεν είναι προσβάσιμα από τρίτους.", - "unassign-entity-view-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε την προβολη οντότητας '{{entityViewName}}';", - "unassign-entity-view-text": "Μετά την επιβεβαίωση, η προβολή της οντότητας θα καταργηθεί και δεν θα είναι προσβάσιμη από τον πελάτη.", - "unassign-entity-view": "Αφαίρεση προβολής οντότητας", - "unassign-entity-views-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, =1 {1 entityView} other {# entityViews} };", - "unassign-entity-views-text": "Μετά την επιβεβαίωση, όλες οι επιλεγμένες προβολές οντοτήτων θα αφαιρεθούν και δεν θα είναι προσβάσιμες από τον πελάτη.", - "entity-view-type": "Τύπος Προβολής Οντότητας", + "delete-entity-view-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την προβολή οντότητας '{{entityViewName}}';", + "delete-entity-view-text": "Προσοχή, μετά την επιβεβαίωση, η προβολή οντότητας και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-entity-views-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 προβολή οντότητας} other {# προβολές οντοτήτων} };", + "delete-entity-views-action-title": "Διαγραφή { count, plural, =1 {1 προβολής οντότητας} other {# προβολών οντοτήτων} }", + "delete-entity-views-text": "Προσοχή, μετά την επιβεβαίωση, όλες οι επιλεγμένες προβολές οντοτήτων θα αφαιρεθούν και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "make-public-entity-view-title": "Είστε βέβαιοι ότι θέλετε να κάνετε την προβολή οντότητας '{{entityViewName}}' δημόσια;", + "make-public-entity-view-text": "Μετά την επιβεβαίωση, η προβολή οντότητας και όλα τα δεδομένα της θα γίνουν δημόσια και προσβάσιμα από άλλους.", + "make-private-entity-view-title": "Είστε βέβαιοι ότι θέλετε να κάνετε την προβολή οντότητας '{{entityViewName}}' ιδιωτική;", + "make-private-entity-view-text": "Μετά την επιβεβαίωση, η προβολή οντότητας και όλα τα δεδομένα της θα γίνουν ιδιωτικά και δεν θα είναι προσβάσιμα από άλλους.", + "unassign-entity-view-title": "Είστε βέβαιοι ότι θέλετε να από-αναθέσετε την προβολή οντότητας '{{entityViewName}}';", + "unassign-entity-view-text": "Μετά την επιβεβαίωση, η προβολή οντότητας θα από-ανατεθεί και δεν θα είναι προσβάσιμη από τον πελάτη.", + "unassign-entity-view": "Από-ανάθεση προβολής οντότητας", + "unassign-entity-views-title": "Είστε βέβαιοι ότι θέλετε να από-αναθέσετε { count, plural, =1 {1 προβολή οντότητας} other {# προβολές οντοτήτων} };", + "unassign-entity-views-text": "Μετά την επιβεβαίωση, όλες οι επιλεγμένες προβολές οντοτήτων θα από-ανατεθούν και δεν θα είναι προσβάσιμες από τον πελάτη.", + "entity-view-type": "Τύπος προβολής οντότητας", "entity-view-type-required": "Απαιτείται τύπος προβολής οντότητας.", - "select-entity-view-type": "Επιλογή τύπου προβολής οντότητας", - "enter-entity-view-type": "Εισαγωγή τύπου προβολής οντοτήτων", + "select-entity-view-type": "Επιλέξτε τύπο προβολής οντότητας", + "enter-entity-view-type": "Εισάγετε τύπο προβολής οντότητας", "any-entity-view": "Οποιαδήποτε προβολή οντότητας", - "no-entity-view-types-matching": "Δεν βρέθηκαν τύποι προβολής οντότητας οι οποίοι να αντιστοιχούν με '{{entitySubtype}}'.", - "entity-view-type-list-empty": "Δεν έχουν επιλεχθεί τύποι προβολής οντότητας.", - "entity-view-types": "Τύποι Προβολής Οντότητας", + "no-entity-view-types-matching": "Δεν βρέθηκαν τύποι προβολής οντότητας που να αντιστοιχούν στο '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Δεν έχουν επιλεγεί τύποι προβολής οντοτήτων.", + "entity-view-types": "Τύποι προβολής οντοτήτων", + "created-time": "Χρόνος δημιουργίας", "name": "Όνομα", "name-required": "Απαιτείται όνομα.", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "type-max-length": "Ο τύπος προβολής οντότητας πρέπει να είναι μικρότερος από 256 χαρακτήρες", "description": "Περιγραφή", - "events": "Γεγονότα", + "events": "Συμβάντα", "details": "Λεπτομέρειες", "copyId": "Αντιγραφή ID προβολής οντότητας", - "idCopiedMessage": "Το ID της προβολής οντότητας έχει αντιγραφεί στο πρόχειρο", - "assignedToCustomer": "Αναθέση σε πελάτη", - "unable-entity-view-device-alias-title": "Αδύνατον να διαγραφεί το ψευδώνυμο προβολής οντότητας", - "unable-entity-view-device-alias-text": "Το ψευδώνυμο συσκευής '{{entityViewAlias}}' δεν μπορεί να διαγραφεί όσο εξακωλουθεί να χρησιμοποιείτε από τα παρακάτω widget(s):
    {{widgetsList}}", - "select-entity-view": "Επιλογή προβολής οντότητας", - "start-date": "Ημερομηνία έναρξης", - "start-ts": "Ώρα έναρξης", - "end-date": "Ημερομηνία λήξης", - "end-ts": "Ώρα λήξης", - "date-limits": "Όρια ημερομηνίας", - "client-attributes": "Χαρακτηριστικά Client", - "shared-attributes": "Κοινόχρηστα Χαρακτηριστικά", - "server-attributes": "Χαρακτηριστικά Server", - "timeseries": "Χρονική σειρά", - "client-attributes-placeholder": "Χαρακτηριστικά Client", - "shared-attributes-placeholder": "Κοινόχρηστα Χαρακτηριστικά", - "server-attributes-placeholder": "Χαρακτηριστικά Server", - "timeseries-placeholder": "Χρονική σειρά", - "target-entity": "Στοχευμένη οντότητα", + "idCopiedMessage": "Το ID της προβολής οντότητας αντιγράφηκε στο πρόχειρο", + "assignedToCustomer": "Ανατέθηκε σε πελάτη", + "unable-entity-view-device-alias-title": "Δεν είναι δυνατή η διαγραφή του ψευδωνύμου προβολής οντότητας", + "unable-entity-view-device-alias-text": "Το ψευδώνυμο '{{entityViewAlias}}' δεν μπορεί να διαγραφεί επειδή χρησιμοποιείται από το(α) εξής widget(s):
    {{widgetsList}}", + "select-entity-view": "Επιλέξτε προβολή οντότητας", + "start-ts": "Χρόνος έναρξης", + "end-ts": "Χρόνος λήξης", + "date-limits": "Όρια ημερομηνιών", + "client-attributes": "Χαρακτηριστικά πελάτη", + "shared-attributes": "Κοινόχρηστα χαρακτηριστικά", + "server-attributes": "Χαρακτηριστικά διακομιστή", + "timeseries": "Χρονοσειρές", + "client-attributes-placeholder": "Χαρακτηριστικά πελάτη", + "shared-attributes-placeholder": "Κοινόχρηστα χαρακτηριστικά", + "server-attributes-placeholder": "Χαρακτηριστικά διακομιστή", + "timeseries-placeholder": "Χρονοσειρές", + "target-entity": "Οντότητα στόχος", "attributes-propagation": "Διάδοση χαρακτηριστικών", - "attributes-propagation-hint": "Η προβολή οντοτήτων θα αντιγράφει αυτόματα καθορισμένα χαρακτηριστικά από την στοχευμένη οντότητα κάθε φορά που αποθηκεύετε ή ενημερώνετε αυτήν την προβολή οντότητας. Για λόγους απόδοσης, τα χαρακτηριστικά της στοχευμένης οντότητας δεν μεταδίδονται στην προβολή οντότητας με κάθε αλλαγή χαρακτηριστικών. Μπορείτε να ενεργοποιήσετε την αυτόματη διάδοση ρυθμίζοντας τον κόμβο \"αντιγραφή για προβολή \" στην αλυσίδα κανόνων σας και συνδέοντας τα μηνύματα \"Χαρακτηριστικά Post \" και \"Ενημερωμένα Χαρακτηριστικά \" στον νέο κόμβο.", - "timeseries-data": "Δεδομένα χρονικής σειράς", - "timeseries-data-hint": "Ρυθμίστε τα δεδομένα της χρονικής σειράς της στοχευμένης οντότητας που θα είναι διαθέσιμα στην προβολή οντοτητας. Αυτά τα δεδομένα χρονικής σειράς είναι μόνο για ανάγνωση.", - "selected-entity-views": "{ count, plural, =1 {1 entity view} other {# entity views} } επιλεγμένα", - "search": "Αναζήτηση προβολών οντότητας", - "select-group-to-add": "Επιλογή ομάδας για προσθήκη των επιλεγμένων προβολών οντότητας", - "select-group-to-move": "Επιλογή ομάδας για μετακίνηση των επιλεγμένων προβολών οντότητας", - "remove-entity-views-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, =1 {1 entity view} other {# entity views} } from group '{entityGroup}'?", - "group": "Ομάδα προβολών οντότητας", - "list-of-groups": "{ count, plural, =1 {One entity view group} other {List of # entity view groups} }", - "group-name-starts-with": "Ομάδες προβολής οντότητας των οποίων το όνομα αρχίζει από '{{prefix}}'" + "attributes-propagation-hint": "Η Προβολή Οντότητας θα αντιγράφει αυτόματα τα καθορισμένα χαρακτηριστικά από την Οντότητα Στόχο κάθε φορά που αποθηκεύετε ή ενημερώνετε αυτή την προβολή. Για λόγους απόδοσης, τα χαρακτηριστικά της οντότητας στόχου δεν μεταφέρονται στην προβολή οντότητας με κάθε αλλαγή χαρακτηριστικού. Μπορείτε να ενεργοποιήσετε την αυτόματη διάδοση ρυθμίζοντας το node κανόνα \"copy to view\" και συνδέοντας τα μηνύματα \"Post attributes\" και \"Attributes Updated\" με το νέο node.", + "timeseries-data": "Δεδομένα χρονοσειρών", + "timeseries-data-hint": "Διαμορφώστε τα κλειδιά δεδομένων χρονοσειρών της οντότητας στόχου που θα είναι προσβάσιμα από την προβολή οντότητας. Αυτά τα δεδομένα χρονοσειρών είναι μόνο για ανάγνωση.", + "search": "Αναζήτηση προβολών οντοτήτων", + "selected-entity-views": "{ count, plural, =1 {1 προβολή οντότητας} other {# προβολές οντοτήτων} } επιλέχθηκαν", + "assign-entity-view-to-edge": "Ανάθεση προβολών οντοτήτων σε Edge", + "assign-entity-view-to-edge-text": "Επιλέξτε τις προβολές οντοτήτων για ανάθεση στο edge", + "unassign-entity-view-from-edge-title": "Είστε βέβαιοι ότι θέλετε να απο-αναθέσετε την προβολή οντότητας '{{entityViewName}}';", + "unassign-entity-view-from-edge-text": "Μετά την επιβεβαίωση, η προβολή οντότητας θα απο-ανατεθεί και δεν θα είναι προσβάσιμη από το edge.", + "unassign-entity-views-from-edge-action-title": "Απο-ανάθεση { count, plural, =1 {1 προβολής οντότητας} other {# προβολών οντοτήτων} } από το edge", + "unassign-entity-view-from-edge": "Απο-ανάθεση προβολής οντότητας", + "unassign-entity-views-from-edge-title": "Είστε βέβαιοι ότι θέλετε να απο-αναθέσετε { count, plural, =1 {1 προβολή οντότητας} other {# προβολές οντοτήτων} };", + "unassign-entity-views-from-edge-text": "Μετά την επιβεβαίωση, όλες οι επιλεγμένες προβολές οντοτήτων θα απο-ανατεθούν και δεν θα είναι προσβάσιμες από το edge." }, "event": { - "events": "Γεγονότα", - "event-type": "Τύπος Γεγονότος", + "event-type": "Τύπος συμβάντος", + "events-filter": "Φίλτρο συμβάντων", + "clean-events": "Καθαρισμός συμβάντων", "type-error": "Σφάλμα", - "type-lc-event": "Γεγονός κύκλου ζωής", - "type-stats": "Στατιστική", - "type-debug-converter": "Αποσφαλμάτωση", - "type-debug-integration": "Αποσφαλμάτωση", + "type-lc-event": "Συμβάν κύκλου ζωής", + "type-stats": "Στατιστικά", "type-debug-rule-node": "Αποσφαλμάτωση", "type-debug-rule-chain": "Αποσφαλμάτωση", - "no-events-prompt": "Δεν βρέθηκαν γεγονότα", + "type-debug-calculated-field": "Αποσφαλμάτωση", + "arguments": "Ορίσματα", + "result": "Αποτέλεσμα", + "no-events-prompt": "Δεν βρέθηκαν συμβάντα", "error": "Σφάλμα", - "alarm": "Alarm", - "event-time": "Ώρα Γεγονότος", + "alarm": "Συναγερμός", + "event-time": "Χρόνος συμβάντος", "server": "Διακομιστής", - "body": "Σώμα (body)", + "body": "Σώμα", "method": "Μέθοδος", "type": "Τύπος", - "in": "Είσοδος", - "out": "Έξοδος", "metadata": "Μεταδεδομένα", "message": "Μήνυμα", - "message-id": "ID Μηνύματος", - "message-type": "Τύπος Μηνύματος", - "data-type": "Τύπος Δεδομένων", - "relation-type": "Τύπος Σχέσης", - "data": "Δεδομέναα", - "event": "Γεγονός", + "message-id": "Αναγνωριστικό μηνύματος", + "copy-message-id": "Αντιγραφή αναγνωριστικού μηνύματος", + "message-type": "Τύπος μηνύματος", + "data-type": "Τύπος δεδομένων", + "relation-type": "Τύπος σχέσης", + "data": "Δεδομένα", + "event": "Συμβάν", "status": "Κατάσταση", "success": "Επιτυχία", - "failed": "Απέτυχε", - "messages-processed": "Επεξεργασμένα μηνύματα", + "failed": "Αποτυχία", + "messages-processed": "Μηνύματα που επεξεργάστηκαν", + "max-messages-processed": "Μέγιστος αριθμός επεξεργασμένων μηνυμάτων", + "min-messages-processed": "Ελάχιστος αριθμός επεξεργασμένων μηνυμάτων", "errors-occurred": "Παρουσιάστηκαν σφάλματα", - "all-events": "Όλοι", - "entity-type": "Τύπος οντοτήτων" + "max-errors-occurred": "Μέγιστος αριθμός σφαλμάτων", + "min-errors-occurred": "Ελάχιστος αριθμός σφαλμάτων", + "min-value": "Η ελάχιστη τιμή είναι 0.", + "all-events": "Όλα", + "has-error": "Έχει σφάλμα", + "entity-id": "Αναγνωριστικό οντότητας", + "copy-entity-id": "Αντιγραφή αναγνωριστικού οντότητας", + "entity-type": "Τύπος οντότητας", + "clear-filter": "Καθαρισμός φίλτρου", + "clear-request-title": "Καθαρισμός όλων των συμβάντων", + "clear-request-text": "Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλα τα συμβάντα;", + "started": "Ξεκίνησε", + "updated": "Ενημερώθηκε", + "stopped": "Σταμάτησε" }, "extension": { "extensions": "Επεκτάσεις", - "selected-extensions": "{ count, plural, =1 {1 extension} other {# extensions} } επιλέχθηκαν", + "selected-extensions": "{ count, plural, =1 {1 επέκταση} other {# επεκτάσεις} } επιλεγμένες", "type": "Τύπος", "key": "Κλειδί", "value": "Τιμή", - "id": "ID", - "extension-id": "ID επέκτασης", + "id": "Αναγνωριστικό", + "extension-id": "Αναγνωριστικό επέκτασης", "extension-type": "Τύπος επέκτασης", "transformer-json": "JSON *", - "unique-id-required": "Το τρέχον ID επέκτασης υπάρχει ήδη.", + "unique-id-required": "Το τρέχον αναγνωριστικό επέκτασης υπάρχει ήδη.", "delete": "Διαγραφή επέκτασης", "add": "Προσθήκη επέκτασης", "edit": "Επεξεργασία επέκτασης", - "view": "Προβολή επέκτασης", - "delete-extension-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την επέκταση '{{extensionId}}';", - "delete-extension-text": "Προσοχή, μετά την επιβεβαίωση, η επέκταση και όλα τα σχετικά δεδομένα θα διαγραφούν μόνιμα.", - "delete-extensions-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 extension} other {# extensions} }?", - "delete-extensions-text": "Προσέξτε, μετά την επιβεβαίωση θα αφαιρεθούν όλες οι επιλεγμένες επεκτάσεις.", + "delete-extension-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την επέκταση '{{extensionId}}';", + "delete-extension-text": "Προσοχή, μετά την επιβεβαίωση η επέκταση και όλα τα σχετικά δεδομένα θα χαθούν οριστικά.", + "delete-extensions-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 επέκταση} other {# επεκτάσεις} };", + "delete-extensions-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες επεκτάσεις θα αφαιρεθούν.", "converters": "Μετατροπείς", - "converter-id": "ID Μετατροπέα", + "converter-id": "Αναγνωριστικό μετατροπέα", "configuration": "Διαμόρφωση", - "converter-configurations": "Ρυθμίσεις μετατροπέα", - "token": "Ετικέτα ασφαλείας", + "converter-configurations": "Διαμορφώσεις μετατροπέα", + "token": "Διακριτικό ασφαλείας", "add-converter": "Προσθήκη μετατροπέα", - "add-config": "Προσθήκη ρυθμήσεων μετατροπέα", + "add-config": "Προσθήκη διαμόρφωσης μετατροπέα", "device-name-expression": "Έκφραση ονόματος συσκευής", "device-type-expression": "Έκφραση τύπου συσκευής", "custom": "Προσαρμοσμένο", - "to-double": "Διπλασιασμός", - "transformer": "Μετατροπέας", - "json-required": "Απαιτείται μετατροπέας JSON.", - "json-parse": "Αδύνατον να γίνει αναλύση του μετατροπέα JSON.", + "to-double": "Σε Διπλό", + "transformer": "Μετασχηματιστής", + "json-required": "Απαιτείται transformer json.", + "json-parse": "Αδύνατη η ανάλυση του transformer json.", "attributes": "Χαρακτηριστικά", "add-attribute": "Προσθήκη χαρακτηριστικού", - "add-map": "Προσθήκη στοιχείου χαρτογράφισης", - "timeseries": "Χρονική σειρά", - "add-timeseries": "Προσθήκη χρονικής σειράς", - "field-required": "Απαιτείται το πεδίο", - "brokers": "Brokers", - "add-broker": "Προσθήκη broker", - "host": "Host", - "port": "Port", - "port-range": "Η Port πρέπει να είναι από 1 ως 65535.", + "add-map": "Προσθήκη στοιχείου αντιστοίχισης", + "timeseries": "Χρονοσειρές", + "add-timeseries": "Προσθήκη χρονοσειράς", + "field-required": "Το πεδίο είναι υποχρεωτικό", + "brokers": "Μεσάζοντες", + "add-broker": "Προσθήκη μεσάζοντα", + "host": "Κεντρικός υπολογιστής", + "port": "Θύρα", + "port-range": "Η θύρα πρέπει να είναι μεταξύ 1 και 65535.", "ssl": "Ssl", "credentials": "Διαπιστευτήρια", - "username": "Όνομα Χρήστη", - "password": "Κωδικός", - "retry-interval": "Διάστημα επανάληψης σε χιλιοστά του δευτερολέπτου", - "anonymous": "Ανώνυμα", - "basic": "Βασικά", + "username": "Όνομα χρήστη", + "password": "Κωδικός πρόσβασης", + "retry-interval": "Διάστημα επανάληψης σε milliseconds", + "anonymous": "Ανώνυμος", + "basic": "Βασικός", "pem": "PEM", "ca-cert": "Αρχείο πιστοποιητικού CA *", "private-key": "Αρχείο ιδιωτικού κλειδιού *", "cert": "Αρχείο πιστοποιητικού *", - "no-file": "Δεν έχει επιλεχθεί αρχείο.", - "drop-file": "Αποθέστε ένα αρχείο ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", - "mapping": "χαρτογράφιση", + "no-file": "Δεν έχει επιλεγεί αρχείο.", + "drop-file": "Σύρετε ένα αρχείο ή κάντε κλικ για επιλογή αρχείου για μεταφόρτωση.", + "mapping": "Αντιστοίχιση", "topic-filter": "Φίλτρο θέματος", "converter-type": "Τύπος μετατροπέα", - "converter-json": "JSON", - "json-name-expression": "Έκφραση json ονόματος συσκευής", - "topic-name-expression": "Έκφραση θέματος ονόματος συσκευής", - "json-type-expression": "Έκφραση json τύπου συσκευής", - "topic-type-expression": "Έκφραση θέματος τύπου συσκευής", - "attribute-key-expression": "Έκφραση χαρακτηριστικού κλειδιού", - "attr-json-key-expression": "Έκφραση JSON χαρακτηριστικού κλειδιού", - "attr-topic-key-expression": "Έκφραση θέματος χαρακτηριστικού κλειδιού", - "request-id-expression": "Αίτημα έκφρασης ID", - "request-id-json-expression": "Αίτημα ID έκφρασης JSON", - "request-id-topic-expression": "Αίτημα ID έκφρασης θέματος", - "response-topic-expression": "Απάντηση έκφρασης θέματος", + "converter-json": "Json", + "json-name-expression": "Έκφραση ονόματος json συσκευής", + "topic-name-expression": "Έκφραση ονόματος θέματος συσκευής", + "json-type-expression": "Έκφραση τύπου json συσκευής", + "topic-type-expression": "Έκφραση τύπου θέματος συσκευής", + "attribute-key-expression": "Έκφραση κλειδιού χαρακτηριστικού", + "attr-json-key-expression": "Έκφραση json κλειδιού χαρακτηριστικού", + "attr-topic-key-expression": "Έκφραση θέματος κλειδιού χαρακτηριστικού", + "request-id-expression": "Έκφραση αναγνωριστικού αιτήματος", + "request-id-json-expression": "Έκφραση json αναγνωριστικού αιτήματος", + "request-id-topic-expression": "Έκφραση θέματος αναγνωριστικού αιτήματος", + "response-topic-expression": "Έκφραση θέματος απόκρισης", "value-expression": "Έκφραση τιμής", "topic": "Θέμα", - "timeout": "Λήξη σε χιλιοστά του δευτερολέπτου", - "converter-json-required": "Απαιτείται μετατροπέας JSON.", - "converter-json-parse": "Αδύνατον να αναλυθεί ο μετατροπέας JSON.", + "timeout": "Χρονικό όριο σε milliseconds", + "converter-json-required": "Απαιτείται json μετατροπέα.", + "converter-json-parse": "Αδύνατη η ανάλυση του json μετατροπέα.", "filter-expression": "Έκφραση φίλτρου", "connect-requests": "Αιτήματα σύνδεσης", "add-connect-request": "Προσθήκη αιτήματος σύνδεσης", "disconnect-requests": "Αιτήματα αποσύνδεσης", "add-disconnect-request": "Προσθήκη αιτήματος αποσύνδεσης", - "attribute-requests": "Χαρακτιριστικό αιτημάτων", - "add-attribute-request": "Προσθήκη χαρακτηριστικού αιτήματος", - "attribute-updates": "Ανανεώσεις χαρακτηριστικού", - "add-attribute-update": "Προσθήκη ανανέωσης χαρακτηριστικού", - "server-side-rpc": "Server side RPC", - "add-server-side-rpc-request": "Προσθήκη αιτήματος server-side RPC", + "attribute-requests": "Αιτήματα χαρακτηριστικών", + "add-attribute-request": "Προσθήκη αιτήματος χαρακτηριστικού", + "attribute-updates": "Ενημερώσεις χαρακτηριστικών", + "add-attribute-update": "Προσθήκη ενημέρωσης χαρακτηριστικού", + "server-side-rpc": "RPC πλευράς διακομιστή", + "add-server-side-rpc-request": "Προσθήκη αιτήματος RPC πλευράς διακομιστή", "device-name-filter": "Φίλτρο ονόματος συσκευής", - "attribute-filter": "ίλτρο χαρακτηριστικού", + "attribute-filter": "Φίλτρο χαρακτηριστικών", "method-filter": "Φίλτρο μεθόδου", - "request-topic-expression": "Αίτημα έκφρασης θέματος", - "response-timeout": "Λήξη απάντησης σε χιλιοστά του δευτερολέπτου", - "topic-expression": "Έκφρασου θέματος", - "client-scope": "Πεδίο εφαρμογής πελάτη", + "request-topic-expression": "Έκφραση θέματος αιτήματος", + "response-timeout": "Χρονικό όριο απόκρισης σε milliseconds", + "topic-expression": "Έκφραση θέματος", + "client-scope": "Πεδίο πελάτη", "add-device": "Προσθήκη συσκευής", - "opc-server": "Servers", - "opc-add-server": "Προσθήκη server", - "opc-add-server-prompt": "Παρακαλούμε προσθέστε server", + "opc-server": "Διακομιστές", + "opc-add-server": "Προσθήκη διακομιστή", + "opc-add-server-prompt": "Παρακαλώ προσθέστε διακομιστή", "opc-application-name": "Όνομα εφαρμογής", - "opc-application-uri": "URI Εφαρμογής", + "opc-application-uri": "URI εφαρμογής", "opc-scan-period-in-seconds": "Περίοδος σάρωσης σε δευτερόλεπτα", "opc-security": "Ασφάλεια", "opc-identity": "Ταυτότητα", - "opc-keystore": "Keystore", + "opc-keystore": "Αποθήκη κλειδιών", "opc-type": "Τύπος", "opc-keystore-type": "Τύπος", - "opc-keystore-location": "τοποθεσία *", - "opc-keystore-password": "Κωδικός", + "opc-keystore-location": "Τοποθεσία *", + "opc-keystore-password": "Κωδικός πρόσβασης", "opc-keystore-alias": "Ψευδώνυμο", - "opc-keystore-key-password": "Κωδικός Κλειδί", + "opc-keystore-key-password": "Κωδικός κλειδιού", "opc-device-node-pattern": "Μοτίβο κόμβου συσκευής", "opc-device-name-pattern": "Μοτίβο ονόματος συσκευής", - "modbus-server": "Servers/slaves", - "modbus-add-server": "Προσθήκη server/slave", - "modbus-add-server-prompt": "Παρακαλούμε προσθέστε server/slave", + "modbus-server": "Διακομιστές/σκλάβοι", + "modbus-add-server": "Προσθήκη διακομιστή/σκλάβου", + "modbus-add-server-prompt": "Παρακαλώ προσθέστε διακομιστή/σκλάβο", "modbus-transport": "Μεταφορά", "modbus-tcp-reconnect": "Αυτόματη επανασύνδεση", - "modbus-rtu-over-tcp": "RTU over TCP", - "modbus-port-name": "Όνομα serial port", - "modbus-encoding": "Encoding", - "modbus-parity": "Parity", - "modbus-baudrate": "Baud rate", - "modbus-databits": "Data bits", - "modbus-stopbits": "Stop bits", - "modbus-databits-range": "Τα Data bits πρέπει να κυμαίνονται από 7 εώς 8.", - "modbus-stopbits-range": "Τα Stop bits πρέπει να κυμαίνονται από 1 εώς 2.", - "modbus-unit-id": "ID Μονάδας", - "modbus-unit-id-range": "Το ID Μονάδας πρέπει να κυμαίνεται από 1 εώς 247.", + "modbus-rtu-over-tcp": "RTU μέσω TCP", + "modbus-port-name": "Όνομα σειριακής θύρας", + "modbus-encoding": "Κωδικοποίηση", + "modbus-parity": "Ισοτιμία", + "modbus-baudrate": "Ρυθμός μετάδοσης", + "modbus-databits": "Bits δεδομένων", + "modbus-stopbits": "Bits παύσης", + "modbus-databits-range": "Τα bits δεδομένων πρέπει να είναι από 7 έως 8.", + "modbus-stopbits-range": "Τα bits παύσης πρέπει να είναι από 1 έως 2.", + "modbus-unit-id": "Μονάδα ID", + "modbus-unit-id-range": "Το Unit ID πρέπει να είναι μεταξύ 1 και 247.", "modbus-device-name": "Όνομα συσκευής", - "modbus-poll-period": "Poll period (ms)", - "modbus-attributes-poll-period": "Χαρακτηριστικά poll period (ms)", - "modbus-timeseries-poll-period": "Χρονική σειρά poll period (ms)", - "modbus-poll-period-range": "Το Poll period θα πρέπει να έχει θετική τιμή.", + "modbus-poll-period": "Περίοδος ελέγχου (ms)", + "modbus-attributes-poll-period": "Περίοδος ελέγχου χαρακτηριστικών (ms)", + "modbus-timeseries-poll-period": "Περίοδος ελέγχου χρονοσειρών (ms)", + "modbus-poll-period-range": "Η περίοδος ελέγχου πρέπει να είναι θετική τιμή.", "modbus-tag": "Ετικέτα", "modbus-function": "Λειτουργία", - "modbus-register-address": "Καταχώριση διεύθυνσης", - "modbus-register-address-range": "η καταχωριμένη διεύθυνση πρεπει να κυμαίνεται από 0 ως 65535.", - "modbus-register-bit-index": "Bit index", - "modbus-register-bit-index-range": "Το Bit index πρέπει να κυμαίνεται από 0 ως 15.", - "modbus-register-count": "Μετρητής καταχώρησης", - "modbus-register-count-range": "Ο μετρητής καταχώρησης πρεέπει να έχει θετική τιμή.", - "modbus-byte-order": "Byte order", + "modbus-register-address": "Διεύθυνση καταχωρητή", + "modbus-register-address-range": "Η διεύθυνση καταχωρητή πρέπει να είναι από 0 έως 65535.", + "modbus-register-bit-index": "Δείκτης bit", + "modbus-register-bit-index-range": "Ο δείκτης bit πρέπει να είναι από 0 έως 15.", + "modbus-register-count": "Πλήθος καταχωρητών", + "modbus-register-count-range": "Το πλήθος καταχωρητών πρέπει να είναι θετική τιμή.", + "modbus-byte-order": "Σειρά byte", "sync": { "status": "Κατάσταση", - "sync": "Σε Συγχρονισμό", - "not-sync": "Δεν Συγχρονίζεται", - "last-sync-time": "Τελευταία φορά συγχρονισμού", + "sync": "Συγχρονισμός", + "not-sync": "Μη συγχρονισμένο", + "last-sync-time": "Τελευταίος συγχρονισμός", "not-available": "Μη διαθέσιμο" }, - "export-extensions-configuration": "Εξαγωγή διαμόρφωσης επεκτάσεων", - "import-extensions-configuration": "Εισαγωγή διαμόρφωσης επεκτάσεων", + "export-extensions-configuration": "Εξαγωγή ρυθμίσεων επεκτάσεων", + "import-extensions-configuration": "Εισαγωγή ρυθμίσεων επεκτάσεων", "import-extensions": "Εισαγωγή επεκτάσεων", "import-extension": "Εισαγωγή επέκτασης", "export-extension": "Εξαγωγή επέκτασης", - "file": "Επεκτάσεις αρχείου", - "invalid-file-error": "Μη έγκυρη επέκταση αρχείου" + "file": "Αρχείο επεκτάσεων", + "invalid-file-error": "Μη έγκυρο αρχείο επέκτασης" + }, + "feature": { + "advanced-features": "Προηγμένες δυνατότητες" + }, + "filter": { + "add": "Προσθήκη φίλτρου", + "edit": "Επεξεργασία φίλτρου", + "name": "Όνομα φίλτρου", + "name-required": "Το όνομα φίλτρου είναι υποχρεωτικό.", + "duplicate-filter": "Υπάρχει ήδη φίλτρο με το ίδιο όνομα.", + "filters": "Φίλτρα", + "unable-delete-filter-title": "Δεν είναι δυνατή η διαγραφή φίλτρου", + "unable-delete-filter-text": "Το φίλτρο '{{filter}}' δεν μπορεί να διαγραφεί επειδή χρησιμοποιείται από τα παρακάτω widget(s):
    {{widgetsList}}", + "duplicate-filter-error": "Βρέθηκε διπλό φίλτρο '{{filter}}'.
    Τα φίλτρα πρέπει να είναι μοναδικά μέσα στον πίνακα ελέγχου.", + "missing-key-filters-error": "Λείπει φίλτρο κλειδιού για το φίλτρο '{{filter}}'.", + "filter": "Φίλτρο", + "editable": "Επεξεργάσιμο", + "no-filters-found": "Δεν βρέθηκαν φίλτρα.", + "no-filter-text": "Δεν έχει καθοριστεί φίλτρο", + "add-filter-prompt": "Παρακαλώ προσθέστε φίλτρο", + "no-filter-matching": "'{{filter}}' δεν βρέθηκε.", + "create-new-filter": "Δημιουργία νέου φίλτρου!", + "create-new": "Δημιουργία νέου", + "filter-required": "Το φίλτρο είναι υποχρεωτικό.", + "operation": { + "operation": "Λειτουργία", + "equal": "ίσο με", + "not-equal": "όχι ίσο με", + "starts-with": "ξεκινά με", + "ends-with": "τελειώνει με", + "contains": "περιέχει", + "not-contains": "δεν περιέχει", + "greater": "μεγαλύτερο από", + "less": "μικρότερο από", + "greater-or-equal": "μεγαλύτερο ή ίσο", + "less-or-equal": "μικρότερο ή ίσο", + "and": "και", + "or": "ή", + "in": "είναι μέσα σε", + "not-in": "δεν είναι μέσα σε" + }, + "ignore-case": "αγνόηση πεζών/κεφαλαίων", + "value": "Τιμή", + "remove-filter": "Αφαίρεση φίλτρου", + "duplicate-filter-action": "Αντιγραφή φίλτρου", + "preview": "Προεπισκόπηση φίλτρου", + "no-filters": "Δεν έχουν ρυθμιστεί φίλτρα", + "add-filter": "Προσθήκη φίλτρου", + "add-complex-filter": "Προσθήκη σύνθετου φίλτρου", + "add-complex": "Προσθήκη σύνθετου", + "complex-filter": "Σύνθετο φίλτρο", + "edit-complex-filter": "Επεξεργασία σύνθετου φίλτρου", + "edit-filter-user-params": "Επεξεργασία παραμέτρων χρήστη φίλτρου", + "filter-user-params": "Παράμετροι χρήστη φίλτρου", + "user-parameters": "Παράμετροι χρήστη", + "display-label": "Ετικέτα εμφάνισης", + "order-priority": "Προτεραιότητα πεδίου", + "key-filter": "Φίλτρο κλειδιού", + "key-filters": "Φίλτρα κλειδιών", + "key-name": "Όνομα κλειδιού", + "key-name-required": "Το όνομα του κλειδιού είναι υποχρεωτικό.", + "key-type": { + "key-type": "Τύπος κλειδιού", + "attribute": "Χαρακτηριστικό", + "timeseries": "Χρονοσειρά", + "entity-field": "Πεδίο οντότητας", + "constant": "Σταθερά", + "client-attribute": "Χαρακτηριστικό πελάτη", + "server-attribute": "Χαρακτηριστικό διακομιστή", + "shared-attribute": "Κοινό χαρακτηριστικό" + }, + "value-type": { + "value-type": "Τύπος τιμής", + "string": "Κείμενο", + "numeric": "Αριθμητικό", + "boolean": "Λογικό", + "date-time": "Ημερομηνία και ώρα" + }, + "value-type-required": "Ο τύπος τιμής είναι υποχρεωτικός.", + "key-value-type-change-title": "Είστε σίγουροι ότι θέλετε να αλλάξετε τον τύπο τιμής του κλειδιού;", + "key-value-type-change-message": "Αν επιβεβαιώσετε, όλα τα φίλτρα κλειδιών που έχουν εισαχθεί θα διαγραφούν.", + "no-key-filters": "Δεν έχουν ρυθμιστεί φίλτρα κλειδιών", + "add-key-filter": "Προσθήκη φίλτρου κλειδιού", + "remove-key-filter": "Αφαίρεση φίλτρου κλειδιού", + "edit-key-filter": "Επεξεργασία φίλτρου κλειδιού", + "date": "Ημερομηνία", + "time": "Ώρα", + "current-tenant": "Τρέχων ενοικιαστής", + "current-customer": "Τρέχων πελάτης", + "current-user": "Τρέχων χρήστης", + "current-device": "Τρέχουσα συσκευή", + "default-value": "Προεπιλεγμένη τιμή", + "default-comma-separated-values": "Προεπιλεγμένες τιμές χωρισμένες με κόμμα", + "dynamic-source-type": "Τύπος δυναμικής πηγής", + "dynamic-value": "Δυναμική τιμή", + "no-dynamic-value": "Δεν υπάρχει δυναμική τιμή", + "source-attribute": "Χαρακτηριστικό πηγής", + "switch-to-dynamic-value": "Μετάβαση σε δυναμική τιμή", + "switch-to-default-value": "Μετάβαση σε προεπιλεγμένη τιμή", + "inherit-owner": "Κληρονόμηση από ιδιοκτήτη", + "source-attribute-not-set": "Εάν δεν έχει οριστεί χαρακτηριστικό πηγής" }, "fullscreen": { - "expand": "Ανάπτυξη σε πλήρη οθόνη", + "expand": "Επέκταση σε πλήρη οθόνη", "exit": "Έξοδος από πλήρη οθόνη", - "toggle": "Εναλλαγή σε πλήρη οθόνη", + "toggle": "Εναλλαγή λειτουργίας πλήρους οθόνης", "fullscreen": "Πλήρης οθόνη" }, "function": { - "function": "Λειτουργία" + "function": "Συνάρτηση" + }, + "gateway": { + "gateway-name": "Όνομα πύλης", + "gateway-name-required": "Απαιτείται όνομα πύλης.", + "gateways": "Πύλες", + "create-new-gateway": "Δημιουργία νέας πύλης", + "create-new-gateway-text": "Είστε σίγουροι ότι θέλετε να δημιουργήσετε μια νέα πύλη με όνομα: '{{gatewayName}}';", + "launch-command": "Εντολή εκκίνησης", + "no-gateway-found": "Δεν βρέθηκε πύλη.", + "no-gateway-matching": "'{{item}}' δεν βρέθηκε." }, "grid": { - "delete-item-title": "Είστε σίγουροι ότι θέλετε να διαγραφεί αυτό το αντικείμενο?", - "delete-item-text": "Προσοχή, μετά την επιβεβαίωση, αυτό το στοιχείο και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-items-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 item} other {# items} }?", - "delete-items-action-title": "Διαγραφή { count, plural, =1 {1 item} other {# items} }", - "delete-items-text": "Προσοχή, μετά την επιβεβαίωση, όλα τα επιλεγμένα στοιχεία θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "add-item-text": "Προσθήκη νέου αντικειμένου", - "no-items-text": "Δεν βρέθηκαν αντικείμενα", - "item-details": "λεπτομέρειες αντικείμενου", - "delete-item": "Διαγραφή Αντικειμένου", - "delete-items": "Διαγραφή Αντικειμένων", - "scroll-to-top": "Κύλιση προς τα πάνω" + "delete-item-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το στοιχείο;", + "delete-item-text": "Προσοχή, μετά την επιβεβαίωση αυτό το στοιχείο και όλα τα σχετικά δεδομένα θα καταστούν μη ανακτήσιμα.", + "delete-items-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 στοιχείο} other {# στοιχεία} };", + "delete-items-action-title": "Διαγραφή { count, plural, =1 {1 στοιχείου} other {# στοιχείων} }", + "delete-items-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα στοιχεία θα διαγραφούν και όλα τα σχετικά δεδομένα θα καταστούν μη ανακτήσιμα.", + "add-item-text": "Προσθήκη νέου στοιχείου", + "no-items-text": "Δεν βρέθηκαν στοιχεία", + "item-details": "Λεπτομέρειες στοιχείου", + "delete-item": "Διαγραφή στοιχείου", + "delete-items": "Διαγραφή στοιχείων", + "scroll-to-top": "Μετακίνηση στην κορυφή" }, "help": { - "goto-help-page": "Πηγαίνετε στη σελίδα" + "goto-help-page": "Μετάβαση στη σελίδα βοήθειας", + "show-help": "Εμφάνιση βοήθειας" }, "home": { - "home": "Αρχικη", + "home": "Αρχική", "profile": "Προφίλ", - "logout": "Αποσυνδέση", + "logout": "Αποσύνδεση", "menu": "Μενού", - "avatar": "Avatar", + "avatar": "Άβαταρ", "open-user-menu": "Άνοιγμα μενού χρήστη" }, + "file-input": { + "browse-file": "Αναζήτηση αρχείου", + "browse-files": "Αναζήτηση αρχείων" + }, + "image": { + "gallery": "Συλλογή εικόνων", + "search": "Αναζήτηση εικόνας", + "selected-images": "{ count, plural, =1 {1 εικόνα} other {# εικόνες} } επιλεγμένες", + "created-time": "Ημερομηνία δημιουργίας", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "resolution": "Ανάλυση", + "size": "Μέγεθος", + "system": "Σύστημα", + "download-image": "Λήψη εικόνας", + "export-image": "Εξαγωγή εικόνας σε JSON", + "import-image": "Εισαγωγή εικόνας από JSON", + "upload-image": "Ανέβασμα εικόνας", + "edit-image": "Επεξεργασία εικόνας", + "image-details": "Λεπτομέρειες εικόνας", + "no-images": "Δεν βρέθηκαν εικόνες", + "delete-image": "Διαγραφή εικόνας", + "delete-image-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την εικόνα '{{imageTitle}}';", + "delete-image-text": "Προσοχή, μετά την επιβεβαίωση η εικόνα δεν θα μπορεί να ανακτηθεί.", + "delete-images-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 εικόνα} other {# εικόνες} };", + "delete-images-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες εικόνες θα διαγραφούν και όλα τα σχετικά δεδομένα θα χαθούν.", + "list-mode": "Προβολή λίστας", + "grid-mode": "Προβολή πλέγματος", + "image-preview": "Προεπισκόπηση εικόνας", + "update-image": "Ενημέρωση εικόνας", + "export-failed-error": "Αδυναμία εξαγωγής εικόνας: {{error}}", + "image-json-file": "Αρχείο εικόνας JSON", + "invalid-image-json-file-error": "Αδυναμία εισαγωγής εικόνας από JSON: Μη έγκυρη δομή δεδομένων εικόνας.", + "image-is-in-use": "Η εικόνα χρησιμοποιείται από άλλες οντότητες", + "images-are-in-use": "Οι εικόνες χρησιμοποιούνται από άλλες οντότητες", + "image-is-in-use-text": "Η εικόνα '{{title}}' δεν διαγράφηκε επειδή χρησιμοποιείται από τις εξής οντότητες:", + "images-are-in-use-text": "Δεν διαγράφηκαν όλες οι εικόνες επειδή χρησιμοποιούνται από άλλες οντότητες.
    Μπορείτε να δείτε τις αναφερόμενες οντότητες κάνοντας κλικ στο κουμπί Αναφορές στη σχετική σειρά εικόνας.
    Αν θέλετε να διαγράψετε τις εικόνες, επιλέξτε τις και κάντε κλικ στο κουμπί Διαγραφή επιλεγμένων.", + "delete-image-in-use-text": "Αν θέλετε να διαγράψετε την εικόνα, κάντε κλικ στο κουμπί Διαγραφή ούτως ή άλλως.", + "system-entities": "Οντότητες συστήματος:", + "entities": "οντότητες:", + "references": "Αναφορές", + "include-system-images": "Συμπερίληψη εικόνων συστήματος", + "clear-image": "Αφαίρεση εικόνας", + "no-image": "Καμία εικόνα", + "no-image-selected": "Δεν έχει επιλεγεί εικόνα", + "browse-from-gallery": "Αναζήτηση από συλλογή", + "set-link": "Ορισμός συνδέσμου", + "image-link": "Σύνδεσμος εικόνας", + "link": "Σύνδεσμος", + "copy-image-link": "Αντιγραφή συνδέσμου εικόνας", + "embed-image": "Ενσωμάτωση εικόνας", + "embed-to-html": "Ενσωμάτωση σε HTML", + "embed-to-html-hint": "Αυτή η δυνατότητα θα καταστήσει το σύνδεσμο προσβάσιμο σε μη εξουσιοδοτημένους χρήστες.", + "embed-to-html-text": "Χρησιμοποιώντας το παρακάτω απόσπασμα κώδικα, μπορείτε να ενσωματώσετε μια εικόνα σε στοιχεία που βασίζονται σε απλό HTML.
    Αυτά τα στοιχεία περιλαμβάνουν widgets HTML, συναρτήσεις περιεχομένου κελιών, κλπ.", + "embed-to-angular-template": "Ενσωμάτωση σε Angular HTML πρότυπο", + "embed-to-angular-template-text": "Χρησιμοποιώντας το παρακάτω απόσπασμα κώδικα, μπορείτε να ενσωματώσετε μια εικόνα στο Angular HTML πρότυπο που χρησιμοποιείται από τα στοιχεία.
    Αυτά περιλαμβάνουν το widget Markdown, το HTML section στον επεξεργαστή widget, προσαρμοσμένες ενέργειες, κλπ." + }, + "image-input": { + "drop-images-or": "Σύρετε και αποθέστε εικόνες ή", + "drag-and-drop": "Σύρετε & Αποθέστε", + "or": "ή", + "browse": "Αναζήτηση", + "no-images": "Δεν έχουν επιλεγεί εικόνες", + "images": "εικόνες" + }, "import": { - "no-file": "Δεν έχει επιλεχθεί αρχείο", - "drop-file": "Αποθέστε ένα αρχείο JSON ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", - "drop-csv-file": "Αποθέστε ένα αρχείο CVS ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", - "drop-file-csv": "Αποθέστε ένα αρχείο CSV ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", + "no-file": "Δεν έχει επιλεγεί αρχείο", + "drop-file": "Αποθέστε ένα αρχείο JSON ή κάντε κλικ για να επιλέξετε ένα αρχείο για αποστολή.", + "drop-json-file-or": "Σύρετε και αποθέστε ένα αρχείο JSON ή", + "drop-file-csv": "Αποθέστε ένα αρχείο CSV ή κάντε κλικ για να επιλέξετε ένα αρχείο για αποστολή.", + "drop-file-csv-or": "Σύρετε και αποθέστε ένα αρχείο CSV ή", "column-value": "Τιμή", "column-title": "Τίτλος", - "column-example": "Παράδειγμα δεδομένων", - "column-key": "Κλειδί χαρακτηριστικού/τηλεμετρίας", - "csv-delimiter": "Οριοθέτηση CSV", - "csv-first-line-header": "Η πρώτη σειρά περιέχει ονόματα στηλών", - "csv-update-data": "Ανανέωση χαρακτηριστικών/τηλεμετρίας", - "import-csv-number-columns-error": "Ένα αρχείο θα πρέπει να περιέχει τουλάχιστον δυο στήλες", - "import-csv-invalid-format-error": "Μη έγκυρη μορφή αρχείου. Σειρά: '{{line}}'", + "column-example": "Παράδειγμα δεδομένων τιμής", + "column-key": "Κλειδί χαρακτηριστικού/χρονοσειράς", + "credentials": "Διαπιστευτήρια", + "csv-delimiter": "Διαχωριστικό CSV", + "csv-first-line-header": "Η πρώτη γραμμή περιέχει ονόματα στηλών", + "csv-update-data": "Ενημέρωση χαρακτηριστικών/χρονοσειρών", + "details": "Λεπτομέρειες", + "import-csv-number-columns-error": "Ένα αρχείο πρέπει να περιέχει τουλάχιστον δύο στήλες", + "import-csv-invalid-format-error": "Μη έγκυρη μορφή αρχείου. Γραμμή: '{{line}}'", "column-type": { "name": "Όνομα", "type": "Τύπος", + "label": "Ετικέτα", "column-type": "Τύπος στήλης", - "client-attribute": "Χαρακτηριστικό Client", - "shared-attribute": "Χαρακτηριστικό Shared", - "server-attribute": "Χαρακτηριστικό Server", - "timeseries": "Χρονική σειρά", - "entity-field": "Πεδίο Οντότητας", - "access-token": "Διακριτικό πρόσβασης" + "client-attribute": "Χαρακτηριστικό πελάτη", + "shared-attribute": "Κοινό χαρακτηριστικό", + "server-attribute": "Χαρακτηριστικό διακομιστή", + "timeseries": "Χρονοσειρά", + "entity-field": "Πεδίο οντότητας", + "access-token": "Διακριτικό πρόσβασης", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT αναγνωριστικό πελάτη", + "user-name": "MQTT όνομα χρήστη", + "password": "MQTT κωδικός πρόσβασης" + }, + "lwm2m": { + "client-endpoint": "Όνομα πελάτη LwM2M", + "security-config-mode": "Λειτουργία διαμόρφωσης ασφάλειας LwM2M", + "client-identity": "Ταυτότητα πελάτη LwM2M", + "client-key": "Κλειδί πελάτη LwM2M", + "client-cert": "Δημόσιο κλειδί πελάτη LwM2M", + "bootstrap-server-security-mode": "Λειτουργία ασφάλειας διακομιστή εκκίνησης LwM2M", + "bootstrap-server-secret-key": "Μυστικό κλειδί διακομιστή εκκίνησης LwM2M", + "bootstrap-server-public-key-id": "Δημόσιο κλειδί ή ταυτότητα διακομιστή εκκίνησης LwM2M", + "lwm2m-server-security-mode": "Λειτουργία ασφάλειας διακομιστή LwM2M", + "lwm2m-server-secret-key": "Μυστικό κλειδί διακομιστή LwM2M", + "lwm2m-server-public-key-id": "Δημόσιο κλειδί ή ταυτότητα διακομιστή LwM2M" + }, + "snmp": { + "host": "SNMP host", + "port": "SNMP port", + "version": "SNMP έκδοση (v1, v2c ή v3)", + "community-string": "SNMP κοινόχρηστη συμβολοσειρά" + }, + "isgateway": "Είναι Πύλη", + "activity-time-from-gateway-device": "Χρόνος δραστηριότητας από συσκευή πύλης", + "description": "Περιγραφή", + "routing-key": "Κλειδί Edge", + "secret": "Μυστικό Edge" }, "stepper-text": { - "select-file": "Επιλογή αρχείου", - "configuration": "Εισαγωγή ρύθμισης", - "column-type": "Επιλογή τύπου στήλης", + "select-file": "Επιλέξτε ένα αρχείο", + "configuration": "Διαμόρφωση εισαγωγής", + "column-type": "Επιλέξτε τύπο στηλών", "creat-entities": "Δημιουργία νέων οντοτήτων" }, "message": { "create-entities": "{{count}} νέες οντότητες δημιουργήθηκαν με επιτυχία.", "update-entities": "{{count}} οντότητες ενημερώθηκαν με επιτυχία.", - "error-entities": "Υπήρξε κάποιο σφάλμα κατά τη δημιουργία {{count}} οντοτήτων." + "error-entities": "Παρουσιάστηκε σφάλμα κατά τη δημιουργία {{count}} οντοτήτων." } }, - "integration": { - "integration": "Ενσωμάτωση", - "integrations": "Ενσωματώσεις", - "select-integration": "Επιλογή ενσωμάτωσης", - "no-integrations-matching": "Δεν βρέθηκαν ενσωματώσεις πο να αντιστοιχούν σε '{{entity}}'.", - "integration-required": "Απαιτείται ενσωμάτωση", - "delete": "Διαγραφή ενσωμάτωσης", - "management": "Διαχείριση Ενσωματώσεων", - "add-integration-text": "Προσθήκη νέας ενσωμάτωσης", - "no-integrations-text": "Δεν βρέθηκαν ενσωματώσεις", - "selected-integrations": "{ count, plural, =1 {1 integration} other {# integrations} } επιλέχθηκαν", - "delete-integration-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την ενσωμάτωση '{{integrationName}}';", - "delete-integration-text": "Προσοχή, μετά την επιβεβαίωση, η ενσωμάτωση και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-integrations-title": "Είστε σίγουροι ότι θέλετε { count, plural, =1 {1 integration} other {# integrations} };", - "delete-integrations-action-title": "Διαγραφή { count, plural, =1 {1 integration} other {# integrations} }", - "delete-integrations-text": "Προσοχή, αφού ολοκληρωθεί η επιβεβαίωση, όλες οι επιλεγμένες ενσωματώσεις θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "events": "Γεγονότα", - "add": "Προσθήκη Ενσωμάτωσης", - "integration-details": "Λεπτομέρειες Ενσωμάτωσης", - "details": "Λεπτομέρειες", - "copyId": "Αντιγραη ID ενσωμάτωσης", - "idCopiedMessage": "Το ID της ενσωμάτωσης έχει αντιγραφεί στο πρόχειρο.", - "debug-mode": "Λειτουργία απασφαλμάτωσης", - "enable-security": "Ενεργοποίηση ασφάλειας", - "headers-filter": "Φίλτρο Headers", - "header": "Header", - "no-headers-filter": "Όχι φίλτρο headers", - "downlink-url": "URL Λήψης", - "application-uri": "URI Εφαρμογής", - "as-id": "AS ID", - "as-id-required": "Απαιτείται AS ID.", - "as-key": "AS Key", - "as-key-required": "Απαιτείται AS Key.", - "max-time-diff-in-seconds": "Μέγιστη χρονική διαφορά (δευτερόλεπτα)", - "max-time-diff-in-seconds-required": "Απαιτείται μέγιστη χρονική διαφορά.", - "name": "Όνομα", - "name-required": "Απαιτείται όνομα.", + "scada": { + "symbols": "Σύμβολα SCADA", + "search": "Αναζήτηση συμβόλου", + "selected-symbols": "{ count, plural, =1 {1 σύμβολο} other {# σύμβολα} } επιλέχθηκαν", + "download-symbol": "Λήψη συμβόλου SCADA", + "export-symbol": "Εξαγωγή συμβόλου SCADA σε JSON", + "import-symbol": "Εισαγωγή συμβόλου SCADA από JSON", + "upload-symbol": "Μεταφόρτωση συμβόλου SCADA", + "update-symbol": "Ενημέρωση συμβόλου SCADA", + "edit-symbol": "Επεξεργασία συμβόλου SCADA", + "symbol-details": "Λεπτομέρειες συμβόλου SCADA", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "Δεν βρέθηκαν σύμβολα", + "show-hidden-elements": "Εμφάνιση κρυφών στοιχείων", + "hide-hidden-elements": "Απόκρυψη κρυφών στοιχείων", + "delete-symbol": "Διαγραφή συμβόλου SCADA", + "delete-symbol-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το σύμβολο SCADA '{{imageTitle}}';", + "delete-symbol-text": "Μετά την επιβεβαίωση το σύμβολο SCADA δεν θα ανακτήσιμο.", + "delete-symbols-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 σύμβολο SCADA} other {# σύμβολα SCADA} };", + "delete-symbols-text": "Μετά την επιβεβαίωση όλα τα επιλεγμένα σύμβολα SCADA θα διαγραφούν και δεν θα ανακτηθούν.", + "include-system-symbols": "Συμπερίληψη συμβόλων συστήματος", + "symbol-preview": "Προεπισκόπηση συμβόλου", + "general": "Γενικά", + "tags": "Ετικέτες", + "properties": "Ιδιότητες", + "title": "Τίτλος", "description": "Περιγραφή", - "base-url": "Base URL", - "base-url-required": "Απαιτείται Base URL", - "security-key": "Κλειδί ασφάλειας", - "http-endpoint": "HTTP endpoint URL", - "copy-http-endpoint-url": "Αντιγραφή HTTP endpoint URL", - "http-endpoint-url-copied-message": "Το HTTP endpoint URL έχει αντιγραφεί στο πρόχειρο", - "host": "Host", - "host-required": "Απαιτείται Host.", - "host-type": "Τύπος Host", - "host-type-required": "Απαιτείται τύπος Host.", - "custom-host": "Προσαρμοσμένο host", - "custom-host-required": "Απαιτείται προσαρμοσμένο host.", - "port": "Port", - "port-required": "Απαιτείται Port.", - "port-range": "Η Port πρέπει να κυμαίνεται από 1 ως 65535.", - "connect-timeout": "Λήξη σύνδεσης (δευτερόλεπτα)", - "connect-timeout-required": "Απαιτείται λήξη σύνδεσης.", - "connect-timeout-range": "Η λήξη σύνδεσης πρέπει να κυμαίνεται από 1 ως 200.", - "client-id": "Client ID", - "clean-session": "Καθαρή συνεδρία session", - "enable-ssl": "Ενεργοποίηση SSL", - "credentials": "Διαπιστευτήρια", - "credentials-type": "Τύπος διαπιστευτηρίων", - "credentials-type-required": "Απαιτείται τύπος διαπιστευτηρίων.", - "username": "Όνομα Χρήστη", - "username-required": "Απαιτείται όνομα χρήστη.", - "password": "Κωδικός", - "password-required": "Απαιτείται κωδικός.", - "ca-cert": "Αρχείο πιστοποιητικού CA *", - "private-key": "Αρχείο ιδιωτικού κλειδιού *", - "private-key-password": "κωδικός ιδιωτικού κλειδιού", - "cert": "Αρχείο πιστοποιητικού *", - "no-file": "Δεν έχει επιλεχθεί αρχείο.", - "drop-file": "Αποθέστε αρχείο ή κάνετε κλικ για να επιλέξετε ένα αρχείο προς ανέβασμα.", - "topic-filters": "Φίτρο θέματος", - "remove-topic-filter": "Αφαίρεση φίλτρου θέματος", - "add-topic-filter": "προσθήκη φίλτρου θέματος", - "add-topic-filter-prompt": "παρακαλούμε προσθέστε φίλτρο θέματος", - "topic": "Θέμα", - "mqtt-qos": "QoS", - "mqtt-qos-at-most-once": "Το πολύ μια", - "mqtt-qos-at-least-once": "Τουλάχιστον μια", - "mqtt-qos-exactly-once": "Ακριβώς μια", - "downlink-topic-pattern": "Μοτίβο θέματος σύνδεσης", - "downlink-topic-pattern-required": "Απαιτείται μοτίβο θέματος σύνδεσης.", - "aws-iot-endpoint": "AWS IoT Endpoint", - "aws-iot-endpoint-required": "Απαιτείται AWS IoT Endpoint.", - "aws-iot-credentials": "AWS IoT Διαπιστευτήρια", - "application-credentials": "Διαπιστευτήρια Εφαρμογής", - "api-key": "API Key", - "api-key-required": "Απαιτείται API Key.", - "auth-token": "Τεκμίριο Ταυτοποίησης", - "auth-token-required": "Απαιτείται τεκμίριο ταυτοποίησης", - "region": "Περιοχή", - "region-required": "Απαιτείται περιοχή.", - "application-id": "ID Εφαρμογής", - "application-id-required": "Απαιτείται ID εφαρμογής.", - "access-key": "Κλειδί πρόσβασης", - "access-key-required": "Απαιτείται κλειδί πρόσβασης.", - "connection-parameters": "Παράμετροι σύνδεσης", - "service-bus-namespace-name": "Service Bus Namespace Name", - "service-bus-namespace-name-required": "Απαιτείται Service Bus Namespace Name.", - "event-hub-name": "Όνομα Event Hub", - "event-hub-name-required": "Απαιτείται όνομα Event Hub.", - "sas-key-name": "Όνομα SAS Key", - "sas-key-name-required": "Απαιτείται όνομα SAS Key.", - "sas-key": "SAS Key", - "sas-key-required": "Απαιτείται SAS Key.", - "iot-hub-name": "IoT Hub Name (απαιτείται για downlink)", - "metadata": "Μεταδεδομένα", - "type": "Τύπος", - "type-required": "Απαιτείται τύπος.", - "uplink-converter": "Μετατροπέας δεδομένων uplink", - "uplink-converter-required": "Απαιτείται μετατροπέας δεδομένων uplink.", - "downlink-converter": "Μετατροπέας δεδομένων downlink", - "type-http": "HTTP", - "type-ocean-connect": "OceanConnect", - "type-sigfox": "SigFox", - "type-thingpark": "ThingPark", - "type-tmobile-iot-cdp": "T-Mobile – IoT CDP", - "type-mqtt": "MQTT", - "type-aws-iot": "AWS IoT", - "type-ibm-watson-iot": "IBM Watson IoT", - "type-ttn": "TheThingsNetwork", - "type-azure-event-hub": "Azure Event Hub", - "type-opc-ua": "OPC-UA", - "opc-ua-application-name": "Όνομα εφαρμογής", - "opc-ua-application-uri": "URI Εφαρμογής", - "opc-ua-scan-period-in-seconds": "Περίοδος σάρωσης σε δευτερόλεπτα", - "opc-ua-scan-period-in-seconds-required": "Απαιτείται περίοδος σάρωσης", - "opc-ua-timeout": "Λήξη χρόνου σε χιλιοστά του δευτερολέπτου", - "opc-ua-timeout-required": "Απαιτείται λήξη χρόνου", - "opc-ua-security": "Ασφάλεια", - "opc-ua-security-required": "Απαιτειται ασφάλεια", - "opc-ua-identity": "Ταυτότητα", - "opc-ua-identity-required": "Απαιτείται ταυτότητα", - "opc-ua-keystore": "Keystore", - "add-opc-ua-keystore-prompt": "Παρακαλούμε προσθέστε αρχείο keystore", - "opc-ua-keystore-required": "Απαιτείται keystore", - "opc-ua-type": "Τύπος", - "opc-ua-keystore-type": "Τύπος", - "opc-ua-keystore-type-required": "Απαιτέιται τύπος", - "opc-ua-keystore-location": "Τοποθεσία *", - "opc-ua-keystore-password": "Κωδικός", - "opc-ua-keystore-password-required": "Απαιτείται κωδικός", - "opc-ua-keystore-alias": "Ψευδώνυμο", - "opc-ua-keystore-alias-required": "Απαιτείται ψευδώνυμο", - "opc-ua-keystore-key-password": "Κλειδί κωδικού", - "opc-ua-keystore-key-password-required": "Απαιτείται κλειδί κωδικού", - "opc-ua-mapping": "χαρτογράφιση", - "add-opc-ua-mapping-prompt": "Παρακαλούμε προσθέστε χαρτογράφιση", - "opc-ua-mapping-type": "Τύπος χαρτογράφισης", - "opc-ua-mapping-type-required": "Απαιτείται τύπος χαρτογράφισης", - "opc-ua-device-node-pattern": "Μοτίβο Κόμβου Συσκευής", - "opc-ua-device-node-pattern-required": "Απαιτείται Μοτίβο Κόμβου Συσκευής", - "opc-ua-namespace": "Namespace", - "opc-ua-add-map": "Προσθήκη στοιχείου χαρτογράφισης", - "subscription-tags": "Ετικέτες εγγραφής", - "remove-subscription-tag": "Αφαίρεση ετικέτας εγγραφής", - "add-subscription-tag": "Προσθήκη ετικέτας εγγραφής", - "add-subscription-tag-prompt": "Παρακαλούμε προσθέστε ετικέτα εγγραφής", - "key": "Κλειδί", - "path": "Μονοπάτι", - "required": "Απαιτείται" + "search-tags": "Αναζήτηση ετικετών", + "widget-size": "Μέγεθος widget", + "cols": "στήλες", + "rows": "γραμμές", + "state-render-function": "Λειτουργία απόδοσης κατάστασης", + "preview": "Προεπισκόπηση", + "preview-widget-action-text": "Η ενέργεια widget '{{type}}' εκτελέστηκε επιτυχώς!", + "no-symbol": "Δεν υπάρχει σύμβολο SCADA", + "no-symbol-selected": "Δεν έχει επιλεγεί σύμβολο SCADA", + "clear-symbol": "Εκκαθάριση συμβόλου SCADA", + "browse-symbol-from-gallery": "Αναζήτηση συμβόλου SCADA από τη συλλογή", + "zoom-in": "Μεγέθυνση", + "zoom-out": "Σμίκρυνση", + "create-widget": "Δημιουργία widget", + "create-widget-from-symbol": "Δημιουργία widget από σύμβολο SCADA", + "hidden": "κρυφό", + "tag": { + "tag": "Ετικέτα", + "on-click-action": "Ενέργεια κατά το κλικ", + "no-tags": "Δεν έχουν ρυθμιστεί ετικέτες", + "delete-tag-text": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την ετικέτα
    {{tag}} από το στοιχείο {{elementType}};", + "update-tag": "Ενημέρωση ετικέτας", + "enter-tag": "Εισαγωγή ετικέτας", + "tag-settings": "Ρυθμίσεις ετικέτας", + "remove-tag": "Αφαίρεση ετικέτας", + "add-tag": "Προσθήκη ετικέτας" + }, + "behavior": { + "behavior": "Συμπεριφορά", + "id": "Id", + "name": "Όνομα", + "type": "Τύπος", + "no-behaviors": "Δεν έχουν ρυθμιστεί συμπεριφορές", + "add-behavior": "Προσθήκη συμπεριφοράς", + "type-action": "Ενέργεια", + "type-value": "Τιμή", + "type-widget-action": "Ενέργεια widget", + "behavior-settings": "Ρυθμίσεις συμπεριφοράς", + "remove-behavior": "Αφαίρεση συμπεριφοράς", + "hint": "Συμβουλή", + "group-title": "Τίτλος ομάδας", + "value-type": "Τύπος τιμής", + "default-value": "Προεπιλεγμένη τιμή", + "true-label": "Ετικέτα true", + "false-label": "Ετικέτα false", + "state-label": "Ετικέτα κατάστασης", + "default-payload": "Προεπιλεγμένο payload", + "not-unique-behavior-ids-error": "Τα Id συμπεριφορών πρέπει να είναι μοναδικά!", + "default-settings": "Προεπιλεγμένες ρυθμίσεις" + }, + "symbol": { + "symbol": "Σύμβολο SCADA", + "fluid-presence": "Παρουσία υγρού", + "fluid-presence-hint": "Δηλώνει εάν υπάρχει υγρό στον σωλήνα.", + "fluid-present": "Υγρό παρόν", + "present": "Παρόν", + "absent": "Απόν", + "flow-presence": "Παρουσία ροής", + "flow-presence-hint": "Δηλώνει εάν υπάρχει ροή υγρού στον σωλήνα.", + "flow-present": "Ροή παρούσα", + "flow-direction": "Κατεύθυνση ροής", + "flow-direction-hint": "Δηλώνει την κατεύθυνση ροής του υγρού.", + "forward": "Εμπρός", + "reverse": "Αντίστροφα", + "flow-animation-speed": "Ταχύτητα κινούμενης ροής", + "flow-animation-speed-hint": "Διπλή τιμή για την ταχύτητα κίνησης. 1 - κανονική, 0 - καμία, < 1 - πιο αργή, > 1 - πιο γρήγορη.", + "leak": "Διαρροή", + "leak-hint": "Δηλώνει εάν υπάρχει διαρροή στον σωλήνα.", + "leak-present": "Διαρροή παρούσα", + "fluid-color": "Χρώμα υγρού", + "pipe-color": "Χρώμα σωλήνα", + "horizontal-pipe": "Οριζόντιος σωλήνας", + "vertical-pipe": "Κατακόρυφος σωλήνας", + "horizontal-fluid-color": "Χρώμα υγρού (οριζόντιο)", + "vertical-fluid-color": "Χρώμα υγρού (κατακόρυφο)", + "left-pipe": "Αριστερός σωλήνας", + "right-pipe": "Δεξιός σωλήνας", + "top-pipe": "Άνω σωλήνας", + "bottom-pipe": "Κάτω σωλήνας", + "left-fluid-color": "Αριστερό χρώμα υγρού", + "right-fluid-color": "Δεξί χρώμα υγρού", + "top-fluid-color": "Άνω χρώμα υγρού", + "bottom-fluid-color": "Κάτω χρώμα υγρού", + "display": "Οθόνη", + "display-format": "Μορφή εμφάνισης", + "value": "Τιμή", + "decimals": "Δεκαδικά", + "units": "Μονάδες", + "flow-meter-value-hint": "Διπλή τιμή στην ένδειξη ροόμετρου", + "value-hint": "Διπλή τιμή που δηλώνει την τρέχουσα τιμή", + "running": "Σε λειτουργία", + "running-hint": "Δηλώνει εάν το εξάρτημα είναι σε λειτουργία.", + "warning-state": "Κατάσταση προειδοποίησης", + "warning": "Προειδοποίηση", + "warning-click": "Κλικ προειδοποίησης", + "warning-state-hint": "Δηλώνει εάν το εξάρτημα είναι σε κατάσταση προειδοποίησης.", + "critical-state": "Κρίσιμη κατάσταση", + "critical": "Κρίσιμο", + "critical-click": "Κλικ κρίσιμης κατάστασης", + "critical-state-hint": "Δηλώνει εάν το εξάρτημα είναι σε κρίσιμη κατάσταση.", + "critical-state-animation": "Κινούμενη εικόνα κρίσιμης κατάστασης", + "critical-state-animation-hint": "Ενεργοποίηση εφέ αναβοσβήματος σε κρίσιμη κατάσταση.", + "warning-critical-state-animation": "Κινούμενη εικόνα προειδοποίησης/κρίσιμης κατάστασης", + "warning-critical-state-animation-hint": "Ενεργοποίηση αναβοσβήματος όταν υπάρχει προειδοποίηση ή κρίσιμη κατάσταση.", + "animation": "Κινούμενη εικόνα", + "broken": "Κατεστραμμένο", + "broken-hint": "Δηλώνει εάν το εξάρτημα είναι κατεστραμμένο.", + "on-display-click": "Κατά το κλικ στην οθόνη", + "on-display-click-hint": "Ενέργεια κατά το κλικ του χρήστη στην οθόνη.", + "pipe": "Σωλήνας", + "default-border-color": "Προεπιλεγμένο χρώμα περιγράμματος", + "active-border-color": "Ενεργό χρώμα περιγράμματος", + "warning-border-color": "Χρώμα περιγράμματος προειδοποίησης", + "critical-border-color": "Χρώμα περιγράμματος κρίσης", + "background-color": "Χρώμα φόντου", + "rotation-animation-speed": "Ταχύτητα περιστροφικής κινούμενης εικόνας", + "rotation-animation-speed-hint": "Διπλή τιμή που δείχνει την ταχύτητα περιστροφής. 1 - κανονική, 0 - χωρίς κινούμενη εικόνα, < 1 - πιο αργή, > 1 - πιο γρήγορη.", + "on-click": "Κατά το κλικ", + "on-click-hint": "Ενέργεια που καλείται όταν ο χρήστης κάνει κλικ στο στοιχείο.", + "connectors-positions": "Θέσεις συνδέσμων", + "right-connector": "Δεξιός σύνδεσμος", + "right-top-connector": "Άνω δεξιός σύνδεσμος", + "right-bottom-connector": "Κάτω δεξιός σύνδεσμος", + "left-connector": "Αριστερός σύνδεσμος", + "left-top-connector": "Άνω αριστερός σύνδεσμος", + "left-bottom-connector": "Κάτω αριστερός σύνδεσμος", + "top-left-connector": "Άνω αριστερός σύνδεσμος", + "top-right-connector": "Άνω δεξιός σύνδεσμος", + "top-connector": "Άνω σύνδεσμος", + "bottom-connector": "Κάτω σύνδεσμος", + "running-color": "Χρώμα λειτουργίας", + "stopped-color": "Χρώμα διακοπής", + "stopped": "Διακοπή", + "warning-color": "Χρώμα προειδοποίησης", + "critical-color": "Χρώμα κρίσης", + "opened": "Ανοιχτό", + "opened-hint": "Υποδεικνύει αν το στοιχείο είναι σε κατάσταση ανοίγματος.", + "open": "Άνοιγμα", + "open-hint": "Ενέργεια που καλείται όταν ο χρήστης κάνει κλικ για άνοιγμα.", + "close": "Κλείσιμο", + "close-hint": "Ενέργεια που καλείται όταν ο χρήστης κάνει κλικ για κλείσιμο.", + "close-state-animation": "Κινούμενη εικόνα κατάστασης κλεισίματος", + "close-state-animation-hint": "Ενεργοποίηση αναβοσβήματος όταν το στοιχείο είναι κλειστό.", + "opened-color": "Χρώμα ανοίγματος", + "closed-color": "Χρώμα κλεισίματος", + "opened-rotation-angle": "Γωνία περιστροφής (ανοιχτό)", + "closed-rotation-angle": "Γωνία περιστροφής (κλειστό)", + "tank-capacity": "Χωρητικότητα δεξαμενής", + "tank-capacity-hint": "Διπλή τιμή που δείχνει τη συνολική χωρητικότητα.", + "current-volume": "Τρέχων όγκος", + "current-volume-hint": "Διπλή τιμή που δείχνει τον τρέχοντα κατειλημμένο όγκο.", + "tank-color": "Χρώμα δεξαμενής", + "value-box": "Πλαίσιο τιμής", + "value-text": "Κείμενο τιμής", + "scale": "Κλίμακα", + "transparent-mode": "Διαφανής λειτουργία", + "major-ticks": "Κύριες διαβαθμίσεις", + "intervals": "Διαστήματα", + "major-ticks-color": "Χρώμα κύριων διαβαθμίσεων", + "normal": "Κανονικό", + "minor-ticks": "Δευτερεύουσες διαβαθμίσεις", + "minor-ticks-color": "Χρώμα δευτερευουσών διαβαθμίσεων", + "temperature": "Θερμοκρασία", + "temperature-hint": "Διπλή τιμή που δείχνει την τρέχουσα θερμοκρασία.", + "update-temperature": "Ενημέρωση θερμοκρασίας", + "update-temperature-hint": "Ενέργεια κατά το κλικ για αλλαγή θερμοκρασίας.", + "run": "Εκκίνηση", + "run-hint": "Ενέργεια κατά το κλικ για εκκίνηση στοιχείου.", + "stop": "Διακοπή", + "stop-hint": "Ενέργεια κατά το κλικ για διακοπή στοιχείου.", + "temperature-step": "Βήμα αύξησης θερμοκρασίας", + "heat-pump-color": "Χρώμα αντλίας θερμότητας", + "power-button-background": "Φόντο κουμπιού ισχύος", + "value-box-background": "Φόντο πλαισίου τιμής", + "value-units": "Μονάδες τιμής", + "filtration-mode": "Λειτουργία φιλτραρίσματος", + "filtration-mode-hint": "Ακέραια τιμή που δηλώνει την τρέχουσα λειτουργία.", + "filtration-mode-update": "Κατάσταση ενημέρωσης λειτουργίας", + "filtration-mode-update-hint": "Ενέργεια κατά το κλικ για αλλαγή λειτουργίας.", + "filter-mode": "Φίλτρο", + "waste-mode": "Απόβλητα", + "backwash-mode": "Αντίστροφη πλύση", + "recirculate-mode": "Ανακύκλωση", + "rinse-mode": "Ξέβγαλμα", + "closed-mode": "Κλειστό", + "sand-filter-color": "Χρώμα φίλτρου άμμου", + "mode-box-background": "Φόντο πλαισίου λειτουργίας", + "border-color": "Χρώμα περιγράμματος", + "label-color": "Χρώμα ετικέτας", + "water-leak-hint": "Υποδεικνύει αν υπάρχει διαρροή.", + "default-color": "Προεπιλεγμένο χρώμα", + "leak-color": "Χρώμα διαρροής", + "full-value": "Πλήρης τιμή", + "full-value-hint": "Διπλή τιμή που δείχνει την πλήρη ποσότητα.", + "label": "Ετικέτα", + "icon": "Εικονίδιο", + "button-color": "Χρώμα κουμπιού", + "on-label": "Κείμενο για 'Ενεργό'", + "off-label": "Κείμενο για 'Ανενεργό'", + "arrow-presence": "Παρουσία βέλους", + "arrow-presence-hint": "Υποδεικνύει αν υπάρχει βέλος στον σύνδεσμο.", + "arrow-present": "Το βέλος είναι παρόν", + "arrow-direction": "Κατεύθυνση ροής", + "arrow-direction-hint": "Υποδεικνύει την κατεύθυνση της ροής.", + "flow-animation": "Παρουσία ροής", + "flow-animation-hint": "Υποδεικνύει αν το υγρό ρέει στον σύνδεσμο.", + "flow": "Ροή", + "flow-line": "Γραμμή", + "flow-line-style": "Στυλ γραμμής", + "flow-style-hint": "Ορίστε τιμές Dash και Gap ώστε το άθροισμά τους να διαιρείται ακριβώς με το 100 για άψογη συγχρονισμένη κινούμενη εικόνα.", + "flow-dash-cap": "Καπάκι παύλας", + "dash-cap-butt": "Επίπεδο", + "dash-cap-round": "Στρογγυλό", + "dash-cap-square": "Τετράγωνο", + "dash": "Παύλα", + "gap": "Κενό", + "main-line": "Κύρια γραμμή", + "line": "Γραμμή", + "line-color": "Χρώμα γραμμής", + "arrow-color": "Χρώμα βέλους", + "target-value": "Τιμή στόχου", + "target-value-hint": "Υποδεικνύει σημείο στόχο στην κλίμακα.", + "min-max-value": "Ελάχιστη και μέγιστη τιμή", + "min-value": "Ελάχιστη", + "max-value": "Μέγιστη", + "progress-bar": "Γραμμή προόδου", + "progress-arrow": "Βέλος προόδου", + "warning-scale-color": "Χρώμα κλίμακας προειδοποίησης", + "critical-scale-color": "Χρώμα κλίμακας κρίσης", + "scale-color": "Χρώμα κλίμακας", + "target": "Στόχος", + "high-warning-state": "Κατάσταση υψηλής προειδοποίησης", + "show-high-warning-scale": "Εμφάνιση κλίμακας υψηλής προειδοποίησης", + "high-warning-scale": "Κλίμακα υψηλής προειδοποίησης", + "high-warning-state-hint": "Διπλή τιμή που υποδεικνύει περιοχή υψηλής προειδοποίησης έως κρίσιμη ή μέγιστη τιμή.", + "low-warning-state": "Κατάσταση χαμηλής προειδοποίησης", + "show-low-warning-scale": "Εμφάνιση κλίμακας χαμηλής προειδοποίησης", + "low-warning-scale": "Κλίμακα χαμηλής προειδοποίησης", + "low-warning-state-hint": "Διπλή τιμή που υποδεικνύει περιοχή χαμηλής προειδοποίησης έως κρίσιμη ή ελάχιστη τιμή.", + "high-critical-state": "Κατάσταση υψηλής κρίσης", + "show-high-critical-scale": "Εμφάνιση κλίμακας υψηλής κρίσης", + "high-critical-scale": "Κλίμακα υψηλής κρίσης", + "high-critical-state-hint": "Διπλή τιμή που υποδεικνύει περιοχή υψηλής κρίσης έως μέγιστη τιμή.", + "low-critical-state": "Κατάσταση χαμηλής κρίσης", + "show-low-critical-scale": "Εμφάνιση κατάστασης χαμηλής κρίσης", + "low-critical-scale": "Κατάσταση χαμηλής κρίσης", + "low-critical-state-hint": "Διπλή τιμή που υποδεικνύει περιοχή χαμηλής κρίσης έως ελάχιστη τιμή.", + "filter-color": "Χρώμα φίλτρου", + "colors": "Χρώματα", + "indicator-colors": "Χρώματα ενδείξεων", + "enabled": "Ενεργοποιημένο", + "disabled": "Απενεργοποιημένο", + "on": "ΕΝΕΡΓΟ", + "off": "ΑΝΕΝΕΡΓΟ", + "on-off-state": "Κατάσταση ενεργοποίησης/απενεργοποίησης", + "on-off-state-hint": "Υποδεικνύει αν το στοιχείο είναι ενεργοποιημένο ή όχι.", + "on-update-state": "Κατάσταση ενημέρωσης σε ενεργό", + "on-update-state-hint": "Ενέργεια κατά το κλικ για ενημέρωση κατάστασης σε ενεργό.", + "off-update-state": "Κατάσταση ενημέρωσης σε ανενεργό", + "off-update-state-hint": "Ενέργεια κατά το κλικ για ενημέρωση κατάστασης σε ανενεργό.", + "voltage": "Τάση", + "input-voltage": "Τάση εισόδου", + "input-voltage-hint": "Διπλή τιμή που υποδεικνύει την τάση εισόδου.", + "output-voltage": "Τάση εξόδου", + "output-voltage-hint": "Διπλή τιμή που υποδεικνύει την τάση εξόδου.", + "first-phase-voltage": "Τάση πρώτης φάσης", + "second-phase-voltage": "Τάση δεύτερης φάσης", + "third-phase-voltage": "Τάση τρίτης φάσης", + "phase-voltage-hint": "Διπλή τιμή που υποδεικνύει την τάση της τρέχουσας φάσης", + "voltage-hint": "Διπλή τιμή που υποδεικνύει την τρέχουσα τάση", + "current-voltage-color": "Χρώμα τρέχουσας τάσης", + "phase-indicator-color": "Χρώμα ένδειξης φάσης", + "measured": "Μετρημένο", + "measured-hint": "Διπλή τιμή που υποδεικνύει την κατανάλωση ενέργειας σε κιλοβατώρες", + "day-rate": "Ημερήσιο τιμολόγιο", + "night-rate": "Νυχτερινό τιμολόγιο", + "off-peak-rate": "Εκτός αιχμής", + "peak-rate": "Ώρα αιχμής", + "export-rate": "Ρυθμός εξαγωγής", + "operating-mode": "Λειτουργία", + "bypass-mode": "Παράκαμψη", + "operating-mode-hint": "Ακέραια τιμή για την τρέχουσα λειτουργία (0 - OFF, 1 - ON, 2 - BYPASS)", + "connected": "Συνδεδεμένο", + "connected-hint": "Υποδεικνύει αν το στοιχείο είναι σε κατάσταση σύνδεσης.", + "disconnected": "Αποσυνδεδεμένο", + "indicator": "Ένδειξη", + "operation-mode": "Λειτουργία συσκευής", + "operation-mode-hint": "Υποδεικνύει αν ο μετατροπέας είναι σε λειτουργία Δικτύου ή Μετατροπέα.", + "operation-mode-indicators-color": "Χρώμα ενδείξεων λειτουργίας", + "mains-on-mode": "Λειτουργία Δικτύου", + "inverter-on-mode": "Λειτουργία Μετατροπέα", + "charging-mode": "Λειτουργία φόρτισης", + "charging-mode-hint": "Ακέραια τιμή που υποδεικνύει την τρέχουσα λειτουργία φόρτισης (1 - Γρήγορη, 2 - Απορρόφηση, 3 - Συντήρηση)", + "charging-mode-indicators-color": "Χρώμα ενδείξεων λειτουργίας φόρτισης", + "inverter-faults": "Σφάλματα", + "inverter-fault-indicators-color": "Χρώμα ενδείξεων σφάλματος", + "overload-fault": "Υπερφόρτωση", + "overload-fault-hint": "Υποδεικνύει αν ο μετατροπέας είναι σε κατάσταση υπερφόρτωσης.", + "low-battery-fault": "Χαμηλή μπαταρία", + "low-battery-fault-hint": "Υποδεικνύει αν η μπαταρία έχει υπερβολική εκφόρτιση.", + "temperature-fault": "Θερμοκρασία", + "temperature-fault-hint": "Υποδεικνύει υψηλή θερμοκρασία στον μετατροπέα.", + "triangle": "Τρίγωνο", + "socket": "Υποδοχή", + "left-button": "Αριστερό κουμπί", + "right-button": "Δεξί κουμπί", + "alarm-colors": "Χρώματα συναγερμού", + "hook-color": "Χρώμα άγκιστρου" + } }, "item": { "selected": "Επιλέχθηκε" }, "js-func": { - "no-return-error": "Η λειτουργία πρέπει να επιστρέφει τιμή!", - "return-type-mismatch": "Η λειτουργία πρέπει να επιστρέφει τιμή από '{{type}}' τύπο!", - "tidy": "Tidy" + "no-return-error": "Η συνάρτηση πρέπει να επιστρέφει τιμή!", + "return-type-mismatch": "Η συνάρτηση πρέπει να επιστρέφει τιμή τύπου '{{type}}'!", + "tidy": "Καθαρισμός", + "mini": "Μίνι", + "modules": "Modules", + "remove-module": "Αφαίρεση module", + "no-modules": "Δεν έχουν οριστεί modules", + "add-module": "Προσθήκη module", + "module-alias": "Ψευδώνυμο", + "invalid-module-alias-name": "Μη έγκυρο όνομα ψευδωνύμου", + "module-resource": "Πηγή JS module", + "not-unique-module-aliases-error": "Τα ψευδώνυμα των modules πρέπει να είναι μοναδικά!", + "show-module-info": "Εμφάνιση πληροφοριών module", + "show-module-source-code": "Εμφάνιση πηγαίου κώδικα module", + "module-members": "Μέλη module", + "module-no-members": "Το module δεν έχει εξαγόμενα μέλη", + "module-load-error": "Σφάλμα φόρτωσης module", + "source-code": "Πηγαίος κώδικας", + "source-code-load-error": "Σφάλμα φόρτωσης πηγαίου κώδικα", + "no-js-module-text": "Δεν βρέθηκαν JS modules", + "no-js-module-matching": "Δεν βρέθηκαν JS modules με όνομα '{{module}}'." }, "key-val": { - "key": "Όνομα", + "key": "Κλειδί", "value": "Τιμή", "remove-entry": "Αφαίρεση καταχώρησης", - "add-entry": "Προσθήκη καταχωρησης", - "no-data": "Καμία καταχώρηση" + "add-entry": "Προσθήκη καταχώρησης", + "no-data": "Δεν υπάρχουν καταχωρήσεις" }, "layout": { "layout": "Διάταξη", - "manage": "Διαχείριση Διατάξεων", - "settings": "Ρυθμίσεις Διάταξης", + "layouts": "Διατάξεις", + "manage": "Διαχείριση διατάξεων", + "settings": "Ρυθμίσεις διάταξης", "color": "Χρώμα", - "main": "Κεντρικά", + "main": "Κύριο", "right": "Δεξιά", - "select": "Επιλογή διάταξης" + "left": "Αριστερά", + "select": "Επιλέξτε διάταξη προορισμού", + "percentage-width": "Ποσοστό πλάτους (%)", + "fixed-width": "Σταθερό πλάτος (px)", + "left-width": "Αριστερή στήλη (%)", + "right-width": "Δεξιά στήλη (%)", + "pick-fixed-side": "Σταθερή πλευρά: ", + "layout-fixed-width": "Σταθερό πλάτος (px)", + "value-min-error": "Η τιμή πρέπει να είναι μεγαλύτερη από {{min}}{{unit}}", + "value-max-error": "Η τιμή πρέπει να είναι μικρότερη από {{max}}{{unit}}", + "layout-fixed-width-required": "Απαιτείται σταθερό πλάτος", + "right-width-percentage-required": "Απαιτείται ποσοστό για δεξιά πλευρά", + "left-width-percentage-required": "Απαιτείται ποσοστό για αριστερή πλευρά", + "divider": "Διαχωριστικό", + "right-side": "Διάταξη δεξιάς πλευράς", + "left-side": "Διάταξη αριστερής πλευράς", + "add-new-breakpoint": "Προσθήκη νέου breakpoint", + "breakpoint": "Breakpoint", + "breakpoints": "Breakpoints", + "copy-from": "Αντιγραφή από", + "size": "Μέγεθος", + "delete-breakpoint-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το breakpoint '{{name}}';", + "delete-breakpoint-text": "Σημειώστε ότι μετά την επιβεβαίωση, το breakpoint δεν θα ανακτηθεί και οι ρυθμίσεις θα επανέλθουν στο προεπιλεγμένο." }, "legend": { - "direction": "Κατεύθυνση Λεζάντας", - "position": "Θέση λεζάντας", - "show-max": "Προβολή μέγιστης τιμής", - "show-min": "Προβολή ελάχιστης τιμής", - "show-avg": "Προβολή μέσης τιμής", - "show-total": "Προβολή συνολικής τιμής", - "settings": "Ρυθμίσεις λεζάντας", - "min": "min", - "max": "max", - "avg": "Μ.Ο.", - "total": "Σύνολο" + "direction": "Κατεύθυνση", + "position": "Θέση", + "show-values": "Εμφάνιση τιμών", + "min-option": "Ελάχιστο", + "max-option": "Μέγιστο", + "average-option": "Μέσος όρος", + "total-option": "Σύνολο", + "latest-option": "Πιο πρόσφατο", + "sort-legend": "Ταξινόμηση δεδομένων στο υπόμνημα", + "show-max": "Εμφάνιση μέγιστης τιμής", + "show-min": "Εμφάνιση ελάχιστης τιμής", + "show-avg": "Εμφάνιση μέσης τιμής", + "show-total": "Εμφάνιση συνολικής τιμής", + "show-latest": "Εμφάνιση τελευταίας τιμής", + "settings": "Ρυθμίσεις υπομνήματος", + "min": "ελάχ.", + "max": "μέγ.", + "avg": "μέσος", + "total": "σύνολο", + "latest": "τελευταίο", + "Min": "Ελάχιστο", + "Max": "Μέγιστο", + "Avg": "Μέσος όρος", + "Total": "Σύνολο", + "Latest": "Τελευταίο", + "comparison-time-ago": { + "previousInterval": "(προηγούμενο διάστημα)", + "customInterval": "(προσαρμοσμένο διάστημα)", + "days": "(πριν μία μέρα)", + "weeks": "(πριν μία εβδομάδα)", + "months": "(πριν ένα μήνα)", + "years": "(πριν ένα έτος)" + }, + "column-title": "Τίτλος στήλης", + "label": "Ετικέτα", + "value": "Τιμή" }, "login": { "login": "Σύνδεση", - "request-password-reset": "Αίτημα επαναφοράς κωδικού πρόσβασης", - "reset-password": "Επαναφορά κωδικού πρόσβασης", - "create-password": "Δημιουργία κωδικού πρόσβασης", - "passwords-mismatch-error": "Οι καταχωρημένοι κωδικοί πρόσβασης πρέπει να είναι ίδιοι!", - "password-again": "Επανάληψη κωδικού πρόσβασης", - "sign-in": "Παρακαλώ, συνδεθείτε", - "username": "Όνομα Χρήστη (email)", - "remember-me": "Να με Θυμάσαι", - "forgot-password": "Ξεχάσατε τον κωδικό πρόσβασης;", - "password-reset": "Επαναφορά κωδικού πρόσβασης", - "new-password": "Νέος κωδικός πρόσβασης", - "new-password-again": "Επανάληψη νέου κωδικού πρόσβασης", - "password-link-sent-message": "Ο σύνδεσμος επαναφοράς κωδικού πρόσβασης στάλθηκε με επιτυχία!", - "email": "Email", - "no-account": "Δεν έχετε λογαριασμό;", - "create-account": "Δημιουργία λογαριασμού", - "login-with": "Σύνδεση μέσω {{name}}", - "or": "ή" - }, - "signup": { - "firstname": "Όνομα", - "lastname": "Επίθετο", - "email": "Email", - "signup": "Εγγραφή", + "request-password-reset": "Αίτηση επαναφοράς κωδικού", + "reset-password": "Επαναφορά κωδικού", "create-password": "Δημιουργία κωδικού", - "repeat-password": "Επαναλάβετε τον κωδικό", - "have-account": "Έχετε ήδη έναν λογαριασμό;", - "signin": "Είσοδος", - "no-captcha-message": "Πρέπει να επιβεβαιώσετε ότι δεν είστε ρομπότ", - "password-length-message": "Ο κωδικός σας πρεπει να περιλαμβάνει τουλάχιστον 6 χαρακτήρες", - "email-verification": "Επιβεβαίωση email", - "email-verification-message": "Έχει σταλεί ένα email επιβεβαίωσης στην διεύθυνση email που καθορίσατε.
    Παρακαλούμε ακολουθήστε τις οδηγίες που συμπεριλαμβανονται στο email ώστε να ολοκληρωθεί η εγγραφή σας.
    Σημείωση: Αν δεν έχει εμφανιστεί σχετικά άμεσα το email, παρακαλούμε ελέγξτε στον φάκελο με την 'Ανεπιθύμητη Αλληλογραφία' (spam) ή ξαναπροσπαθείστε να στείλετε το email κάνοντας κλικ στο κουμπί 'Αποστολή εκ νέου'", - "account-activation-title": "Ενεργοποίηση λογαριασμού", - "account-activated": "Ο λογαριασμός ενεργοποιήθηκε με επιτυχία!", - "account-activated-text": "Συγχαρητήρια!
    Ο λογαριασμός σας έχει ενεργοποιηθεί.
    Τώρα μπορείται να κάνετε είσοδο στην πλατφόρμα.", - "resend": "Αποστολή εκ νέου", - "inactive-user-exists-title": "Ο ανενεργός χρήστης υπάρχει ήδη", - "inactive-user-exists-text": "Υπάρχει ήδη εγγεγραμμένος χρήστης με μη επιβεβαιωμένη διεύθυνση email.
    Click Πατήστε το κουμπί 'Επαναποστολή', αν θέλετε να στείλετε ξανά email επιβεβαίωσης.", - "activating-account": "Ενεργοποίηση λογαριασμού...", - "activating-account-text": "Ο λογαριασμός σας ενεργοποιείται αυτήν τη στιγμή. Παρακαλούμε περιμένετε...", - "accept-privacy-policy": "Αποδοχή Πολιτικής Απορρήτου", - "accept": "Αποδοχή", - "privacy-policy": "Πολιτική Απορρήτου" + "two-factor-authentication": "Έλεγχος ταυτότητας δύο παραγόντων", + "passwords-mismatch-error": "Οι κωδικοί πρέπει να είναι ίδιοι!", + "password-again": "Επαλήθευση κωδικού", + "sign-in": "Παρακαλώ συνδεθείτε", + "username": "Όνομα χρήστη (email)", + "remember-me": "Να με θυμάσαι", + "forgot-password": "Ξεχάσατε τον κωδικό;", + "password-reset": "Επαναφορά κωδικού", + "expired-password-reset-message": "Τα διαπιστευτήριά σας έχουν λήξει! Παρακαλώ δημιουργήστε νέο κωδικό.", + "new-password": "Νέος κωδικός", + "new-password-again": "Επιβεβαίωση νέου κωδικού", + "password-link-sent-message": "Ο σύνδεσμος επαναφοράς έχει σταλεί", + "email": "Email", + "invalid-email-format": "Μη έγκυρη μορφή email.", + "login-with": "Σύνδεση με {{name}}", + "or": "ή", + "error": "Σφάλμα σύνδεσης", + "verify-your-identity": "Επαληθεύστε την ταυτότητά σας", + "select-way-to-verify": "Επιλέξτε τρόπο επαλήθευσης", + "resend-code": "Επαναποστολή κωδικού", + "resend-code-wait": "Επαναποστολή σε { time, plural, =1 {1 δευτερόλεπτο} other {# δευτερόλεπτα} }", + "try-another-way": "Δοκιμάστε άλλο τρόπο", + "totp-auth-description": "Παρακαλώ εισάγετε τον κωδικό από την εφαρμογή ελέγχου ταυτότητας.", + "totp-auth-placeholder": "Κωδικός", + "sms-auth-description": "Ένας κωδικός έχει σταλεί στο τηλέφωνο σας στο {{contact}}.", + "sms-auth-placeholder": "Κωδικός SMS", + "email-auth-description": "Ένας κωδικός έχει σταλεί στο email σας στο {{contact}}.", + "email-auth-placeholder": "Κωδικός email", + "backup-code-auth-description": "Παρακαλώ εισάγετε έναν από τους εφεδρικούς σας κωδικούς.", + "backup-code-auth-placeholder": "Εφεδρικός κωδικός", + "activation-link-expired": "Ο σύνδεσμος ενεργοποίησης έχει λήξει", + "activation-link-expired-message": "Ο σύνδεσμος ενεργοποίησης του προφίλ σας έχει λήξει. Μπορείτε να επιστρέψετε στη σελίδα σύνδεσης για να λάβετε νέο email.", + "reset-password-link-expired": "Ο σύνδεσμος επαναφοράς κωδικού έχει λήξει", + "reset-password-link-expired-message": "Ο σύνδεσμος για την επαναφορά του κωδικού έχει λήξει. Μπορείτε να επιστρέψετε στη σελίδα σύνδεσης για να λάβετε νέο email." + }, + "mobile": { + "add-application": "Προσθήκη εφαρμογής", + "app-id": "App ID", + "app-id-required": "Το App ID είναι υποχρεωτικό", + "app-id-pattern": "Μη έγκυρη μορφή App ID", + "app-store-link": "Σύνδεσμος App Store", + "app-store-link-required": "Ο σύνδεσμος App Store είναι υποχρεωτικός", + "application-details": "Λεπτομέρειες εφαρμογής", + "application-package": "Πακέτο εφαρμογής", + "application-secret": "Μυστικό εφαρμογής", + "application-secret-required": "Το μυστικό εφαρμογής είναι υποχρεωτικό", + "application": "Εφαρμογή", + "applications": "Εφαρμογές", + "copy-app-id": "Αντιγραφή App ID", + "copy-app-store-link": "Αντιγραφή συνδέσμου App Store", + "copy-application-package": "Αντιγραφή πακέτου εφαρμογής", + "copy-application-secret": "Αντιγραφή μυστικού εφαρμογής", + "copy-google-play-link": "Αντιγραφή συνδέσμου Google Play", + "copy-sha256-certificate-fingerprints": "Αντιγραφή αποτυπωμάτων πιστοποιητικού SHA256", + "delete-application": "Διαγραφή εφαρμογής", + "delete-application-button-text": "Κατανοώ τις συνέπειες, διαγραφή εφαρμογής", + "delete-application-text": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Η εφαρμογή σας θα διαγραφεί οριστικά.
    Αν δεν θέλετε να τη διαγράψετε μόνιμα μπορείτε να την αναστείλετε προσωρινά.
    Για να διαγράψετε την εφαρμογή πληκτρολογήστε \"{{phrase}}\" για επιβεβαίωση.", + "delete-application-title-short": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την εφαρμογή '{{name}}';", + "delete-application-text-short": "Προσοχή, μετά την επιβεβαίωση η εφαρμογή και όλα τα σχετικά δεδομένα θα χαθούν οριστικά.", + "delete-application-phrase": "delete application", + "delete-applications-bundle-text": "Προσοχή, μετά την επιβεβαίωση το πακέτο κινητής και όλα τα σχετικά δεδομένα θα χαθούν οριστικά.", + "delete-applications-bundle-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το πακέτο κινητής '{{bundleName}}';", + "generate-application-secret": "Δημιουργία μυστικού εφαρμογής", + "google-play-link": "Σύνδεσμος Google Play", + "google-play-link-required": "Ο σύνδεσμος Google Play είναι υποχρεωτικός", + "latest-version": "Τελευταία έκδοση", + "min-version": "Ελάχιστη έκδοση", + "invalid-version-pattern": "Μη έγκυρη μορφή έκδοσης. Χρησιμοποιήστε τη μορφή: major.minor.patch (π.χ. 1.0.0).", + "mobile-center": "Κέντρο κινητής", + "mobile-package": "Πακέτο εφαρμογής", + "mobile-package-max-length": "Το πακέτο εφαρμογής πρέπει να είναι λιγότερο από 256 χαρακτήρες", + "mobile-package-required": "Το πακέτο εφαρμογής είναι υποχρεωτικό.", + "mobile-package-pattern": "Μη έγκυρη μορφή πακέτου εφαρμογής", + "no-application": "Δεν βρέθηκαν εφαρμογές", + "no-bundles": "Δεν βρέθηκαν πακέτα", + "platform-type": "Τύπος πλατφόρμας", + "search-application": "Αναζήτηση εφαρμογών", + "search-bundles": "Αναζήτηση πακέτων", + "set": "Ορισμός", + "sha256-certificate-fingerprints": "Αποτυπώματα πιστοποιητικού SHA256", + "sha256-certificate-fingerprints-required": "Τα αποτυπώματα πιστοποιητικού SHA256 είναι υποχρεωτικά", + "sha256-certificate-fingerprints-pattern": "Μη έγκυρη μορφή αποτυπώματος SHA256", + "show-hidden-pages": "Εμφάνιση κρυφών σελίδων", + "status": "Κατάσταση", + "status-type": { + "deprecated": "Καταργημένο", + "draft": "Πρόχειρο", + "published": "Δημοσιευμένο", + "suspended": "Ανασταλμένο" + }, + "store-information": "Πληροφορίες καταστήματος", + "version-information": "Πληροφορίες έκδοσης", + "min-version-release-notes": "Σημειώσεις έκδοσης ελάχιστης έκδοσης", + "latest-version-release-notes": "Σημειώσεις έκδοσης τελευταίας έκδοσης", + "bundle": "Πακέτο", + "bundles": "Πακέτα", + "add-bundle": "Προσθήκη πακέτου", + "title": "Τίτλος", + "title-required": "Ο τίτλος είναι υποχρεωτικός", + "title-cannot-contain-only-spaces": "Ο τίτλος δεν μπορεί να περιέχει μόνο κενά", + "title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "oauth-clients": "Πελάτες OAuth 2.0", + "android-app": "Εφαρμογή Android", + "android-application": "Εφαρμογή Android", + "ios-app": "Εφαρμογή iOS", + "ios-application": "Εφαρμογή iOS", + "invalid-store-link": "Μη έγκυρος σύνδεσμος καταστήματος", + "enable-oauth": "Ενεργοποίηση OAuth 2.0", + "enable-self-registration": "Ενεργοποίηση αυτόματης εγγραφής", + "edit-bundle": "Επεξεργασία πακέτου", + "description": "Περιγραφή", + "basic-settings": "Βασικές ρυθμίσεις", + "no-application-matching": "Δεν βρέθηκε εφαρμογή που να ταιριάζει με '{{entity}}'", + "no-bundle-matching": "Δεν βρέθηκε πακέτο που να ταιριάζει με '{{entity}}'", + "application-required": "Η εφαρμογή είναι υποχρεωτική.", + "bundle-required": "Το πακέτο είναι υποχρεωτικό.", + "no-application-text": "Δεν βρέθηκαν εφαρμογές", + "no-bundle-text": "Δεν βρέθηκαν πακέτα", + "layout": "Διάταξη", + "pages": "Σελίδες", + "hide-all-pages": "Απόκρυψη όλων των σελίδων", + "reset-to-default-pages": "Επαναφορά σε προεπιλεγμένες σελίδες", + "add-specific-page": "Προσθήκη συγκεκριμένης σελίδας", + "visible": "Ορατό", + "hidden": "Κρυφό", + "reset-to-page-default": "Επαναφορά σελίδας στις προεπιλογές", + "mobile-599": "Κινητό (μέγιστο 599px)", + "tablet-959": "Tablet (μέγιστο 959px)", + "max-element-number": "Μέγιστος αριθμός στοιχείων", + "page-name": "Όνομα σελίδας", + "page-name-required": "Το όνομα σελίδας είναι υποχρεωτικό.", + "page-name-cannot-contain-only-spaces": "Το όνομα σελίδας δεν μπορεί να περιέχει μόνο κενά.", + "page-name-max-length": "Το όνομα σελίδας πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "page-type": "Τύπος σελίδας", + "pages-types": { + "dashboard": "Πίνακας ελέγχου", + "web-view": "Web προβολή", + "custom": "Προσαρμοσμένη" + }, + "url": "URL", + "invalid-url-format": "Μη έγκυρη μορφή URL", + "path": "Διαδρομή", + "invalid-path-format": "Μη έγκυρη μορφή διαδρομής", + "custom-page": "Προσαρμοσμένη σελίδα", + "edit-page": "Επεξεργασία σελίδας", + "edit-custom-page": "Επεξεργασία προσαρμοσμένης σελίδας", + "delete-page": "Διαγραφή σελίδας", + "qr-code-widget": "Widget QR κώδικα", + "type-here": "Πληκτρολογήστε εδώ", + "configuration-dialog": "Διάλογος ρυθμίσεων", + "configuration-app": "Εφαρμογή ρυθμίσεων", + "configuration-step": { + "prepare-environment-title": "Προετοιμασία περιβάλλοντος ανάπτυξης", + "prepare-environment-text": "Η εφαρμογή ThingsBoard Mobile απαιτεί το Flutter SDK. Ακολουθήστε τις οδηγίες για να ρυθμίσετε το Flutter SDK.", + "get-source-code-title": "Λήψη πηγαίου κώδικα", + "get-source-code-text": "Μπορείτε να λάβετε τον πηγαίο κώδικα της εφαρμογής ThingsBoard Mobile με cloning από το GitHub:", + "configure-api-title": "Διαμόρφωση ThingsBoard API endpoint", + "configure-api-text": "Ανοίξτε το έργο flutter_thingsboard_pe_app στον επεξεργαστή σας. Επεξεργαστείτε:", + "configure-api-hint": "Ορίστε την τιμή της σταθεράς thingsBoardApiEndpoint ώστε να ταιριάζει με το API endpoint του ThingsBoard server. Μην χρησιμοποιείτε 'localhost' ή '127.0.0.1'.", + "run-app-title": "Εκτέλεση εφαρμογής", + "run-app-text": "Εκτελέστε την εφαρμογή μέσω του IDE σας ή μέσω τερματικού με την ακόλουθη εντολή:", + "more-information": "Περισσότερες πληροφορίες βρίσκονται στην τεκμηρίωση έναρξης.", + "getting-started": "Ξεκινώντας", + "configure-package-title": "Διαμόρφωση πακέτου εφαρμογής", + "configure-package-text": "Μπορείτε να αλλάξετε χειροκίνητα το Πακέτο Εφαρμογής ή να χρησιμοποιήσετε εργαλείο CLI τρίτων.", + "configure-package-text-install": "Για να εγκαταστήσετε το εργαλείο Rename CLI, εκτελέστε την ακόλουθη εντολή:", + "configure-package-run-commands": "Εκτελέστε αυτές τις εντολές στον root φάκελο του έργου σας:" + } + }, + "notification": { + "action-button": "Κουμπί ενέργειας", + "action-type": "Τύπος ενέργειας", + "active": "Ενεργό", + "add-notification-recipients-group": "Προσθήκη ομάδας παραληπτών ειδοποιήσεων", + "add-notification-template": "Προσθήκη προτύπου ειδοποίησης", + "add-recipient": "Προσθήκη παραλήπτη", + "add-recipients": "Προσθήκη παραληπτών", + "add-rule": "Προσθήκη κανόνα", + "add-stage": "Προσθήκη σταδίου", + "add-template": "Προσθήκη προτύπου", + "after": "Μετά", + "alarm-assignment-trigger-settings": "Ρυθμίσεις σκανδάλης ανάθεσης συναγερμού", + "alarm-comment-trigger-settings": "Ρυθμίσεις σκανδάλης σχολίων συναγερμού", + "alarm-trigger-settings": "Ρυθμίσεις σκανδάλης συναγερμού", + "all": "Όλα", + "api-feature-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλα τα χαρακτηριστικά API", + "api-usage-trigger-settings": "Ρυθμίσεις σκανδάλης χρήσης API", + "new-platform-version-trigger-settings": "Ρυθμίσεις σκανδάλης νέας έκδοσης πλατφόρμας", + "rate-limits-trigger-settings": "Ρυθμίσεις σκανδάλης υπέρβασης ορίων ρυθμού", + "task-processing-failure-trigger-settings": "Ρυθμίσεις σκανδάλης αποτυχίας επεξεργασίας εργασίας", + "at-least-one-should-be-selected": "Πρέπει να επιλεγεί τουλάχιστον ένα", + "basic-settings": "Βασικές ρυθμίσεις", + "button-text": "Κείμενο κουμπιού", + "button-text-required": "Το κείμενο κουμπιού είναι υποχρεωτικό", + "button-text-max-length": "Το κείμενο κουμπιού πρέπει να είναι έως {{ length }} χαρακτήρες", + "compose": "Σύνθεση", + "conversation": "Συνομιλία", + "conversation-required": "Απαιτείται συνομιλία", + "copy-notification-template": "Αντιγραφή προτύπου ειδοποίησης", + "copy-rule": "Αντιγραφή κανόνα", + "copy-template": "Αντιγραφή προτύπου", + "create-new": "Δημιουργία νέου", + "created": "Δημιουργήθηκε", + "customize-messages": "Προσαρμογή μηνυμάτων", + "delete-notification-text": "Προσοχή, μετά την επιβεβαίωση η ειδοποίηση δεν θα είναι ανακτήσιμη.", + "delete-notification-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την ειδοποίηση;", + "delete-notifications-text": "Προσοχή, μετά την επιβεβαίωση οι ειδοποιήσεις δεν θα είναι ανακτήσιμες.", + "delete-notifications-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 ειδοποίηση} other {# ειδοποιήσεις} };", + "delete-recipient-text": "Προσοχή, μετά την επιβεβαίωση ο παραλήπτης δεν θα είναι ανακτήσιμος.", + "delete-recipient-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον παραλήπτη '{{recipientName}}';", + "delete-recipients-text": "Προσοχή, μετά την επιβεβαίωση οι παραλήπτες δεν θα είναι ανακτήσιμοι.", + "delete-recipients-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 παραλήπτη} other {# παραλήπτες} };", + "delete-request-text": "Προσοχή, μετά την επιβεβαίωση το αίτημα δεν θα είναι ανακτήσιμο.", + "delete-request-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το αίτημα;", + "delete-requests-text": "Προσοχή, μετά την επιβεβαίωση τα αιτήματα δεν θα είναι ανακτήσιμα.", + "delete-requests-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 αίτημα} other {# αιτήματα} };", + "delete-rule-text": "Προσοχή, μετά την επιβεβαίωση ο κανόνας δεν θα είναι ανακτήσιμος.", + "delete-rule-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον κανόνα '{{ruleName}}';", + "delete-rules-text": "Προσοχή, μετά την επιβεβαίωση οι κανόνες δεν θα είναι ανακτήσιμοι.", + "delete-rules-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 κανόνα} other {# κανόνες} };", + "delete-template-text": "Προσοχή, μετά την επιβεβαίωση το πρότυπο δεν θα είναι ανακτήσιμο.", + "delete-template-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το πρότυπο '{{templateName}}';", + "delete-templates-text": "Προσοχή, μετά την επιβεβαίωση τα πρότυπα δεν θα είναι ανακτήσιμα.", + "delete-templates-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 πρότυπο} other {# πρότυπα} };", + "deleted": "Διαγράφηκε", + "delivery-method": { + "delivery-method": "Μέθοδος παράδοσης", + "email": "Email", + "email-preview": "Προεπισκόπηση ειδοποίησης Email", + "slack": "Slack", + "slack-preview": "Προεπισκόπηση ειδοποίησης Slack", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Προεπισκόπηση ειδοποίησης Microsoft Teams", + "sms": "SMS", + "sms-preview": "Προεπισκόπηση ειδοποίησης SMS", + "web": "Web", + "web-preview": "Προεπισκόπηση ειδοποίησης Web", + "mobile-app": "Κινητή εφαρμογή", + "mobile-app-preview": "Προεπισκόπηση ειδοποίησης κινητής εφαρμογής" + }, + "delivery-method-not-configure-click": "Η μέθοδος παράδοσης δεν έχει ρυθμιστεί. Κάντε κλικ για ρύθμιση.", + "delivery-method-not-configure-contact": "Η μέθοδος παράδοσης δεν έχει ρυθμιστεί. Επικοινωνήστε με τον διαχειριστή του συστήματος.", + "delivery-methods": "Μέθοδοι παράδοσης", + "description": "Περιγραφή", + "device-activity-trigger-settings": "Ρυθμίσεις σκανδάλης δραστηριότητας συσκευής", + "device-list-rule-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλες τις συσκευές", + "device-profiles-list-rule-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλα τα προφίλ συσκευών", + "disabled": "Ανενεργό", + "edge-trigger-settings": "Ρυθμίσεις σκανδάλης Edge", + "edge-list-rule-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλα τα Edge instances", + "edit-notification-recipients-group": "Επεξεργασία ομάδας παραληπτών ειδοποιήσεων", + "edit-notification-template": "Επεξεργασία προτύπου ειδοποίησης", + "edit-rule": "Επεξεργασία κανόνα", + "edit-template": "Επεξεργασία προτύπου", + "enabled": "Ενεργοποιημένο", + "entities-limit-trigger-settings": "Ρυθμίσεις σκανδάλης ορίου οντοτήτων", + "entity-action-trigger-settings": "Ρυθμίσεις σκανδάλης ενέργειας οντοτήτων", + "entity-type": "Τύπος οντότητας", + "escalation-chain": "Αλυσίδα κλιμάκωσης", + "failed-send": "Αποτυχίες αποστολής", + "fails": "{ count, plural, =1 {1 αποτυχία} other {# αποτυχίες} }", + "filter": "Φίλτρο", + "first-recipient": "Πρώτος παραλήπτης", + "inactive": "Ανενεργό", + "inbox": "Εισερχόμενα", + "notification-inbox": "Ειδοποιήσεις / Εισερχόμενα", + "input-field-support-templatization": "Το πεδίο εισαγωγής υποστηρίζει δυναμική μορφοποίηση.", + "input-fields-support-templatization": "Τα πεδία εισαγωγής υποστηρίζουν δυναμική μορφοποίηση.", + "link": "Σύνδεσμος", + "link-required": "Ο σύνδεσμος είναι υποχρεωτικός", + "link-type": { + "dashboard": "Άνοιγμα πίνακα ελέγχου", + "link": "Άνοιγμα συνδέσμου URL" + }, + "loading-notifications": "Φόρτωση ειδοποιήσεων...", + "management": "Διαχείριση ειδοποιήσεων", + "mark-all-as-read": "Σημείωση όλων ως αναγνωσμένα", + "mark-as-read": "Σήμανση ως αναγνωσμένο", + "message": "Μήνυμα", + "message-required": "Το μήνυμα είναι υποχρεωτικό", + "message-max-length": "Το μήνυμα πρέπει να είναι μικρότερο ή ίσο με {{ length }} χαρακτήρες", + "name": "Όνομα", + "name-required": "Το όνομα είναι υποχρεωτικό", + "new-notification": "Νέα ειδοποίηση", + "no-inbox-notification": "Δεν βρέθηκε ειδοποίηση", + "no-notification-request": "Δεν υπάρχει αίτημα ειδοποίησης", + "no-notification-templates": "Δεν βρέθηκαν πρότυπα ειδοποιήσεων", + "no-notifications-yet": "Δεν υπάρχουν ειδοποιήσεις ακόμα", + "no-recipients-notification": "Δεν υπάρχουν παραλήπτες ειδοποίησης", + "no-recipients-matching": "Δεν βρέθηκαν παραλήπτες που να ταιριάζουν με '{{entity}}'", + "no-recipients-text": "Δεν βρέθηκε παραλήπτης", + "no-rule": "Δεν έχει ρυθμιστεί κανόνας", + "no-rules-notification": "Δεν υπάρχουν ειδοποιήσεις κανόνων", + "no-severity-found": "Δεν βρέθηκε σοβαρότητα", + "no-severity-matching": "'{{severity}}' δεν βρέθηκε.", + "no-template-matching": "Δεν βρέθηκε πόρος που να ταιριάζει με '{{template}}'", + "not-found-slack-recipient": "Δεν βρέθηκε παραλήπτης Slack", + "notification": "Ειδοποίηση", + "notification-center": "Κέντρο ειδοποιήσεων", + "notification-tap-action": "Ενέργεια πάτησης ειδοποίησης", + "notification-tap-action-hint": "Αν δεν είναι ενεργοποιημένη, θα χρησιμοποιηθεί ο προεπιλεγμένος πίνακας συναγερμών", + "notify": "ειδοποίηση", + "notify-again": "Ειδοποίηση ξανά", + "notify-alarm-action": { + "acknowledged": "Ο συναγερμός επιβεβαιώθηκε", + "assigned": "Ο συναγερμός ανατέθηκε", + "cleared": "Ο συναγερμός καθαρίστηκε", + "created": "Δημιουργήθηκε συναγερμός", + "severity-changed": "Η σοβαρότητα του συναγερμού άλλαξε", + "unassigned": "Ο συναγερμός αποεπιλέχθηκε" + }, + "notify-on": "Ειδοποίηση για", + "notify-on-comment-update": "Ειδοποίηση για ενημέρωση σχολίου", + "notify-on-required": "Το πεδίο ειδοποίησης για είναι υποχρεωτικό", + "notify-on-unassign": "Ειδοποίηση για αποεπιλογή", + "notify-only-user-comments": "Ειδοποίηση μόνο για σχόλια χρηστών", + "only-rule-chain-lifecycle-failures": "Μόνο αποτυχίες κύκλου ζωής αλυσίδας κανόνων", + "only-rule-node-lifecycle-failures": "Μόνο αποτυχίες κύκλου ζωής κόμβου κανόνων", + "platform-users": "Χρήστες πλατφόρμας", + "rate-limits": "Όρια ρυθμού", + "rate-limits-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλα τα όρια ρυθμού", + "recipient": "Παραλήπτης", + "recipient-group": "Ομάδα παραληπτών", + "recipient-type": { + "affected-tenant-administrators": "Διαχειριστές ενοικιαστών που επηρεάζονται", + "affected-user": "Χρήστης που επηρεάζεται", + "all-users": "Όλοι οι χρήστες", + "customer-users": "Χρήστες πελατών", + "system-administrators": "Διαχειριστές συστήματος", + "tenant-administrators": "Διαχειριστές ενοικιαστών", + "user-filters": "Φίλτρο χρηστών", + "user-list": "Λίστα χρηστών", + "users-entity-owner": "Χρήστες ιδιοκτήτη οντότητας" + }, + "recipients": "Παραλήπτες", + "notification-recipient": "Παραλήπτης ειδοποίησης", + "notification-recipient-required": "Ο παραλήπτης ειδοποίησης είναι υποχρεωτικός.", + "notification-recipients": "Ειδοποιήσεις / Παραλήπτες", + "recipients-count": "{ count, plural, =1 {1 παραλήπτης} other {# παραλήπτες} }", + "recipients-required": "Οι παραλήπτες είναι υποχρεωτικοί", + "refresh-allow-delivery-method": "Ανανέωση μεθόδου παράδοσης", + "request-search": "Αναζήτηση αιτήματος", + "request-status": { + "processing": "Επεξεργασία", + "scheduled": "Προγραμματισμένο", + "sent": "Απεσταλμένο" + }, + "review": "Ανασκόπηση", + "rule": "Κανόνας", + "rule-chain-list-rule-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλες τις αλυσίδες κανόνων", + "rule-engine-events-trigger-settings": "Ρυθμίσεις σκανδάλης γεγονότων μηχανής κανόνων", + "rule-engine-filter": "Φίλτρο μηχανής κανόνων", + "rule-name": "Όνομα κανόνα", + "rule-name-required": "Το όνομα είναι υποχρεωτικό", + "rule-disable": "Απενεργοποίηση κανόνα ειδοποίησης", + "rule-enable": "Ενεργοποίηση κανόνα ειδοποίησης", + "rule-node-filter": "Φίλτρο κόμβου κανόνα", + "rules": "Κανόνες", + "notification-rules": "Ειδοποιήσεις / Κανόνες", + "scheduler-later": "Προγραμματισμός για αργότερα", + "search-notification": "Αναζήτηση ειδοποιήσεων", + "search-recipients": "Αναζήτηση παραληπτών", + "search-rules": "Αναζήτηση κανόνων", + "search-templates": "Αναζήτηση προτύπων", + "see-documentation": "Δείτε την τεκμηρίωση", + "selected-notifications": "{ count, plural, =1 {1 ειδοποίηση} other {# ειδοποιήσεις} } επιλέχθηκαν", + "selected-recipients": "{ count, plural, =1 {1 παραλήπτης} other {# παραλήπτες} } επιλέχθηκαν", + "selected-requests": "{ count, plural, =1 {1 αίτημα} other {# αιτήματα} } επιλέχθηκαν", + "selected-rules": "{ count, plural, =1 {1 κανόνας} other {# κανόνες} } επιλέχθηκαν", + "selected-template": "{ count, plural, =1 {1 πρότυπο} other {# πρότυπα} } επιλέχθηκαν", + "send-notification": "Αποστολή ειδοποίησης", + "sent": "Απεσταλμένο", + "setup": "Ρύθμιση", + "notification-sent": "Ειδοποιήσεις / Απεσταλμένα", + "set-entity-from-notification": "Ορισμός οντότητας από ειδοποίηση σε κατάσταση πίνακα", + "slack-chanel-type": "Τύπος καναλιού Slack", + "slack-chanel-types": { + "direct": "Άμεσο μήνυμα", + "private-channel": "Ιδιωτικό κανάλι", + "public-channel": "Δημόσιο κανάλι" + }, + "start-from-scratch": "Ξεκινήστε από την αρχή", + "status": "Κατάσταση", + "stop-escalation-alarm-status-become": "Διακοπή κλιμάκωσης όταν η κατάσταση του συναγερμού γίνει:", + "subject": "Θέμα", + "subject-required": "Το θέμα είναι υποχρεωτικό", + "subject-max-length": "Το θέμα πρέπει να είναι μικρότερο ή ίσο με {{ length }} χαρακτήρες", + "template": "Πρότυπο", + "template-name": "Όνομα προτύπου", + "template-required": "Το πρότυπο είναι υποχρεωτικό", + "template-type": { + "alarm": "Συναγερμός", + "alarm-assignment": "Ανάθεση συναγερμού", + "alarm-comment": "Σχόλιο συναγερμού", + "api-usage-limit": "Όριο χρήσης API", + "device-activity": "Δραστηριότητα συσκευής", + "entities-limit": "Όριο οντοτήτων", + "entity-action": "Ενέργεια οντότητας", + "general": "Γενικό", + "rule-engine-lifecycle-event": "Γεγονός κύκλου ζωής μηχανής κανόνων", + "rule-node": "Κόμβος κανόνα", + "new-platform-version": "Νέα έκδοση πλατφόρμας", + "rate-limits": "Υπέρβαση ορίων ρυθμού", + "edge-communication-failure": "Αποτυχία επικοινωνίας Edge", + "edge-connection": "Σύνδεση Edge", + "task-processing-failure": "Αποτυχία επεξεργασίας εργασίας" + }, + "templates": "Πρότυπα", + "notification-templates": "Ειδοποιήσεις / Πρότυπα", + "tenant-profiles-list-rule-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλα τα προφίλ ενοικιαστών", + "tenants-list-rule-hint": "Αν το πεδίο είναι κενό, η σκανδάλη θα εφαρμοστεί σε όλους τους ενοικιαστές", + "threshold": "Κατώφλι", + "theme-color": "Χρώμα θέματος", + "time": "Ώρα", + "track-rule-node-events": "Παρακολούθηση γεγονότων κόμβου κανόνα", + "trigger": { + "alarm": "Συναγερμός", + "alarm-assignment": "Ανάθεση συναγερμού", + "alarm-comment": "Σχόλιο συναγερμού", + "api-usage-limit": "Όριο χρήσης API", + "device-activity": "Δραστηριότητα συσκευής", + "entities-limit": "Όριο οντοτήτων", + "entity-action": "Ενέργεια οντότητας", + "rule-engine-lifecycle-event": "Γεγονός κύκλου ζωής μηχανής κανόνων", + "new-platform-version": "Νέα έκδοση πλατφόρμας", + "rate-limits": "Υπέρβαση ορίων ρυθμού", + "edge-connection": "Σύνδεση Edge", + "edge-communication-failure": "Αποτυχία επικοινωνίας Edge", + "task-processing-failure": "Αποτυχία επεξεργασίας εργασίας", + "trigger": "Σκανδάλη", + "trigger-required": "Η σκανδάλη είναι υποχρεωτική" + }, + "type": "Τύπος", + "unread": "Μη αναγνωσμένο", + "updated": "Ενημερώθηκε", + "use-deprecated-webhook-connectors": "Χρήση παρωχημένων συνδετών Webhook", + "use-old-api": "Χρήση παλιού API", + "use-template": "Χρήση προτύπου", + "view-all": "Προβολή όλων", + "warning": "Προειδοποίηση", + "webhook-url": "Webhook URL", + "webhook-url-required": "Το Webhook URL είναι υποχρεωτικό", + "workflow-url": "Workflow URL", + "workflow-url-required": "Το Workflow URL είναι υποχρεωτικό", + "channel-name": "Όνομα καναλιού", + "channel-name-required": "Το όνομα καναλιού είναι υποχρεωτικό", + "settings": { + "notification-settings": "Ρυθμίσεις ειδοποιήσεων", + "reset-all": "Επαναφορά όλων των ρυθμίσεων", + "reset-all-title": "Είστε βέβαιοι ότι θέλετε να επαναφέρετε τη φόρμα;", + "reset-all-text": "Μετά την επιβεβαίωση, η φόρμα ρυθμίσεων θα επανέλθει στην προεπιλεγμένη τιμή και θα αποθηκευτεί.", + "type": "Τύπος", + "enable-all": "Ενεργοποίηση όλων", + "disable-all": "Απενεργοποίηση όλων", + "delivery-not-configured": "Η μέθοδος παράδοσης δεν έχει ρυθμιστεί" + } + }, + "ota-update": { + "add": "Προσθήκη πακέτου", + "assign-firmware": "Εκχωρημένο firmware", + "assign-firmware-required": "Το εκχωρημένο firmware είναι υποχρεωτικό", + "assign-software": "Εκχωρημένο λογισμικό", + "assign-software-required": "Το εκχωρημένο λογισμικό είναι υποχρεωτικό", + "auto-generate-checksum": "Αυτόματη δημιουργία αθροίσματος ελέγχου", + "checksum": "Άθροισμα ελέγχου", + "checksum-hint": "Αν το άθροισμα ελέγχου είναι κενό, θα δημιουργηθεί αυτόματα", + "checksum-algorithm": "Αλγόριθμος αθροίσματος ελέγχου", + "checksum-copied-message": "Το άθροισμα ελέγχου του πακέτου έχει αντιγραφεί στο πρόχειρο", + "change-firmware": "Η αλλαγή του firmware μπορεί να προκαλέσει ενημέρωση σε { count, plural, =1 {1 συσκευή} other {# συσκευές} }.", + "change-software": "Η αλλαγή του λογισμικού μπορεί να προκαλέσει ενημέρωση σε { count, plural, =1 {1 συσκευή} other {# συσκευές} }.", + "chose-compatible-device-profile": "Το ανεβασμένο πακέτο θα είναι διαθέσιμο μόνο για συσκευές με το επιλεγμένο προφίλ.", + "chose-firmware-distributed-device": "Επιλέξτε το firmware που θα διανεμηθεί στις συσκευές", + "chose-software-distributed-device": "Επιλέξτε το λογισμικό που θα διανεμηθεί στις συσκευές", + "content-type": "Τύπος περιεχομένου", + "copy-checksum": "Αντιγραφή αθροίσματος ελέγχου", + "copy-direct-url": "Αντιγραφή άμεσου URL", + "copyId": "Αντιγραφή ID πακέτου", + "copied": "Αντιγράφηκε!", + "delete": "Διαγραφή πακέτου", + "delete-ota-update-text": "Προσοχή, μετά την επιβεβαίωση η ενημέρωση OTA δεν θα είναι ανακτήσιμη.", + "delete-ota-update-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την ενημέρωση OTA '{{title}}';", + "delete-ota-updates-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες ενημερώσεις OTA θα διαγραφούν.", + "delete-ota-updates-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 ενημέρωση OTA} other {# ενημερώσεις OTA} };", + "description": "Περιγραφή", + "direct-url": "Άμεσο URL", + "direct-url-copied-message": "Το άμεσο URL του πακέτου έχει αντιγραφεί στο πρόχειρο", + "direct-url-required": "Το άμεσο URL είναι υποχρεωτικό", + "download": "Λήψη πακέτου", + "drop-file": "Αφήστε ένα αρχείο πακέτου ή κάντε κλικ για να επιλέξετε ένα αρχείο για μεταφόρτωση.", + "drop-package-file-or": "Σύρετε και αφήστε ένα αρχείο πακέτου ή", + "file-name": "Όνομα αρχείου", + "file-size": "Μέγεθος αρχείου", + "file-size-bytes": "Μέγεθος αρχείου σε bytes", + "idCopiedMessage": "Το ID του πακέτου έχει αντιγραφεί στο πρόχειρο", + "no-firmware-matching": "Δεν βρέθηκαν συμβατά πακέτα OTA ενημερώσεων firmware που να ταιριάζουν με '{{entity}}'.", + "no-firmware-text": "Δεν έχουν προβλεφθεί συμβατά πακέτα OTA ενημερώσεων firmware.", + "no-packages-text": "Δεν βρέθηκαν πακέτα", + "no-software-matching": "Δεν βρέθηκαν συμβατά πακέτα OTA ενημερώσεων λογισμικού που να ταιριάζουν με '{{entity}}'.", + "no-software-text": "Δεν έχουν προβλεφθεί συμβατά πακέτα OTA ενημερώσεων λογισμικού.", + "ota-update": "Ενημέρωση OTA", + "ota-update-details": "Λεπτομέρειες ενημέρωσης OTA", + "ota-updates": "Ενημερώσεις OTA", + "package-file": "Αρχείο πακέτου", + "package-type": "Τύπος πακέτου", + "packages-repository": "Αποθετήριο πακέτων", + "search": "Αναζήτηση πακέτων", + "selected-package": "{ count, plural, =1 {1 πακέτο} other {# πακέτα} } επιλέχθηκαν", + "title": "Τίτλος", + "title-required": "Ο τίτλος είναι υποχρεωτικός.", + "title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "types": { + "firmware": "Firmware", + "software": "Λογισμικό" + }, + "upload-binary-file": "Μεταφόρτωση δυαδικού αρχείου", + "use-external-url": "Χρήση εξωτερικού URL", + "version": "Έκδοση", + "version-required": "Η έκδοση είναι υποχρεωτική.", + "version-tag": "Ετικέτα έκδοσης", + "version-tag-hint": "Η προσαρμοσμένη ετικέτα πρέπει να ταιριάζει με την έκδοση του πακέτου που αναφέρεται από τη συσκευή σας.", + "version-max-length": "Η έκδοση πρέπει να είναι μικρότερη από 256 χαρακτήρες", + "warning-after-save-no-edit": "Μόλις το πακέτο μεταφορτωθεί, δεν θα μπορείτε να τροποποιήσετε τον τίτλο, την έκδοση, το προφίλ συσκευής και τον τύπο πακέτου." }, "position": { - "top": "Κορυφή", - "bottom": "Κάτω μέρος", + "top": "Πάνω", + "bottom": "Κάτω", "left": "Αριστερά", "right": "Δεξιά" }, "profile": { "profile": "Προφίλ", + "last-login-time": "Τελευταία σύνδεση", "change-password": "Αλλαγή κωδικού", - "current-password": "Τρέχων κωδικός" + "current-password": "Τρέχων κωδικός", + "copy-jwt-token": "Αντιγραφή JWT token", + "jwt-token": "JWT token", + "token-valid-till": "Το token είναι έγκυρο έως", + "tokenCopiedSuccessMessage": "Το JWT token αντιγράφηκε στο πρόχειρο", + "tokenCopiedWarnMessage": "Το JWT token έχει λήξει! Παρακαλώ, ανανεώστε τη σελίδα." + }, + "profiles": { + "profiles": "Προφίλ" + }, + "security": { + "security": "Ασφάλεια", + "general-settings": "Γενικές ρυθμίσεις ασφαλείας", + "access-token": "Access token", + "access-token-required": "Το access token είναι υποχρεωτικό", + "clientId": "Client ID", + "clientId-required": "Το Client ID είναι υποχρεωτικό", + "username": "Όνομα χρήστη", + "username-required": "Το όνομα χρήστη είναι υποχρεωτικό", + "ca-cert": "Πιστοποιητικό CA", + "2fa": { + "2fa": "Διπλή επαλήθευση", + "2fa-description": "Η διπλή επαλήθευση προστατεύει τον λογαριασμό σας από μη εξουσιοδοτημένη πρόσβαση. Απλώς εισάγετε έναν κωδικό ασφαλείας κατά τη σύνδεση.", + "authenticate-with": "Μπορείτε να επαληθευτείτε με:", + "disable-2fa-provider-text": "Η απενεργοποίηση του {{name}} θα κάνει τον λογαριασμό σας λιγότερο ασφαλή", + "disable-2fa-provider-title": "Είστε βέβαιοι ότι θέλετε να απενεργοποιήσετε το {{name}};", + "get-new-code": "Λήψη νέου κωδικού", + "main-2fa-method": "Χρήση ως κύρια μέθοδος διπλής επαλήθευσης", + "dialog": { + "activation-step-description-email": "Την επόμενη φορά που θα συνδεθείτε, θα σας ζητηθεί να εισάγετε τον κωδικό ασφαλείας που θα σταλεί στο email σας.", + "activation-step-description-sms": "Την επόμενη φορά που θα συνδεθείτε, θα σας ζητηθεί να εισάγετε τον κωδικό ασφαλείας που θα σταλεί στον αριθμό τηλεφώνου.", + "activation-step-description-totp": "Την επόμενη φορά που θα συνδεθείτε, θα πρέπει να παρέχετε έναν κωδικό επαλήθευσης.", + "activation-step-label": "Ενεργοποίηση", + "backup-code-description": "Εκτυπώστε τους κωδικούς για χρήση όταν δεν έχετε πρόσβαση στο τηλέφωνό σας.", + "backup-code-warn": "Μόλις φύγετε από αυτή τη σελίδα, οι κωδικοί δεν θα είναι πλέον ορατοί. Αποθηκεύστε τους με ασφάλεια.", + "download-txt": "Λήψη (txt)", + "email-step-description": "Εισάγετε email για χρήση ως μέθοδος επαλήθευσης.", + "email-step-label": "Email", + "enable-email-title": "Ενεργοποίηση επαλήθευσης μέσω email", + "enable-sms-title": "Ενεργοποίηση επαλήθευσης μέσω SMS", + "enable-totp-title": "Ενεργοποίηση εφαρμογής επαλήθευσης", + "enter-verification-code": "Εισάγετε τον 6-ψήφιο κωδικό εδώ", + "get-backup-code-title": "Λήψη εφεδρικού κωδικού", + "next": "Επόμενο", + "scan-qr-code": "Σαρώστε τον κωδικό QR με την εφαρμογή επαλήθευσης", + "send-code": "Αποστολή κωδικού", + "sms-step-description": "Εισάγετε αριθμό τηλεφώνου για χρήση ως μέθοδος επαλήθευσης.", + "sms-step-label": "Αριθμός τηλεφώνου", + "success": "Επιτυχία!", + "totp-step-description-install": "Μπορείτε να εγκαταστήσετε εφαρμογές όπως Google Authenticator, Authy ή Duo.", + "totp-step-description-open": "Ανοίξτε την εφαρμογή επαλήθευσης στο κινητό σας.", + "totp-step-label": "Λήψη εφαρμογής", + "verification-code": "6-ψήφιος κωδικός", + "verification-code-invalid": "Μη έγκυρη μορφή κωδικού", + "verification-code-incorrect": "Ο κωδικός είναι λανθασμένος", + "verification-code-many-request": "Πολλές προσπάθειες, ελέγξτε τον κωδικό", + "verification-step-description": "Εισάγετε τον 6-ψήφιο κωδικό που μόλις σας στείλαμε στο {{address}}", + "verification-step-label": "Επαλήθευση" + }, + "provider": { + "email": "Email", + "email-description": "Χρησιμοποιήστε κωδικό που αποστέλλεται στο email σας.", + "email-hint": "Οι κωδικοί αποστέλλονται στο {{ info }}", + "sms": "SMS", + "sms-description": "Χρησιμοποιήστε το κινητό σας για επαλήθευση. Θα σας αποστείλουμε έναν κωδικό μέσω SMS.", + "sms-hint": "Οι κωδικοί αποστέλλονται στο {{ info }}", + "totp": "Εφαρμογή επαλήθευσης", + "totp-description": "Χρησιμοποιήστε εφαρμογές όπως Google Authenticator, Authy ή Duo για την επαλήθευση.", + "totp-hint": "Η εφαρμογή επαλήθευσης έχει ρυθμιστεί για τον λογαριασμό σας", + "backup_code": "Εφεδρικός κωδικός", + "backup-code-description": "Οι εφεδρικοί κωδικοί επιτρέπουν σύνδεση όταν δεν έχετε πρόσβαση στο κινητό σας.", + "backup-code-hint": "{{ info }} μονο-χρησιμοποιούμενοι κωδικοί είναι ενεργοί αυτή τη στιγμή" + } + }, + "password-requirement": { + "at-least": "Τουλάχιστον:", + "character": "{ count, plural, =1 {1 χαρακτήρας} other {# χαρακτήρες} }", + "digit": "{ count, plural, =1 {1 ψηφίο} other {# ψηφία} }", + "incorrect-password-try-again": "Λανθασμένος κωδικός. Προσπαθήστε ξανά", + "lowercase-letter": "{ count, plural, =1 {1 πεζό γράμμα} other {# πεζά γράμματα} }", + "new-passwords-not-match": "Οι νέοι κωδικοί δεν ταιριάζουν", + "password-should-not-contain-spaces": "Ο κωδικός σας δεν πρέπει να περιέχει κενά", + "password-not-meet-requirements": "Ο κωδικός δεν πληροί τις απαιτήσεις", + "password-requirements": "Απαιτήσεις κωδικού", + "password-should-difference": "Ο νέος κωδικός πρέπει να διαφέρει από τον τρέχοντα", + "special-character": "{ count, plural, =1 {1 ειδικός χαρακτήρας} other {# ειδικοί χαρακτήρες} }", + "uppercase-letter": "{ count, plural, =1 {1 κεφαλαίο γράμμα} other {# κεφαλαία γράμματα} }", + "at-most": "Το πολύ:" + } }, "relation": { - "relations": "Σχέσεις", + "relations": "Συσχετίσεις", "direction": "Κατεύθυνση", + "clear-relation-type": "Καθαρισμός τύπου συσχέτισης", "search-direction": { "FROM": "Από", - "TO": "Σε" + "TO": "Προς" }, "direction-type": { "FROM": "από", - "TO": "σε" + "TO": "προς" }, - "from-relations": "Εξωτερικές σχέσεις", - "to-relations": "Εσωτερικές σχέσεις", - "selected-relations": "{ count, plural, =1 {1 relation} other {# relations} } επιλέχθηκαν", + "from-relations": "Εξερχόμενες συσχετίσεις", + "to-relations": "Εισερχόμενες συσχετίσεις", + "selected-relations": "{ count, plural, =1 {1 συσχέτιση} other {# συσχετίσεις} } επιλέχθηκαν", "type": "Τύπος", - "to-entity-type": "Στον τύπο οντότητας", - "to-entity-name": "Στο όνομα οντότητας", - "from-entity-type": "Από τύπο οντότητας", + "to-entity-type": "Προς τύπος οντότητας", + "to-entity-name": "Προς όνομα οντότητας", + "from-entity-type": "Από τύπος οντότητας", "from-entity-name": "Από όνομα οντότητας", - "to-entity": "Σε οντότητα", + "to-entity": "Προς οντότητα", "from-entity": "Από οντότητα", - "delete": "Διαγραφή σχέσης", - "relation-type": "Relation type", - "relation-type-required": "Απαιτείται τύπος σχέσης.", + "delete": "Διαγραφή συσχέτισης", + "relation-type": "Τύπος συσχέτισης", + "relation-type-required": "Ο τύπος συσχέτισης είναι υποχρεωτικός.", + "relation-type-max-length": "Ο τύπος συσχέτισης πρέπει να είναι μικρότερος από 256 χαρακτήρες", "any-relation-type": "Οποιοσδήποτε τύπος", - "add": "Προσθήκη σχέσης", - "edit": "Επεξεργασία σχέσης", - "delete-to-relation-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τη σχέση με την οντότητα '{{entityName}}'?", + "add": "Προσθήκη συσχέτισης", + "edit": "Επεξεργασία συσχέτισης", + "delete-to-relation-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τη συσχέτιση προς την οντότητα '{{entityName}}';", "delete-to-relation-text": "Προσοχή, μετά την επιβεβαίωση η οντότητα '{{entityName}}' δεν θα σχετίζεται με την τρέχουσα οντότητα.", - "delete-to-relations-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 relation} other {# relations} };", - "delete-to-relations-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες σχέσεις θα αφαιρεθούν και οι αντίστοιχες οντότητες δεν θα σχετίζονται με την τρέχουσα οντότητα.", - "delete-from-relation-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε σχέση από την οντότητα '{{entityName}}';", + "delete-to-relations-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 συσχέτιση} other {# συσχετίσεις} };", + "delete-to-relations-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες συσχετίσεις θα διαγραφούν και οι σχετικές οντότητες δεν θα σχετίζονται με την τρέχουσα οντότητα.", + "delete-from-relation-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τη συσχέτιση από την οντότητα '{{entityName}}';", "delete-from-relation-text": "Προσοχή, μετά την επιβεβαίωση η τρέχουσα οντότητα δεν θα σχετίζεται με την οντότητα '{{entityName}}'.", - "delete-from-relations-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 relation} other {# relations} };", - "delete-from-relations-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες σχέσεις θα αφαιρεθούν και η τρέχουσα οντότητα δεν θα σχετίζεται με τις αντίστοιχες οντότητες.", - "remove-relation-filter": "Αφαίρεση φίλτρου σχέσης", - "add-relation-filter": "Προσθήκη φίλτρου σχέσης", - "any-relation": "Οποιαδήποτε σχέση", - "relation-filters": "Φίλτρα σχέσεων", - "additional-info": "Συμπληρωματικές πληροφορίες (JSON)", - "invalid-additional-info": "Δεν είναι δυνατή η ανάλυση των πρόσθετων πληροφοριών json." + "delete-from-relations-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 συσχέτιση} other {# συσχετίσεις} };", + "delete-from-relations-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες συσχετίσεις θα διαγραφούν και η τρέχουσα οντότητα δεν θα σχετίζεται με τις αντίστοιχες οντότητες.", + "remove-relation-filter": "Αφαίρεση φίλτρου συσχέτισης", + "remove-filter": "Αφαίρεση φίλτρου", + "add-relation-filter": "Προσθήκη φίλτρου συσχέτισης", + "any-relation": "Οποιαδήποτε συσχέτιση", + "relation-filters": "Φίλτρα συσχετίσεων", + "additional-info": "Επιπλέον πληροφορίες (JSON)", + "invalid-additional-info": "Δεν ήταν δυνατή η ανάλυση του JSON των επιπλέον πληροφοριών.", + "no-relations-text": "Δεν βρέθηκαν συσχετίσεις", + "not": "Όχι" + }, + "resource": { + "add": "Προσθήκη πόρου", + "all-types": "Όλα", + "copyId": "Αντιγραφή αναγνωριστικού πόρου", + "delete": "Διαγραφή πόρου", + "delete-resource-text": "Προσοχή, μετά την επιβεβαίωση ο πόρος δεν θα είναι ανακτήσιμος.", + "delete-resource-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τον πόρο '{{resourceTitle}}';", + "delete-resources-action-title": "Διαγραφή { count, plural, =1 {1 πόρου} other {# πόρων} }", + "delete-resources-text": "Σημειώστε ότι οι επιλεγμένοι πόροι, ακόμα και αν χρησιμοποιούνται σε προφίλ συσκευών, θα διαγραφούν.", + "delete-resources-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 πόρο} other {# πόρους} };", + "download": "Λήψη πόρου", + "drop-file": "Σύρετε ένα αρχείο πόρου ή κάντε κλικ για να επιλέξετε ένα αρχείο για μεταφόρτωση.", + "drop-resource-file-or": "Σύρετε ένα αρχείο πόρου ή", + "empty": "Ο πόρος είναι κενός", + "file-name": "Όνομα αρχείου", + "idCopiedMessage": "Το αναγνωριστικό του πόρου αντιγράφηκε στο πρόχειρο", + "no-resource-matching": "Δεν βρέθηκαν πόροι που να αντιστοιχούν στο '{{widgetsBundle}}'.", + "no-resource-text": "Δεν βρέθηκαν πόροι", + "open-widgets-bundle": "Άνοιγμα πακέτου γραφικών", + "resource": "Πόρος", + "resource-file": "Αρχείο πόρου", + "resource-files": "Αρχεία πόρων", + "resource-library-details": "Λεπτομέρειες πόρου", + "resource-type": "Τύπος πόρου", + "resources-library": "Βιβλιοθήκη πόρων", + "search": "Αναζήτηση πόρων", + "selected-resources": "{ count, plural, =1 {1 πόρος} other {# πόροι} } επιλέχθηκαν", + "system": "Σύστημα", + "title": "Τίτλος", + "title-required": "Ο τίτλος είναι υποχρεωτικός.", + "title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "type": { + "jks": "JKS", + "js-module": "JS module", + "lwm2m-model": "Μοντέλο LWM2M", + "pkcs-12": "PKCS #12" + }, + "resource-sub-type": "Υποτύπος", + "sub-type": { + "image": "εικόνα", + "scada-symbol": "Σύμβολο SCADA", + "extension": "Επέκταση", + "module": "Μονάδα" + } + }, + "javascript": { + "add": "Προσθήκη πόρου JavaScript", + "delete": "Διαγραφή πόρου JavaScript", + "delete-javascript-resource-text": "Προσοχή, μετά την επιβεβαίωση ο πόρος JavaScript δεν θα είναι ανακτήσιμος.", + "delete-javascript-resource-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τον πόρο JavaScript '{{resourceTitle}}';", + "delete-javascript-resources-action-title": "Διαγραφή JavaScript { count, plural, =1 {1 πόρου} other {# πόρων} }", + "delete-javascript-resources-text": "Σημειώστε ότι οι επιλεγμένοι πόροι JavaScript, ακόμα και αν χρησιμοποιούνται σε συναρτήσεις JavaScript, θα διαγραφούν.", + "delete-javascript-resources-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε JavaScript { count, plural, =1 {1 πόρο} other {# πόρους} };", + "delete-javascript-resource-in-use-text": "Εάν εξακολουθείτε να θέλετε να διαγράψετε τον πόρο JavaScript, κάντε κλικ στο κουμπί Διαγραφή ούτως ή άλλως.", + "download": "Λήψη πόρου JavaScript", + "upload-from-file": "Μεταφόρτωση JavaScript από αρχείο", + "resource-file": "Αρχείο πόρου JavaScript", + "drop-file": "Σύρετε ένα αρχείο JavaScript ή κάντε κλικ για να επιλέξετε ένα αρχείο για μεταφόρτωση.", + "drop-resource-file-or": "Σύρετε ένα αρχείο JavaScript ή", + "javascript-library": "Βιβλιοθήκη JavaScript", + "javascript-type": "Τύπος JavaScript", + "javascript-resource-details": "Λεπτομέρειες πόρου JavaScript", + "javascript-resource-is-in-use": "Ο πόρος JavaScript χρησιμοποιείται από άλλες οντότητες", + "javascript-resources-are-in-use": "Οι πόροι JavaScript χρησιμοποιούνται από άλλες οντότητες", + "javascript-resource-is-in-use-text": "Ο πόρος JavaScript '{{title}}' δεν διαγράφηκε επειδή χρησιμοποιείται από τις ακόλουθες οντότητες:", + "javascript-resources-are-in-use-text": "Δεν διαγράφηκαν όλοι οι πόροι JavaScript επειδή χρησιμοποιούνται από άλλες οντότητες.
    Μπορείτε να δείτε τις αναφορές κάνοντας κλικ στο κουμπί Αναφορές στη σειρά του αντίστοιχου πόρου.
    Αν θέλετε να τους διαγράψετε, επιλέξτε τους στον παρακάτω πίνακα και κάντε κλικ στο κουμπί Διαγραφή επιλεγμένων.", + "search": "Αναζήτηση πόρων JavaScript", + "selected-javascript-resources": "{ count, plural, =1 {1 πόρος JavaScript} other {# πόροι JavaScript} } επιλέχθηκαν", + "no-javascript-resource-text": "Δεν βρέθηκαν πόροι JavaScript", + "all-types": "Όλα", + "module-script": "Σενάριο μονάδας" + }, + "rpc": { + "error": { + "target-device-is-not-set": "Η συσκευή στόχος δεν έχει οριστεί!", + "invalid-target-entity": "Οι εντολές RPC δεν υποστηρίζονται από την οντότητα {{entityType}}.", + "failed-to-resolve-target-device": "Αποτυχία εύρεσης της συσκευής στόχου!", + "request-timeout": "Λήξη χρονικού ορίου αιτήματος", + "rpc-http-error": "Σφάλμα: {{status}} - {{statusText}}" + } }, "rulechain": { - "rulechain": "Αλυσίδα Κανόνων", - "rulechains": "Κανόνες", + "rulechain": "Αλυσίδα κανόνων", + "rulechain-events": "Συμβάντα αλυσίδας κανόνων", + "rulechains": "Αλυσίδες κανόνων", "root": "Ρίζα", - "delete": "Διαγραφή Αλυσίδας Κανόνων", + "delete": "Διαγραφή αλυσίδας κανόνων", "name": "Όνομα", - "name-required": "Απαιτείται όνομα.", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", "description": "Περιγραφή", - "add": "Προσθήκη Αλυσίδας Κανόνων", - "set-root": "Δημιουργία ριζικής Αλυσίδας Κανόνων", - "set-root-rulechain-title": "Είστε σίγουροι ότι θέλετε να δημιουργήσετε τη ριζική Αλυσίδα Κανόνων '{{ruleChainName}}' ;", - "set-root-rulechain-text": "Μετά την επιβεβαίωση, η Αλυσίδα Κανόνων θα γίνει ριζική και θα χειριστεί όλα τα εισερχόμενα μηνύματα μεταφοράς.", - "delete-rulechain-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την Αλυσίδα Κανόνων '{{ruleChainName}}';", - "delete-rulechain-text": "Προσοχή, μετά την επιβεβαίωση η Αλυσίδα Κανόνων θα αφαιρεθεί και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-rulechains-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 rule chain} other {# rule chains} };", - "delete-rulechains-action-title": "Διαγραφή { count, plural, =1 {1 rule chain} other {# rule chains} }", - "delete-rulechains-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες αλυσίδες κανόνων θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "add-rulechain-text": "Προσθήκη νέας Αλυσίδας Κανόνων", - "no-rulechains-text": "Δεν βρέθηκαν Αλυσίδες Κανόνων", - "rulechain-details": "Λεπτομέρειες Αλυσίδας Κανόνων", - "details": "Λεπομέρειες", - "events": "Γεγονότα", + "add": "Προσθήκη αλυσίδας κανόνων", + "set-root": "Ορισμός αλυσίδας κανόνων ως ρίζα", + "set-root-rulechain-title": "Είστε σίγουροι ότι θέλετε να ορίσετε την αλυσίδα κανόνων '{{ruleChainName}}' ως ρίζα;", + "set-root-rulechain-text": "Μετά την επιβεβαίωση, η αλυσίδα κανόνων θα γίνει ρίζα και θα χειρίζεται όλα τα εισερχόμενα μηνύματα μεταφοράς.", + "delete-rulechain-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την αλυσίδα κανόνων '{{ruleChainName}}';", + "delete-rulechain-text": "Προσοχή, μετά την επιβεβαίωση η αλυσίδα κανόνων και όλα τα σχετικά δεδομένα θα γίνουν μη ανακτήσιμα.", + "delete-rulechains-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 αλυσίδα κανόνων} other {# αλυσίδες κανόνων} };", + "delete-rulechains-action-title": "Διαγραφή { count, plural, =1 {1 αλυσίδας κανόνων} other {# αλυσίδων κανόνων} }", + "delete-rulechains-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες αλυσίδες κανόνων θα διαγραφούν και όλα τα σχετικά δεδομένα θα γίνουν μη ανακτήσιμα.", + "add-rulechain-text": "Προσθήκη νέας αλυσίδας κανόνων", + "no-rulechains-text": "Δεν βρέθηκαν αλυσίδες κανόνων", + "rulechain-details": "Λεπτομέρειες αλυσίδας κανόνων", + "details": "Λεπτομέρειες", + "events": "Συμβάντα", "system": "Σύστημα", - "import": "Εισαγωγή Αλυσίδας Κανόνων", - "export": "Εξαγωγή Αλυσίδας Κανόνων", - "export-failed-error": "Δεν είναι δυνατή η εξαγωγή Αλυσίδας Κανόνων: {{error}}", - "create-new-rulechain": "Δημιουργία νέας Αλυσίδας Κανόνων", - "rulechain-file": "Αρχείο Αλυσίδας Κανόνων", - "invalid-rulechain-file-error": "Δεν είναι δυνατή η εισαγωγή Αλυσίδας Κανόνων: Μη έγκυρη δομή δεδομένων Αλυσίδας Κανόνων.", - "copyId": "Αντιγραφή ταυτότητας Αλυσίδας Κανόνων", - "idCopiedMessage": "Η ταυτότητα της Αλυσίδας Κανόνων έχει αντιγραφεί στο πρόχειρο", - "select-rulechain": "Επιλογή Αλυσίδας Κανόνων", - "no-rulechains-matching": "Δεν βρέθηκαν Αλυσίδες Κανόνων που να ταιριάζουν '{{entity}}'.", - "rulechain-required": "Απαιτείται Αλυσίδα Κανόνων", + "import": "Εισαγωγή αλυσίδας κανόνων", + "export": "Εξαγωγή αλυσίδας κανόνων", + "export-failed-error": "Αδυναμία εξαγωγής αλυσίδας κανόνων: {{error}}", + "create-new-rulechain": "Δημιουργία νέας αλυσίδας κανόνων", + "rulechain-file": "Αρχείο αλυσίδας κανόνων", + "invalid-rulechain-file-error": "Αδυναμία εισαγωγής αλυσίδας κανόνων: Μη έγκυρη δομή δεδομένων.", + "copyId": "Αντιγραφή αναγνωριστικού αλυσίδας κανόνων", + "idCopiedMessage": "Το αναγνωριστικό της αλυσίδας κανόνων αντιγράφηκε στο πρόχειρο", + "select-rulechain": "Επιλέξτε αλυσίδα κανόνων", + "no-rulechains-matching": "Δεν βρέθηκαν αλυσίδες κανόνων που να ταιριάζουν με '{{entity}}'.", + "rulechain-required": "Η αλυσίδα κανόνων είναι υποχρεωτική", "management": "Διαχείριση κανόνων", - "debug-mode": "Λειτουργία Εκσφαλμάτωσης" + "debug-mode": "Λειτουργία αποσφαλμάτωσης", + "search": "Αναζήτηση αλυσίδων κανόνων", + "selected-rulechains": "{ count, plural, =1 {1 αλυσίδα κανόνων} other {# αλυσίδες κανόνων} } επιλέχθηκαν", + "open-rulechain": "Άνοιγμα αλυσίδας κανόνων", + "edge-template-root": "Ρίζα προτύπου edge", + "assign-to-edge": "Ανάθεση σε edge", + "edge-rulechain": "Αλυσίδα κανόνων edge", + "unassign-rulechain-from-edge-text": "Μετά την επιβεβαίωση η αλυσίδα κανόνων θα αποανατεθεί και δεν θα είναι προσβάσιμη από το edge.", + "unassign-rulechains-from-edge-title": "Είστε σίγουροι ότι θέλετε να αποαναθέσετε { count, plural, =1 {1 αλυσίδα κανόνων} other {# αλυσίδες κανόνων} };", + "unassign-rulechains-from-edge-text": "Μετά την επιβεβαίωση όλες οι επιλεγμένες αλυσίδες κανόνων θα αποανατεθούν και δεν θα είναι προσβάσιμες από το edge.", + "assign-rulechain-to-edge-title": "Ανάθεση αλυσίδας(ών) κανόνων στο Edge", + "assign-rulechain-to-edge-text": "Επιλέξτε τις αλυσίδες κανόνων που θα ανατεθούν στο edge", + "set-edge-template-root-rulechain": "Ορισμός ως ρίζα προτύπου edge", + "set-edge-template-root-rulechain-title": "Είστε σίγουροι ότι θέλετε να ορίσετε την αλυσίδα κανόνων '{{ruleChainName}}' ως ρίζα προτύπου edge;", + "set-edge-template-root-rulechain-text": "Μετά την επιβεβαίωση, η αλυσίδα κανόνων θα γίνει ρίζα προτύπου edge και θα είναι η βασική αλυσίδα για νέα edge.", + "invalid-rulechain-type-error": "Αδυναμία εισαγωγής: Μη έγκυρος τύπος αλυσίδας κανόνων. Αναμενόμενος τύπος: {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Ανάθεση σε edge κατά τη δημιουργία", + "set-auto-assign-to-edge-title": "Είστε σίγουροι ότι θέλετε να αναθέσετε την αλυσίδα '{{ruleChainName}}' σε edge κατά τη δημιουργία;", + "set-auto-assign-to-edge-text": "Μετά την επιβεβαίωση, η αλυσίδα θα ανατίθεται αυτόματα σε νέο edge.", + "unset-auto-assign-to-edge": "Μη αυτόματη ανάθεση σε edge κατά τη δημιουργία", + "unset-auto-assign-to-edge-title": "Είστε σίγουροι ότι δεν θέλετε την αυτόματη ανάθεση της αλυσίδας '{{ruleChainName}}';", + "unset-auto-assign-to-edge-text": "Μετά την επιβεβαίωση η αλυσίδα δεν θα ανατίθεται αυτόματα σε νέο edge.", + "unassign-rulechain-title": "Είστε σίγουροι ότι θέλετε να αποαναθέσετε την αλυσίδα '{{ruleChainName}}';", + "unassign-rulechains": "Αποανάθεση αλυσίδων κανόνων" }, "rulenode": { + "rule-node-events": "Συμβάντα κόμβου κανόνα", "details": "Λεπτομέρειες", - "events": "Γεγονότα", + "events": "Συμβάντα", "search": "Αναζήτηση κόμβων", "open-node-library": "Άνοιγμα βιβλιοθήκης κόμβων", - "add": "Προσθήκη κανόνα κόμβου", + "close-node-library": "Κλείσιμο βιβλιοθήκης κόμβων", + "add": "Προσθήκη κόμβου κανόνα", "name": "Όνομα", - "name-required": "Απαιτείται όνομα.", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", "type": "Τύπος", - "delete": "Διαγραφή κανόνα κόμβου", + "rule-node-description": "Περιγραφή κόμβου κανόνα", + "delete": "Διαγραφή κόμβου κανόνα", "select-all-objects": "Επιλογή όλων των κόμβων και συνδέσεων", "deselect-all-objects": "Αποεπιλογή όλων των κόμβων και συνδέσεων", "delete-selected-objects": "Διαγραφή επιλεγμένων κόμβων και συνδέσεων", - "delete-selected": "Delete selected", - "select-all": "Select all", - "copy-selected": "Επιλογή αντιγραφής", + "delete-selected": "Διαγραφή επιλεγμένων", + "create-nested-rulechain": "Δημιουργία ένθετης αλυσίδας κανόνων", + "select-all": "Επιλογή όλων", + "copy-selected": "Αντιγραφή επιλεγμένων", "deselect-all": "Αποεπιλογή όλων", - "rulenode-details": "Λεπτομέρειες κανόνα κόμβου", - "debug-mode": "Λειτουργία εντοπισμού σφαλμάτων", - "configuration": "Διαμόρφωση", + "rulenode-details": "Λεπτομέρειες κόμβου κανόνα", + "debug-mode": "Λειτουργία αποσφαλμάτωσης", + "singleton": "Μοναδικός", + "configuration": "Ρύθμιση", "link": "Σύνδεσμος", - "link-details": "Λεπτομέρειες συνδέσμου κανόνα κόμβου", + "link-details": "Λεπτομέρειες συνδέσμου κόμβου κανόνα", "add-link": "Προσθήκη συνδέσμου", "link-label": "Ετικέτα συνδέσμου", - "link-label-required": "Απαιτείται ετικετα συνδέσμου.", - "custom-link-label": "Ετικέτα προσαρμοσμένου συνδέσμου", - "custom-link-label-required": "Απαιτείται ετικέτα προσαρμοσμένου συνδέσμου.", + "link-label-required": "Η ετικέτα συνδέσμου είναι υποχρεωτική.", + "custom-link-label": "Προσαρμοσμένη ετικέτα συνδέσμου", + "custom-link-label-required": "Η προσαρμοσμένη ετικέτα συνδέσμου είναι υποχρεωτική.", "link-labels": "Ετικέτες συνδέσμου", "link-labels-required": "Απαιτούνται ετικέτες συνδέσμου.", "no-link-labels-found": "Δεν βρέθηκαν ετικέτες συνδέσμου", - "no-link-label-matching": "Δεν βρέθηκαν '{{label}}'.", - "create-new-link-label": "Δημιουργήστε μια νέα!", + "no-link-label-matching": "'{{label}}' δεν βρέθηκε.", + "create-new-link-label": "Δημιουργία νέας!", "type-filter": "Φίλτρο", - "type-filter-details": "Φιλτράρετε εισερχόμενα μηνύματα με διαμορφωμένες συνθήκες", + "type-filter-details": "Φιλτράρει εισερχόμενα μηνύματα με καθορισμένες συνθήκες", "type-enrichment": "Εμπλουτισμός", - "type-enrichment-details": "Προσθέστε επιπλέον πληροφορίες στα Μεταδεδομένα του μηνύματος", - "type-transformation": "Μεταμόρφωση", - "type-transformation-details": "Αλλαγή ωφέλιμου φορτίου και μεταδεδομένων μηνύματος", + "type-enrichment-details": "Προσθέτει επιπλέον πληροφορίες στα μεταδεδομένα του μηνύματος", + "type-transformation": "Μετασχηματισμός", + "type-transformation-details": "Αλλάζει το περιεχόμενο και τα μεταδεδομένα του μηνύματος", "type-action": "Ενέργεια", - "type-action-details": "Εκτέλεση ειδικής ενέργειας", - "type-analytics": "Analytics", - "type-analytics-details": "Εκτελέστε ανάλυση δεδομένων που διαβιβάζονται με ροή ή συνεχίζονται", + "type-action-details": "Εκτελεί ειδικές ενέργειες", "type-external": "Εξωτερικός", - "type-external-details": "Αλληλεπίδραση με εξωτερικό σύστημα", + "type-external-details": "Αλληλεπιδρά με εξωτερικά συστήματα", "type-rule-chain": "Αλυσίδα κανόνων", - "type-rule-chain-details": "Προωθεί τα εισερχόμενα μηνύματα σε συγκεκριμένη αλυσίδα κανόνων", - "type-input": "Εισαγωγήγή", - "type-input-details": "Λογική εισαγωγή της αλυσίδας κανόνων, προωθεί τα εισερχόμενα μηνύματα στον επόμενο σχετικό κανόνα κόμβου", + "type-rule-chain-details": "Προωθεί μηνύματα σε καθορισμένη αλυσίδα κανόνων", + "type-flow": "Ροή", + "type-flow-details": "Οργανώνει τη ροή μηνυμάτων", + "type-input": "Είσοδος", + "type-input-details": "Λογική είσοδος αλυσίδας κανόνων, προωθεί τα μηνύματα στον επόμενο κόμβο", "type-unknown": "Άγνωστο", - "type-unknown-details": "Ανεπεξέργαστος κανόνας κόμβου", - "directive-is-not-loaded": "Δεν είναι διαθέσιμη καθορισμένη οδηγία διαμόρφωσης '{{directiveName}}'.", - "ui-resources-load-error": "Αποτυχία φόρτωσης διαμόρφωσης πόρων ui.", - "invalid-target-rulechain": "Δεν είναι δυνατή η επίλυση της αλυσίδας κανόνα στόχου!", - "test-script-function": "Λειτουργία σεναρίου δοκιμής", + "type-unknown-details": "Μη αναγνωρισμένος κόμβος κανόνα", + "directive-is-not-loaded": "Η καθορισμένη οδηγία ρύθμισης '{{directiveName}}' δεν είναι διαθέσιμη.", + "ui-resources-load-error": "Αποτυχία φόρτωσης πόρων διεπαφής ρύθμισης.", + "invalid-target-rulechain": "Αδυναμία εύρεσης της στοχευμένης αλυσίδας κανόνων!", + "test-script-function": "Δοκιμή συνάρτησης script", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", "message": "Μήνυμα", "message-type": "Τύπος μηνύματος", - "select-message-type": "Επιλογή τύπου μηνύματος", - "message-type-required": "Απαιτείται τύπος μηνύματος", + "select-message-type": "Επιλέξτε τύπο μηνύματος", + "message-type-required": "Ο τύπος μηνύματος είναι υποχρεωτικός", "metadata": "Μεταδεδομένα", - "metadata-required": "Οι καταχωρίσεις μεταδεδομένων δεν μπορούν να είναι κενές.", - "output": "Απόδοση", - "test": "Τεστ", - "help": "Βοήθεια" - }, - "role": { - "role": "Ρόλος", - "roles": "Ρόλοι", - "management": "Διαχείριση ρόλων", - "view-roles": "Προβολή ρόλων", - "no-roles-matching": "Δεν βρέθηκαν ρόλοι που να ταιριάζουν '{{entity}}'.", - "role-list": "Λίστα ρόλων", - "add": "Προσθήκη ρόλου", - "view": "Προβολή ρόλου", - "no-roles-text": "Δεν βρέθηκαν ρόλοι", - "role-details": "Λεπτομέρειες ρόλου", - "add-role-text": "Προσθήκη νέου ρόλου", - "delete": "Διαγραφή ρόλου", - "delete-roles": "Διαγραφή ρόλων", - "delete-role-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το ρόλο '{{roleName}}';", - "delete-role-text": "Προσοχή, μετά την επιβεβαίωση ο ρόλος και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-roles-title": "Είστε σίγουροι ότι θέλετε να παίξετε { count, plural, =1 {1 role} other {# roles} };", - "delete-roles-action-title": "Διαγραφή { count, plural, =1 {1 role} other {# roles} }", - "delete-roles-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι ρόλοι θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "role-type": "΄Τύπος ρόλου", - "role-type-required": "Απαιτείται τύπος ρόλου.", - "select-role-type": "Επιλογή τύπου ρόλου", - "enter-role-type": "Εισαγωγή τύπου ρόλου", - "any-role": "Οποιοσδήποτε ρόλος", - "no-role-types-matching": "Δεν βρέθηκαν τύποι ρόλων που να ταιριάζουν '{{entitySubtype}}'.", - "role-type-list-empty": "Δεν έγινε επιλογή τύπου ρόλου.", - "role-types": "Τύποι ρόλου", - "name": "Όνομα", - "name-required": "Απαιτείται όνομα.", - "description": "Περιγραφή", - "events": "Γεγονότα", - "details": "Λεπτομέρειες", - "copyId": "Αντιγραφή ταυτότητας ρόλου", - "idCopiedMessage": "Η ταυτότητα ρόλου έχει αντιγραφεί στο πρόχειρο", - "permissions": "Άδειες", - "role-required": "Απαιτείται ρόλος", - "display-type": { - "GENERIC": "Γενικός", - "GROUP": "Ομάδα" - } + "metadata-required": "Οι καταχωρήσεις μεταδεδομένων δεν μπορούν να είναι κενές.", + "output": "Έξοδος", + "test": "Δοκιμή", + "help": "Βοήθεια", + "reset-debug-settings": "Επαναφορά ρυθμίσεων αποσφαλμάτωσης σε όλους τους κόμβους", + "test-with-this-message": "{{test}} με αυτό το μήνυμα", + "queue-hint": "Επιλέξτε ουρά για προώθηση μηνυμάτων σε άλλη ουρά. Η 'Κύρια' ουρά χρησιμοποιείται από προεπιλογή.", + "queue-singleton-hint": "Επιλέξτε ουρά για προώθηση μηνυμάτων σε περιβάλλοντα πολλαπλών παρουσιών. Η 'Κύρια' ουρά χρησιμοποιείται από προεπιλογή." }, - "group-permission": { - "user-group-roles": "Ρόλοι ομάδας χρηστών", - "entity-group-permissions": "Άδειες ομάδας Οντοτήτων", - "role-type": "Τύπος ρόλου", - "role-name": "Όνομα ρόλου", - "group-type": "Τύπος ομάδας", - "group-name": "Όνομα ομάδας", - "group-owner": "Κάτοχος ομάδας", - "user-group-name": "Όνομα χρήστη ομάδας", - "user-group-owner": "Κάτοχος χρήστη ομάδας", - "edit": "Επεξεργασία Αδειών", - "delete": "Διαγραφή αδειών", - "selected-group-permissions": "{ count, plural, =1 {1 group permission} other {# group permissions} } επιλέχθηκαν", - "delete-group-permission-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την άδεια ομάδας '{{roleName}}';", - "delete-group-permission-text": "Προσοχή, μετά την επιβεβαίωση η άδεια ομάδας και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-group-permission": "Διαγραφή άδειας ομάδας", - "delete-group-permissions-title": "Έίστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 group permission} other {# group permission} };", - "delete-group-permissions-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες άδειες ομάδας θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-group-permissions": "Διαγραφή αδειών ομάδας", - "add-group-permission": "Προσθήκη ομαδικής άδειας", - "edit-group-permission": "Επεξεργασία άδειας ομάδας", - "entity-group": "Ομάδα οντοτήτων", - "user-group": "Χρήστης ομάδας", - "no-owners-matching": "Δεν βρέθηκαν κάτοχοι που να ταιριάζουν '{{owner}}'.", - "target-owner-required": " Απαιτείται κάτοχος της ομάδας οντότητας.", - "target-user-group-owner-required": "Απαιτείται κάτοχος χρήστη ομάδας." - }, - "permission": { - "permissions-required": "Τουλάχιστον μία εγγραφή άδειας πρέπει να οριστεί.", - "remove-permission": "Καταργήστε την καταχώριση δικαιωμάτων", - "add-permission": "Προσθήκη καταχώρησης άδειας", - "resource": { - "resource": "Πηγή", - "select-resource": "Επιλογή πηγής", - "resource-required": "Απαιτείται πηγή", - "no-resources-matching": "Δεν βρέθηκαν πηγές που να ταιριάζουν '{{resource}}'.", - "display-type": { - "ALL": "Όλες", - "PROFILE": "Προφίλ", - "ADMIN_SETTINGS": "Ρυθμίσεις Διαχειριστή", - "ALARM": "Alarm", - "DEVICE": "Συσκευή", - "ASSET": "Asset", - "CUSTOMER": "Πελάτης", - "DASHBOARD": "Dashboard", - "ENTITY_VIEW": "Προβολή οντοτήτων", - "TENANT": "Μισθωτής", - "RULE_CHAIN": "Αλυσίδα Κανόνα", - "USER": "Χρήστης", - "WIDGETS_BUNDLE": "Δέσμη Widgets", - "WIDGET_TYPE": "Τύπος Widget", - "CONVERTER": "Μετατροπέας", - "INTEGRATION": "Ενσωμάτωση", - "SCHEDULER_EVENT": "Πρόγραμμα εκδηλώσεων", - "BLOB_ENTITY": "Δέσμη Οντότητας", - "CUSTOMER_GROUP": "Ομάδα Πελατών", - "DEVICE_GROUP": "Ομάδα Συσκευών", - "ASSET_GROUP": "Ομάδες Asset", - "USER_GROUP": "Ομάδα Χρηστών", - "ENTITY_VIEW_GROUP": "Ομάδα Οντοτήτων", - "DASHBOARD_GROUP": "Ομάδα Dashboard", - "ROLE": "Ρόλος", - "GROUP_PERMISSION": "Ομαδική Άδεια", - "WHITE_LABELING": "Εμφάνιση", - "AUDIT_LOG": "Αρχείο Ελέγχου" - } + "rule-node-config": { + "id": "Id", + "additional-info": "Πρόσθετες πληροφορίες", + "advanced-settings": "Προηγμένες ρυθμίσεις", + "create-entity-if-not-exists": "Δημιουργία οντότητας αν δεν υπάρχει", + "create-entity-if-not-exists-hint": "Αν είναι ενεργοποιημένο, θα δημιουργηθεί νέα οντότητα με τα καθορισμένα χαρακτηριστικά, εκτός αν υπάρχει ήδη. Οι υπάρχουσες οντότητες θα χρησιμοποιηθούν ως έχουν για τη σχέση.", + "select-device-connectivity-event": "Επιλέξτε γεγονός συνδεσιμότητας συσκευής", + "entity-name-pattern": "Μοτίβο ονόματος", + "device-name-pattern": "Όνομα συσκευής", + "asset-name-pattern": "Όνομα περιουσιακού στοιχείου", + "entity-view-name-pattern": "Όνομα προβολής οντότητας", + "customer-title-pattern": "Τίτλος πελάτη", + "dashboard-name-pattern": "Τίτλος πίνακα ελέγχου", + "user-name-pattern": "Email χρήστη", + "edge-name-pattern": "Όνομα Edge", + "entity-name-pattern-required": "Απαιτείται μοτίβο ονόματος", + "entity-name-pattern-hint": "Το πεδίο μοτίβου ονόματος υποστηρίζει templatization. Χρησιμοποιήστε $[messageKey] για να εξάγετε τιμή από το μήνυμα και ${metadataKey} από τα μεταδεδομένα.", + "copy-message-type": "Αντιγραφή τύπου μηνύματος", + "entity-type-pattern": "Μοτίβο τύπου", + "entity-type-pattern-required": "Απαιτείται μοτίβο τύπου", + "message-type-value": "Τιμή τύπου μηνύματος", + "message-type-value-required": "Απαιτείται τιμή τύπου μηνύματος", + "message-type-value-max-length": "Η τιμή τύπου μηνύματος πρέπει να είναι μικρότερη από 256 χαρακτήρες", + "output-message-type": "Τύπος μηνύματος εξόδου", + "entity-cache-expiration": "Χρόνος λήξης προσωρινής αποθήκευσης οντοτήτων (δευτ)", + "entity-cache-expiration-hint": "Καθορίζει το μέγιστο χρονικό διάστημα αποθήκευσης των οντοτήτων. Η τιμή 0 σημαίνει ότι δεν λήγουν ποτέ.", + "entity-cache-expiration-required": "Απαιτείται χρόνος λήξης προσωρινής αποθήκευσης οντοτήτων.", + "entity-cache-expiration-range": "Ο χρόνος πρέπει να είναι μεγαλύτερος ή ίσος με 0.", + "customer-name-pattern": "Τίτλος πελάτη", + "customer-name-pattern-required": "Ο τίτλος πελάτη είναι υποχρεωτικός", + "customer-name-pattern-hint": "Χρησιμοποιήστε $[messageKey] και ${metadataKey} για τιμές από το μήνυμα ή μεταδεδομένα.", + "create-customer-if-not-exists": "Δημιουργία νέου πελάτη αν δεν υπάρχει", + "unassign-from-customer": "Αποσυσχέτιση από συγκεκριμένο πελάτη αν το αρχικό είναι πίνακας ελέγχου", + "unassign-from-customer-tooltip": "Μόνο πίνακες ελέγχου μπορούν να συσχετιστούν με πολλούς πελάτες. Αν ο αποστολέας είναι πίνακας, καθορίστε τίτλο πελάτη για αποσυσχέτιση.", + "customer-cache-expiration": "Χρόνος λήξης προσωρινής αποθήκευσης πελατών (δευτ)", + "customer-cache-expiration-hint": "Ορίζει το μέγιστο χρονικό διάστημα αποθήκευσης πελατών. 0 σημαίνει χωρίς λήξη.", + "customer-cache-expiration-required": "Απαιτείται χρόνος λήξης προσωρινής αποθήκευσης πελατών.", + "customer-cache-expiration-range": "Ο χρόνος πρέπει να είναι μεγαλύτερος ή ίσος με 0.", + "interval-start": "Έναρξη διαστήματος", + "interval-end": "Τέλος διαστήματος", + "time-unit": "Μονάδα χρόνου", + "fetch-mode": "Λειτουργία ανάκτησης", + "order-by-timestamp": "Ταξινόμηση κατά χρονική σήμανση", + "limit": "Όριο", + "limit-hint": "Ελάχιστο 2, μέγιστο 1000. Για μία καταχώρηση, επιλέξτε 'Πρώτο' ή 'Τελευταίο'.", + "limit-required": "Απαιτείται όριο.", + "limit-range": "Το όριο πρέπει να είναι μεταξύ 2 και 1000.", + "time-unit-milliseconds": "Χιλιοστά του δευτερολέπτου", + "time-unit-seconds": "Δευτερόλεπτα", + "time-unit-minutes": "Λεπτά", + "time-unit-hours": "Ώρες", + "time-unit-days": "Ημέρες", + "time-value-range": "Επιτρεπτό εύρος 1 έως 2147483647.", + "start-interval-value-required": "Απαιτείται η έναρξη διαστήματος.", + "end-interval-value-required": "Απαιτείται το τέλος διαστήματος.", + "filter": "Φίλτρο", + "switch": "Εναλλαγή", + "math-templatization-tooltip": "Υποστηρίζει templatization με $[messageKey] και ${metadataKey}.", + "add-message-type": "Προσθήκη τύπου μηνύματος", + "select-message-types-required": "Απαιτείται τουλάχιστον ένας τύπος μηνύματος.", + "select-message-types": "Επιλογή τύπων μηνύματος", + "no-message-types-found": "Δεν βρέθηκαν τύποι μηνυμάτων", + "no-message-type-matching": "'{{messageType}}' δεν βρέθηκε.", + "create-new-message-type": "Δημιουργία νέου.", + "message-types-required": "Απαιτούνται τύποι μηνυμάτων.", + "client-attributes": "Χαρακτηριστικά πελάτη", + "shared-attributes": "Κοινά χαρακτηριστικά", + "server-attributes": "Χαρακτηριστικά διακομιστή", + "attributes-keys": "Κλειδιά χαρακτηριστικών", + "attributes-keys-required": "Απαιτούνται κλειδιά χαρακτηριστικών", + "attributes-scope": "Πεδίο χαρακτηριστικών", + "attributes-scope-value": "Τιμή πεδίου χαρακτηριστικών", + "attributes-scope-value-copy": "Αντιγραφή τιμής πεδίου", + "attributes-scope-hint": "Χρησιμοποιήστε το μεταδεδομένο 'scope' για δυναμική ρύθμιση. Εάν υπάρχει, παρακάμπτει τη ρύθμιση.", + "notify-device": "Εξαναγκασμένη ειδοποίηση συσκευής", + "send-attributes-updated-notification": "Αποστολή ειδοποίησης ενημέρωσης χαρακτηριστικών", + "send-attributes-updated-notification-hint": "Στέλνει ειδοποίηση ενημέρωσης ως ξεχωριστό μήνυμα στην ουρά.", + "send-attributes-deleted-notification": "Αποστολή ειδοποίησης διαγραφής χαρακτηριστικών", + "send-attributes-deleted-notification-hint": "Στέλνει ειδοποίηση διαγραφής ως ξεχωριστό μήνυμα στην ουρά.", + "update-attributes-only-on-value-change": "Αποθήκευση μόνο σε αλλαγή τιμής", + "update-attributes-only-on-value-change-hint": "Ενημερώνει σε κάθε μήνυμα ανεξαρτήτως αλλαγής. Μειώνει απόδοση.", + "update-attributes-only-on-value-change-hint-enabled": "Ενημερώνει μόνο αν αλλάξει τιμή. Διαφορετικά, δεν ενημερώνεται ούτε timestamp ούτε αποστέλλεται ειδοποίηση.", + "fetch-credentials-to-metadata": "Ανάκτηση διαπιστευτηρίων στα μεταδεδομένα", + "notify-device-on-update-hint": "Αν είναι ενεργό, ειδοποιεί τη συσκευή για ενημέρωση κοινών χαρακτηριστικών. Αλλιώς, ελέγχεται από το metadata 'notifyDevice'.", + "notify-device-on-delete-hint": "Αν είναι ενεργό, ειδοποιεί τη συσκευή για διαγραφή κοινών χαρακτηριστικών. Αλλιώς, ελέγχεται από το metadata 'notifyDevice'.", + "latest-timeseries": "Τελευταία δεδομένα χρονικής σειράς", + "timeseries-keys": "Κλειδιά χρονικής σειράς", + "timeseries-keys-required": "Απαιτείται τουλάχιστον ένα κλειδί χρονικής σειράς.", + "add-timeseries-key": "Προσθήκη κλειδιού χρονικής σειράς", + "add-message-field": "Προσθήκη πεδίου μηνύματος", + "relation-search-parameters": "Παράμετροι αναζήτησης σχέσεων", + "relation-parameters": "Παράμετροι σχέσης", + "add-metadata-field": "Προσθήκη πεδίου μεταδεδομένων", + "data-keys": "Ονόματα πεδίων μηνύματος", + "copy-from": "Αντιγραφή από", + "data-to-metadata": "Δεδομένα σε μεταδεδομένα", + "metadata-to-data": "Μεταδεδομένα σε δεδομένα", + "use-regular-expression-hint": "Χρησιμοποιήστε κανονική έκφραση για αντιγραφή κλειδιών με βάση μοτίβα.\n\nΣυμβουλές:\nΠατήστε 'Enter' για να ολοκληρώσετε την εισαγωγή ονόματος πεδίου.\nΠατήστε 'Backspace' για διαγραφή.\nΥποστηρίζονται πολλαπλά ονόματα.", + "interval": "Διάστημα", + "interval-required": "Απαιτείται διάστημα", + "interval-hint": "Χρονικό διάστημα αποφυγής διπλοτύπων σε δευτερόλεπτα.", + "interval-min-error": "Ελάχιστη επιτρεπτή τιμή είναι 1", + "max-pending-msgs": "Μέγιστος αριθμός εκκρεμών μηνυμάτων", + "max-pending-msgs-hint": "Μέγιστος αριθμός μηνυμάτων στη μνήμη για κάθε μοναδικό id αποφυγής διπλοτύπων.", + "max-pending-msgs-required": "Απαιτείται μέγιστος αριθμός εκκρεμών μηνυμάτων", + "max-pending-msgs-max-error": "Μέγιστη επιτρεπτή τιμή είναι 1000", + "max-pending-msgs-min-error": "Ελάχιστη επιτρεπτή τιμή είναι 1", + "max-retries": "Μέγιστες επαναλήψεις", + "max-retries-required": "Απαιτούνται μέγιστες επαναλήψεις", + "max-retries-hint": "Μέγιστες προσπάθειες για ώθηση μηνυμάτων στη σειρά. Καθυστέρηση 10 δευτερολέπτων ανά επανάληψη.", + "max-retries-max-error": "Μέγιστη επιτρεπτή τιμή είναι 100", + "max-retries-min-error": "Ελάχιστη επιτρεπτή τιμή είναι 0", + "strategy": "Στρατηγική", + "strategy-required": "Απαιτείται στρατηγική", + "strategy-all-hint": "Επιστρέφει όλα τα μηνύματα της περιόδου αποφυγής διπλοτύπων ως πίνακα JSON. Κάθε στοιχείο περιλαμβάνει msg και metadata.", + "strategy-first-hint": "Επιστρέφει το πρώτο μήνυμα της περιόδου.", + "strategy-last-hint": "Επιστρέφει το τελευταίο μήνυμα της περιόδου.", + "first": "Πρώτο", + "last": "Τελευταίο", + "all": "Όλα", + "output-msg-type-hint": "Τύπος μηνύματος του αποτελέσματος αποφυγής διπλοτύπων.", + "queue-name-hint": "Όνομα ουράς για δημοσίευση αποτελέσματος αποφυγής διπλοτύπων.", + "keys": "Κλειδιά", + "keys-required": "Απαιτούνται κλειδιά", + "rename-keys-in": "Μετονομασία κλειδιών σε", + "data": "Δεδομένα", + "message": "Μήνυμα", + "metadata": "Μεταδεδομένα", + "current-key-name": "Τρέχον όνομα κλειδιού", + "key-name-required": "Απαιτείται όνομα κλειδιού", + "new-key-name": "Νέο όνομα κλειδιού", + "new-key-name-required": "Απαιτείται νέο όνομα κλειδιού", + "metadata-keys": "Ονόματα πεδίων μεταδεδομένων", + "json-path-expression": "Έκφραση JSON path", + "json-path-expression-required": "Απαιτείται έκφραση JSON path", + "json-path-expression-hint": "Το JSONPath καθορίζει διαδρομή προς στοιχείο JSON. Το '$' είναι η ρίζα.", + "relations-query": "Ερώτημα σχέσεων", + "device-relations-query": "Ερώτημα σχέσεων συσκευής", + "max-relation-level": "Μέγιστο επίπεδο σχέσης", + "max-relation-level-error": "Η τιμή πρέπει να είναι > 0 ή να μην οριστεί.", + "max-relation-level-invalid": "Η τιμή πρέπει να είναι ακέραιος.", + "relation-type": "Τύπος σχέσης", + "relation-type-pattern": "Μοτίβο τύπου σχέσης", + "relation-type-pattern-required": "Απαιτείται μοτίβο τύπου σχέσης", + "relation-types-list": "Τύποι σχέσεων για διάδοση", + "relation-types-list-hint": "Αν δεν επιλεγούν τύποι, διαδίδονται χωρίς φιλτράρισμα.", + "unlimited-level": "Απεριόριστο επίπεδο", + "latest-telemetry": "Τελευταία τηλεμετρία", + "add-telemetry-key": "Προσθήκη κλειδιού τηλεμετρίας", + "delete-from": "Διαγραφή από", + "use-regular-expression-delete-hint": "Χρησιμοποιήστε κανονικές εκφράσεις για διαγραφή με βάση μοτίβα.\n\nΣυμβουλές:\n'Enter' για επιβεβαίωση, 'Backspace' για διαγραφή.\nΥποστηρίζονται πολλά πεδία.", + "fetch-into": "Ανάκτηση σε", + "attr-mapping": "Αντιστοίχιση χαρακτηριστικών:", + "source-attribute": "Κλειδί προέλευσης χαρακτηριστικού", + "source-attribute-required": "Απαιτείται κλειδί προέλευσης χαρακτηριστικού", + "source-telemetry": "Κλειδί προέλευσης τηλεμετρίας", + "source-telemetry-required": "Απαιτείται κλειδί προέλευσης τηλεμετρίας", + "target-key": "Κλειδί προορισμού", + "target-key-required": "Απαιτείται κλειδί προορισμού", + "attr-mapping-required": "Πρέπει να οριστεί τουλάχιστον μία αντιστοίχιση.", + "fields-mapping": "Αντιστοίχιση πεδίων", + "fields-mapping-hint": "Αν το πεδίο είναι $entityId, αποθηκεύεται το id της αρχικής οντότητας.", + "relations-query-config-direction-suffix": "προέλευση", + "profile-name": "Όνομα προφίλ", + "fetch-circle-parameter-info-from-metadata-hint": "Το μεταδεδομένο '{{perimeterKeyName}}' πρέπει να έχει μορφή: {\"latitude\":48.196, \"longitude\":24.6532, \"radius\":100.0, \"radiusUnit\":\"METER\"}", + "fetch-poligon-parameter-info-from-metadata-hint": "Το μεταδεδομένο '{{perimeterKeyName}}' πρέπει να έχει μορφή: [[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "Χρησιμοποιήστε $[messageKey] και ${metadataKey} για εξαγωγή τιμής.", + "fields-mapping-required": "Απαιτείται τουλάχιστον μία αντιστοίχιση πεδίου.", + "at-least-one-field-required": "Πρέπει να καθοριστεί τουλάχιστον ένα πεδίο με τιμή.", + "originator-fields-sv-map-hint": "Τα πεδία προορισμού υποστηρίζουν templatization με $[messageKey] και ${metadataKey}.", + "sv-map-hint": "Μόνο τα πεδία προορισμού υποστηρίζουν templatization με $[messageKey] και ${metadataKey}.", + "source-field": "Πεδίο προέλευσης", + "source-field-required": "Απαιτείται πεδίο προέλευσης.", + "originator-source": "Προέλευση", + "new-originator": "Νέα προέλευση", + "originator-customer": "Πελάτης", + "originator-tenant": "Ενοικιαστής", + "originator-related": "Συσχετιζόμενη οντότητα", + "originator-alarm-originator": "Προέλευση συναγερμού", + "originator-entity": "Οντότητα με μοτίβο ονόματος", + "clone-message": "Κλωνοποίηση μηνύματος", + "transform": "Μετασχηματισμός", + "default-ttl": "Προεπιλεγμένο TTL", + "default-ttl-required": "Απαιτείται προεπιλεγμένο TTL.", + "default-ttl-hint": "Το node θα πάρει το TTL από τα μεταδεδομένα. Αν λείπει, θα χρησιμοποιήσει την τιμή από τη διαμόρφωση. Αν είναι 0, θα εφαρμοστεί η τιμή από το προφίλ του ενοικιαστή.", + "default-ttl-zero-hint": "Το TTL δεν θα εφαρμοστεί αν είναι 0.", + "min-default-ttl-message": "Μόνο TTL 0 επιτρέπεται ως ελάχιστο.", + "generation-parameters": "Παράμετροι δημιουργίας", + "message-count": "Όριο παραγόμενων μηνυμάτων (0 - απεριόριστο)", + "message-count-required": "Απαιτείται όριο παραγόμενων μηνυμάτων.", + "min-message-count-message": "Μόνο 0 επιτρέπεται ως ελάχιστος αριθμός.", + "period-seconds": "Περίοδος σε δευτερόλεπτα", + "period-seconds-required": "Απαιτείται περίοδος.", + "generation-frequency-seconds": "Συχνότητα δημιουργίας σε δευτερόλεπτα", + "generation-frequency-required": "Απαιτείται συχνότητα δημιουργίας.", + "min-generation-frequency-message": "Ελάχιστο 60 δευτερόλεπτα επιτρέπεται.", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "Χρήση μοτίβου περιόδου από μεταδεδομένα", + "use-metadata-period-in-seconds-patterns-hint": "Αν επιλεγεί, θα χρησιμοποιείται μοτίβο περιόδου σε δευτερόλεπτα από μεταδεδομένα ή δεδομένα.", + "period-in-seconds-pattern": "Μοτίβο περιόδου σε δευτερόλεπτα", + "period-in-seconds-pattern-required": "Απαιτείται μοτίβο περιόδου.", + "min-period-seconds-message": "Μόνο 60 δευτερόλεπτα ελάχιστο επιτρέπεται.", + "originator": "Προέλευση", + "message-body": "Σώμα μηνύματος", + "message-metadata": "Μεταδεδομένα μηνύματος", + "generate": "Δημιουργία", + "current-rule-node": "Τρέχον Node Κανόνα", + "current-tenant": "Τρέχων ενοικιαστής", + "generator-function": "Συνάρτηση δημιουργίας", + "test-generator-function": "Δοκιμή συνάρτησης δημιουργίας", + "generator": "Γεννήτρια", + "test-filter-function": "Δοκιμή συνάρτησης φίλτρου", + "test-switch-function": "Δοκιμή συνάρτησης διακόπτη", + "test-transformer-function": "Δοκιμή συνάρτησης μετασχηματιστή", + "transformer": "Μετασχηματιστής", + "alarm-create-condition": "Συνθήκη δημιουργίας συναγερμού", + "test-condition-function": "Δοκιμή συνθήκης", + "alarm-clear-condition": "Συνθήκη καθαρισμού συναγερμού", + "alarm-details-builder": "Κατασκευή λεπτομερειών συναγερμού", + "test-details-function": "Δοκιμή συνάρτησης λεπτομερειών", + "alarm-type": "Τύπος συναγερμού", + "select-entity-types": "Επιλέξτε τύπους οντοτήτων", + "alarm-type-required": "Απαιτείται τύπος συναγερμού.", + "alarm-severity": "Σοβαρότητα συναγερμού", + "alarm-severity-required": "Απαιτείται σοβαρότητα συναγερμού", + "alarm-severity-pattern": "Μοτίβο σοβαρότητας συναγερμού", + "alarm-status-filter": "Φίλτρο κατάστασης συναγερμού", + "alarm-status-list-empty": "Η λίστα κατάστασης είναι κενή", + "no-alarm-status-matching": "Δεν βρέθηκε αντίστοιχη κατάσταση συναγερμού.", + "propagate": "Διάδοση συναγερμού σε σχετικές οντότητες", + "propagate-to-owner": "Διάδοση σε κάτοχο (Πελάτη ή Ενοικιαστή)", + "propagate-to-tenant": "Διάδοση στον ενοικιαστή", + "condition": "Συνθήκη", + "details": "Λεπτομέρειες", + "to-string": "Σε string", + "test-to-string-function": "Δοκιμή toString συνάρτησης", + "from-template": "Από", + "from-template-required": "Απαιτείται πεδίο Από", + "message-to-metadata": "Μήνυμα σε μεταδεδομένα", + "metadata-to-message": "Μεταδεδομένα σε μήνυμα", + "from-message": "Από μήνυμα", + "from-metadata": "Από μεταδεδομένα", + "to-template": "Προς", + "to-template-required": "Απαιτείται πεδίο Προς", + "mail-address-list-template-hint": "Λίστα διευθύνσεων χωρισμένη με κόμμα, χρησιμοποιήστε ${metadataKey} για τιμή από τα μεταδεδομένα, $[messageKey] για τιμή από το σώμα του μηνύματος", + "cc-template": "Cc", + "bcc-template": "Bcc", + "subject-template": "Θέμα", + "subject-template-required": "Απαιτείται πεδίο Θέμα", + "body-template": "Σώμα", + "body-template-required": "Απαιτείται πεδίο Σώμα", + "dynamic-mail-body-type": "Δυναμικός τύπος σώματος", + "mail-body-type": "Τύπος σώματος email", + "body-type-template": "Πρότυπο τύπου σώματος", + "reply-routing-configuration": "Διαμόρφωση δρομολόγησης απάντησης", + "rpc-reply-routing-configuration-hint": "Καθορίζει metadata κλειδιά για προσδιορισμό υπηρεσίας, συνεδρίας και αιτήματος για αποστολή απάντησης.", + "reply-routing-configuration-hint": "Καθορίζει metadata κλειδιά για προσδιορισμό υπηρεσίας και αιτήματος για απάντηση.", + "request-id-metadata-attribute": "Id Αιτήματος", + "service-id-metadata-attribute": "Id Υπηρεσίας", + "session-id-metadata-attribute": "Id Συνεδρίας", + "timeout-sec": "Χρονικό όριο (δευτερόλεπτα)", + "timeout-required": "Απαιτείται χρονικό όριο", + "min-timeout-message": "Μόνο τιμή 0 επιτρέπεται ως ελάχιστο χρονικό όριο.", + "endpoint-url-pattern": "Μοτίβο URL τελικού σημείου", + "endpoint-url-pattern-required": "Απαιτείται μοτίβο URL τελικού σημείου", + "request-method": "Μέθοδος αιτήματος", + "use-simple-client-http-factory": "Χρήση απλού HTTP client factory", + "ignore-request-body": "Χωρίς σώμα αιτήματος", + "parse-to-plain-text": "Μετατροπή σε απλό κείμενο", + "parse-to-plain-text-hint": "Αν επιλεγεί, το σώμα αιτήματος θα μετατραπεί από JSON σε απλό κείμενο, π.χ. msg = \"Hello,\\t\"world\"\" γίνεται Hello, \"world\"", + "read-timeout": "Χρονικό όριο ανάγνωσης (ms)", + "read-timeout-hint": "Τιμή 0 σημαίνει άπειρο χρονικό όριο", + "max-parallel-requests-count": "Μέγιστος αριθμός παραλλήλων αιτημάτων", + "max-parallel-requests-count-hint": "Τιμή 0 σημαίνει χωρίς περιορισμό", + "max-response-size": "Μέγιστο μέγεθος απόκρισης (KB)", + "max-response-size-hint": "Μέγιστο μέγεθος μνήμης για αποκωδικοποίηση/κωδικοποίηση HTTP", + "headers": "Κεφαλίδες", + "headers-hint": "Χρησιμοποιήστε ${metadataKey} για τιμή από τα μεταδεδομένα, $[messageKey] για τιμή από το σώμα του μηνύματος στα πεδία κεφαλίδας/τιμής", + "header": "Κεφαλίδα", + "header-required": "Απαιτείται κεφαλίδα", + "value": "Τιμή", + "value-required": "Απαιτείται τιμή", + "topic-pattern": "Μοτίβο θέματος", + "key-pattern": "Μοτίβο κλειδιού", + "key-pattern-hint": "Αν δοθεί partition number, θα χρησιμοποιηθεί. Διαφορετικά, θα χρησιμοποιηθεί το κλειδί.", + "topic-pattern-required": "Απαιτείται μοτίβο θέματος", + "topic": "Θέμα", + "topic-required": "Απαιτείται θέμα", + "bootstrap-servers": "Bootstrap servers", + "bootstrap-servers-required": "Απαιτείται τιμή bootstrap servers", + "other-properties": "Άλλες ιδιότητες", + "key": "Κλειδί", + "key-required": "Απαιτείται κλειδί", + "retries": "Αυτόματες επαναπροσπάθειες σε αποτυχία", + "min-retries-message": "Μόνο 0 επιτρέπεται ως ελάχιστο", + "batch-size-bytes": "Μέγεθος παρτίδας σε bytes", + "min-batch-size-bytes-message": "Μόνο 0 επιτρέπεται ως ελάχιστο", + "linger-ms": "Χρόνος τοπικής προσωρινής αποθήκευσης (ms)", + "min-linger-ms-message": "Μόνο 0 επιτρέπεται ως ελάχιστο", + "buffer-memory-bytes": "Μέγιστο μέγεθος buffer client σε bytes", + "min-buffer-memory-message": "Μόνο 0 επιτρέπεται ως ελάχιστο", + "memory-buffer-size-range": "Το μέγεθος buffer πρέπει να είναι μεταξύ 0 και {{max}} KB", + "acks": "Αριθμός επιβεβαιώσεων", + "topic-arn-pattern": "Μοτίβο ARN θέματος", + "topic-arn-pattern-required": "Απαιτείται μοτίβο ARN θέματος", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "Απαιτείται AWS Access Key ID", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "Απαιτείται AWS Secret Access Key", + "aws-region": "Περιοχή AWS", + "aws-region-required": "Απαιτείται περιοχή AWS", + "exchange-name-pattern": "Μοτίβο ονόματος ανταλλαγής", + "routing-key-pattern": "Μοτίβο routing key", + "message-properties": "Ιδιότητες μηνύματος", + "host": "Host", + "host-required": "Απαιτείται Host", + "port": "Θύρα", + "port-required": "Απαιτείται θύρα", + "port-range": "Η θύρα πρέπει να είναι από 1 έως 65535.", + "virtual-host": "Εικονικός host", + "username": "Όνομα χρήστη", + "password": "Κωδικός", + "automatic-recovery": "Αυτόματη επαναφορά", + "connection-timeout-ms": "Χρονικό όριο σύνδεσης (ms)", + "min-connection-timeout-ms-message": "Μόνο 0 επιτρέπεται ως ελάχιστο", + "handshake-timeout-ms": "Χρονικό όριο handshake (ms)", + "min-handshake-timeout-ms-message": "Μόνο 0 επιτρέπεται ως ελάχιστο", + "client-properties": "Ιδιότητες πελάτη", + "queue-url-pattern": "Μοτίβο URL ουράς", + "queue-url-pattern-required": "Απαιτείται μοτίβο URL ουράς", + "delay-seconds": "Καθυστέρηση (δευτ)", + "min-delay-seconds-message": "Ελάχιστο 0 δευτερόλεπτα επιτρέπεται", + "max-delay-seconds-message": "Μέγιστο 900 δευτερόλεπτα επιτρέπεται", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα", + "queue-type": "Τύπος ουράς", + "sqs-queue-standard": "Standard", + "sqs-queue-fifo": "FIFO", + "gcp-project-id": "GCP Project ID", + "gcp-project-id-required": "Απαιτείται GCP Project ID", + "gcp-service-account-key": "GCP αρχείο κλειδιού λογαριασμού υπηρεσίας", + "gcp-service-account-key-required": "Απαιτείται αρχείο κλειδιού GCP", + "pubsub-topic-name": "Όνομα θέματος", + "pubsub-topic-name-required": "Απαιτείται όνομα θέματος", + "message-attributes": "Ιδιότητες μηνύματος", + "message-attributes-hint": "Χρησιμοποιήστε ${metadataKey} για τιμή από τα μεταδεδομένα, $[messageKey] για τιμή από το σώμα του μηνύματος στα πεδία ονόματος/τιμής", + "connect-timeout": "Χρονικό όριο σύνδεσης (δευτ)", + "connect-timeout-required": "Απαιτείται χρονικό όριο σύνδεσης.", + "connect-timeout-range": "Το χρονικό όριο πρέπει να είναι από 1 έως 200.", + "client-id": "Client ID", + "client-id-hint": "Προαιρετικό. Αφήστε κενό για αυτόματη δημιουργία. Αν δοθεί, να είναι μοναδικό.", + "append-client-id-suffix": "Προσθήκη ID υπηρεσίας στο Client ID", + "client-id-suffix-hint": "Εφαρμόζεται όταν δίνεται Client ID. Προσθέτει το ID υπηρεσίας ως επίθημα για αποφυγή συγκρούσεων.", + "device-id": "ID Συσκευής", + "device-id-required": "Απαιτείται ID Συσκευής", + "clean-session": "Καθαρή συνεδρία", + "enable-ssl": "Ενεργοποίηση SSL", + "credentials": "Διαπιστευτήρια", + "credentials-type": "Τύπος διαπιστευτηρίων", + "credentials-type-required": "Απαιτείται τύπος διαπιστευτηρίων.", + "credentials-anonymous": "Ανώνυμα", + "credentials-basic": "Βασικά", + "credentials-pem": "PEM", + "credentials-pem-hint": "Απαιτείται τουλάχιστον το αρχείο πιστοποιητικού Server CA ή το ζεύγος Client certificate και Client private key", + "credentials-sas": "Shared Access Signature", + "sas-key": "SAS Κλειδί", + "sas-key-required": "Απαιτείται SAS Κλειδί.", + "hostname": "Hostname", + "hostname-required": "Απαιτείται hostname.", + "azure-ca-cert": "Αρχείο πιστοποιητικού CA", + "username-required": "Απαιτείται όνομα χρήστη.", + "password-required": "Απαιτείται κωδικός πρόσβασης.", + "ca-cert": "Αρχείο πιστοποιητικού Server CA", + "private-key": "Αρχείο ιδιωτικού κλειδιού πελάτη", + "cert": "Αρχείο πιστοποιητικού πελάτη", + "no-file": "Δεν έχει επιλεγεί αρχείο.", + "drop-file": "Αποθέστε ένα αρχείο ή κάντε κλικ για να επιλέξετε.", + "private-key-password": "Κωδικός ιδιωτικού κλειδιού", + "use-system-smtp-settings": "Χρήση ρυθμίσεων SMTP του συστήματος", + "use-metadata-dynamic-interval": "Χρήση δυναμικού διαστήματος", + "metadata-dynamic-interval-hint": "Υποστηρίζεται τεμπλοποίηση στα πεδία. Τιμές σε milliseconds.", + "use-metadata-interval-patterns-hint": "Αν επιλεγεί, χρησιμοποιούνται μοτίβα από metadata για αρχή/τέλος διαστήματος (ms).", + "use-message-alarm-data": "Χρήση δεδομένων συναγερμού από το μήνυμα", + "overwrite-alarm-details": "Αντικατάσταση λεπτομερειών συναγερμού", + "use-alarm-severity-pattern": "Χρήση μοτίβου σοβαρότητας συναγερμού", + "check-all-keys": "Έλεγχος παρουσίας όλων των πεδίων", + "check-all-keys-hint": "Αν επιλεγεί, ελέγχεται η παρουσία όλων των καθορισμένων κλειδιών.", + "check-relation-to-specific-entity": "Έλεγχος σχέσης με συγκεκριμένη οντότητα", + "check-relation-to-specific-entity-tooltip": "Αν ενεργοποιηθεί, ελέγχει για συγκεκριμένη οντότητα αντί για οποιαδήποτε.", + "check-relation-hint": "Ελέγχει ύπαρξη σχέσης με βάση κατεύθυνση και τύπο.", + "delete-relation-with-specific-entity": "Διαγραφή σχέσης με συγκεκριμένη οντότητα", + "delete-relation-with-specific-entity-hint": "Αν ενεργοποιηθεί, διαγράφει μόνο τη συγκεκριμένη σχέση.", + "delete-relation-hint": "Διαγράφει σχέσεις από originator προς οντότητα/οντότητες με βάση κατεύθυνση/τύπο.", + "remove-current-relations": "Αφαίρεση τρεχουσών σχέσεων", + "remove-current-relations-hint": "Αφαιρεί σχέσεις από τον originator.", + "change-originator-to-related-entity": "Αλλαγή originator σε σχετική οντότητα", + "change-originator-to-related-entity-hint": "Επεξεργασία μηνύματος ως από άλλη οντότητα.", + "start-interval": "Έναρξη διαστήματος", + "end-interval": "Τέλος διαστήματος", + "start-interval-required": "Απαιτείται έναρξη διαστήματος.", + "end-interval-required": "Απαιτείται τέλος διαστήματος.", + "smtp-protocol": "Πρωτόκολλο", + "smtp-host": "SMTP host", + "smtp-host-required": "Απαιτείται SMTP host.", + "smtp-port": "SMTP port", + "smtp-port-required": "Πρέπει να καθοριστεί θύρα SMTP.", + "smtp-port-range": "Η θύρα SMTP πρέπει να είναι από 1 έως 65535.", + "timeout-msec": "Timeout ms", + "min-timeout-msec-message": "Μόνο τιμή 0 επιτρέπεται ως ελάχιστο.", + "enter-username": "Εισαγωγή ονόματος χρήστη", + "enter-password": "Εισαγωγή κωδικού πρόσβασης", + "enable-tls": "Ενεργοποίηση TLS", + "tls-version": "Έκδοση TLS", + "enable-proxy": "Ενεργοποίηση proxy", + "use-system-proxy-properties": "Χρήση proxy ρυθμίσεων συστήματος", + "proxy-host": "Host proxy", + "proxy-host-required": "Απαιτείται proxy host.", + "proxy-port": "Θύρα proxy", + "proxy-port-required": "Απαιτείται θύρα proxy.", + "proxy-port-range": "Η θύρα proxy πρέπει να είναι από 1 έως 65535.", + "proxy-user": "Χρήστης proxy", + "proxy-password": "Κωδικός proxy", + "proxy-scheme": "Σχήμα proxy", + "numbers-to-template": "Πρότυπο τηλεφώνων", + "numbers-to-template-required": "Απαιτείται πρότυπο τηλεφώνων", + "numbers-to-template-hint": "Αριθμοί τηλεφώνου χωρισμένοι με κόμμα, χρησιμοποιήστε ${metadataKey} για τιμή από τα μεταδεδομένα, $[messageKey] για τιμή από το σώμα του μηνύματος", + "sms-message-template": "Πρότυπο μηνύματος SMS", + "sms-message-template-required": "Απαιτείται πρότυπο μηνύματος SMS", + "use-system-sms-settings": "Χρήση ρυθμίσεων SMS συστήματος", + "min-period-0-seconds-message": "Μόνο τιμή 0 επιτρέπεται ως ελάχιστη περίοδος.", + "max-pending-messages": "Μέγιστος αριθμός εκκρεμών μηνυμάτων", + "max-pending-messages-required": "Απαιτείται μέγιστος αριθμός εκκρεμών μηνυμάτων.", + "max-pending-messages-range": "Ο μέγιστος αριθμός εκκρεμών μηνυμάτων πρέπει να είναι εντός του εύρους από 1 έως 100000.", + "originator-types-filter": "Φίλτρο τύπων αποστολέα", + "interval-seconds": "Διάστημα σε δευτερόλεπτα", + "interval-seconds-required": "Απαιτείται διάστημα.", + "int-range": "Η τιμή δεν πρέπει να υπερβαίνει το μέγιστο όριο ακέραιου (2147483648)", + "min-interval-seconds-message": "Επιτρέπεται μόνο ελάχιστο διάστημα 1 δευτερολέπτου.", + "output-timeseries-key-prefix": "Πρόθεμα κλειδιού χρονοσειράς εξόδου", + "output-timeseries-key-prefix-required": "Απαιτείται πρόθεμα κλειδιού χρονοσειράς εξόδου.", + "separator-hint": "Πατήστε \"Enter\" για να ολοκληρώσετε την εισαγωγή.", + "select-details": "Επιλογή λεπτομερειών", + "entity-details-id": "Id", + "entity-details-title": "Τίτλος", + "entity-details-country": "Χώρα", + "entity-details-state": "Πολιτεία", + "entity-details-city": "Πόλη", + "entity-details-zip": "Ταχ. Κώδικας", + "entity-details-address": "Διεύθυνση", + "entity-details-address2": "Διεύθυνση 2", + "entity-details-additional_info": "Επιπλέον πληροφορίες", + "entity-details-phone": "Τηλέφωνο", + "entity-details-email": "Email", + "email-sender": "Αποστολέας Email", + "fields-to-check": "Πεδία προς έλεγχο", + "add-detail": "Προσθήκη λεπτομέρειας", + "check-all-keys-tooltip": "Εάν ενεργοποιηθεί, ελέγχει την παρουσία όλων των πεδίων που αναφέρονται στα ονόματα πεδίων μηνύματος και μεταδεδομένων στο εισερχόμενο μήνυμα και τα μεταδεδομένα του.", + "fields-to-check-hint": "Πατήστε \"Enter\" για να ολοκληρώσετε την εισαγωγή πεδίου. Υποστηρίζονται πολλά πεδία.", + "entity-details-list-empty": "Πρέπει να επιλεγεί τουλάχιστον μία λεπτομέρεια.", + "alarm-status": "Κατάσταση συναγερμού", + "alarm-required": "Πρέπει να επιλεγεί τουλάχιστον μία κατάσταση συναγερμού.", + "no-entity-details-matching": "Δεν βρέθηκαν αντίστοιχες λεπτομέρειες οντοτήτων.", + "custom-table-name": "Προσαρμοσμένο όνομα πίνακα", + "custom-table-name-required": "Απαιτείται όνομα πίνακα", + "custom-table-hint": "Ο πίνακας πρέπει να έχει δημιουργηθεί στον Cassandra cluster και το όνομά του να ξεκινά με 'cs_tb_'. Εισαγάγετε το όνομα χωρίς το πρόθεμα.", + "message-field": "Πεδίο μηνύματος", + "message-field-required": "Απαιτείται πεδίο μηνύματος.", + "table-col": "Στήλη πίνακα", + "table-col-required": "Απαιτείται στήλη πίνακα.", + "latitude-field-name": "Όνομα πεδίου γεωγραφικού πλάτους", + "longitude-field-name": "Όνομα πεδίου γεωγραφικού μήκους", + "latitude-field-name-required": "Απαιτείται όνομα πεδίου γεωγραφικού πλάτους.", + "longitude-field-name-required": "Απαιτείται όνομα πεδίου γεωγραφικού μήκους.", + "fetch-perimeter-info-from-metadata": "Ανάκτηση πληροφοριών περιμέτρου από μεταδεδομένα", + "fetch-perimeter-info-from-metadata-tooltip": "Εάν ο τύπος περιμέτρου είναι 'Polygon', η τιμή του πεδίου '{{perimeterKeyName}}' χρησιμοποιείται απευθείας. Αν είναι 'Circle', το πεδίο αναλύεται για εξαγωγή 'latitude', 'longitude', 'radius', 'radiusUnit'.", + "perimeter-key-name": "Όνομα κλειδιού περιμέτρου", + "perimeter-key-name-hint": "Όνομα πεδίου μεταδεδομένων που περιέχει πληροφορίες περιμέτρου.", + "perimeter-key-name-required": "Απαιτείται όνομα κλειδιού περιμέτρου.", + "perimeter-circle": "Κύκλος", + "perimeter-polygon": "Πολύγωνο", + "perimeter-type": "Τύπος περιμέτρου", + "circle-center-latitude": "Γεωγραφικό πλάτος κέντρου", + "circle-center-latitude-required": "Απαιτείται γεωγραφικό πλάτος κέντρου.", + "circle-center-longitude": "Γεωγραφικό μήκος κέντρου", + "circle-center-longitude-required": "Απαιτείται γεωγραφικό μήκος κέντρου.", + "range-unit-meter": "Μέτρο", + "range-unit-kilometer": "Χιλιόμετρο", + "range-unit-foot": "Πόδι", + "range-unit-mile": "Μίλι", + "range-unit-nautical-mile": "Ναυτικό μίλι", + "range-units": "Μονάδες απόστασης", + "range-units-required": "Απαιτούνται μονάδες απόστασης.", + "range": "Απόσταση", + "range-required": "Απαιτείται απόσταση.", + "polygon-definition": "Ορισμός πολυγώνου", + "polygon-definition-required": "Απαιτείται ορισμός πολυγώνου.", + "polygon-definition-hint": "Χρησιμοποιήστε μορφή: [[lat1,lon1],[lat2,lon2],...,[latN,lonN]].", + "min-inside-duration": "Ελάχιστη διάρκεια εντός", + "min-inside-duration-value-required": "Απαιτείται ελάχιστη διάρκεια εντός.", + "min-inside-duration-time-unit": "Μονάδα χρόνου ελάχιστης διάρκειας εντός", + "min-outside-duration": "Ελάχιστη διάρκεια εκτός", + "min-outside-duration-value-required": "Απαιτείται ελάχιστη διάρκεια εκτός.", + "min-outside-duration-time-unit": "Μονάδα χρόνου ελάχιστης διάρκειας εκτός", + "tell-failure-if-absent": "Αναφορά αποτυχίας αν απουσιάζει", + "tell-failure-if-absent-hint": "Αν λείπει κάποιο κλειδί, θα επιστραφεί μήνυμα \"Failure\".", + "get-latest-value-with-ts": "Ανάκτηση χρονικής σήμανσης για την τελευταία τιμή", + "get-latest-value-with-ts-hint": "Αν ενεργοποιηθεί, οι τιμές τηλεμετρίας θα περιλαμβάνουν και χρονική σήμανση.", + "ignore-null-strings": "Αγνόηση κενών συμβολοσειρών", + "ignore-null-strings-hint": "Αν ενεργοποιηθεί, οι κενές τιμές αγνοούνται.", + "add-metadata-key-values-as-kafka-headers": "Προσθήκη μεταδεδομένων στα Kafka headers", + "add-metadata-key-values-as-kafka-headers-hint": "Τα ζεύγη μεταδεδομένων προστίθενται ως headers στην Kafka εγγραφή.", + "charset-encoding": "Κωδικοποίηση χαρακτήρων", + "charset-encoding-required": "Απαιτείται κωδικοποίηση χαρακτήρων.", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "Μπορείτε να επιλέξετε από τη λίστα ή να προσθέσετε δικό σας όνομα.", + "device-profile-node-hint": "Χρήσιμο για συνθήκες διάρκειας/επαναλήψεων.", + "persist-alarm-rules": "Αποθήκευση κατάστασης κανόνων συναγερμού", + "persist-alarm-rules-hint": "Εάν ενεργοποιηθεί, η κατάσταση θα αποθηκεύεται στη βάση.", + "fetch-alarm-rules": "Ανάκτηση κατάστασης κανόνων συναγερμού", + "fetch-alarm-rules-hint": "Εάν ενεργοποιηθεί, η κατάσταση αποκαθίσταται κατά την αρχικοποίηση.", + "input-value-key": "Κλειδί εισερχόμενης τιμής", + "input-value-key-required": "Απαιτείται κλειδί εισερχόμενης τιμής.", + "output-value-key": "Κλειδί εξόδου τιμής", + "output-value-key-required": "Απαιτείται το κλειδί εξόδου τιμής.", + "number-of-digits-after-floating-point": "Αριθμός ψηφίων μετά την υποδιαστολή", + "number-of-digits-after-floating-point-range": "Ο αριθμός ψηφίων πρέπει να είναι από 0 έως 15.", + "failure-if-delta-negative": "Αναφορά αποτυχίας αν η διαφορά είναι αρνητική", + "failure-if-delta-negative-tooltip": "Ο κόμβος κανόνα αποτυγχάνει την επεξεργασία αν η τιμή διαφοράς είναι αρνητική.", + "use-caching": "Χρήση προσωρινής αποθήκευσης", + "use-caching-tooltip": "Ο κόμβος κανόνα θα αποθηκεύει προσωρινά την τιμή του \"{{inputValueKey}}\" για βελτίωση απόδοσης.", + "add-time-difference-between-readings": "Προσθήκη χρονικής διαφοράς μεταξύ των αναγνώσεων \"{{inputValueKey}}\"", + "add-time-difference-between-readings-tooltip": "Αν ενεργοποιηθεί, η διαφορά χρόνου θα προστεθεί στο εξερχόμενο μήνυμα ως \"{{periodValueKey}}\".", + "period-value-key": "Κλειδί τιμής περιόδου", + "period-value-key-required": "Απαιτείται κλειδί τιμής περιόδου.", + "general-pattern-hint": "Χρησιμοποιήστε ${metadataKey} για τιμή από μεταδεδομένα και $[messageKey] για τιμή από το σώμα μηνύματος.", + "alarm-severity-pattern-hint": "Χρησιμοποιήστε ${metadataKey} για τιμή από τα μεταδεδομένα, $[messageKey] για τιμή από το σώμα του μηνύματος. Η σοβαρότητα του συναγερμού πρέπει να είναι συστημική (CRITICAL, MAJOR κ.λπ.)", + "output-node-name-hint": "Το όνομα κόμβου αντιστοιχεί στον τύπο σχέσης για την προώθηση μηνυμάτων σε άλλους κόμβους.", + "use-server-ts": "Χρήση χρονικής σήμανσης διακομιστή", + "use-server-ts-hint": "Χρησιμοποιεί χρονική σήμανση διακομιστή όταν δεν ορίζεται στα δεδομένα.", + "kv-map-pattern-hint": "Όλα τα πεδία εισόδου υποστηρίζουν template. Χρησιμοποιήστε $[messageKey] ή ${metadataKey}.", + "kv-map-single-pattern-hint": "Το πεδίο εισόδου υποστηρίζει template.", + "shared-scope": "Κοινόχρηστο scope", + "server-scope": "Scope διακομιστή", + "client-scope": "Scope πελάτη", + "attribute-type": "Ιδιότητα", + "attribute-type-description": "Ανάκτηση τιμής από βάση δεδομένων", + "attribute-type-result-description": "Αποθήκευση ως ιδιότητα οντότητας", + "constant-type": "Σταθερά", + "constant-type-description": "Καθορισμός σταθερής τιμής", + "time-series-type": "Χρονοσειρά", + "time-series-type-description": "Ανάκτηση τελευταίας τιμής χρονοσειράς", + "time-series-type-result-description": "Αποθήκευση ως χρονοσειρά οντότητας", + "message-body-type": "Μήνυμα", + "message-body-type-description": "Ανάκτηση τιμής από σώμα μηνύματος", + "message-body-type-result-description": "Προσθήκη στο εξερχόμενο μήνυμα", + "message-metadata-type": "Μεταδεδομένα", + "message-metadata-type-description": "Ανάκτηση από μεταδεδομένα μηνύματος", + "message-metadata-result-description": "Προσθήκη στα μεταδεδομένα εξόδου", + "argument-tile": "Ορίσματα", + "no-arguments-prompt": "Δεν έχουν οριστεί ορίσματα", + "result-title": "Αποτέλεσμα", + "functions-field-input": "Συναρτήσεις", + "no-option-found": "Δεν βρέθηκε επιλογή", + "argument-source-field-input": "Πηγή", + "argument-source-field-input-required": "Απαιτείται πηγή ορίσματος.", + "argument-key-field-input": "Κλειδί", + "argument-key-field-input-required": "Απαιτείται κλειδί ορίσματος.", + "constant-value-field-input": "Σταθερή τιμή", + "constant-value-field-input-required": "Απαιτείται σταθερή τιμή.", + "attribute-scope-field-input": "Scope ιδιότητας", + "attribute-scope-field-input-required": "Απαιτείται scope ιδιότητας.", + "default-value-field-input": "Προεπιλεγμένη τιμή", + "type-field-input": "Τύπος", + "type-field-input-required": "Απαιτείται τύπος.", + "key-field-input": "Κλειδί", + "add-entity-type": "Προσθήκη τύπου οντότητας", + "add-device-profile": "Προσθήκη προφίλ συσκευής", + "key-field-input-required": "Απαιτείται κλειδί.", + "number-floating-point-field-input": "Ψηφία μετά την υποδιαστολή", + "number-floating-point-field-input-hint": "Χρησιμοποιήστε 0 για μετατροπή σε ακέραιο", + "add-to-message-field-input": "Προσθήκη στο μήνυμα", + "add-to-metadata-field-input": "Προσθήκη στα μεταδεδομένα", + "custom-expression-field-input": "Μαθηματική έκφραση", + "custom-expression-field-input-required": "Απαιτείται μαθηματική έκφραση", + "custom-expression-field-input-hint": "Ορισμός έκφρασης (π.χ. Fahrenheit σε Celsius)", + "retained-message": "Διατηρούμενο", + "attributes-mapping": "Αντιστοίχιση ιδιοτήτων", + "latest-telemetry-mapping": "Αντιστοίχιση τελευταίας τηλεμετρίας", + "add-mapped-attribute-to": "Προσθήκη ιδιοτήτων σε", + "add-mapped-latest-telemetry-to": "Προσθήκη τηλεμετρίας σε", + "add-mapped-fields-to": "Προσθήκη πεδίων σε", + "add-selected-details-to": "Προσθήκη επιλεγμένων λεπτομερειών σε", + "clear-selected-types": "Απαλοιφή τύπων", + "clear-selected-details": "Απαλοιφή λεπτομερειών", + "clear-selected-fields": "Απαλοιφή πεδίων", + "clear-selected-keys": "Απαλοιφή κλειδιών", + "geofence-configuration": "Ρυθμίσεις γεωφράχτη", + "coordinate-field-names": "Ονόματα πεδίων συντεταγμένων", + "coordinate-field-hint": "Αναζήτηση πεδίων από μήνυμα ή μεταδεδομένα", + "presence-monitoring-strategy": "Στρατηγική παρακολούθησης παρουσίας", + "presence-monitoring-strategy-on-first-message": "Στο πρώτο μήνυμα", + "presence-monitoring-strategy-on-each-message": "Σε κάθε μήνυμα", + "presence-monitoring-strategy-on-first-message-hint": "Αναφέρει 'Inside/Outside' μετά το πρώτο μήνυμα και την ελάχιστη διάρκεια.", + "presence-monitoring-strategy-on-each-message-hint": "Αναφέρει 'Inside/Outside' σε κάθε μήνυμα μετά από αλλαγή παρουσίας.", + "fetch-credentials-to": "Ανάκτηση διαπιστευτηρίων σε", + "add-originator-attributes-to": "Προσθήκη ιδιοτήτων αποστολέα σε", + "originator-attributes": "Ιδιότητες αποστολέα", + "fetch-latest-telemetry-with-timestamp": "Ανάκτηση τελευταίας τηλεμετρίας με timestamp", + "fetch-latest-telemetry-with-timestamp-tooltip": "Οι τιμές προστίθενται στα μεταδεδομένα με χρονική σήμανση.", + "tell-failure": "Αναφορά αποτυχίας αν λείπουν ιδιότητες", + "tell-failure-tooltip": "Αν λείπει κάποιο κλειδί, το μήνυμα εξόδου αποτυγχάνει.", + "created-time": "Ώρα δημιουργίας", + "chip-help": "Πατήστε 'Enter' για να ολοκληρώσετε την εισαγωγή {{inputName}}. \nΠατήστε 'Backspace' για να διαγράψετε το {{inputName}}. \nΥποστηρίζονται πολλαπλές τιμές.", + "detail": "λεπτομέρεια", + "field-name": "όνομα πεδίου", + "device-profile": "προφίλ συσκευής", + "entity-type": "τύπος οντότητας", + "message-type": "τύπος μηνύματος", + "timeseries-key": "κλειδί χρονοσειράς", + "type": "Τύπος", + "first-name": "Όνομα", + "last-name": "Επώνυμο", + "label": "Ετικέτα", + "originator-fields-mapping": "Χαρτογράφηση πεδίων αποστολέα", + "add-mapped-originator-fields-to": "Προσθήκη χαρτογραφημένων πεδίων αποστολέα σε", + "fields": "Πεδία", + "skip-empty-fields": "Παράλειψη κενών πεδίων", + "skip-empty-fields-tooltip": "Τα πεδία με κενές τιμές δεν θα προστεθούν στο μήνυμα εξόδου ή τα μεταδεδομένα εξόδου.", + "fetch-interval": "Διάστημα ανάκτησης", + "fetch-strategy": "Στρατηγική ανάκτησης", + "fetch-timeseries-from-to": "Ανάκτηση χρονοσειρών από {{startInterval}} {{startIntervalTimeUnit}} πριν έως {{endInterval}} {{endIntervalTimeUnit}} πριν.", + "fetch-timeseries-from-to-invalid": "Μη έγκυρη ανάκτηση χρονοσειρών (\"Η αρχή του διαστήματος\" πρέπει να είναι μικρότερη από \"το τέλος του διαστήματος\").", + "use-metadata-dynamic-interval-tooltip": "Εάν επιλεγεί, ο κόμβος κανόνα θα χρησιμοποιήσει δυναμικά πρότυπα για την έναρξη και λήξη του διαστήματος βάσει μηνύματος και μεταδεδομένων.", + "all-mode-hint": "Αν επιλεγεί 'Όλα', θα ανακτηθεί τηλεμετρία εντός του καθορισμένου διαστήματος με παραμέτρους ερωτήματος.", + "first-mode-hint": "Αν επιλεγεί 'Πρώτο', θα ανακτηθεί η πιο κοντινή τηλεμετρία στην αρχή του διαστήματος.", + "last-mode-hint": "Αν επιλεγεί 'Τελευταίο', θα ανακτηθεί η πιο κοντινή τηλεμετρία στο τέλος του διαστήματος.", + "ascending": "Αύξουσα", + "descending": "Φθίνουσα", + "min": "Ελάχιστο", + "max": "Μέγιστο", + "average": "Μέσος όρος", + "sum": "Άθροισμα", + "count": "Πλήθος", + "none": "Καμία", + "last-level-relation-tooltip": "Αν επιλεγεί, η αναζήτηση σχετικών οντοτήτων περιορίζεται στο καθορισμένο επίπεδο σχέσεων.", + "last-level-device-relation-tooltip": "Αν επιλεγεί, η αναζήτηση σχετικών συσκευών περιορίζεται στο καθορισμένο επίπεδο σχέσεων.", + "data-to-fetch": "Δεδομένα προς ανάκτηση", + "mapping-of-customers": "Χαρτογράφηση πελατών", + "map-fields-required": "Απαιτούνται όλα τα πεδία χαρτογράφησης.", + "attributes": "Ιδιότητες", + "related-device-attributes": "Ιδιότητες σχετικής συσκευής", + "add-selected-attributes-to": "Προσθήκη επιλεγμένων ιδιοτήτων σε", + "device-profiles": "Προφίλ συσκευών", + "mapping-of-tenant": "Χαρτογράφηση ενοικιαστή", + "add-attribute-key": "Προσθήκη κλειδιού ιδιότητας", + "message-template": "Πρότυπο μηνύματος", + "message-template-required": "Απαιτείται πρότυπο μηνύματος", + "use-system-slack-settings": "Χρήση ρυθμίσεων συστήματος για Slack", + "slack-api-token": "Slack API token", + "slack-api-token-required": "Απαιτείται Slack API token", + "keys-mapping": "Χαρτογράφηση κλειδιών", + "add-key": "Προσθήκη κλειδιού", + "recipients": "Παραλήπτες", + "message-subject-and-content": "Θέμα και περιεχόμενο μηνύματος", + "template-rules-hint": "Και τα δύο πεδία υποστηρίζουν template. Χρησιμοποιήστε $[messageKey] ή ${metadataKey}.", + "originator-customer-desc": "Χρήση πελάτη αποστολέα ως νέος αποστολέας.", + "originator-tenant-desc": "Χρήση τρέχοντος ενοικιαστή ως νέος αποστολέας.", + "originator-related-entity-desc": "Χρήση σχετικής οντότητας ως νέος αποστολέας βάσει σχέσης.", + "originator-alarm-originator-desc": "Χρήση αποστολέα συναγερμού ως νέος αποστολέας (μόνο αν είναι αποστολέας συναγερμού).", + "originator-entity-by-name-pattern-desc": "Χρήση οντότητας από βάση δεδομένων ως αποστολέας βάσει ονόματος.", + "email-from-template-hint": "Χρησιμοποιήστε $[messageKey] ή ${metadataKey} για τιμές.", + "recipients-block-main-hint": "Διευθύνσεις διαχωρισμένες με κόμμα. Υποστηρίζονται templates.", + "forward-msg-default-rule-chain": "Προώθηση μηνύματος στην προεπιλεγμένη αλυσίδα κανόνων αποστολέα", + "forward-msg-default-rule-chain-tooltip": "Αν δεν υπάρχει προεπιλεγμένη αλυσίδα, χρησιμοποιείται αυτή από τη διαμόρφωση προφίλ.", + "exclude-zero-deltas": "Αποκλεισμός μηδενικών διαφορών από μήνυμα εξόδου", + "exclude-zero-deltas-hint": "Το {{outputValueKey}} προστίθεται μόνο αν η τιμή δεν είναι 0.", + "exclude-zero-deltas-time-difference-hint": "Το {{outputValueKey}} και {{periodValueKey}} προστίθενται μόνο αν η τιμή {{outputValueKey}} δεν είναι 0.", + "search-direction-from": "Από αποστολέα προς στόχο", + "search-direction-to": "Από στόχο προς αποστολέα", + "del-relation-direction-from": "Από αποστολέα", + "del-relation-direction-to": "Προς αποστολέα", + "target-entity": "Οντότητα στόχος", + "function-configuration": "Διαμόρφωση συνάρτησης", + "function-name": "Όνομα συνάρτησης", + "function-name-required": "Απαιτείται όνομα συνάρτησης.", + "qualifier": "Καταληκτικό", + "qualifier-hint": "Αν δεν καθοριστεί, χρησιμοποιείται το \"$LATEST\".", + "aws-credentials": "Διαπιστευτήρια AWS", + "connection-timeout": "Χρονικό όριο σύνδεσης", + "connection-timeout-required": "Απαιτείται χρονικό όριο σύνδεσης.", + "connection-timeout-min": "Ελάχιστο χρονικό όριο σύνδεσης είναι 0.", + "connection-timeout-hint": "Ο χρόνος αναμονής σε δευτερόλεπτα κατά την αρχική σύνδεση πριν από διακοπή. Τιμή 0 σημαίνει απεριόριστος χρόνος και δεν συνιστάται.", + "request-timeout": "Χρονικό όριο αιτήματος", + "request-timeout-required": "Απαιτείται χρονικό όριο αιτήματος", + "request-timeout-min": "Ελάχιστο χρονικό όριο αιτήματος είναι 0", + "request-timeout-hint": "Ο χρόνος αναμονής σε δευτερόλεπτα για την ολοκλήρωση του αιτήματος πριν από διακοπή. Τιμή 0 σημαίνει απεριόριστος χρόνος και δεν συνιστάται.", + "units": "Μονάδες", + "tell-failure-aws-lambda": "Αναφορά αποτυχίας αν η AWS Lambda επιστρέψει εξαίρεση", + "tell-failure-aws-lambda-hint": "Ο κόμβος αναγκάζει αποτυχία στην επεξεργασία αν η AWS Lambda επιστρέψει εξαίρεση.", + "basic-mode": "Βασικό", + "advanced-mode": "Για προχωρημένους", + "save-time-series": { + "processing-settings": "Ρυθμίσεις επεξεργασίας", + "processing-settings-hint": "Καθορίστε πώς θα γίνει η επεξεργασία των εισερχόμενων μηνυμάτων. Οι βασικές ρυθμίσεις επιτρέπουν προκαθορισμένες στρατηγικές, ενώ οι προχωρημένες δίνουν περισσότερη ευελιξία.", + "advanced-settings-hint": "Να είστε προσεκτικοί. Ορισμένοι συνδυασμοί μπορεί να προκαλέσουν απροσδόκητη συμπεριφορά.", + "strategy": "Στρατηγική", + "deduplication-interval": "Διάστημα απαλοιφής διπλότυπων", + "deduplication-interval-required": "Απαιτείται διάστημα απαλοιφής διπλότυπων", + "deduplication-interval-min-max-range": "Το διάστημα πρέπει να είναι τουλάχιστον 1 δευτερόλεπτο και το μέγιστο 1 ημέρα", + "strategy-type": { + "every-message": "Σε κάθε μήνυμα", + "skip": "Παράλειψη", + "deduplicate": "Απαλοιφή διπλότυπων", + "web-sockets-only": "Μόνο WebSockets" + }, + "time-series": "Χρονοσειρές", + "latest": "Τελευταίες τιμές", + "web-sockets": "WebSockets", + "calculated-fields": "Υπολογιζόμενα πεδία" }, - "operation": { - "operation": "Εργασία", - "operations": "Εργασίες", - "operations-required": "Τουλάχιστον μία εργασία πρέπει να καθοριστεί.", - "enter-operation": "Εισαγωγή εργασίας", - "no-operations-matching": "Δεν βρέθηκαν εργασίες που να ταιριάζουν '{{operation}}'.", - "display-type": { - "ALL": "'Ολες", - "CREATE": "Δημιουργία", - "READ": "Ανάγνωση", - "WRITE": "Εγγραφή", - "DELETE": "Διαγραφή", - "ASSIGN_TO_CUSTOMER": "Ανάθεση σε Πελάτη", - "UNASSIGN_FROM_CUSTOMER": "Αποσύνδεση από Πελάτη", - "RPC_CALL": "Κλήση RPC", - "READ_CREDENTIALS": "Ανάγνωση Διαπιστευτηρίων", - "WRITE_CREDENTIALS": "Γράψτε Διαπιστευτήρια", - "READ_ATTRIBUTES": "Ανάγνωση Χαρακτηριστικών", - "WRITE_ATTRIBUTES": "Γράψτε Χαρακτηριστικά", - "READ_TELEMETRY": "Ανάγνωση Τηλεμετρίας", - "WRITE_TELEMETRY": "Γράψτε Τηλεμετρία", - "CLAIM_DEVICES": "Αιτήματα Συσκευών", - "IMPERSONATE": "Impersonate", - "CHANGE_OWNER": "Αλλαγή Κατόχου", - "ADD_TO_GROUP": "Προσθήκη στην Ομάδα", - "REMOVE_FROM_GROUP": "Αφαίρεση από την Ομάδα" - } + "save-attribute": { + "processing-settings": "Ρυθμίσεις επεξεργασίας", + "processing-settings-hint": "Καθορίστε πώς θα γίνει η επεξεργασία των εισερχόμενων μηνυμάτων.", + "advanced-settings-hint": "Να είστε προσεκτικοί κατά τη διαμόρφωση στρατηγικών.", + "strategy": "Στρατηγική", + "deduplication-interval": "Διάστημα απαλοιφής διπλότυπων", + "deduplication-interval-required": "Απαιτείται διάστημα απαλοιφής διπλότυπων", + "deduplication-interval-min-max-range": "Το διάστημα πρέπει να είναι τουλάχιστον 1 δευτερόλεπτο και το μέγιστο 1 ημέρα", + "scope": "Πεδίο", + "strategy-type": { + "every-message": "Σε κάθε μήνυμα", + "skip": "Παράλειψη", + "deduplicate": "Απαλοιφή διπλότυπων", + "web-sockets-only": "Μόνο WebSockets" + }, + "attributes": "Ιδιότητες" + }, + "key-val": { + "key": "Κλειδί", + "value": "Τιμή", + "see-examples": "Δείτε παραδείγματα.", + "remove-entry": "Αφαίρεση καταχώρησης", + "remove-mapping-entry": "Αφαίρεση χαρτογράφησης", + "add-mapping-entry": "Προσθήκη χαρτογράφησης", + "add-entry": "Προσθήκη καταχώρησης", + "copy-key-values-from": "Αντιγραφή τιμών από", + "delete-key-values": "Διαγραφή τιμών", + "delete-key-values-from": "Διαγραφή τιμών από", + "at-least-one-key-error": "Πρέπει να επιλεγεί τουλάχιστον ένα κλειδί.", + "unique-key-value-pair-error": "Το '{{keyText}}' πρέπει να διαφέρει από το '{{valText}}'!" + }, + "mail-body-types": { + "plain-text": "Απλό κείμενο", + "html": "HTML", + "dynamic": "Δυναμικό", + "use-body-type-template": "Χρήση προτύπου τύπου σώματος", + "plain-text-description": "Απλό, μη μορφοποιημένο κείμενο χωρίς στυλ.", + "html-text-description": "Υποστηρίζει ετικέτες HTML για μορφοποίηση, συνδέσμους και εικόνες.", + "dynamic-text-description": "Υποστηρίζει δυναμική εναλλαγή μεταξύ Plain Text και HTML βάσει προτύπων.", + "after-template-evaluation-hint": "Μετά την αξιολόγηση, αν η τιμή είναι true τότε είναι HTML, αλλιώς Plain text." } }, - "scheduler": { - "scheduler": "Προγραμματιστής", - "scheduler-event": "Προγραμματισμένο γεγονός", - "select-scheduler-event": "Επιλέξτε προγραμματισμένα γεγονότα που να ταιριάζουν '{{entity}}'", - "scheduler-event-required": "Απαιτείται προγραμματισμένο γεγονός", - "management": "Διαχείριση Προγράμματος", - "scheduler-events": "Προγραμματισμένα γεγονότα", - "add-scheduler-event": "Προσθήκη προγραμματισμένου γεγονότος", - "search-scheduler-events": "Αναζήτηση προγραμματισμένων γεγονότων", - "created-time": "Χρόνος που δημιουργήθηκε", - "name": "Όνομα", - "type": "Τύπος", - "created_customer": "Πελάτης που δημιουργήθηκε", - "edit-scheduler-event": "Επεξεργασία προγραμματισμένου γεγονότος", - "view-scheduler-event": "Προβολή προγραμματισμένου γεγονότος", - "delete-scheduler-event": "Διαγραφή προγραμματισμένου γεγονότος", - "no-scheduler-events": "Δεν βρέθηκαν προγραμματισμένα γεγονότα", - "selected-scheduler-events": "{ count, plural, =1 {1 scheduler event} other {# scheduler events} } επιλέχθηκαν", - "delete-scheduler-event-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το προγραμματισμένο γεγονός '{{schedulerEventName}}';", - "delete-scheduler-event-text": "Προσοχή, μετά την επιβεβαίωση το προγραμματισμένο γεγονός και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-scheduler-events-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 scheduler event} other {# scheduler events} };", - "delete-scheduler-events-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα προγραμματισμένα γεγονότα θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "create": "Δημιουργία προγραμματισμένου γεγονότος", - "edit": "Επεξεργασία προγραμματισμένου γεγονότος", - "view": "Προβολή προγραμματισμένου γεγονότος", - "name-required": "Απαιτείται Όνομα", - "configuration": "Διαμόρφωση", - "schedule": "Πρόγραμμα", - "start-time": "Ώρα έναρξης", - "repeat": "Επανάληψη", - "repeats": "Επαναλήψεις", - "daily": "Καθημερινά", - "weekly": "Εβδομαδιαία", - "timer": "Χρονομετρητής", - "repeats-required": "Απαιτούνται επαναλήψεις.", - "repeat-on": "Επανάληψη σε", - "repeat-every": "Επανάληψη κάθε", - "ends-on": "Τελειώνει σε", - "sunday-label": "K", - "monday-label": "Δ", - "tuesday-label": "T", - "wednesday-label": "Τ", - "thursday-label": "Π", - "friday-label": "Π", - "saturday-label": "Σ", - "repeat-on-sunday": "Επανάληψη την Κυριακή", - "repeat-on-monday": "Επανάληψη τη Δευτέρα", - "repeat-on-tuesday": "Επανάληψη την Τρίτη", - "repeat-on-wednesday": "Επανάληψη την Τετάρτη", - "repeat-on-thursday": "Επανάληψη την Πέμπτη", - "repeat-on-friday": "Επανάληψη την Παρασκευή", - "repeat-on-saturday": "Επανάληψη το Σάββατο", - "event-type": "Τύπος Γεγονότος", - "select-event-type": "Επιλογή Τύπου Γεγονότος", - "event-type-required": "Απαιτείται Τύπος Γεγονότος.", - "list-mode": "Προβολή Λίστας", - "calendar-mode": "Προβολή Ημερολογίου", - "calendar-view-type": "Προβολή τύπου ημερολογίου", - "month": "Μήνας", - "week": "Εβδομάδα", - "day": "Ημέρα", - "agenda-week": "Εβδομαδιαία Ατζέντα", - "agenda-day": "Ημερήσια Ατζέντα", - "list-year": "Λίστα Έτους", - "list-month": "Λίστα Μήνα", - "list-week": "Λίστα Εβδομάδος", - "list-day": "Λίστα Ημέρας", - "today": "Σήμερα", - "navigate-before": "Πλοηγηθείτε Πριν", - "navigate-next": "Πλοηγηθείτε Μετά", - "starting-from": "Έναρξη Από", - "until": "μέχρι", - "on": "σε", - "sunday": "Κυριακή", - "monday": "Δευτέρα", - "tuesday": "Τρίτη", - "wednesday": "Τετάρτη", - "thursday": "Πέμπτη", - "friday": "Παρασκευή", - "saturday": "Σάββατο", - "originator": "Δημιουργός" , - "single-entity": "Ενιαία Οντότητα", - "group-of-entities": "Ομάδα Οντοτήτων", - "single-device": "Ενιαία Συσκευή", - "group-of-devices": "Ομάδα Συσκευών", - "message-body": "Σώμα μηνυμάτων", - "target": "Στόχος", - "rpc-method": "Μέθοδος", - "rpc-method-required": "Απαιτείται μέθοδος", - "rpc-params": "Παράμετροι", - "select-dashboard-state": "Επιλογή κατάστασης πίνακα ελέγχου", - "hours": "Ώρες", - "minutes": "Λεπτά", - "seconds": "Δευτερόλεπτα", - "time-interval-required": "Απαιτείται χρονικό διάστημα", - "time-unit-required": "Απαιτείται μονάδα ώρας" - }, - "report": { - "report-config": "Διαμόρφωση αναφοράς", - "email-config": "Διαμόρφωση email", - "dashboard-state-param": "Τιμή παραμέτρου κατάστασης πίνακα", - "base-url": "Βάση URL", - "base-url-required": "Απαιτείται Βάση URL.", - "use-dashboard-timewindow": "Χρησιμοποιήστε το χρονικό πλαίσιο του πίνακα ελέγχου", - "timewindow": "Χρονικό πλαίσιο", - "name-pattern": "Αναφορά πρότυπου ονόματος", - "name-pattern-required": "Απαιτείται αναφορά πρότυπου ονόματος", - "type": "Τύπος αναφοράς", - "use-current-user-credentials": "Χρησιμοποιήστε τα τρέχοντα διαπιστευτήρια του χρήστη", - "customer-user-credentials": "Διαπιστευτήρια του χρήστη του πελάτη", - "customer-user-credentials-required": "Απαιτούνται διαπιστευτήρια του χρήστη του πελάτη", - "generate-test-report": "Δημιουργία αναφοράς δοκιμής", - "send-email": "Αποστολή email", - "from": "Από", - "from-required": "Απαιτείται Από.", - "to": "Σε", - "to-required": "Απαιτείται Σε.", - "cc": "Cc", - "bcc": "Bcc", - "subject": "Θέμα", - "subject-required": "Απαιτείται Θέμα.", - "body": "Σώμα", - "body-required": "Απαιτείται Σώμα." - }, - "blob-entity": { - "blob-entity": "Ογκώδης οντότητα", - "select-blob-entity": "Επιλογή ογκώδους οντότητας", - "no-blob-entities-matching": "Δεν βρέθηκαν ογκώδεις οντότητες που να ταιριάζουν '{{entity}}'.", - "blob-entity-required": "Απαιτείται ογκώδης οντότητα", - "files": "Αρχεία", - "search": "Αναζήτηση αρχείων", - "clear-search": "Εκκαθάριση αναζήτησης", - "no-blob-entities-prompt": "Δεν βρέθηκαν αρχεία", - "report": "Αναφορά", - "created-time": "Χρόνος που δημιουργήθηκε", + "timezone": { + "timezone": "Ζώνη ώρας", + "select-timezone": "Επιλέξτε ζώνη ώρας", + "no-timezones-matching": "Δεν βρέθηκαν ζώνες ώρας που να ταιριάζουν με '{{timezone}}'.", + "timezone-required": "Απαιτείται ζώνη ώρας.", + "browser-time": "Ώρα προγράμματος περιήγησης" + }, + "queue": { + "queue-name": "Ουρά", + "no-queues-found": "Δεν βρέθηκαν ουρές.", + "no-queues-matching": "Δεν βρέθηκαν ουρές που να ταιριάζουν με '{{queue}}'.", + "select-name": "Επιλέξτε όνομα ουράς", "name": "Όνομα", - "type": "Τύπος", - "created_customer": "Δημιουργήθηκε από Πελάτη", - "download-blob-entity": "Λήψη αρχείου", - "delete-blob-entity": "Διαγραφή αρχείου", - "delete-blob-entity-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αρχείο '{{blobEntityName}}';", - "delete-blob-entity-text": "Προσοχή, μετά την επιβεβαίωση τα δεδομένα αρχείου θα διαγραφούν οριστικά." + "name-required": "Απαιτείται όνομα ουράς!", + "name-unique": "Το όνομα ουράς δεν είναι μοναδικό!", + "name-pattern": "Το όνομα ουράς περιέχει χαρακτήρες εκτός ASCII αλφαριθμητικών, '.', '_' και '-'!", + "queue-required": "Απαιτείται ουρά!", + "topic-required": "Απαιτείται θέμα ουράς!", + "poll-interval-required": "Απαιτείται διάστημα ανάκτησης!", + "poll-interval-min-value": "Η τιμή διαστήματος ανάκτησης δεν μπορεί να είναι μικρότερη από 1", + "partitions-required": "Απαιτούνται κατατμήσεις!", + "partitions-min-value": "Η τιμή κατατμήσεων δεν μπορεί να είναι μικρότερη από 1", + "pack-processing-timeout-required": "Απαιτείται χρονικό όριο επεξεργασίας", + "pack-processing-timeout-min-value": "Η τιμή χρονικού ορίου επεξεργασίας δεν μπορεί να είναι μικρότερη από 1", + "batch-size-required": "Απαιτείται μέγεθος παρτίδας!", + "batch-size-min-value": "Η τιμή μεγέθους παρτίδας δεν μπορεί να είναι μικρότερη από 1", + "retries-required": "Απαιτούνται προσπάθειες!", + "retries-min-value": "Ο αριθμός προσπαθειών δεν μπορεί να είναι αρνητικός", + "failure-percentage-required": "Απαιτείται ποσοστό αποτυχίας!", + "failure-percentage-min-value": "Το ποσοστό αποτυχίας δεν μπορεί να είναι μικρότερο από 0", + "failure-percentage-max-value": "Το ποσοστό αποτυχίας δεν μπορεί να υπερβαίνει το 100", + "pause-between-retries-required": "Απαιτείται παύση μεταξύ προσπαθειών!", + "pause-between-retries-min-value": "Η παύση δεν μπορεί να είναι μικρότερη από 1", + "max-pause-between-retries-required": "Απαιτείται μέγιστη παύση μεταξύ προσπαθειών!", + "max-pause-between-retries-min-value": "Η μέγιστη παύση δεν μπορεί να είναι μικρότερη από 1", + "submit-strategy-type-required": "Απαιτείται τύπος στρατηγικής αποστολής!", + "processing-strategy-type-required": "Απαιτείται τύπος στρατηγικής επεξεργασίας!", + "queues": "Ουρές", + "selected-queues": "{ count, plural, =1 {1 ουρά} other {# ουρές} } επιλέχθηκαν", + "delete-queue-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την ουρά '{{queueName}}';", + "delete-queues-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 ουρά} other {# ουρές} };", + "delete-queue-text": "Προσοχή, μετά την επιβεβαίωση η ουρά και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-queues-text": "Μετά την επιβεβαίωση όλες οι επιλεγμένες ουρές θα διαγραφούν και δεν θα είναι προσβάσιμες.", + "search": "Αναζήτηση ουράς", + "add": "Προσθήκη ουράς", + "details": "Λεπτομέρειες ουράς", + "topic": "Θέμα", + "submit-settings": "Ρυθμίσεις αποστολής", + "submit-strategy": "Τύπος στρατηγικής *", + "grouping-parameter": "Παράμετρος ομαδοποίησης", + "processing-settings": "Ρυθμίσεις επαναλήψεων", + "processing-strategy": "Τύπος επεξεργασίας *", + "retries-settings": "Ρυθμίσεις επαναλήψεων", + "polling-settings": "Ρυθμίσεις ανάκτησης", + "batch-processing": "Επεξεργασία παρτίδας", + "poll-interval": "Διάστημα ανάκτησης", + "partitions": "Κατατμήσεις", + "immediate-processing": "Άμεση επεξεργασία", + "consumer-per-partition": "Ανάκτηση ανά καταναλωτή", + "consumer-per-partition-hint": "Ενεργοποίηση ξεχωριστού καταναλωτή για κάθε κατάτμηση", + "duplicate-msg-to-all-partitions": "Αντιγραφή μηνύματος σε όλες τις κατατμήσεις", + "processing-timeout": "Χρονικό όριο επεξεργασίας, ms", + "batch-size": "Μέγεθος παρτίδας", + "retries": "Αριθμός προσπαθειών (0 – απεριόριστο)", + "failure-percentage": "Ποσοστό αποτυχίας, %", + "pause-between-retries": "Επανάληψη μετά από, δευτ.", + "max-pause-between-retries": "Επιπλέον επανάληψη μετά από, δευτ.", + "delete": "Διαγραφή ουράς", + "copyId": "Αντιγραφή Id ουράς", + "idCopiedMessage": "Το Id της ουράς αντιγράφηκε στο πρόχειρο", + "description": "Περιγραφή", + "description-hint": "Αυτό το κείμενο θα εμφανίζεται στην περιγραφή ουράς αντί για τη στρατηγική", + "alt-description": "Στρατηγική αποστολής: {{submitStrategy}}, Στρατηγική επεξεργασίας: {{processingStrategy}}", + "custom-properties": "Προσαρμοσμένες ιδιότητες", + "custom-properties-hint": "Προσαρμοσμένες ιδιότητες δημιουργίας ουράς (θέματος), π.χ. 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "Σειριακά ανά δημιουργό", + "sequential-by-originator-hint": "Νέο μήνυμα για π.χ. συσκευή Α δεν υποβάλλεται μέχρι να αναγνωριστεί το προηγούμενο μήνυμα για τη συσκευή Α", + "sequential-by-tenant-label": "Σειριακά ανά ενοικιαστή", + "sequential-by-tenant-hint": "Νέο μήνυμα για π.χ. ενοικιαστή Α δεν υποβάλλεται μέχρι να αναγνωριστεί το προηγούμενο μήνυμα για τον ενοικιαστή Α", + "sequential-label": "Σειριακό", + "sequential-hint": "Νέο μήνυμα δεν υποβάλλεται μέχρι να αναγνωριστεί το προηγούμενο", + "burst-label": "Ροή", + "burst-hint": "Όλα τα μηνύματα υποβάλλονται με τη σειρά άφιξής τους", + "batch-label": "Παρτίδα", + "batch-hint": "Νέα παρτίδα δεν υποβάλλεται μέχρι να αναγνωριστεί η προηγούμενη", + "skip-all-failures-label": "Παράλειψη όλων αποτυχιών", + "skip-all-failures-hint": "Παράβλεψη όλων αποτυχιών", + "skip-all-failures-and-timeouts-label": "Παράλειψη αποτυχιών και χρονικών ορίων", + "skip-all-failures-and-timeouts-hint": "Παράβλεψη όλων αποτυχιών και χρονικών ορίων", + "retry-all-label": "Επανάληψη όλων", + "retry-all-hint": "Επανάληψη όλων των μηνυμάτων της παρτίδας", + "retry-failed-label": "Επανάληψη αποτυχημένων", + "retry-failed-hint": "Επανάληψη όλων των αποτυχημένων μηνυμάτων της παρτίδας", + "retry-timeout-label": "Επανάληψη λόγω χρονικού ορίου", + "retry-timeout-hint": "Επανάληψη όλων των μηνυμάτων που απέτυχαν λόγω χρονικού ορίου", + "retry-failed-and-timeout-label": "Επανάληψη αποτυχημένων και χρονομετρημένων", + "retry-failed-and-timeout-hint": "Επανάληψη όλων των αποτυχημένων και χρονομετρημένων μηνυμάτων" + } }, - "timezone": { - "timezone": "Ζώνη Ώρας", - "select-timezone": "Επιλογή Ζώνης Ώρας", - "no-timezones-matching": "Δεν βρέθηκαν Ζώνες ώρας που να ταιριάζουν'{{timezone}}' .", - "timezone-required": "Απαιτείται Ζώνη Ώρας." + "queue-statistics": { + "queue-statistics": "Στατιστικά ουράς", + "no-queue-statistics-matching": "Δεν βρέθηκαν στατιστικά ουράς που να ταιριάζουν με '{{entity}}'.", + "queue-statistics-required": "Απαιτούνται στατιστικά ουράς.", + "list-of-queue-statistics": "{ count, plural, =1 {Ένα στατιστικό ουράς} other {Λίστα με # στατιστικά ουράς} }", + "selected-queue-statistics": "{ count, plural, =1 {1 στατιστικό ουράς} other {# στατιστικά ουράς} } επιλέχθηκαν", + "no-queue-statistics-text": "Δεν βρέθηκαν στατιστικά ουράς", + "queue-statistics-starts-with": "Στατιστικά ουράς με ονόματα που ξεκινούν με '{{prefix}}'" + }, + "server-error": { + "general": "Γενικό σφάλμα διακομιστή", + "authentication": "Σφάλμα ταυτοποίησης", + "jwt-token-expired": "Το JWT token έχει λήξει", + "tenant-trial-expired": "Η δοκιμή του ενοικιαστή έχει λήξει", + "credentials-expired": "Τα διαπιστευτήρια έχουν λήξει", + "permission-denied": "Άρνηση πρόσβασης", + "invalid-arguments": "Μη έγκυρα επιχειρήματα", + "bad-request-params": "Μη έγκυρες παράμετροι αιτήματος", + "item-not-found": "Το στοιχείο δεν βρέθηκε", + "too-many-requests": "Πάρα πολλά αιτήματα", + "too-many-updates": "Πάρα πολλές ενημερώσεις" }, "tenant": { - "tenant": "Μισθωτής", - "tenants": "Μισθωτές", - "management": "Διαχείριση Μισθωτών", - "add": "Πρόσθεση Μισθωτή", + "tenant": "Ενοικιαστής", + "tenants": "Ενοικιαστές", + "management": "Διαχείριση ενοικιαστών", + "add": "Προσθήκη ενοικιαστή", "admins": "Διαχειριστές", - "manage-tenant-admins": "Επεξεργασία των διαχειριστών του Μισθωτή", - "delete": "Διαγραφή Μισθωτή", - "add-tenant-text": "Πρόσθεση νέου Μισθωτή", - "no-tenants-text": "Δεν βρέθηκαν Μισθωτές", - "tenant-details": "Λεπτομέρειες Μισθωτή", - "delete-tenant-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τον Μισθωτή '{{tenantTitle}}';", - "delete-tenant-text": "Προσοχή, μετά την επιβεβαίωση ο Μισθωτής και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-tenants-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 tenant} other {# tenants} };", - "delete-tenants-action-title": "Διαγραφή { count, plural, =1 {1 tenant} other {# tenants} }", - "delete-tenants-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι Μισθωτές θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "manage-tenant-admins": "Διαχείριση διαχειριστών ενοικιαστή", + "delete": "Διαγραφή ενοικιαστή", + "add-tenant-text": "Προσθήκη νέου ενοικιαστή", + "no-tenants-text": "Δεν βρέθηκαν ενοικιαστές", + "tenant-details": "Λεπτομέρειες ενοικιαστή", + "title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "delete-tenant-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον ενοικιαστή '{{tenantTitle}}';", + "delete-tenant-text": "Προσοχή, μετά την επιβεβαίωση ο ενοικιαστής και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-tenants-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 ενοικιαστή} other {# ενοικιαστές} };", + "delete-tenants-action-title": "Διαγραφή { count, plural, =1 {1 ενοικιαστή} other {# ενοικιαστών} }", + "delete-tenants-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι ενοικιαστές θα διαγραφούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", "title": "Τίτλος", - "title-required": "Απαιτείται Τίτλος.", + "title-required": "Απαιτείται τίτλος.", "description": "Περιγραφή", "details": "Λεπτομέρειες", - "events": "Γεγονότα", - "copyId": "Αντιγραφή Ταυτότητας του Μισθωτή", - "idCopiedMessage": "Η Ταυτότητα του Μισθωτή έχει αντιγραφεί στο πρόχειρο", - "select-tenant": "Επιλογή Μισθωτή", - "no-tenants-matching": "Δεν βρέθηκαν Μισθωτές που να ταιριάζουν '{{entity}}'.", - "tenant-required": "Απαιτείται Μισθωτής", - "selected-tenants": "{ count, plural, =1 {1 tenant} other {# tenants} } επιλέχθηκαν", - "search": "Αναζήτηση Μισθωτών", - "allow-white-labeling": "Επιτρέπεται Προσαρμογή Εμφάνισης", - "allow-customer-white-labeling": "Επιτρέπεται Προσαρμογή Εμφάνισης Πελάτη" + "events": "Συμβάντα", + "copyId": "Αντιγραφή Id ενοικιαστή", + "idCopiedMessage": "Το Id του ενοικιαστή αντιγράφηκε στο πρόχειρο", + "select-tenant": "Επιλέξτε ενοικιαστή", + "no-tenants-matching": "Δεν βρέθηκαν ενοικιαστές που να ταιριάζουν με '{{entity}}'.", + "tenant-required": "Απαιτείται ενοικιαστής", + "search": "Αναζήτηση ενοικιαστών", + "selected-tenants": "{ count, plural, =1 {1 ενοικιαστής} other {# ενοικιαστές} } επιλέχθηκαν", + "isolated-tb-rule-engine": "Χρήση απομονωμένων ουρών Rule Engine του ThingsBoard", + "isolated-tb-rule-engine-details": "Κάθε ενοικιαστής θα έχει αφιερωμένες ουρές Rule Engine" + }, + "tenant-profile": { + "tenant-profile": "Προφίλ ενοικιαστή", + "tenant-profiles": "Προφίλ ενοικιαστών", + "add": "Προσθήκη προφίλ ενοικιαστή", + "add-profile": "Προσθήκη προφίλ", + "debug": "Αποσφαλμάτωση", + "edit": "Επεξεργασία προφίλ ενοικιαστή", + "tenant-profile-details": "Λεπτομέρειες προφίλ ενοικιαστή", + "no-tenant-profiles-text": "Δεν βρέθηκαν προφίλ ενοικιαστών", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", + "search": "Αναζήτηση προφίλ ενοικιαστών", + "selected-tenant-profiles": "{ count, plural, =1 {1 προφίλ ενοικιαστή} other {# προφίλ ενοικιαστών} } επιλέχθηκαν", + "no-tenant-profiles-matching": "Δεν βρέθηκε προφίλ ενοικιαστή που να ταιριάζει με '{{entity}}'.", + "tenant-profile-required": "Απαιτείται προφίλ ενοικιαστή", + "idCopiedMessage": "Το Id του προφίλ ενοικιαστή αντιγράφηκε στο πρόχειρο", + "set-default": "Ορισμός προεπιλεγμένου προφίλ ενοικιαστή", + "delete": "Διαγραφή προφίλ ενοικιαστή", + "copyId": "Αντιγραφή Id προφίλ ενοικιαστή", + "name": "Όνομα", + "name-required": "Το όνομα είναι υποχρεωτικό.", + "data": "Δεδομένα προφίλ", + "profile-configuration": "Διαμόρφωση προφίλ", + "description": "Περιγραφή", + "default": "Προεπιλογή", + "delete-tenant-profile-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το προφίλ ενοικιαστή '{{tenantProfileName}}';", + "delete-tenant-profile-text": "Προσοχή, μετά την επιβεβαίωση το προφίλ ενοικιαστή και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-tenant-profiles-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 προφίλ ενοικιαστή} other {# προφίλ ενοικιαστών} };", + "delete-tenant-profiles-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα προφίλ ενοικιαστών θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "set-default-tenant-profile-title": "Είστε βέβαιοι ότι θέλετε να ορίσετε το προφίλ ενοικιαστή '{{tenantProfileName}}' ως προεπιλογή;", + "set-default-tenant-profile-text": "Μετά την επιβεβαίωση, το προφίλ ενοικιαστή θα επισημανθεί ως προεπιλεγμένο και θα χρησιμοποιηθεί για νέους ενοικιαστές χωρίς ορισμένο προφίλ.", + "no-tenant-profiles-found": "Δεν βρέθηκαν προφίλ ενοικιαστών.", + "create-new-tenant-profile": "Δημιουργήστε ένα νέο!", + "create-tenant-profile": "Δημιουργία νέου προφίλ ενοικιαστή", + "import": "Εισαγωγή προφίλ ενοικιαστή", + "export": "Εξαγωγή προφίλ ενοικιαστή", + "export-failed-error": "Αδυναμία εξαγωγής προφίλ ενοικιαστή: {{error}}", + "tenant-profile-file": "Αρχείο προφίλ ενοικιαστή", + "invalid-tenant-profile-file-error": "Αδυναμία εισαγωγής προφίλ ενοικιαστή: Μη έγκυρη δομή δεδομένων προφίλ.", + "advanced-settings": "Προηγμένες ρυθμίσεις", + "entities": "Οντότητες", + "rule-engine": "Μηχανή κανόνων", + "time-to-live": "Διάρκεια ζωής", + "calculated-fields": "Υπολογισμένα πεδία", + "alarms-and-notifications": "Συναγερμοί και ειδοποιήσεις", + "ota-files-in-bytes": "Αρχεία", + "ws-title": "WS", + "unlimited": "(0 - απεριόριστο)", + "maximum-devices": "Μέγιστος αριθμός συσκευών", + "maximum-devices-required": "Απαιτείται μέγιστος αριθμός συσκευών.", + "maximum-devices-range": "Ο μέγιστος αριθμός συσκευών δεν μπορεί να είναι αρνητικός", + "maximum-assets": "Μέγιστος αριθμός πόρων", + "maximum-assets-required": "Απαιτείται μέγιστος αριθμός πόρων.", + "maximum-assets-range": "Ο μέγιστος αριθμός πόρων δεν μπορεί να είναι αρνητικός", + "maximum-customers": "Μέγιστος αριθμός πελατών", + "maximum-customers-required": "Απαιτείται μέγιστος αριθμός πελατών.", + "maximum-customers-range": "Ο μέγιστος αριθμός πελατών δεν μπορεί να είναι αρνητικός", + "maximum-users": "Μέγιστος αριθμός χρηστών", + "maximum-users-required": "Απαιτείται μέγιστος αριθμός χρηστών.", + "maximum-users-range": "Ο μέγιστος αριθμός χρηστών δεν μπορεί να είναι αρνητικός", + "maximum-dashboards": "Μέγιστος αριθμός πινάκων ελέγχου", + "maximum-dashboards-required": "Απαιτείται μέγιστος αριθμός πινάκων ελέγχου.", + "maximum-dashboards-range": "Ο μέγιστος αριθμός πινάκων ελέγχου δεν μπορεί να είναι αρνητικός", + "maximum-edges": "Μέγιστος αριθμός Edge", + "maximum-edges-required": "Απαιτείται μέγιστος αριθμός Edge.", + "maximum-edges-range": "Ο μέγιστος αριθμός Edge δεν μπορεί να είναι αρνητικός", + "maximum-rule-chains": "Μέγιστος αριθμός αλυσίδων κανόνων", + "maximum-rule-chains-required": "Απαιτείται μέγιστος αριθμός αλυσίδων κανόνων.", + "maximum-rule-chains-range": "Ο μέγιστος αριθμός αλυσίδων κανόνων δεν μπορεί να είναι αρνητικός", + "maximum-resources-sum-data-size": "Μέγιστο συνολικό μέγεθος αρχείων πόρων (bytes)", + "maximum-resources-sum-data-size-required": "Απαιτείται μέγιστο συνολικό μέγεθος αρχείων πόρων.", + "maximum-resources-sum-data-size-range": "Το μέγιστο συνολικό μέγεθος αρχείων πόρων δεν μπορεί να είναι αρνητικό", + "maximum-resource-size": "Μέγιστο μέγεθος αρχείου πόρου (bytes)", + "maximum-resource-size-required": "Απαιτείται μέγιστο μέγεθος αρχείου πόρου", + "maximum-resource-size-range": "Το μέγιστο μέγεθος αρχείου πόρου δεν μπορεί να είναι αρνητικό", + "maximum-ota-packages-sum-data-size": "Μέγιστο συνολικό μέγεθος OTA αρχείων (bytes)", + "maximum-ota-package-sum-data-size-required": "Απαιτείται μέγιστο συνολικό μέγεθος OTA αρχείων.", + "maximum-ota-package-sum-data-size-range": "Το μέγιστο συνολικό μέγεθος OTA αρχείων δεν μπορεί να είναι αρνητικό", + "maximum-debug-duration-min": "Μέγιστη διάρκεια αποσφαλμάτωσης (λεπτά)", + "maximum-debug-duration-min-range": "Η μέγιστη διάρκεια αποσφαλμάτωσης δεν μπορεί να είναι αρνητική", + "rest-requests-for-tenant": "REST αιτήματα για τον ενοικιαστή", + "transport-tenant-telemetry-msg-rate-limit": "Μηνύματα τηλεμετρίας μεταφοράς ενοικιαστή", + "transport-tenant-telemetry-data-points-rate-limit": "Σημεία δεδομένων τηλεμετρίας μεταφοράς ενοικιαστή", + "transport-device-msg-rate-limit": "Μηνύματα μεταφοράς συσκευής", + "transport-device-telemetry-msg-rate-limit": "Μηνύματα τηλεμετρίας μεταφοράς συσκευής", + "transport-device-telemetry-data-points-rate-limit": "Σημεία δεδομένων τηλεμετρίας μεταφοράς συσκευής", + "transport-gateway-msg-rate-limit": "Μηνύματα μεταφοράς gateway", + "transport-gateway-telemetry-msg-rate-limit": "Μηνύματα τηλεμετρίας μεταφοράς gateway", + "transport-gateway-telemetry-data-points-rate-limit": "Σημεία δεδομένων τηλεμετρίας μεταφοράς gateway", + "transport-gateway-device-msg-rate-limit": "Μηνύματα συσκευής gateway", + "transport-gateway-device-telemetry-msg-rate-limit": "Μηνύματα τηλεμετρίας συσκευής gateway", + "transport-gateway-device-telemetry-data-points-rate-limit": "Σημεία δεδομένων τηλεμετρίας συσκευής gateway", + "tenant-entity-export-rate-limit": "Δημιουργία έκδοσης οντότητας", + "tenant-entity-import-rate-limit": "Φόρτωση έκδοσης οντότητας", + "tenant-notification-request-rate-limit": "Αιτήματα ειδοποιήσεων", + "tenant-notification-requests-per-rule-rate-limit": "Αιτήματα ειδοποιήσεων ανά κανόνα", + "max-calculated-fields": "Μέγιστος αριθμός υπολογισμένων πεδίων ανά οντότητα", + "max-calculated-fields-range": "Ο μέγιστος αριθμός υπολογισμένων πεδίων δεν μπορεί να είναι αρνητικός", + "max-calculated-fields-required": "Απαιτείται ο μέγιστος αριθμός υπολογισμένων πεδίων", + "max-data-points-per-rolling-arg": "Μέγιστος αριθμός σημείων δεδομένων σε rolling παραμέτρους", + "max-data-points-per-rolling-arg-range": "Ο μέγιστος αριθμός σημείων δεδομένων δεν μπορεί να είναι αρνητικός", + "max-data-points-per-rolling-arg-required": "Απαιτείται μέγιστος αριθμός σημείων δεδομένων σε rolling παραμέτρους", + "max-arguments-per-cf": "Μέγιστος αριθμός παραμέτρων ανά υπολογισμένο πεδίο", + "max-arguments-per-cf-range": "Ο μέγιστος αριθμός παραμέτρων δεν μπορεί να είναι αρνητικός", + "max-arguments-per-cf-required": "Απαιτείται μέγιστος αριθμός παραμέτρων ανά υπολογισμένο πεδίο", + "max-state-size": "Μέγιστο μέγεθος κατάστασης σε KB", + "max-state-size-range": "Το μέγιστο μέγεθος κατάστασης δεν μπορεί να είναι αρνητικό", + "max-state-size-required": "Απαιτείται μέγιστο μέγεθος κατάστασης", + "max-value-argument-size": "Μέγιστο μέγεθος παραμέτρου μονής τιμής σε KB", + "max-value-argument-size-range": "Το μέγιστο μέγεθος παραμέτρου μονής τιμής δεν μπορεί να είναι αρνητικό", + "max-value-argument-size-required": "Απαιτείται μέγιστο μέγεθος παραμέτρου μονής τιμής", + "max-transport-messages": "Μέγιστος αριθμός μηνυμάτων μεταφοράς", + "max-transport-messages-required": "Απαιτείται μέγιστος αριθμός μηνυμάτων μεταφοράς", + "max-transport-messages-range": "Ο μέγιστος αριθμός μηνυμάτων μεταφοράς δεν μπορεί να είναι αρνητικός", + "max-transport-data-points": "Μέγιστος αριθμός σημείων δεδομένων μεταφοράς", + "max-transport-data-points-required": "Απαιτείται μέγιστος αριθμός σημείων δεδομένων μεταφοράς", + "max-transport-data-points-range": "Ο μέγιστος αριθμός σημείων δεδομένων μεταφοράς δεν μπορεί να είναι αρνητικός", + "max-r-e-executions": "Μέγιστος αριθμός εκτελέσεων Rule Engine", + "max-r-e-executions-required": "Απαιτείται μέγιστος αριθμός εκτελέσεων Rule Engine", + "max-r-e-executions-range": "Ο μέγιστος αριθμός εκτελέσεων Rule Engine δεν μπορεί να είναι αρνητικός", + "max-j-s-executions": "Μέγιστος αριθμός εκτελέσεων JavaScript", + "max-j-s-executions-required": "Απαιτείται μέγιστος αριθμός εκτελέσεων JavaScript", + "max-j-s-executions-range": "Ο μέγιστος αριθμός εκτελέσεων JavaScript δεν μπορεί να είναι αρνητικός", + "max-tbel-executions": "Μέγιστος αριθμός εκτελέσεων TBEL", + "max-tbel-executions-required": "Απαιτείται μέγιστος αριθμός εκτελέσεων TBEL", + "max-tbel-executions-range": "Ο μέγιστος αριθμός εκτελέσεων TBEL δεν μπορεί να είναι αρνητικός", + "max-d-p-storage-days": "Μέγιστες ημέρες αποθήκευσης σημείων δεδομένων", + "max-d-p-storage-days-required": "Απαιτούνται μέγιστες ημέρες αποθήκευσης σημείων δεδομένων", + "max-d-p-storage-days-range": "Οι μέγιστες ημέρες αποθήκευσης σημείων δεδομένων δεν μπορεί να είναι αρνητικές", + "default-storage-ttl-days": "Προεπιλεγμένες ημέρες TTL αποθήκευσης", + "default-storage-ttl-days-required": "Απαιτούνται προεπιλεγμένες ημέρες TTL αποθήκευσης", + "default-storage-ttl-days-range": "Οι προεπιλεγμένες ημέρες TTL αποθήκευσης δεν μπορεί να είναι αρνητικές", + "alarms-ttl-days": "Ημέρες TTL συναγερμών", + "alarms-ttl-days-required": "Απαιτούνται ημέρες TTL συναγερμών", + "alarms-ttl-days-days-range": "Οι ημέρες TTL συναγερμών δεν μπορεί να είναι αρνητικές", + "rpc-ttl-days": "Ημέρες TTL RPC", + "rpc-ttl-days-required": "Απαιτούνται ημέρες TTL RPC", + "rpc-ttl-days-days-range": "Οι ημέρες TTL RPC δεν μπορεί να είναι αρνητικές", + "queue-stats-ttl-days": "Ημέρες TTL στατιστικών ουράς", + "queue-stats-ttl-days-required": "Απαιτούνται ημέρες TTL στατιστικών ουράς", + "queue-stats-ttl-days-range": "Οι ημέρες TTL στατιστικών ουράς δεν μπορεί να είναι αρνητικές", + "rule-engine-exceptions-ttl-days": "Ημέρες TTL εξαιρέσεων Rule Engine", + "rule-engine-exceptions-ttl-days-required": "Απαιτούνται ημέρες TTL εξαιρέσεων Rule Engine", + "rule-engine-exceptions-ttl-days-range": "Οι ημέρες TTL εξαιρέσεων Rule Engine δεν μπορεί να είναι αρνητικές", + "max-rule-node-executions-per-message": "Μέγιστος αριθμός εκτελέσεων rule node ανά μήνυμα", + "max-rule-node-executions-per-message-required": "Απαιτείται μέγιστος αριθμός εκτελέσεων rule node ανά μήνυμα", + "max-rule-node-executions-per-message-range": "Ο μέγιστος αριθμός εκτελέσεων rule node ανά μήνυμα δεν μπορεί να είναι αρνητικός", + "max-emails": "Μέγιστος αριθμός αποσταλμένων emails", + "max-emails-required": "Απαιτείται μέγιστος αριθμός αποσταλμένων emails", + "max-emails-range": "Ο μέγιστος αριθμός αποσταλμένων emails δεν μπορεί να είναι αρνητικός", + "sms-enabled": "SMS ενεργοποιημένο", + "max-sms": "Μέγιστος αριθμός αποσταλμένων SMS", + "max-sms-required": "Απαιτείται μέγιστος αριθμός αποσταλμένων SMS.", + "max-sms-range": "Ο μέγιστος αριθμός αποσταλμένων SMS δεν μπορεί να είναι αρνητικός", + "max-created-alarms": "Μέγιστος αριθμός δημιουργημένων συναγερμών", + "max-created-alarms-required": "Απαιτείται μέγιστος αριθμός δημιουργημένων συναγερμών.", + "max-created-alarms-range": "Ο μέγιστος αριθμός δημιουργημένων συναγερμών δεν μπορεί να είναι αρνητικός", + "no-queue": "Δεν έχει διαμορφωθεί ουρά", + "add-queue": "Προσθήκη ουράς", + "queues-with-count": "Ουρές ({{count}})", + "tenant-rest-limits": "Αιτήματα REST για ενοικιαστή", + "customer-rest-limits": "Αιτήματα REST για πελάτη", + "incorrect-pattern-for-rate-limits": "Η μορφή είναι ζεύγη χωρητικότητας και περιόδου (σε δευτερόλεπτα) διαχωρισμένα με κόμμα και άνω-κάτω τελεία, π.χ. 100:1,2000:60", + "too-small-value-zero": "Η τιμή πρέπει να είναι μεγαλύτερη από 0", + "too-small-value-one": "Η τιμή πρέπει να είναι μεγαλύτερη από 1", + "queue-size-is-limited-by-system-configuration": "Το μέγεθος της ουράς περιορίζεται επίσης από τη διαμόρφωση του συστήματος.", + "cassandra-tenant-limits-configuration": "Ερώτημα Cassandra για ενοικιαστή", + "ws-limit-max-sessions-per-tenant": "Μέγιστος αριθμός συνεδριών ανά ενοικιαστή", + "ws-limit-max-sessions-per-customer": "Μέγιστος αριθμός συνεδριών ανά πελάτη", + "ws-limit-max-sessions-per-regular-user": "Μέγιστος αριθμός συνεδριών ανά χρήστη", + "ws-limit-max-sessions-per-public-user": "Μέγιστος αριθμός συνεδριών ανά δημόσιο χρήστη", + "ws-limit-queue-per-session": "Μέγιστο μέγεθος ουράς ανά συνεδρία", + "ws-limit-max-subscriptions-per-tenant": "Μέγιστος αριθμός εγγραφών ανά ενοικιαστή", + "ws-limit-max-subscriptions-per-customer": "Μέγιστος αριθμός εγγραφών ανά πελάτη", + "ws-limit-max-subscriptions-per-regular-user": "Μέγιστος αριθμός εγγραφών ανά χρήστη", + "ws-limit-max-subscriptions-per-public-user": "Μέγιστος αριθμός εγγραφών ανά δημόσιο χρήστη", + "ws-limit-updates-per-session": "WS ενημερώσεις ανά συνεδρία", + "rate-limits": { + "add-limit": "Προσθήκη ορίου", + "advanced-settings": "Προχωρημένες ρυθμίσεις", + "edit-limit": "Επεξεργασία ορίου", + "but-less-than": "αλλά λιγότερο από", + "calculated-field-debug-event-rate-limit": "Σφάλματα πεδίου υπολογισμού", + "edit-calculated-field-debug-event-rate-limit": "Επεξεργασία ορίων σφαλμάτων πεδίου υπολογισμού", + "edit-transport-tenant-msg-title": "Επεξεργασία ορίων μηνυμάτων μεταφοράς ενοικιαστή", + "edit-transport-tenant-telemetry-msg-title": "Επεξεργασία ορίων τηλεμετρικών μηνυμάτων μεταφοράς ενοικιαστή", + "edit-transport-tenant-telemetry-data-points-title": "Επεξεργασία ορίων σημείων δεδομένων τηλεμετρίας μεταφοράς ενοικιαστή", + "edit-transport-device-msg-title": "Επεξεργασία ορίων μηνυμάτων μεταφοράς συσκευής", + "edit-transport-device-telemetry-msg-title": "Επεξεργασία ορίων τηλεμετρικών μηνυμάτων μεταφοράς συσκευής", + "edit-transport-device-telemetry-data-points-title": "Επεξεργασία ορίων σημείων δεδομένων τηλεμετρίας μεταφοράς συσκευής", + "edit-transport-gateway-msg-title": "Επεξεργασία ορίων μηνυμάτων μεταφοράς πύλης", + "edit-transport-gateway-telemetry-msg-title": "Επεξεργασία ορίων τηλεμετρικών μηνυμάτων μεταφοράς πύλης", + "edit-transport-gateway-telemetry-data-points-title": "Επεξεργασία ορίων σημείων δεδομένων τηλεμετρίας μεταφοράς πύλης", + "edit-transport-gateway-device-msg-title": "Επεξεργασία ορίων μηνυμάτων συσκευής πύλης", + "edit-transport-gateway-device-telemetry-msg-title": "Επεξεργασία ορίων τηλεμετρίας συσκευής πύλης", + "edit-transport-gateway-device-telemetry-data-points-title": "Επεξεργασία ορίων σημείων δεδομένων τηλεμετρίας συσκευής πύλης", + "edit-tenant-rest-limits-title": "Επεξεργασία ορίων αιτημάτων REST για τον ενοικιαστή", + "edit-customer-rest-limits-title": "Επεξεργασία ορίων αιτημάτων REST για πελάτη", + "edit-ws-limit-updates-per-session-title": "Επεξεργασία ορίων ενημερώσεων WS ανά συνεδρία", + "edit-cassandra-tenant-limits-configuration-title": "Επεξεργασία ορίων ερωτημάτων Cassandra για ενοικιαστή", + "edit-tenant-entity-export-rate-limit-title": "Επεξεργασία ορίων δημιουργίας εκδόσεων οντοτήτων", + "edit-tenant-entity-import-rate-limit-title": "Επεξεργασία ορίων φόρτωσης εκδόσεων οντοτήτων", + "edit-tenant-notification-request-rate-limit-title": "Επεξεργασία ορίων αιτημάτων ειδοποίησης", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Επεξεργασία ορίων αιτημάτων ειδοποίησης ανά κανόνα", + "edit-edge-events-rate-limit": "Επεξεργασία ορίων συμβάντων άκρης", + "edit-edge-events-per-edge-rate-limit": "Επεξεργασία ορίων συμβάντων ανά άκρη", + "edge-events-rate-limit": "Συμβάντα άκρης", + "edge-events-per-edge-rate-limit": "Συμβάντα ανά άκρη", + "edit-edge-uplink-messages-rate-limit": "Επεξεργασία ορίων μηνυμάτων uplink άκρης", + "edit-edge-uplink-messages-per-edge-rate-limit": "Επεξεργασία ορίων μηνυμάτων uplink ανά άκρη", + "edge-uplink-messages-rate-limit": "Μηνύματα uplink άκρης", + "edge-uplink-messages-per-edge-rate-limit": "Μηνύματα uplink ανά άκρη", + "messages-per": "μηνύματα ανά", + "not-set": "Δεν έχει οριστεί", + "number-of-messages": "Αριθμός μηνυμάτων", + "number-of-messages-required": "Απαιτείται αριθμός μηνυμάτων.", + "number-of-messages-min": "Η ελάχιστη τιμή είναι 1.", + "preview": "Προεπισκόπηση", + "per-seconds": "Ανά δευτερόλεπτα", + "per-seconds-required": "Απαιτείται χρονικός ρυθμός.", + "per-seconds-min": "Η ελάχιστη τιμή είναι 1.", + "rate-limits": "Όρια ρυθμού", + "remove-limit": "Αφαίρεση ορίου", + "transport-tenant-msg": "Μηνύματα μεταφοράς ενοικιαστή", + "transport-tenant-telemetry-msg": "Τηλεμετρικά μηνύματα ενοικιαστή", + "transport-tenant-telemetry-data-points": "Σημεία τηλεμετρίας ενοικιαστή", + "transport-device-msg": "Μηνύματα μεταφοράς συσκευής", + "transport-device-telemetry-msg": "Τηλεμετρικά μηνύματα συσκευής", + "transport-device-telemetry-data-points": "Σημεία τηλεμετρίας συσκευής", + "transport-gateway-msg": "Μηνύματα μεταφοράς πύλης", + "transport-gateway-telemetry-msg": "Τηλεμετρικά μηνύματα πύλης", + "transport-gateway-telemetry-data-points": "Σημεία τηλεμετρίας πύλης", + "transport-gateway-device-msg": "Μηνύματα συσκευής πύλης", + "transport-gateway-device-telemetry-msg": "Τηλεμετρικά μηνύματα συσκευής πύλης", + "transport-gateway-device-telemetry-data-points": "Σημεία τηλεμετρίας συσκευής πύλης", + "sec": "δευτ." + } }, "timeinterval": { - "seconds-interval": "{ seconds, plural, =1 {1 second} other {# seconds} }", - "minutes-interval": "{ minutes, plural, =1 {1 minute} other {# minutes} }", - "hours-interval": "{ hours, plural, =1 {1 hour} other {# hours} }", - "days-interval": "{ days, plural, =1 {1 day} other {# days} }", + "seconds-interval": "{ seconds, plural, =1 {1 δευτερόλεπτο} other {# δευτερόλεπτα} }", + "minutes-interval": "{ minutes, plural, =1 {1 λεπτό} other {# λεπτά} }", + "hours-interval": "{ hours, plural, =1 {1 ώρα} other {# ώρες} }", + "days-interval": "{ days, plural, =1 {1 ημέρα} other {# ημέρες} }", "days": "Ημέρες", "hours": "Ώρες", "minutes": "Λεπτά", "seconds": "Δευτερόλεπτα", - "advanced": "Προηγμένος" + "advanced": "Προχωρημένο", + "custom": "Προσαρμοσμένο", + "predefined": { + "yesterday": "Χθες", + "day-before-yesterday": "Προχθές", + "this-day-last-week": "Αυτή η ημέρα την προηγούμενη εβδομάδα", + "previous-week": "Προηγούμενη εβδομάδα (Κυρ - Σάβ)", + "previous-week-iso": "Προηγούμενη εβδομάδα (Δευ - Κυρ)", + "previous-month": "Προηγούμενος μήνας", + "previous-quarter": "Προηγούμενο τρίμηνο", + "previous-half-year": "Προηγούμενο εξάμηνο", + "previous-year": "Προηγούμενο έτος", + "current-hour": "Τρέχουσα ώρα", + "current-day": "Τρέχουσα ημέρα", + "current-day-so-far": "Τρέχουσα ημέρα μέχρι τώρα", + "current-week": "Τρέχουσα εβδομάδα (Κυρ - Σάβ)", + "current-week-iso": "Τρέχουσα εβδομάδα (Δευ - Κυρ)", + "current-week-so-far": "Τρέχουσα εβδομάδα μέχρι τώρα (Κυρ - Σάβ)", + "current-week-iso-so-far": "Τρέχουσα εβδομάδα μέχρι τώρα (Δευ - Κυρ)", + "current-month": "Τρέχων μήνας", + "current-month-so-far": "Τρέχων μήνας μέχρι τώρα", + "current-quarter": "Τρέχον τρίμηνο", + "current-quarter-so-far": "Τρέχον τρίμηνο μέχρι τώρα", + "current-half-year": "Τρέχον εξάμηνο", + "current-half-year-so-far": "Τρέχον εξάμηνο μέχρι τώρα", + "current-year": "Τρέχον έτος", + "current-year-so-far": "Τρέχον έτος μέχρι τώρα" + }, + "type": { + "week": "Εβδομάδα (Κυρ - Σάβ)", + "week-iso": "Εβδομάδα (Δευ - Κυρ)", + "month": "Μήνας", + "quarter": "Τρίμηνο" + } + }, + "timeunit": { + "milliseconds": "Χιλιοστά του δευτερολέπτου", + "seconds": "Δευτερόλεπτα", + "minutes": "Λεπτά", + "hours": "Ώρες", + "days": "Ημέρες" }, "timewindow": { - "days": "{ days, plural, =1 { day } other {# days } }", - "hours": "{ hours, plural, =0 { hour } =1 {1 hour } other {# hours } }", - "minutes": "{ minutes, plural, =0 { minute } =1 {1 minute } other {# minutes } }", - "seconds": "{ seconds, plural, =0 { second } =1 {1 second } other {# seconds } }", - "realtime": "Πραγματικός Χρόνος", + "timewindow": "Χρονικό παράθυρο", + "timewindow-settings": "Ρυθμίσεις χρονικού παραθύρου", + "years": "{ years, plural, =1 {1 έτος} other {# έτη} }", + "years-short": "{{ years }}χρ", + "months": "{ months, plural, =1 {1 μήνας} other {# μήνες} }", + "months-short": "{{ months }}Μ", + "weeks": "{ weeks, plural, =1 {1 εβδομάδα} other {# εβδομάδες} }", + "weeks-short": "{{ weeks }}εβδ", + "days": "{ days, plural, =1 {1 ημέρα} other {# ημέρες} }", + "days-short": "{{ days }}ημ", + "hours": "{ hours, plural, =0 {0 ώρες} =1 {1 ώρα} other {# ώρες} }", + "hr": "{{ hr }} ώρ", + "hr-short": "{{ hr }}ώ", + "minutes": "{ minutes, plural, =0 {0 λεπτά} =1 {1 λεπτό} other {# λεπτά} }", + "min": "{{ min }} λεπ", + "min-short": "{{ min }}λ", + "seconds": "{ seconds, plural, =0 {0 δευτερόλεπτα} =1 {1 δευτερόλεπτο} other {# δευτερόλεπτα} }", + "sec": "{{ sec }} δευτ", + "sec-short": "{{ sec }}δ", + "short": { + "years": "{ years, plural, =1 {1 έτος} other {# έτη} }", + "days": "{ days, plural, =1 {1 ημέρα} other {# ημέρες} }", + "hours": "{ hours, plural, =1 {1 ώρα} other {# ώρες} }", + "minutes": "{{minutes}} λ", + "seconds": "{{seconds}} δ" + }, + "realtime": "Σε πραγματικό χρόνο", "history": "Ιστορικό", - "last-prefix": "Τελευταίος", - "period": "από {{ startTime }} σε {{ endTime }}", - "edit": "Επεξεργασία Χρονικού Πλαισίου", + "last-prefix": "τελευταία", + "period": "από {{ startTime }} έως {{ endTime }}", + "edit": "Επεξεργασία χρονικού παραθύρου", "date-range": "Εύρος ημερομηνιών", - "last": "Τελευταίος", - "time-period": "Χρονική Περίοδος" + "for-all-time": "Για όλο τον χρόνο", + "last": "Τελευταίο", + "time-period": "Χρονική περίοδος", + "hide": "Απόκρυψη", + "interval": "Διάστημα", + "just-now": "Μόλις τώρα", + "just-now-lower": "μόλις τώρα", + "ago": "πριν", + "style": "Στυλ χρονικού παραθύρου", + "icon": "Εικονίδιο", + "icon-position": "Θέση εικονιδίου", + "icon-position-left": "Αριστερά", + "icon-position-right": "Δεξιά", + "font": "Γραμματοσειρά", + "color": "Χρώμα", + "displayTypePrefix": "Εμφάνιση προθέματος Πραγματικού χρόνου/Ιστορικού", + "preview": "Προεπισκόπηση", + "relative": "Σχετικό", + "range": "Εύρος", + "hide-timewindow-section": "Απόκρυψη ενότητας χρονικού παραθύρου από τους τελικούς χρήστες", + "hide-last-interval": "Απόκρυψη τελευταίου διαστήματος από τους τελικούς χρήστες", + "hide-relative-interval": "Απόκρυψη σχετικού διαστήματος από τους τελικούς χρήστες", + "hide-fixed-interval": "Απόκρυψη σταθερού διαστήματος από τους τελικούς χρήστες", + "hide-aggregation": "Απόκρυψη ομαδοποίησης από τους τελικούς χρήστες", + "hide-group-interval": "Απόκρυψη διαστήματος ομαδοποίησης από τους τελικούς χρήστες", + "hide-max-values": "Απόκρυψη μέγιστων τιμών από τους τελικούς χρήστες", + "hide-timezone": "Απόκρυψη ζώνης ώρας από τους τελικούς χρήστες", + "disable-custom-interval": "Απενεργοποίηση επιλογής προσαρμοσμένου διαστήματος", + "edit-aggregation-functions-list": "Επεξεργασία λίστας συναρτήσεων ομαδοποίησης", + "edit-aggregation-functions-list-hint": "Μπορεί να καθοριστεί λίστα διαθέσιμων επιλογών.", + "allowed-aggregation-functions": "Επιτρεπόμενες συναρτήσεις ομαδοποίησης", + "edit-intervals-list": "Επεξεργασία λίστας διαστημάτων", + "allowed-agg-intervals": "Διαστήματα ομαδοποίησης", + "default-agg-interval": "Προεπιλεγμένο διάστημα ομαδοποίησης", + "edit-intervals-list-hint": "Μπορεί να καθοριστεί λίστα διαθέσιμων διαστημάτων.", + "edit-grouping-intervals-list-hint": "Δυνατότητα διαμόρφωσης λίστας και προεπιλογής διαστημάτων ομαδοποίησης.", + "all": "Όλα" + }, + "tooltip": { + "trigger": "Ενεργοποίηση", + "trigger-point": "Σημείο", + "trigger-axis": "Άξονας", + "label": "Ετικέτα", + "value": "Τιμή", + "date": "Ημερομηνία", + "show-date-time-interval": "Εμφάνιση χρονικού διαστήματος", + "show-date-time-interval-hint": "Εμφάνιση χρονικού διαστήματος σύμφωνα με την ομαδοποίηση δεδομένων.", + "background-color": "Χρώμα φόντου", + "background-blur": "Θόλωση φόντου" + }, + "unit": { + "millimeter": "Χιλιοστόμετρο", + "centimeter": "Εκατοστόμετρο", + "angstrom": "Άνγκστρομ", + "nanometer": "Νανομέτρο", + "micrometer": "Μικρόμετρο", + "meter": "Μέτρο", + "kilometer": "Χιλιόμετρο", + "inch": "Ίντσα", + "foot": "Πόδι", + "yard": "Γιάρδα", + "mile": "Μίλι", + "nautical-mile": "Ναυτικό μίλι", + "astronomical-unit": "Αστρονομική μονάδα", + "reciprocal-metre": "Αντίστροφο μέτρο", + "meter-per-meter": "Μέτρο ανά μέτρο", + "steradian": "Στερακάνιο", + "thou": "Χιλιοστό της ίντσας", + "barleycorn": "Κόκκος κριθαριού", + "hand": "Χέρι (μονάδα μέτρησης)", + "chain": "Αλυσίδα", + "furlong": "Φερλόνγκ", + "league": "Λέγγα", + "fathom": "Οργιά", + "cable": "Καλώδιο (μονάδα)", + "link": "Σύνδεσμος (μονάδα)", + "rod": "Ράβδος", + "nanogram": "Νανογραμμάριο", + "microgram": "Μικρογραμμάριο", + "milligram": "Χιλιοστόγραμμο", + "gram": "Γραμμάριο", + "kilogram": "Κιλό", + "tonne": "Τόννος", + "ounce": "Ουγγιά", + "pound": "Λίβρα", + "stone": "Στόουν", + "hundredweight-count": "Κεντ. μονάδα βάρους", + "short-tons": "Αμερικανικοί τόνοι", + "dalton": "Ντάλτον", + "grain": "Σπυρί (μονάδα)", + "drachm": "Δραχμή (παλιά μονάδα βάρους)", + "quarter": "Κουόρτερ", + "slug": "Σλαγκ", + "carat": "Καράτι", + "cubic-millimeter": "Κυβικό χιλιοστόμετρο", + "cubic-centimeter": "Κυβικό εκατοστόμετρο", + "cubic-meter": "Κυβικό μέτρο", + "cubic-kilometer": "Κυβικό χιλιόμετρο", + "microliter": "Μικρολίτρο", + "milliliter": "Χιλιοστόλιτρο", + "liter": "Λίτρο", + "hectoliter": "Εκατόλιτρο", + "cubic-inch": "Κυβική ίντσα", + "cubic-foot": "Κυβικό πόδι", + "cubic-yard": "Κυβική γιάρδα", + "fluid-ounce": "Υγρή ουγγιά", + "pint": "Πίντα", + "quart": "Κουάρτο", + "gallon": "Γαλόνι", + "oil-barrels": "Βαρέλι πετρελαίου", + "cubic-meter-per-kilogram": "Κυβικό μέτρο ανά κιλό", + "gill": "Τέταρτο πίντας", + "hogshead": "Βαρέλι (hogshead)", + "teaspoon": "Κουταλάκι του γλυκού", + "tablespoon": "Κουτάλι της σούπας", + "cup": "Κούπα", + "celsius": "Κελσίου", + "kelvin": "Κέλβιν", + "rankine": "Ράνκιν", + "fahrenheit": "Φαρενάιτ", + "percent": "Ποσοστό", + "meter-per-second": "Μέτρο ανά δευτερόλεπτο", + "kilometer-per-hour": "Χιλιόμετρα ανά ώρα", + "foot-per-second": "Πόδια ανά δευτερόλεπτο", + "mile-per-hour": "Μίλια ανά ώρα", + "knot": "Κόμβος", + "millimeters-per-minute": "Χιλιοστά ανά λεπτό", + "kilometer-per-hour-squared": "Χιλιόμετρα ανά ώρα τετράγωνο", + "foot-per-second-squared": "Πόδια ανά δευτερόλεπτο τετράγωνο", + "pascal": "Πασκάλ", + "kilopascal": "Κιλοπασκάλ", + "megapascal": "Μεγαπασκάλ", + "gigapascal": "Γιγαπασκάλ", + "millibar": "Μιλιμπάρ", + "bar": "Μπάρ", + "kilobar": "Κιλομπάρ", + "newton": "Νιούτον", + "newton-meter": "Νιούτον μέτρο", + "foot-pounds": "Πόδι-λίβρες", + "inch-pounds": "Ίντσα-λίβρες", + "newton-per-meter": "Νιούτον ανά μέτρο", + "atmospheres": "Ατμόσφαιρες", + "pounds-per-square-inch": "Λίβρες ανά τετραγωνική ίντσα", + "torr": "Τορρ", + "inches-of-mercury": "Ίντσες υδραργύρου", + "pascal-per-square-meter": "Πασκάλ ανά τετραγωνικό μέτρο", + "pound-per-square-inch": "Λίβρα ανά τετραγωνική ίντσα", + "newton-per-square-meter": "Νιούτον ανά τετραγωνικό μέτρο", + "kilogram-force-per-square-meter": "Κιλογραμμοδύναμη ανά τετραγωνικό μέτρο", + "pascal-per-square-centimeter": "Πασκάλ ανά τετραγωνικό εκατοστό", + "ton-force-per-square-inch": "Τόνος δύναμης ανά τετραγωνική ίντσα", + "kilonewton-per-square-meter": "Κιλονιούτον ανά τετραγωνικό μέτρο", + "newton-per-square-millimeter": "Νιούτον ανά τετραγωνικό χιλιοστόμετρο", + "microjoule": "Μικροτζάουλ", + "millijoule": "Χιλιοστοτζάουλ", + "joule": "Τζάουλ", + "kilojoule": "Κιλοτζάουλ", + "megajoule": "Μεγατζάουλ", + "gigajoule": "Γιγκατζάουλ", + "watt-hour": "Βατ-ώρα", + "kilowatt-hour": "Κιλοβατώρα", + "electron-volts": "Ηλεκτρονιοβόλτ", + "joules-per-coulomb": "Τζάουλ ανά κουλόμπ", + "british-thermal-unit": "Βρετανικές θερμικές μονάδες", + "foot-pound": "Πόδι-λίβρα", + "calorie": "Θερμίδα", + "small-calorie": "Μικρή θερμίδα", + "kilocalorie": "Θερμίδα (kcal)", + "joule-per-kelvin": "Τζάουλ ανά Κέλβιν", + "joule-per-kilogram-kelvin": "Τζάουλ ανά κιλό-Κέλβιν", + "joule-per-kilogram": "Τζάουλ ανά κιλό", + "watt-per-meter-kelvin": "Βατ ανά μέτρο-Κέλβιν", + "joule-per-cubic-meter": "Τζάουλ ανά κυβικό μέτρο", + "therm": "Θερμίδα (Therm)", + "electric-dipole-moment": "Ηλεκτρική διπολική ροπή", + "magnetic-dipole-moment": "Μαγνητική διπολική ροπή", + "debye": "Ντεμπάι", + "coulomb-per-square-meter-per-volt": "Κουλόμπ ανά τετρ. μέτρο ανά Βολτ", + "milliwatt": "Μιλιβάτ", + "microwatt": "Μικροβάτ", + "watt": "Βατ", + "kilowatt": "Κιλοβάτ", + "megawatt": "Μεγαβάτ", + "gigawatt": "Γιγαβάτ", + "metric-horsepower": "Μετρικός ίππος", + "milliwatt-per-square-centimeter": "Μιλιβάτ ανά τετραγωνικό εκατοστό", + "watt-per-square-centimeter": "Βατ ανά τετραγωνικό εκατοστό", + "kilowatt-per-square-centimeter": "Κιλοβάτ ανά τετραγωνικό εκατοστό", + "milliwatt-per-square-meter": "Μιλιβάτ ανά τετραγωνικό μέτρο", + "watt-per-square-meter": "Βατ ανά τετραγωνικό μέτρο", + "kilowatt-per-square-meter": "Κιλοβάτ ανά τετραγωνικό μέτρο", + "watt-per-square-inch": "Βατ ανά τετραγωνική ίντσα", + "kilowatt-per-square-inch": "Κιλοβάτ ανά τετραγωνική ίντσα", + "horsepower": "Ίππος", + "btu-per-hour": "BTU ανά ώρα", + "coulomb": "Κουλόμπ", + "millicoulomb": "Μιλικουλόμπ", + "microcoulomb": "Μικροκουλόμπ", + "picocoulomb": "Πικοκουλόμπ", + "coulomb-per-meter": "Κουλόμπ ανά μέτρο", + "coulomb-per-cubic-meter": "Κουλόμπ ανά κυβικό μέτρο", + "coulomb-per-square-meter": "Κουλόμπ ανά τετραγωνικό μέτρο", + "square-millimeter": "Τετραγωνικό χιλιοστόμετρο", + "square-centimeter": "Τετραγωνικό εκατοστόμετρο", + "square-meter": "Τετραγωνικό μέτρο", + "hectare": "Εκτάριο", + "square-kilometer": "Τετραγωνικό χιλιόμετρο", + "square-inch": "Τετραγωνική ίντσα", + "square-foot": "Τετραγωνικό πόδι", + "square-yard": "Τετραγωνική γιάρδα", + "acre": "Ακρ", + "square-mile": "Τετραγωνικό μίλι", + "are": "Άρ", + "barn": "Μπαρν", + "circular-inch": "Κυκλική Ίντσα", + "milliampere-hour": "Μιλιαμπερώρια", + "ampere-hours": "Αμπερώρια", + "kiloampere-hours": "Κιλοαμπερώρια", + "nanoampere": "Νανοαμπέρ", + "picoampere": "Πικοαμπέρ", + "microampere": "Μικροαμπέρ", + "milliampere": "Μιλιαμπέρ", + "ampere": "Αμπέρ", + "microampere-per-square-centimeter": "Μικροαμπέρ ανά τετραγωνικό εκατοστό", + "ampere-per-square-meter": "Αμπέρ ανά τετραγωνικό μέτρο", + "ampere-per-meter": "Αμπέρ ανά μέτρο", + "oersted": "Όερστεντ", + "bohr-magneton": "Μπορ μαγνητόνιο", + "ampere-meter-squared": "Αμπέρ-μέτρο τετραγωνικό", + "nanovolt": "Νανοβόλτ", + "picovolt": "Πικοβόλτ", + "volt": "Βολτ", + "dbmV": "dBmV", + "dbm": "dBm", + "volt-meter": "Βολτόμετρο", + "kilovolt-meter": "Κιλοβολτόμετρο", + "megavolt-meter": "Μεγαβολτόμετρο", + "microvolt-meter": "Μικροβολτόμετρο", + "millivolt-meter": "Μιλιβολτόμετρο", + "nanovolt-meter": "Νανοβολτόμετρο", + "ohm": "Ωμ", + "microohm": "Μικροωμ", + "milliohm": "Μιλιωμ", + "kilohm": "Κιλοωμ", + "megohm": "Μεγαωμ", + "gigohm": "Γιγαωμ", + "hertz": "Χερτζ", + "kilohertz": "Κιλοχέρτζ", + "megahertz": "Μεγαχέρτζ", + "gigahertz": "Γιγαχέρτζ", + "rpm": "Στροφές ανά λεπτό", + "candela-per-square-meter": "Καντέλα ανά τετραγωνικό μέτρο", + "candela": "Καντέλα", + "lumen": "Λούμεν", + "lux": "Λουξ", + "foot-candle": "Πόδι-κερί", + "lumen-per-square-meter": "Λούμεν ανά τετραγωνικό μέτρο", + "lux-second": "Λουξ-δευτερόλεπτο", + "lumen-second": "Λούμεν-δευτερόλεπτο", + "lumens-per-watt": "Λούμεν ανά βατ", + "mole": "Μόλιο", + "nanomole": "Νανομόλιο", + "micromole": "Μικρομόλιο", + "millimole": "Μιλιμόλιο", + "kilomole": "Κιλομόλιο", + "mole-per-cubic-meter": "Μόλιο ανά κυβικό μέτρο", + "rssi": "RSSI", + "ppm": "Μέρη ανά εκατομμύριο", + "ppb": "Μέρη ανά δισεκατομμύριο", + "micrograms-per-cubic-meter": "Μικρογραμμάρια ανά κυβικό μέτρο", + "aqi": "Δείκτης Ποιότητας Αέρα (AQI)", + "gram-per-cubic-meter": "Γραμμάριο ανά κυβικό μέτρο", + "gram-per-kilogram": "Ειδική υγρασία", + "millimeters-per-second": "Χιλιοστά ανά δευτερόλεπτο", + "neper": "Νέπερ", + "bel": "Μπελ", + "decibel": "Ντεσιμπέλ", + "meters-per-second-squared": "Μέτρα ανά δευτερόλεπτο τετράγωνο", + "becquerel": "Μπεκερέλ", + "curie": "Κιουρί", + "gray": "Γκρέι", + "sievert": "Σίβερτ", + "roentgen": "Ρέντγκεν", + "cps": "Καταμετρήσεις ανά δευτερόλεπτο", + "rad": "Ραντ", + "rem": "Ρεμ", + "dps": "Αποσυνθέσεις ανά δευτερόλεπτο", + "rutherford": "Ράδερφορντ", + "coulombs-per-kilogram": "Κουλόμπ ανά κιλό", + "becquerels-per-cubic-meter": "Μπεκερέλ ανά κυβικό μέτρο", + "curies-per-liter": "Κιουρί ανά λίτρο", + "becquerels-per-second": "Μπεκερέλ ανά δευτερόλεπτο", + "curies-per-second": "Κιουρί ανά δευτερόλεπτο", + "gy-per-second": "Γκρέι ανά δευτερόλεπτο", + "watt-per-steradian": "Βατ ανά στερακτίδιο", + "watt-per-square-metre-steradian": "Βατ ανά τετραγωνικό μέτρο-στερακτίδιο", + "ph-level": "Επίπεδο pH", + "turbidity": "Θολότητα", + "mg-per-liter": "Μιλιγραμμάρια ανά λίτρο", + "microsiemens-per-centimeter": "Μικροσίμενς ανά εκατοστό", + "millisiemens-per-meter": "Μιλισίμενς ανά μέτρο", + "siemens-per-meter": "Σίμενς ανά μέτρο", + "kilogram-per-cubic-meter": "Κιλό ανά κυβικό μέτρο", + "gram-per-cubic-centimeter": "Γραμμάριο ανά κυβικό εκατοστό", + "kilogram-per-square-meter": "Κιλό ανά τετραγωνικό μέτρο", + "milligram-per-milliliter": "Μιλιγραμμάριο ανά χιλιοστόλιτρο", + "milligram-per-cubic-meter": "Μιλιγραμμάριο ανά κυβικό μέτρο", + "pound-per-cubic-foot": "Λίβρα ανά κυβικό πόδι", + "ounces-per-cubic-inch": "Ουγγιά ανά κυβική ίντσα", + "tons-per-cubic-yard": "Τόνοι ανά κυβική γιάρδα", + "particle-density": "Πυκνότητα σωματιδίων", + "kilometers-per-liter": "Χιλιόμετρα ανά λίτρο", + "miles-per-gallon": "Μίλια ανά γαλόνι", + "liters-per-100-km": "Λίτρα ανά 100 χιλιόμετρα", + "gallons-per-mile": "Γαλόνια ανά μίλι", + "liters-per-hour": "Λίτρα ανά ώρα", + "gallons-per-hour": "Γαλόνια ανά ώρα", + "beats-per-minute": "Κτύποι ανά λεπτό", + "millimeters-of-mercury": "Χιλιοστά υδραργύρου", + "milligrams-per-deciliter": "Μιλιγραμμάρια ανά δεκατόλιτρο", + "g-force": "Επιτάχυνση βαρύτητας (g-force)", + "kilonewton": "Κιλονιούτον", + "kilogram-force": "Δύναμη κιλού", + "pound-force": "Δύναμη λίβρας", + "kilopound-force": "Κιλολίβρα-δύναμη", + "dyne": "Ντίν", + "poundal": "Πάουνταλ", + "kip": "Κιπ", + "gal": "Γκαλ", + "gravity": "Βαρύτητα", + "hectopascal": "Εκτοπασκάλ", + "atmosphere": "Ατμόσφαιρα", + "millibars": "Μιλιμπάρ", + "inch-of-mercury": "Ίντσα υδραργύρου", + "richter-scale": "Κλίμακα Ρίχτερ", + "second": "Δευτερόλεπτο", + "minute": "Λεπτό", + "hour": "Ώρα", + "day": "Ημέρα", + "week": "Εβδομάδα", + "month": "Μήνας", + "year": "Έτος", + "cubic-foot-per-minute": "Κυβικό πόδι ανά λεπτό", + "cubic-meters-per-hour": "Κυβικά μέτρα ανά ώρα", + "cubic-meters-per-second": "Κυβικά μέτρα ανά δευτερόλεπτο", + "liter-per-second": "Λίτρο ανά δευτερόλεπτο", + "liter-per-minute": "Λίτρο ανά λεπτό", + "gallons-per-minute": "Γαλόνια ανά λεπτό", + "cubic-foot-per-second": "Κυβικό πόδι ανά δευτερόλεπτο", + "milliliters-per-minute": "Χιλιοστόλιτρα ανά λεπτό", + "bit": "Bit", + "byte": "Byte", + "kilobyte": "Kilobyte", + "megabyte": "Megabyte", + "gigabyte": "Gigabyte", + "terabyte": "Terabyte", + "petabyte": "Petabyte", + "exabyte": "Exabyte", + "zettabyte": "Zettabyte", + "yottabyte": "Yottabyte", + "bit-per-second": "Bit ανά δευτερόλεπτο", + "kilobit-per-second": "Kilobit ανά δευτερόλεπτο", + "megabit-per-second": "Megabit ανά δευτερόλεπτο", + "gigabit-per-second": "Gigabit ανά δευτερόλεπτο", + "terabit-per-second": "Terabit ανά δευτερόλεπτο", + "byte-per-second": "Byte ανά δευτερόλεπτο", + "kilobyte-per-second": "Kilobyte ανά δευτερόλεπτο", + "megabyte-per-second": "Megabyte ανά δευτερόλεπτο", + "gigabyte-per-second": "Gigabyte ανά δευτερόλεπτο", + "degree": "Μοίρα", + "radian": "Ακτίνιο", + "gradian": "Γκραντ", + "revolution": "Περιστροφή", + "siemens": "Σίμενς", + "millisiemens": "Μιλισίμενς", + "microsiemens": "Μικροσίμενς", + "kilosiemens": "Κιλοσίμενς", + "megasiemens": "Μεγασίμενς", + "gigasiemens": "Γιγασίμενς", + "farad": "Φαράντ", + "millifarad": "Μιλιφαράντ", + "microfarad": "Μικροφαράντ", + "nanofarad": "Νανοφαράντ", + "picofarad": "Πικοφαράντ", + "kilofarad": "Κιλοφαράντ", + "megafarad": "Μεγαφαράντ", + "gigafarad": "Γιγαφαράντ", + "terfarad": "Τεραφαράντ", + "farad-per-meter": "Φαράντ ανά μέτρο", + "tesla": "Τέσλα", + "gauss": "Γκάους", + "kilogauss": "Κιλογκάους", + "millitesla": "Μιλιτέσλα", + "microtesla": "Μικροτέσλα", + "nanotesla": "Νανοτέσλα", + "kilotesla": "Κιλοτέσλα", + "megatesla": "Μεγατέσλα", + "millitesla-square-meters": "Μιλιτέσλα τετραγωνικά μέτρα", + "gamma": "Γάμα", + "lambda": "Λάμδα", + "square-meter-per-second": "Τετραγωνικό μέτρο ανά δευτερόλεπτο", + "square-centimeter-per-second": "Τετραγωνικό εκατοστό ανά δευτερόλεπτο", + "stoke": "Στόουκς", + "centistokes": "Σεντιστόουκς", + "square-foot-per-second": "Τετραγωνικό πόδι ανά δευτερόλεπτο", + "square-inch-per-second": "Τετραγωνική ίντσα ανά δευτερόλεπτο", + "pascal-second": "Πασκάλ-δευτερόλεπτο", + "centipoise": "Σεντιπόιζ", + "poise": "Πόιζ", + "reynolds": "Ρέινολντς", + "pound-per-foot-hour": "Λίβρα ανά πόδι-ώρα", + "newton-second-per-square-meter": "Νιούτον-δευτερόλεπτο ανά τετραγωνικό μέτρο", + "dyne-second-per-square-centimeter": "Ντάιν-δευτερόλεπτο ανά τετραγωνικό εκατοστό", + "kilogram-per-meter-second": "Κιλό ανά μέτρο-δευτερόλεπτο", + "tesla-square-meters": "Τέσλα τετραγωνικά μέτρα", + "maxwell": "Μάξγουελ", + "tesla-per-meter": "Τέσλα ανά μέτρο", + "gauss-per-centimeter": "Γκάους ανά εκατοστό", + "weber": "Βέμπερ", + "microweber": "Μικροβέμπερ", + "milliweber": "Μιλιβέμπερ", + "gauss-square-centimeter": "Γκάους-τετραγωνικό εκατοστό", + "kilogauss-square-centimeter": "Κιλογκάους-τετραγωνικό εκατοστό", + "henry": "Χένρι", + "millihenry": "Μιλιχένρι", + "microhenry": "Μικροχένρι", + "nanohenry": "Νανοχένρι", + "henry-per-meter": "Χένρι ανά μέτρο", + "tesla-meter-per-ampere": "Τέσλα μέτρο ανά Αμπέρ", + "gauss-per-oersted": "Γκάους ανά Όερστεντ", + "kilogram-per-mole": "Κιλό ανά μολ", + "gram-per-mole": "Γραμμάριο ανά μολ", + "milligram-per-mole": "Μιλιγραμμάριο ανά μολ", + "joule-per-mole": "Τζάουλ ανά μολ", + "joule-per-mole-kelvin": "Τζάουλ ανά μολ-Κέλβιν", + "millivolts-per-meter": "Μιλιβόλτ ανά μέτρο", + "volts-per-meter": "Βόλτ ανά μέτρο", + "kilovolts-per-meter": "Κιλοβόλτ ανά μέτρο", + "radian-per-second": "Ακτίνιο ανά δευτερόλεπτο", + "radian-per-second-squared": "Ακτίνιο ανά δευτερόλεπτο τετράγωνο", + "revolutions-per-minute-per-second": "Γωνιακή επιτάχυνση", + "deg-per-second": "μοίρες/δευτ.", + "degrees-brix": "Μοίρες Brix", + "katal": "Κατάλ", + "katal-per-cubic-metre": "Κατάλ ανά κυβικό μέτρο" }, "user": { "user": "Χρήστης", "users": "Χρήστες", - "management": "Διαχείριση Χρηστών", - "customer-users": "Χρήστες του Πελάτη", - "tenant-admins": "Διαχειριστές Μισθωτή", - "sys-admin": "Διαχειριστής Συστήματος", - "tenant-admin": "Διαχειριστής Μισθωτή", + "customer-users": "Χρήστες πελάτη", + "tenant-admins": "Διαχειριστές ενοικιαστή", + "sys-admin": "Διαχειριστής συστήματος", + "tenant-admin": "Διαχειριστής ενοικιαστή", "customer": "Πελάτης", "anonymous": "Ανώνυμος", "add": "Προσθήκη χρήστη", "delete": "Διαγραφή χρήστη", - "add-user-text": "Προσθήκη νέου Χρήστη", - "no-users-text": "Δεν βρέθηκαν Χρήστες", - "user-details": "Λεπτομέρειες Χρήστη", - "delete-users": "Διαγραφή Χρηστών", - "delete-user-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το Χρήστη '{{userEmail}}'?", - "delete-user-text": "Προσοχή, μετά την επιβεβαίωση ο Χρήστης και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-users-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 user} other {# users} };", - "delete-users-action-title": "Διαγραφή { count, plural, =1 {1 user} other {# users} }", - "delete-users-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι Χρήστες θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "add-user-text": "Προσθήκη νέου χρήστη", + "no-users-text": "Δεν βρέθηκαν χρήστες", + "user-details": "Λεπτομέρειες χρήστη", + "delete-user-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον χρήστη '{{userEmail}}';", + "delete-user-text": "Προσοχή, μετά την επιβεβαίωση, ο χρήστης και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", + "delete-users-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 χρήστη} other {# χρήστες} };", + "delete-users-action-title": "Διαγραφή { count, plural, =1 {1 χρήστη} other {# χρηστών} }", + "delete-users-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι χρήστες θα αφαιρεθούν και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", "activation-email-sent-message": "Το email ενεργοποίησης στάλθηκε με επιτυχία!", - "resend-activation": "Επανάληψη ενεργοποίησης", + "resend-activation": "Επαναποστολή ενεργοποίησης", "email": "Email", "email-required": "Απαιτείται email.", "invalid-email-format": "Μη έγκυρη μορφή email.", "first-name": "Όνομα", - "last-name": "Επίθετο", + "last-name": "Επώνυμο", "description": "Περιγραφή", - "default-dashboard": "Προκαθορισμένος πίνακας ελέγχου", - "always-fullscreen": "Πάντα με πλήρη οθόνη", - "select-user": "Επιλογή Χρήστη", - "no-users-matching": "Δεν βρέθηκαν χρήστες που να ταιριάζουν '{{entity}}'.", - "user-required": "Απαιτείται Χρήστης", + "default-dashboard": "Προεπιλεγμένος πίνακας ελέγχου", + "always-fullscreen": "Πάντα σε πλήρη οθόνη", + "select-user": "Επιλέξτε χρήστη", + "no-users-matching": "Δεν βρέθηκαν χρήστες που να ταιριάζουν με '{{entity}}'.", + "user-required": "Απαιτείται χρήστης", "activation-method": "Μέθοδος ενεργοποίησης", "display-activation-link": "Εμφάνιση συνδέσμου ενεργοποίησης", - "send-activation-mail": "Αποστολή mail ενεργοποίησης", - "activation-link": "Σύνδεσμος ενεργοποίησης Χρήστη", - "activation-link-text": "΄Προκειμένου να ενεργοποιήσετε το Χρήστη, χρησιμοποιήστε το εξής activation link :", + "send-activation-mail": "Αποστολή email ενεργοποίησης", + "activation-link": "Σύνδεσμος ενεργοποίησης χρήστη", + "activation-link-text": "Για να ενεργοποιήσετε τον χρήστη, χρησιμοποιήστε τον παρακάτω σύνδεσμο ενεργοποίησης (λήγει σε {{activationLinkTtl}}) :", "copy-activation-link": "Αντιγραφή συνδέσμου ενεργοποίησης", - "activation-link-copied-message": "Ο σύνδεσμος ενεργοποίησης χρήστη έχει αντιγραφεί στο πρόχειρο", - "selected-users": "{ count, plural, =1 {1 user} other {# users} } επιλέχθηκαν", - "search": "Αναζήτηση Χρηστών", + "activation-link-copied-message": "Ο σύνδεσμος ενεργοποίησης χρήστη αντιγράφηκε στο πρόχειρο", "details": "Λεπτομέρειες", - "login-as-tenant-admin": "Συνδεθείτε ως Διαχειριστής Μισθωτή", - "login-as-customer-user": "Συνδεθείτε ως Χρήστης του Πελάτη", - "select-group-to-add": "Επιλέξτε την ομάδα προορισμού για να προσθέσετε επιλεγμένους Χρήστες", - "select-group-to-move": "Επιλέξτε την ομάδα προορισμού για να μετακινήσετε επιλεγμένους χρήστες", - "remove-users-from-group": "Είστε σίγουροι ότι θέλετε να καταργήσετε { count, plural, =1 {1 user} other {# users} } από την ομάδα '{entityGroup}';", - "group": "Ομάδα από Χρήστες", - "list-of-groups": "{ count, plural, =1 {One user group} other {List of # user groups} }", - "group-name-starts-with": "Ομάδες Χρηστών των οποίων τα ονόματα ξεκινούν με'{{prefix}}'" + "login-as-tenant-admin": "Σύνδεση ως διαχειριστής ενοικιαστή", + "login-as-customer-user": "Σύνδεση ως χρήστης πελάτη", + "search": "Αναζήτηση χρηστών", + "selected-users": "{ count, plural, =1 {1 χρήστης} other {# χρήστες} } επιλέχθηκαν", + "disable-account": "Απενεργοποίηση λογαριασμού χρήστη", + "enable-account": "Ενεργοποίηση λογαριασμού χρήστη", + "enable-account-message": "Ο λογαριασμός χρήστη ενεργοποιήθηκε επιτυχώς!", + "disable-account-message": "Ο λογαριασμός χρήστη απενεργοποιήθηκε επιτυχώς!", + "copyId": "Αντιγραφή αναγνωριστικού χρήστη", + "idCopiedMessage": "Το αναγνωριστικό χρήστη αντιγράφηκε στο πρόχειρο", + "user-list": "Λίστα χρηστών", + "user-list-required": "Απαιτείται λίστα χρηστών" }, "value": { - "type": "Είδος τιμής", + "type": "Τύπος τιμής", "string": "Συμβολοσειρά", "string-value": "Τιμή συμβολοσειράς", + "string-value-required": "Απαιτείται τιμή συμβολοσειράς", "integer": "Ακέραιος", - "integer-value": "Ακέραια τιμή", - "invalid-integer-value": "Μη έγκυρη ακέραια τιμή", - "double": "Πραγματικός", - "double-value": "Πραγματική τιμή", - "boolean": "Λογικό", - "boolean-value": "Λογική τιμή", - "false": "Εσφαλμένος", - "true": "Αληθής", - "long": "Μακρύς" + "integer-value": "Τιμή ακέραιου", + "integer-value-required": "Απαιτείται τιμή ακέραιου", + "invalid-integer-value": "Μη έγκυρη τιμή ακέραιου", + "double": "Δεκαδικός", + "double-value": "Τιμή δεκαδικού", + "double-value-required": "Απαιτείται τιμή δεκαδικού", + "boolean": "Λογική τιμή", + "boolean-value": "Τιμή λογικής", + "false": "Ψευδές", + "true": "Αληθές", + "long": "Long", + "json": "JSON", + "json-value": "Τιμή JSON", + "json-value-invalid": "Η τιμή JSON έχει μη έγκυρη μορφή", + "json-value-required": "Απαιτείται τιμή JSON." + }, + "version-control": { + "version-control": "Έλεγχος εκδόσεων", + "management": "Διαχείριση ελέγχου εκδόσεων", + "search": "Αναζήτηση εκδόσεων", + "branch": "Κλάδος", + "default": "Προεπιλογή", + "select-branch": "Επιλέξτε κλάδο", + "branch-required": "Απαιτείται κλάδος", + "create-entity-version": "Δημιουργία έκδοσης οντότητας", + "version-name": "Όνομα έκδοσης", + "version-name-required": "Απαιτείται όνομα έκδοσης", + "author": "Συγγραφέας", + "export-relations": "Εξαγωγή σχέσεων", + "export-attributes": "Εξαγωγή ιδιοτήτων", + "export-credentials": "Εξαγωγή διαπιστευτηρίων", + "export-calculated-fields": "Εξαγωγή υπολογιζόμενων πεδίων", + "entity-versions": "Εκδόσεις οντοτήτων", + "versions": "Εκδόσεις", + "created-time": "Ώρα δημιουργίας", + "version-id": "Αναγνωριστικό έκδοσης", + "no-entity-versions-text": "Δεν βρέθηκαν εκδόσεις οντοτήτων", + "no-versions-text": "Δεν βρέθηκαν εκδόσεις", + "copy-full-version-id": "Αντιγραφή πλήρους αναγνωριστικού έκδοσης", + "create-version": "Δημιουργία έκδοσης", + "creating-version": "Δημιουργία έκδοσης... Παρακαλώ περιμένετε", + "nothing-to-commit": "Δεν υπάρχουν αλλαγές για καταχώρηση", + "restore-version": "Επαναφορά έκδοσης", + "restore-entity-from-version": "Επαναφορά οντότητας από την έκδοση '{{versionName}}'", + "restoring-entity-version": "Επαναφορά έκδοσης οντότητας... Παρακαλώ περιμένετε", + "load-relations": "Φόρτωση σχέσεων", + "load-attributes": "Φόρτωση ιδιοτήτων", + "load-credentials": "Φόρτωση διαπιστευτηρίων", + "load-calculated-fields": "Φόρτωση υπολογιζόμενων πεδίων", + "compare-with-current": "Σύγκριση με την τρέχουσα", + "diff-entity-with-version": "Διαφορά με την έκδοση οντότητας '{{versionName}}'", + "previous-difference": "Προηγούμενη διαφορά", + "next-difference": "Επόμενη διαφορά", + "current": "Τρέχουσα", + "differences": "{ count, plural, =1 {1 διαφορά} other {# διαφορές} }", + "create-entities-version": "Δημιουργία έκδοσης οντοτήτων", + "default-sync-strategy": "Προεπιλεγμένη στρατηγική συγχρονισμού", + "sync-strategy-merge": "Συγχώνευση", + "sync-strategy-overwrite": "Αντικατάσταση", + "entities-to-export": "Οντότητες προς εξαγωγή", + "entities-to-restore": "Οντότητες προς επαναφορά", + "sync-strategy": "Στρατηγική συγχρονισμού", + "all-entities": "Όλες οι οντότητες", + "no-entities-to-export-prompt": "Καθορίστε οντότητες για εξαγωγή", + "no-entities-to-restore-prompt": "Καθορίστε οντότητες για επαναφορά", + "add-entity-type": "Προσθήκη τύπου οντότητας", + "remove-all": "Αφαίρεση όλων", + "version-create-result": "{ added, plural, =0 {Καμία οντότητα} =1 {1 οντότητα} other {# οντότητες} } προστέθηκαν.
    { modified, plural, =0 {Καμία οντότητα} =1 {1 οντότητα} other {# οντότητες} } τροποποιήθηκαν.
    { removed, plural, =0 {Καμία οντότητα} =1 {1 οντότητα} other {# οντότητες} } αφαιρέθηκαν.", + "remove-other-entities": "Αφαίρεση άλλων οντοτήτων", + "find-existing-entity-by-name": "Εύρεση υπάρχουσας οντότητας βάσει ονόματος", + "restore-entities-from-version": "Επαναφορά οντοτήτων από την έκδοση '{{versionName}}'", + "restoring-entities-from-version": "Επαναφορά οντοτήτων... Παρακαλώ περιμένετε", + "no-entities-restored": "Δεν επαναφέρθηκαν οντότητες", + "created": "{{created}} δημιουργήθηκαν", + "updated": "{{updated}} ενημερώθηκαν", + "deleted": "{{deleted}} διαγράφηκαν", + "remove-other-entities-confirm-text": "Προσοχή! Αυτό θα διαγράψει οριστικά όλες τις τρέχουσες οντότητες
    που δεν υπάρχουν στην έκδοση που θέλετε να επαναφέρετε.

    Παρακαλώ πληκτρολογήστε \"remove other entities\" για επιβεβαίωση.", + "auto-commit-to-branch": "αυτόματη καταχώρηση στον κλάδο {{branch}}", + "default-create-entity-version-name": "Ενημέρωση {{entityName}}", + "sync-strategy-merge-hint": "Δημιουργεί ή ενημερώνει τις επιλεγμένες οντότητες στο αποθετήριο. Όλες οι άλλες οντότητες δεν τροποποιούνται.", + "sync-strategy-overwrite-hint": "Δημιουργεί ή ενημερώνει τις επιλεγμένες οντότητες στο αποθετήριο. Όλες οι άλλες οντότητες διαγράφονται.", + "device-credentials-conflict": "Αποτυχία φόρτωσης της συσκευής με εξωτερικό ID {{entityId}}
    λόγω ύπαρξης των ίδιων διαπιστευτηρίων σε άλλη συσκευή.
    Σκεφτείτε να απενεργοποιήσετε την επιλογή φόρτωση διαπιστευτηρίων στη φόρμα επαναφοράς.", + "missing-referenced-entity": "Αποτυχία φόρτωσης της {{sourceEntityTypeName}} με εξωτερικό ID {{sourceEntityId}}
    επειδή αναφέρεται σε ανύπαρκτη {{targetEntityTypeName}} με ID {{targetEntityId}}.", + "runtime-failed": "Αποτυχία: {{message}}", + "auto-commit-settings-read-only-hint": "Η δυνατότητα αυτόματης καταχώρησης δεν λειτουργεί όταν έχει ενεργοποιηθεί η επιλογή μόνο για ανάγνωση στις ρυθμίσεις αποθετηρίου.", + "rollback-on-error": "Αναίρεση σε περίπτωση σφάλματος", + "rollback-on-error-hint": "Αν έχετε μεγάλο αριθμό οντοτήτων για επαναφορά, εξετάστε το ενδεχόμενο να απενεργοποιήσετε αυτήν την επιλογή για αυξημένη απόδοση.\n Σημειώστε ότι, αν παρουσιαστεί σφάλμα κατά τη διάρκεια της φόρτωσης έκδοσης, οι ήδη αποθηκευμένες οντότητες (με σχέσεις, ιδιότητες, κ.λπ.) θα παραμείνουν ως έχουν" }, "widget": { - "widget-library": "Βιβλιοθήκη Widget", - "widget-bundle": "Δέσμη Widget", - "select-widgets-bundle": "Επιλογή δέσμης Widgets", - "management": "Διαχείριση Widget", - "editor": "Συντάκτης Widget", - "widget-type-not-found": "Πρόβλημα φόρτωσης διαμόρφωσης Widget.
    Probably associated\n widget type was removed.", - "widget-type-load-error": "Το Widget δεν φορτώθηκε λόγω των παρακάτω σφαλμάτων:", - "remove": "Αφαίρεση Widget", - "edit": "Επεξεργασία Widget", - "remove-widget-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε το Widget '{{widgetTitle}}'?", - "remove-widget-text": "Προσοχή, μετά την επιβεβαίωση το widget και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "timeseries": "Χρονική σειρά", + "widget-library": "Βιβλιοθήκη widgets", + "widget-bundle": "Πακέτο widgets", + "all-bundles": "Όλα τα πακέτα", + "select-widgets-bundle": "Επιλέξτε πακέτο widgets", + "widgets": "Widgets", + "all-widgets": "Όλα τα widgets", + "widget": "Widget", + "select-widget": "Επιλέξτε widget", + "no-widgets-matching": "Δεν βρέθηκαν widgets που να ταιριάζουν με '{{entity}}'.", + "no-widgets": "Δεν υπάρχουν ακόμα widgets", + "no-widgets-text": "Δεν βρέθηκαν widgets", + "management": "Διαχείριση widgets", + "editor": "Επεξεργαστής widget", + "confirm-to-exit-editor-html": "Έχετε μη αποθηκευμένες ρυθμίσεις widget.
    Είστε σίγουροι ότι θέλετε να φύγετε από αυτή τη σελίδα;", + "widget-type-not-found": "Πρόβλημα κατά τη φόρτωση της διαμόρφωσης του widget.
    Πιθανόν ο τύπος widget να έχει αφαιρεθεί.", + "widget-type-load-error": "Το widget δεν φορτώθηκε λόγω των εξής σφαλμάτων:", + "remove": "Αφαίρεση widget", + "delete": "Διαγραφή widget", + "edit": "Επεξεργασία widget", + "remove-widget-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε το widget '{{widgetTitle}}';", + "remove-widget-text": "Μετά την επιβεβαίωση, το widget και όλα τα σχετικά δεδομένα δεν θα μπορούν να ανακτηθούν.", + "replace-reference-with-widget-copy": "Αντικατάσταση αναφοράς με αντίγραφο widget", + "timeseries": "Χρονοσειρά", "search-data": "Αναζήτηση δεδομένων", "no-data-found": "Δεν βρέθηκαν δεδομένα", - "latest": "Τελευταίες αξίες", - "rpc": "Έλεγχος Widget", - "alarm": "Alarm widget", + "latest": "Τελευταίες τιμές", + "rpc": "Widget ελέγχου", + "alarm": "Widget συναγερμού", "static": "Στατικό widget", - "select-widget-type": "Επιλογή τύπου Widget", - "missing-widget-title-error": "Ο τίτλος Widget πρέπει να καθοριστεί!", - "widget-saved": "Το Widget αποθηκεύτηκε", - "unable-to-save-widget-error": "Δεν είναι δυνατή η αποθήκευση του Widget! Το Widget έχει σφάλματα!", + "timeseries-short": "σειρά", + "latest-short": "τελευταία", + "rpc-short": "έλεγχος", + "alarm-short": "συναγερμός", + "static-short": "στατικό", + "select-widget-type": "Επιλέξτε τύπο widget", + "missing-widget-title-error": "Πρέπει να καθοριστεί τίτλος widget!", + "widget-saved": "Το widget αποθηκεύτηκε", + "unable-to-save-widget-error": "Αδυναμία αποθήκευσης του widget! Περιέχει σφάλματα!", "save": "Αποθήκευση widget", "saveAs": "Αποθήκευση widget ως", - "save-widget-type-as": "Αποθήκευση τύπου Widget type ως", - "save-widget-type-as-text": "Παρακαλούμε εισάγετε νέο τίτλο Widget και/ή επιλέξετε στοχευμένη δέσμη widgets", - "toggle-fullscreen": "Λειτουργεία πλήρους οθόνης", - "run": "Εκτέλεση Widget", - "title": "Τίτλος Widget", - "title-required": "Απαιτείται τίτλος Widget.", - "type": "Τύπος Widget", + "move": "Μετακίνηση widget", + "save-widget-as": "Αποθήκευση widget ως", + "save-widget-as-text": "Εισάγετε νέο τίτλο widget", + "toggle-fullscreen": "Εναλλαγή πλήρους οθόνης", + "run": "Εκτέλεση widget", + "widget-title": "Τίτλος widget", + "title": "Τίτλος", + "title-required": "Απαιτείται τίτλος widget.", + "title-max-length": "Ο τίτλος πρέπει να είναι μικρότερος από 256 χαρακτήρες", + "system": "Σύστημα", + "type": "Τύπος widget", "resources": "Πόροι", "resource-url": "JavaScript/CSS URL", - "remove-resource": "Αφαίρεση πηγής", - "add-resource": "Προσθήκη πηγής", + "resource-is-extension": "Είναι επέκταση", + "remove-resource": "Αφαίρεση πόρου", + "add-resource": "Προσθήκη πόρου", "html": "HTML", - "tidy": "Τακτοποιημένος", + "tidy": "Τακτοποίηση", "css": "CSS", - "settings-schema": "Ρυθμίσεις σχήματος", - "datakey-settings-schema": "Πλήκτρο δεδομένων σχήματος ρυθμίσεων", + "settings-form": "Φόρμα ρυθμίσεων", + "data-key-settings-form": "Φόρμα ρυθμίσεων data key", + "latest-data-key-settings-form": "Φόρμα ρυθμίσεων τελευταίων data key", + "widget-settings": "Ρυθμίσεις widget", + "description": "Περιγραφή", + "tags": "Ετικέτες", + "image-preview": "Προεπισκόπηση εικόνας", + "settings-form-selector": "Επιλογέας φόρμας ρυθμίσεων", + "data-key-settings-form-selector": "Επιλογέας φόρμας ρυθμίσεων data key", + "latest-data-key-settings-form-selector": "Επιλογέας φόρμας ρυθμίσεων τελευταίων data key", + "all": "Όλα", + "actual": "Πραγματικό", + "scada": "Σύμβολο SCADA", + "deprecated": "Καταργημένο", + "has-basic-mode": "Διαθέτει βασική λειτουργία", + "basic-mode-form-selector": "Επιλογέας φόρμας βασικής λειτουργίας", + "basic-mode": "Βασική", + "advanced-mode": "Προχωρημένη", "javascript": "Javascript", - "add-widget-type": "Προσθήκη νέου τύπου Widget", - "widget-template-load-failed-error": "Αποτυχία φόρτωσης προτύπου Widget!", - "add": "Προσθήκη Widget", - "undo": "Αναίρεση αλλαγών Widget", - "export": "Εξαγωγή Widget", - "export-data": "Εξαγωγή δεδομένων Widget", - "export-to-csv": "Εξαγωγή δεδομένων σε CSV...", - "export-to-excel": "Εξαγωγή δεδομένων σε XLS...", - "no-data": "Δεν υπάρχουν δεδομένα για εμφάνιση στο Widget" + "js": "JS", + "delete-widget-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το widget '{{widgetName}}';", + "delete-widget-text": "Μετά την επιβεβαίωση, το widget και όλα τα σχετικά δεδομένα δεν θα μπορούν να ανακτηθούν.", + "delete-widgets-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 widget} other {# widgets} };", + "delete-widgets-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα widgets θα διαγραφούν και όλα τα σχετικά δεδομένα δεν θα μπορούν να ανακτηθούν.", + "delete-widget": "Διαγραφή widget", + "widget-template-load-failed-error": "Αποτυχία φόρτωσης προτύπου widget!", + "details": "Λεπτομέρειες", + "widget-details": "Λεπτομέρειες widget", + "add": "Προσθήκη widget", + "add-existing-widget": "Προσθήκη υπάρχοντος widget", + "add-new-widget": "Προσθήκη νέου widget", + "search-widgets": "Αναζήτηση widgets", + "selected-widgets": "{ count, plural, =1 {1 widget} other {# widgets} } επιλεγμένο", + "undo": "Αναίρεση αλλαγών widget", + "export": "Εξαγωγή widget", + "export-prompt": "Ενσωμάτωση εικόνων και πόρων του widget", + "export-widgets": "Εξαγωγή widgets", + "export-widgets-prompt": "Ενσωμάτωση εικόνων και πόρων των widgets", + "import": "Εισαγωγή widget", + "no-data": "Δεν υπάρχουν δεδομένα για εμφάνιση στο widget", + "data-overflow": "Το widget εμφανίζει {{count}} από {{total}} οντότητες", + "alarm-data-overflow": "Το widget εμφανίζει συναγερμούς για {{allowedEntities}} (μέγιστο επιτρεπόμενο) οντότητες από {{totalEntities}} οντότητες", + "search": "Αναζήτηση widget", + "filter": "Τύπος φίλτρου widget", + "loading-widgets": "Φόρτωση widgets...", + "widget-template-error": "Μη έγκυρο πρότυπο HTML του widget.", + "reference": "Αναφορά" }, "widget-action": { - "header-button": "Κουμπί κεφαλίδας στη νέα κατάσταση του πίνακα ελέγχου", - "update-dashboard-state": "Ενημέρωση της τρέχουσας κατάστασης του dashboard", - "open-dashboard": "Πλοήγηση σε άλλο dashboard", + "header-button": "Κουμπί κεφαλίδας widget", + "do-nothing": "Καμία ενέργεια", + "open-dashboard-state": "Μετάβαση σε νέα κατάσταση πίνακα ελέγχου", + "update-dashboard-state": "Ενημέρωση τρέχουσας κατάστασης πίνακα ελέγχου", + "open-dashboard": "Μετάβαση σε άλλο πίνακα ελέγχου", "custom": "Προσαρμοσμένη ενέργεια", - "target-dashboard-state": "Κατάσταση προορισμού dashboard", - "target-dashboard-state-required": "Απαιτείται κατάσταση προορισμού dashboard", - "set-entity-from-widget": "Ορισμός οντότητας από Widget", - "target-dashboard": "dashboard προορισμού", - "open-right-layout": "Ανοίξτε τη δεξιά διάταξη του dashboard (προβολή κινητού)" + "custom-pretty": "Προσαρμοσμένη ενέργεια (με πρότυπο HTML)", + "custom-pretty-error-title": "Σφάλμα προσαρμοσμένου διαλόγου", + "custom-pretty-template-error": "Μη έγκυρο πρότυπο προσαρμοσμένου διαλόγου.", + "custom-pretty-controller-error": "Παρουσιάστηκε σφάλμα κατά την αξιολόγηση της συνάρτησης προσαρμοσμένου διαλόγου.", + "mobile-action": "Ενέργεια κινητής συσκευής", + "target-dashboard-state": "Στοχευμένη κατάσταση πίνακα ελέγχου", + "target-dashboard-state-required": "Απαιτείται στοχευμένη κατάσταση πίνακα ελέγχου", + "set-entity-from-widget": "Ορισμός οντότητας από widget", + "target-dashboard": "Στοχευμένος πίνακας ελέγχου", + "select-target-dashboard": "Επιλογή στοχευμένου πίνακα ελέγχου", + "target-dashboard-required": "Απαιτείται στοχευμένος πίνακας ελέγχου.", + "open-right-layout": "Άνοιγμα δεξιάς διάταξης πίνακα ελέγχου (προβολή κινητού)", + "state-display-type": "Επιλογή εμφάνισης κατάστασης πίνακα ελέγχου", + "open-normal": "Κανονική", + "open-in-separate-dialog": "Άνοιγμα σε ξεχωριστό διάλογο", + "open-in-popover": "Άνοιγμα σε αναδυόμενο παράθυρο", + "dialog-title": "Τίτλος διαλόγου", + "dialog-hide-dashboard-toolbar": "Απόκρυψη γραμμής εργαλείων πίνακα ελέγχου στο διάλογο", + "dialog-width": "Πλάτος διαλόγου σε ποσοστό σε σχέση με το πλάτος του παραθύρου προβολής", + "dialog-height": "Ύψος διαλόγου σε ποσοστό σε σχέση με το ύψος του παραθύρου προβολής", + "dialog-size-range-error": "Η τιμή ποσοστού μεγέθους διαλόγου πρέπει να είναι μεταξύ 1 και 100.", + "popover-preferred-placement": "Προτιμώμενη θέση αναδυόμενου παραθύρου", + "popover-placement-top": "Πάνω", + "popover-placement-topLeft": "Πάνω αριστερά", + "popover-placement-topRight": "Πάνω δεξιά", + "popover-placement-right": "Δεξιά", + "popover-placement-rightTop": "Δεξιά πάνω", + "popover-placement-rightBottom": "Δεξιά κάτω", + "popover-placement-bottom": "Κάτω", + "popover-placement-bottomLeft": "Κάτω αριστερά", + "popover-placement-bottomRight": "Κάτω δεξιά", + "popover-placement-left": "Αριστερά", + "popover-placement-leftTop": "Αριστερά πάνω", + "popover-placement-leftBottom": "Αριστερά κάτω", + "popover-hide-on-click-outside": "Απόκρυψη αναδυόμενου παραθύρου με κλικ εκτός", + "popover-hide-dashboard-toolbar": "Απόκρυψη γραμμής εργαλείων πίνακα ελέγχου στο αναδυόμενο παράθυρο", + "popover-width": "Πλάτος αναδυόμενου παραθύρου", + "popover-height": "Ύψος αναδυόμενου παραθύρου", + "popover-style": "Στυλ αναδυόμενου παραθύρου", + "open-new-browser-tab": "Άνοιγμα σε νέα καρτέλα προγράμματος περιήγησης", + "open-URL": "Άνοιγμα URL", + "URL": "URL", + "url-required": "Απαιτείται URL.", + "mobile": { + "device-provision": "Παροχή συσκευής", + "action-type": "Τύπος ενέργειας κινητής συσκευής", + "select-action-type": "Επιλογή τύπου ενέργειας κινητής συσκευής", + "action-type-required": "Απαιτείται τύπος ενέργειας κινητής συσκευής", + "take-picture-from-gallery": "Επιλογή εικόνας από τη συλλογή", + "take-photo": "Λήψη φωτογραφίας", + "map-direction": "Άνοιγμα οδηγιών χάρτη", + "map-location": "Άνοιγμα τοποθεσίας χάρτη", + "scan-qr-code": "Σάρωση κωδικού QR", + "make-phone-call": "Πραγματοποίηση τηλεφωνικής κλήσης", + "get-location": "Λήψη τοποθεσίας τηλεφώνου", + "take-screenshot": "Λήψη στιγμιότυπου οθόνης" + }, + "custom-action-function": "Συνάρτηση προσαρμοσμένης ενέργειας", + "custom-pretty-function": "Συνάρτηση προσαρμοσμένης ενέργειας (με πρότυπο HTML)", + "map-item-type": "Τύπος στοιχείου χάρτη", + "map-item": { + "marker": "Δείκτης", + "polygon": "Πολύγωνο", + "rectangle": "Ορθογώνιο", + "circle": "Κύκλος" + }, + "place-map-item": "Τοποθέτηση στοιχείου χάρτη", + "map-item-tooltip": { + "customize-map-item-tooltips": "Προσαρμογή υποδείξεων στοιχείων χάρτη", + "place-marker": "Τοποθέτηση δείκτη", + "start-draw-rectangle": "Έναρξη σχεδίασης ορθογωνίου", + "finish-draw-rectangle": "Ολοκλήρωση σχεδίασης ορθογωνίου", + "start-draw-polygon": "Έναρξη σχεδίασης πολυγώνου", + "continue-draw-polygon": "Συνέχεια σχεδίασης πολυγώνου", + "finish-draw-polygon": "Ολοκλήρωση σχεδίασης πολυγώνου", + "start-draw-circle": "Έναρξη σχεδίασης κύκλου", + "finish-draw-circle": "Ολοκλήρωση σχεδίασης κύκλου" + } }, "widgets-bundle": { - "current": "Τρέχουσα δέσμη", - "widgets-bundles": "Δέσμες Widgets", - "add": "Προσθήκη δέσμης Widgets", - "delete": "Διαγραφή δέσμης Widgets", + "current": "Τρέχον πακέτο widgets", + "widgets-bundles": "Πακέτα widgets", + "widgets-bundle-widgets": "Widgets του πακέτου", + "add": "Προσθήκη πακέτου widgets", + "delete": "Διαγραφή πακέτου widgets", "title": "Τίτλος", "title-required": "Απαιτείται τίτλος.", - "add-widgets-bundle-text": "Προσθήκη νέας δέσμης widgets", - "no-widgets-bundles-text": "Δεν βρέθηκαν δέσμες widgets", - "empty": "Η δέσμη Widgets είναι κενή", + "title-max-length": "Ο τίτλος πρέπει να έχει λιγότερους από 256 χαρακτήρες", + "description": "Περιγραφή", + "image-preview": "Προεπισκόπηση εικόνας", + "scada": "Πακέτο widgets SCADA", + "order": "Σειρά", + "add-widgets-bundle-text": "Προσθήκη νέου πακέτου widgets", + "no-widgets-bundles-text": "Δεν βρέθηκαν πακέτα widgets", + "empty": "Το πακέτο widgets είναι κενό", "details": "Λεπτομέρειες", - "widgets-bundle-details": "Λεπτομέρειες δέσμης Widgets", - "delete-widgets-bundle-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τη δέσμη Widgets '{{widgetsBundleTitle}}';", - "delete-widgets-bundle-text": "Προσοχή, μετά την επιβεβαίωση η δέσμη Widget και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "delete-widgets-bundles-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, =1 {1 widgets bundle} other {# widgets bundles} };", - "delete-widgets-bundles-action-title": "Διαγραφή { count, plural, =1 {1 widgets bundle} other {# widgets bundles} }", - "delete-widgets-bundles-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες δέσμες Widget και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", - "no-widgets-bundles-matching": "Δεν βρέθηκαν δέσμες Widgets που να ταιριάζουν'{{widgetsBundle}}'.", - "widgets-bundle-required": "Απαιτείται δέσμη Widgets.", + "widgets-bundle-details": "Λεπτομέρειες πακέτου widgets", + "delete-widgets-bundle-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το πακέτο widgets '{{widgetsBundleTitle}}';", + "delete-widgets-bundle-text": "Προσοχή, μετά την επιβεβαίωση το πακέτο widgets και όλα τα σχετικά δεδομένα θα γίνουν μη ανακτήσιμα.", + "delete-widgets-bundles-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 πακέτο widgets} other {# πακέτα widgets} };", + "delete-widgets-bundles-action-title": "Διαγραφή { count, plural, =1 {1 πακέτου widgets} other {# πακέτων widgets} }", + "delete-widgets-bundles-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα πακέτα widgets θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα γίνουν μη ανακτήσιμα.", + "no-widgets-bundles-matching": "Δεν βρέθηκαν πακέτα widgets που να ταιριάζουν με '{{widgetsBundle}}'.", + "widgets-bundle-required": "Απαιτείται πακέτο widgets.", "system": "Σύστημα", - "import": "Εισαγωγή δέσμης Widgets", - "export": "Εξαγωγή δέσμης Widgets", - "export-failed-error": "Δεν είναι δυνατή η εξαγωγή δέσμης Widgets: {{error}}", - "create-new-widgets-bundle": "Δημιουργία νέας δέσμης Widgets", - "widgets-bundle-file": "Αρχείο δέσμης Widgets", - "invalid-widgets-bundle-file-error": "Δεν είναι δυνατή η εισαγωγή δέσμης Widgets: Μη έγκυρη δομή δεδομένων Widgets." + "import": "Εισαγωγή πακέτου widgets", + "export": "Εξαγωγή πακέτου widgets", + "export-widgets-bundle-widgets-prompt": "Συμπερίληψη των widgets του πακέτου στα εξαγόμενα δεδομένα (διαφορετικά θα εξαχθούν μόνο οι αναφορές στα FQNs των widgets)", + "export-failed-error": "Αδυναμία εξαγωγής πακέτου widgets: {{error}}", + "create-new-widgets-bundle": "Δημιουργία νέου πακέτου widgets", + "widgets-bundle-file": "Αρχείο πακέτου widgets", + "invalid-widgets-bundle-file-error": "Αδυναμία εισαγωγής πακέτου widgets: Μη έγκυρη δομή δεδομένων πακέτου.", + "search": "Αναζήτηση πακέτων widgets", + "selected-widgets-bundles": "{ count, plural, =1 {1 πακέτο widgets} other {# πακέτα widgets} } επιλεγμένα", + "open-widgets-bundle": "Άνοιγμα πακέτου widgets", + "loading-widgets-bundles": "Φόρτωση πακέτων widgets...", + "create-new": "Δημιουργία νέου πακέτου widgets" }, "widget-config": { "data": "Δεδομένα", "settings": "Ρυθμίσεις", - "advanced": "Προηγμένος", + "advanced": "Για προχωρημένους", + "appearance": "Εμφάνιση", + "widget-card": "Κάρτα widget", + "mobile": "Κινητό", "title": "Τίτλος", + "title-tooltip": "Υπόδειξη τίτλου", "general-settings": "Γενικές ρυθμίσεις", - "display-title": "Εμφάνιση τίτλου", - "drop-shadow": "Σκίαση", + "display-title": "Εμφάνιση τίτλου widget", + "card-title": "Τίτλος κάρτας", + "drop-shadow": "Σκιά κάρτας", "enable-fullscreen": "Ενεργοποίηση πλήρους οθόνης", - "enable-data-export": "Ενεργοποίηση εξαγωγής δεδομένων", "background-color": "Χρώμα φόντου", "text-color": "Χρώμα κειμένου", + "border-radius": "Ακτίνα περιγράμματος", "padding": "Εσωτερικό περιθώριο", - "margin": "Περιθώριο", - "widget-style": "Στυλ Widget", - "title-style": "Στυλ τίτλου", - "mobile-mode-settings": "Ρυθμίσεις λειτουργίας κινητού", - "order": "Εντολή", + "margin": "Εξωτερικό περιθώριο", + "widget-style": "Στιλ widget", + "widget-css": "CSS widget", + "title-style": "Στιλ τίτλου", + "mobile-mode-settings": "Λειτουργία κινητού", + "order": "Σειρά", "height": "Ύψος", - "units": "Ειδικό σύμβολο για εμφάνιση δίπλα στην αξία", - "decimals": "Αριθμός ψηφίων μετά το κυμαινόμενο σημείο", - "timewindow": "Timewindow", - "use-dashboard-timewindow": "Χρήση dashboard timewindow", - "display-timewindow": "Απεικόνιση timewindow", - "display-legend": "Απεικόνιση λεζάντας", + "mobile-hide": "Απόκρυψη widget σε κινητή προβολή", + "desktop-hide": "Απόκρυψη widget σε προβολή επιφάνειας εργασίας", + "units": "Ειδικό σύμβολο δίπλα στην τιμή", + "units-by-default": "Μονάδες εξ ορισμού", + "decimals": "Αριθμός ψηφίων μετά την υποδιαστολή", + "decimals-by-default": "Δεκαδικά εξ ορισμού", + "default-data-key-parameter-hint": "Αυτή η ρύθμιση εφαρμόζεται σε όλες τις τιμές του widget εκτός αν παρακαμφθεί από τις ρυθμίσεις κλειδιού δεδομένων", + "units-short": "Μονάδες", + "decimals-short": "Δεκαδικά", + "decimals-suffix": "δεκαδικά", + "digits-suffix": "ψηφία", + "timewindow": "Χρονικό παράθυρο", + "use-dashboard-timewindow": "Χρήση χρονικού παραθύρου πίνακα ελέγχου", + "use-widget-timewindow": "Χρήση χρονικού παραθύρου widget", + "display-timewindow": "Εμφάνιση χρονικού παραθύρου", + "legend": "Υπόμνημα", + "display-legend": "Εμφάνιση υπομνήματος", "datasources": "Πηγές δεδομένων", - "maximum-datasources": "Το μέγιστο { count, plural, =1 {1 datasource is allowed.} other {# datasources are allowed} }", + "datasource": "Πηγή δεδομένων", + "maximum-datasources": "Μέγιστο { count, plural, =1 {1 πηγή δεδομένων επιτρέπεται.} other {επιτρέπονται # πηγές δεδομένων} }", + "timeseries-key-error": "Πρέπει να καθοριστεί τουλάχιστον ένα κλειδί δεδομένων χρονοσειράς", "datasource-type": "Τύπος", "datasource-parameters": "Παράμετροι", - "remove-datasource": "Κατάργηση της πηγής δεδομένων", + "remove-datasource": "Αφαίρεση πηγής δεδομένων", "add-datasource": "Προσθήκη πηγής δεδομένων", - "target-device": "Target device", - "alarm-source": "Πηγή Alarm", + "target-device": "Συσκευή στόχος", + "alarm-source": "Πηγή συναγερμού", "actions": "Ενέργειες", - "action": "ενέργεια", + "action": "Ενέργεια", "add-action": "Προσθήκη ενέργειας", "search-actions": "Αναζήτηση ενεργειών", + "no-actions-text": "Δεν βρέθηκαν ενέργειες", "action-source": "Πηγή ενέργειας", + "select-action-source": "Επιλογή πηγής ενέργειας", "action-source-required": "Απαιτείται πηγή ενέργειας.", + "column-index": "Δείκτης στήλης", + "select-column-index": "Επιλογή δείκτη στήλης", + "column-index-required": "Απαιτείται δείκτης στήλης.", + "not-set": "Μη καθορισμένο", "action-name": "Όνομα", "action-name-required": "Απαιτείται όνομα ενέργειας.", - "action-name-not-unique": "Μια άλλη ενέργεια με το ίδιο όνομα υπάρχει ήδη.\nΤο όνομα ενέργειας πρέπει να είναι μοναδικό μέσα στην ίδια πηγή ενέργειας.", + "action-name-not-unique": "Υπάρχει ήδη άλλη ενέργεια με το ίδιο όνομα.\nΤο όνομα της ενέργειας πρέπει να είναι μοναδικό εντός της ίδιας πηγής.", "action-icon": "Εικονίδιο", + "header-button": { + "button-settings": "Ρυθμίσεις κουμπιού", + "button-type": "Τύπος κουμπιού", + "button-type-basic": "Βασικό", + "button-type-raised": "Ανυψωμένο", + "button-type-stroked": "Περίγραμμα", + "button-type-flat": "Επίπεδο", + "button-type-icon": "Εικονίδιο", + "button-type-mini-fab": "FAB", + "colors": "Χρώματα", + "color": "Χρώμα", + "background": "Φόντο", + "border": "Περίγραμμα", + "advanced-button-style": "Για προχωρημένους στιλ κουμπιού", + "button-style": "Στιλ κουμπιού" + }, + "show-hide-action-using-function": "Εμφάνιση/Απόκρυψη ενέργειας με χρήση συνάρτησης", + "show-action-function": "Συνάρτηση εμφάνισης ενέργειας", "action-type": "Τύπος", "action-type-required": "Απαιτείται τύπος ενέργειας.", "edit-action": "Επεξεργασία ενέργειας", "delete-action": "Διαγραφή ενέργειας", - "delete-action-title": "Διαγραφή ενέργειας Widget", - "delete-action-text": "Είστε σίγουροι ότι θέλετε να διαγράψετε δράση widget με όνομα '{{actionName}}';" + "delete-action-title": "Διαγραφή ενέργειας widget", + "delete-action-text": "Είστε βέβαιοι ότι θέλετε να διαγράψετε την ενέργεια widget με όνομα '{{actionName}}';", + "title-icon": "Εικονίδιο τίτλου", + "display-icon": "Εμφάνιση εικονιδίου τίτλου", + "card-icon": "Εικονίδιο κάρτας", + "icon": "Εικονίδιο", + "icon-color": "Χρώμα εικονιδίου", + "icon-size": "Μέγεθος εικονιδίου", + "advanced-settings": "Για προχωρημένους", + "data-settings": "Ρυθμίσεις δεδομένων", + "limits": "Όρια", + "no-data-display-message": "Εναλλακτικό μήνυμα \"Δεν υπάρχουν δεδομένα προς εμφάνιση\"", + "data-page-size": "Μέγιστος αριθμός οντοτήτων ανά πηγή δεδομένων", + "settings-component-not-found": "Το στοιχείο φόρμας ρυθμίσεων δεν βρέθηκε για τον επιλεγμένο selector '{{selector}}'", + "preview": "Προεπισκόπηση", + "set": "Ορισμός", + "set-message": "Ορισμός μηνύματος", + "advanced-title-style": "Προηγμένο στιλ τίτλου", + "card-style": "Στιλ κάρτας", + "text": "Κείμενο", + "background": "Φόντο", + "advanced-widget-style": "Προηγμένο στιλ widget", + "card-buttons": "Κουμπιά κάρτας", + "show-card-buttons": "Εμφάνιση κουμπιών κάρτας", + "card-border-radius": "Ακτίνα περιγράμματος κάρτας", + "card-padding": "Περιθώριο κάρτας", + "card-appearance": "Εμφάνιση κάρτας", + "color": "Χρώμα", + "tooltip": "Υπόδειξη", + "units-required": "Απαιτείται μονάδα.", + "list-layout": "Διάταξη λίστας", + "layout": "Διάταξη", + "resize-options": "Επιλογές αλλαγής μεγέθους", + "resizable": "Με δυνατότητα αλλαγής μεγέθους", + "preserve-aspect-ratio": "Διατήρηση αναλογίας διαστάσεων" }, "widget-type": { - "import": "Εισαγωγή τύπου Widget", - "export": "Εξαγωγή τύπου Widget", - "export-failed-error": "Δεν είναι δυνατή η εξαγωγή τύπου Widget: {{error}}", - "create-new-widget-type": "Δημιουργία νέου τύπου Widget", - "widget-type-file": "Αρχείο τύπου Widget", - "invalid-widget-type-file-error": "Δεν είναι δυνατή η εισαγωγή τύπου Widget: Μη έγκυρη δομή δεδομένων τύπου Widget." - }, - "self-registration": { - "self-registration": "Αυτόματη εγγραφή", - "self-registration-url": "Αυτόματη εγγραφή URL", - "captcha-site-key": "reCAPTCHA κλειδί ιστότοπου", - "captcha-secret-key": "reCAPTCHA μυστικό κλειδί", - "notification-email": "Εmail γνωστοποίησης", - "privacy-policy-text": "Κείμενο πολιτικής απορρήτου", - "text-message-page": "Μήνυμα κειμένου για τη σελίδα εγγραφής" - }, - "white-labeling": { - "white-labeling": "Εμφάνιση", - "login-white-labeling": "Εμφάνιση Σύδεσης", + "import": "Εισαγωγή τύπου widget", + "export": "Εξαγωγή τύπου widget", + "export-failed-error": "Αδυναμία εξαγωγής του widget: {{error}}", + "widget-file": "Αρχείο widget", + "invalid-widget-file-error": "Αδυναμία εισαγωγής widget: Μη έγκυρη δομή δεδομένων widget." + }, + "markdown": { + "edit": "Επεξεργασία", "preview": "Προεπισκόπηση", - "app-title": "Τίτλος εφαρμογής", - "favicon": "Εικονίδιο Ιστότοπου", - "favicon-description": "Εικόνα *.ico, *.gif or *.png με μέγιστο μέγεθος {{kbSize}} KBytes.", - "favicon-size-error": "Η εικόνα ιστότοπου είναι πολύ μεγάλη. Μέγιστο επιτρεπόμενο μέγεθος {{kbSize}} KBytes.", - "favicon-type-error": "Μη έγκυρη μορφή αρχείου εικόνας ιστότοπου. Μόνο εικόνες ICO, GIF ή PNG γίνονται αποδεκτές.", - "drop-favicon-image": "Σύρετε ένα εικονίδιο ιστότοπου ή κάντε κλικ για να επιλέξετε ένα αρχείο για μεταφόρτωση.", - "no-favicon-image": "Δεν έχει επιλεχθεί εικονίδιο", - "logo": "Logo", - "logo-description": "Οποιαδήποτε εικόνα με μέγιστο μέγεθος {{kbSize}} KBytes.", - "logo-size-error": "Η εικόνα του λογότυπου είναι πολύ μεγάλη. Μέγιστο επιτρεπόμενο μέγεθος {{kbSize}} KBytes.", - "logo-type-error": "Μη έγκυρη μορφή αρχείου λογότυπου. Μόνο εικόνες είναι αποδεκτές.", - "drop-logo-image": "Σείρετε μια εικόνα λογότυπου ή κάντε κλικ για να επιλέξετε ένα αρχείο για μεταφόρτωση.", - "no-logo-image": "Δεν έχει επιλεγεί λογότυπο", - "logo-height": "Ύψος λογότυπου, px", - "primary-palette": "Κύρια παλέτα", - "accent-palette": "Παλέτα τονισμών", - "customize-palette": "Προσαρμογή", - "edit-palette": "Επεξεργασία παλέτας", - "save-palette": "Αποθήκευση παλέτας", - "primary-background": "Κύριο χρώμα υποβάθρου", - "secondary-background": "Δευτερεύον χρώμα υποβάθρου", - "hue1": "HUE 1", - "hue2": "HUE 2", - "hue3": "HUE 3", - "page-background-color": "Χρώμα υποβάθρου σελίδας", - "dark-foreground": "Σκοτεινό χρώμα προσκηνίου", - "domain-name": "Όνομα Domain", - "help-link-base-url": "Base url για συνδέσμους βοηθείας", - "enable-help-links": "Ενεργοποίηση συνδέσμων βοηθείας", - "error-verification-url": "Ένα όνομα domain δεν πρέπει να περιέχει σύμβολα '/' και ':'. Παράδειγμα: thingsboard.io", - "show-platform-name-version": "Εμφάνιση ονόματος και έκδοσης πλατφόρμας", - "platform-name": "Όνομα πλατφόρμας", - "platform-version": "Έκδοση πλατφόρμας", - "version-mask": "{{name}} v.{{verion}}", - "position": { - "label": "Όνομα πλατφόρμας και θέση έκδοσης", - "under-logo": "Κάτω από το λογότυπο", - "bottom": "Στο κάτω μέρος της φόρμας σύνδεσης" - } + "copy-code": "Κάντε κλικ για αντιγραφή", + "copied": "Αντιγράφηκε!" }, "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "Η διαμόρφωση εξαρτάται από το widget QR code της εφαρμογής κινητού στις βασικές ρυθμίσεις της πλατφόρμας", + "get-it-on-google-play": "Λήψη από το Google Play", + "download-on-the-app-store": "Λήψη από το App Store" + }, + "action-button": { + "behavior": "Συμπεριφορά", + "on-click": "Κατά το κλικ", + "on-click-hint": "Ενέργεια που εκτελείται όταν γίνεται κλικ στο κουμπί", + "first-button-click": "Κλικ στο πρώτο κουμπί", + "first-button-click-hint": "Ενέργεια κατά την πίεση του πρώτου κουμπιού.", + "second-button-click": "Κλικ στο δεύτερο κουμπί", + "second-button-click-hint": "Ενέργεια κατά την πίεση του δεύτερου κουμπιού.", + "button-click-hint": "Ενέργεια κατά την πίεση στο widget." + }, + "command-button": { + "behavior": "Συμπεριφορά", + "on-click": "Κατά το κλικ", + "on-click-hint": "Ενέργεια που εκτελείται όταν γίνεται κλικ στο κουμπί." + }, + "power-button": { + "behavior": "Συμπεριφορά", + "power-on": "Ενεργοποίηση", + "power-on-hint": "Ενέργεια για ενεργοποίηση του στοιχείου.", + "power-off": "Απενεργοποίηση", + "power-off-hint": "Ενέργεια για απενεργοποίηση του στοιχείου.", + "on-label": "Ενεργό", + "off-label": "Ανενεργό", + "layout": "Διάταξη", + "layout-default": "Προεπιλεγμένη", + "layout-simplified": "Απλοποιημένη", + "layout-outlined": "Περίγραμμα", + "layout-default-volume": "Προεπιλεγμένη.Ένταση", + "layout-simplified-volume": "Απλοποιημένη.Ένταση", + "layout-outlined-volume": "Περίγραμμα.Ένταση", + "layout-default-icon": "Προεπιλεγμένη.Εικονίδιο", + "layout-simplified-icon": "Απλοποιημένη.Εικονίδιο", + "layout-outlined-icon": "Περίγραμμα.Εικονίδιο", + "main": "Κύριο", + "background": "Φόντο", + "button-icon-on": "Εικονίδιο κουμπιού 'On'", + "button-icon-off": "Εικονίδιο κουμπιού 'Off'", + "power-on-colors": "Χρώματα κατά την ενεργοποίηση", + "power-off-colors": "Χρώματα κατά την απενεργοποίηση", + "disabled-colors": "Χρώματα απενεργοποίησης", + "button": "Κουμπί" + }, + "toggle-button": { + "behavior": "Συμπεριφορά", + "checked": "Επιλεγμένο", + "unchecked": "Μη επιλεγμένο", + "check": "Επιλογή", + "check-hint": "Ενέργεια για επιλογή του στοιχείου.", + "uncheck": "Αποεπιλογή", + "uncheck-hint": "Ενέργεια για αποεπιλογή του στοιχείου.", + "auto-scale": "Αυτόματη κλιμάκωση", + "horizontal-fill": "Οριζόντια κάλυψη", + "vertical-fill": "Κάθετη κάλυψη", + "button-appearance": "Εμφάνιση κουμπιού" + }, + "segmented-button": { + "layout": "Διάταξη", + "layout-squared": "Τετραγωνισμένο", + "layout-rounded": "Στρογγυλεμένο", + "card-border": "Περίγραμμα κάρτας", + "button-appearance": "Εμφάνιση κουμπιού", + "first": "Πρώτο", + "second": "Δεύτερο", + "color-styles": "Στυλ χρώματος", + "selected": "Επιλεγμένο", + "unselected": "Μη επιλεγμένο" + }, + "button": { + "layout": "Διάταξη", + "outlined": "Με περίγραμμα", + "filled": "Γεμάτο", + "underlined": "Υπογραμμισμένο", + "basic": "Βασικό", + "auto-scale": "Αυτόματη κλιμάκωση", + "label": "Ετικέτα", + "icon": "Εικονίδιο", + "border-radius": "Ακτίνα περιγράμματος", + "color-palette": "Παλέτα χρωμάτων", + "main": "Κύριο", + "background": "Φόντο", + "border": "Περίγραμμα", + "custom-styles": "Προσαρμοσμένα στυλ", + "clear-style": "Καθαρισμός στυλ", + "shadow": "Σκιά", + "enabled": "Ενεργοποιημένο", + "disabled": "Απενεργοποιημένο", + "preview": "Προεπισκόπηση", + "copy-style-from": "Αντιγραφή στυλ από" + }, + "value-stepper": { + "behavior": "Συμπεριφορά", + "simplified": "Απλοποιημένο", + "filled": "Συμπληρωμένο", + "outlined": "Περίγραμμα", + "volume": "Ένταση", + "initial-state": "Αρχική κατάσταση", + "initial-state-hint": "Ενέργεια για λήψη της αρχικής τιμής.", + "disabled-state": "Απενεργοποιημένη κατάσταση", + "disabled-state-hint": "Διαμόρφωση συνθήκης κατά την οποία το στοιχείο είναι απενεργοποιημένο.", + "right-button-click": "Κλικ δεξιού κουμπιού", + "right-button-click-hint": "Ενέργεια κατά το πάτημα του δεξιού κουμπιού.", + "left-button-click": "Κλικ αριστερού κουμπιού", + "left-button-click-hint": "Ενέργεια κατά το πάτημα του αριστερού κουμπιού.", + "auto-scale": "Αυτόματη κλιμάκωση", + "value-range": "Εύρος", + "min-range": "Ελάχιστο", + "max-range": "Μέγιστο", + "value-increment-decrement-step": "Βήμα αύξησης/μείωσης τιμής", + "value": "Τιμή", + "value-box-background": "Φόντο πλαισίου τιμής", + "border": "Περίγραμμα", + "button-appearance": "Εμφάνιση κουμπιού", + "left": "Αριστερά", + "right": "Δεξιά", + "left-button": "Αριστερό κουμπί", + "right-button": "Δεξί κουμπί", + "icon": "Εικονίδιο", + "color-palette": "Παλέτα χρωμάτων", + "main": "Κύριο", + "background": "Φόντο", + "button-icon-on": "Εικονίδιο κουμπιού 'Ενεργό'", + "button-on-colors": "Χρώματα κατά την ενεργοποίηση", + "disabled-colors": "Χρώματα απενεργοποίησης" + }, + "button-state": { + "activated-state": "Κατάσταση ενεργοποίησης", + "activated-state-hint": "Ρύθμιση συνθήκης κατά την οποία το κουμπί είναι ενεργό.", + "disabled-state": "Απενεργοποιημένη κατάσταση", + "disabled-state-hint": "Ρύθμιση συνθήκης κατά την οποία το κουμπί είναι απενεργοποιημένο.", + "selected-state": "Επιλογή κουμπιού", + "selected-state-hint": "Ρύθμιση συνθήκης κατά την οποία το κουμπί επιλέγεται.", + "enabled": "Ενεργοποιημένο", + "hovered": "Με αιώρηση", + "pressed": "Πατημένο", + "activated": "Ενεργοποιημένο", + "disabled": "Απενεργοποιημένο", + "initial": "Πρώτο κουμπί", + "first": "Πρώτο", + "second": "Δεύτερο" + }, + "background": { + "background": "Φόντο", + "background-settings": "Ρυθμίσεις φόντου", + "background-type-image": "Εικόνα", + "background-type-color": "Χρώμα", + "image-url": "URL εικόνας", + "overlay": "Επικάλυψη", + "enable-overlay": "Ενεργοποίηση επικάλυψης", + "blur": "Θόλωμα", + "preview": "Προεπισκόπηση" + }, + "bar-chart": { + "bar-appearance": "Εμφάνιση γραμμής", + "label-on-bar": "Ετικέτα στη γραμμή", + "value-on-bar": "Τιμή στη γραμμή", + "bar-chart-style": "Στυλ ραβδογράμματος", + "bar-axis": "Άξονας ράβδων" + }, + "polar-area-chart": { + "polar-axis": "Πολικός άξονας", + "start-angle": "Γωνία εκκίνησης", + "polar-area-chart-style": "Στυλ πολικού διαγράμματος" + }, + "battery-level": { + "layout": "Διάταξη", + "layout-vertical-solid": "Κατακόρυφη. Συμπαγής", + "layout-horizontal-solid": "Οριζόντια. Συμπαγής", + "layout-vertical-divided": "Κατακόρυφη. Διαχωρισμένη", + "layout-horizontal-divided": "Οριζόντια. Διαχωρισμένη", + "icon": "Εικονίδιο", + "value": "Τιμή", + "auto-scale": "Αυτόματη κλιμάκωση", + "battery-level-color": "Χρώμα στάθμης μπαταρίας", + "battery-shape-color": "Χρώμα σχήματος μπαταρίας", + "battery-level-card-style": "Στυλ κάρτας στάθμης μπαταρίας", + "sections-count": "Αριθμός τμημάτων" + }, + "signal-strength": { + "value": "Τιμή", + "last-update": "Τελευταία ενημέρωση", + "no-signal": "Χωρίς σήμα", + "layout": "Διάταξη", + "layout-wifi": "Wi-Fi", + "layout-cellular-bar": "Γραμμή κινητής", + "icon": "Εικονίδιο", + "date": "Ημερομηνία", + "active-bars-color": "Χρώμα ενεργών γραμμών σήματος", + "inactive-bars-color": "Χρώμα ανενεργών γραμμών σήματος", + "signal-strength-card-style": "Στυλ κάρτας έντασης σήματος", + "no-signal-rssi-value": "Τιμή rssi για \"Χωρίς σήμα\"" + }, + "status-widget": { + "behavior": "Συμπεριφορά", + "layout": "Διάταξη", + "layout-default": "Προεπιλεγμένη", + "layout-center": "Κέντρο", + "layout-icon": "Εικονίδιο", + "on": "Ενεργό", + "off": "Ανενεργό", + "label": "Ετικέτα", + "status": "Κατάσταση", + "icon": "Εικονίδιο", + "color-palette": "Παλέτα χρωμάτων", + "disabled-color-palette": "Παλέτα χρωμάτων απενεργοποίησης", + "primary": "Πρωτεύον", + "primary-color-hint": "Χρώμα εικονιδίου και ετικέτας", + "secondary": "Δευτερεύον", + "secondary-color-hint": "Χρώμα κατάστασης", + "background": "Φόντο" + }, + "chart": { + "common-settings": "Κοινές ρυθμίσεις", + "enable-stacking-mode": "Ενεργοποίηση λειτουργίας στοίβαξης", + "selection": "Επιλογή εύρους χρόνου", + "enable-selection-mode": "Ενεργοποίηση λειτουργίας επιλογής", + "line-shadow-size": "Μέγεθος σκιάς γραμμής", + "display-smooth-lines": "Εμφάνιση ομαλών (καμπύλων) γραμμών", + "default-bar-width": "Προεπιλεγμένο πλάτος ράβδου για μη συγκεντρωμένα δεδομένα (milliseconds)", + "bar-alignment": "Στοίχιση ράβδων", + "bar-alignment-left": "Αριστερά", + "bar-alignment-right": "Δεξιά", + "bar-alignment-center": "Κέντρο", + "default-font": "Προεπιλεγμένη γραμματοσειρά", + "default-font-size": "Προεπιλεγμένο μέγεθος γραμματοσειράς", + "default-font-color": "Προεπιλεγμένο χρώμα γραμματοσειράς", + "thresholds-line-width": "Προεπιλεγμένο πάχος γραμμής για όλα τα όρια", + "tooltip-settings": "Ρυθμίσεις υπόδειξης", + "tooltip": "Υπόδειξη", + "show-tooltip": "Εμφάνιση υπόδειξης", + "hover-individual-points": "Επισήμανση μεμονωμένων σημείων", + "show-cumulative-values": "Εμφάνιση αθροιστικών τιμών σε λειτουργία στοίβαξης", + "hide-zero-false-values": "Απόκρυψη μηδενικών/ψευδών τιμών από την υπόδειξη", + "tooltip-value-format-function": "Συνάρτηση μορφοποίησης τιμής υπόδειξης", + "grid-settings": "Ρυθμίσεις πλέγματος", + "show-vertical-lines": "Εμφάνιση κάθετων γραμμών", + "show-horizontal-lines": "Εμφάνιση οριζόντιων γραμμών", + "grid-outline-border-width": "Πάχος περιγράμματος πλέγματος (px)", + "primary-color": "Πρωτεύον χρώμα", + "background-color": "Χρώμα φόντου", + "ticks-color": "Χρώμα τιμών άξονα", + "xaxis-settings": "Ρυθμίσεις άξονα Χ", + "axis-title": "Τίτλος άξονα", + "xaxis-tick-labels-settings": "Ρυθμίσεις ετικετών άξονα Χ", + "show-tick-labels": "Εμφάνιση ετικετών άξονα", + "yaxis-settings": "Ρυθμίσεις άξονα Υ", + "min-scale-value": "Ελάχιστη τιμή κλίμακας", + "max-scale-value": "Μέγιστη τιμή κλίμακας", + "yaxis-tick-labels-settings": "Ρυθμίσεις ετικετών άξονα Υ", + "tick-step-size": "Μέγεθος βήματος τιμών", + "number-of-decimals": "Αριθμός δεκαδικών ψηφίων", + "ticks-formatter-function": "Συνάρτηση μορφοποίησης τιμών", + "comparison-settings": "Ρυθμίσεις σύγκρισης", + "enable-comparison": "Ενεργοποίηση σύγκρισης", + "time-for-comparison": "Περίοδος σύγκρισης", + "time-for-comparison-previous-interval": "Προηγούμενο διάστημα (προεπιλογή)", + "time-for-comparison-days": "Πριν από μία ημέρα", + "time-for-comparison-weeks": "Πριν από μία εβδομάδα", + "time-for-comparison-months": "Πριν από έναν μήνα", + "time-for-comparison-years": "Πριν από έναν χρόνο", + "time-for-comparison-custom-interval": "Προσαρμοσμένο διάστημα", + "custom-interval-value": "Τιμή προσαρμοσμένου διαστήματος (ms)", + "comparison-x-axis-settings": "Ρυθμίσεις άξονα Χ για σύγκριση", + "axis-position": "Θέση άξονα", + "axis-position-top": "Πάνω (προεπιλογή)", + "axis-position-bottom": "Κάτω", + "custom-legend-settings": "Ρυθμίσεις προσαρμοσμένου υπομνήματος", + "enable-custom-legend": "Ενεργοποίηση προσαρμοσμένου υπομνήματος (επιτρέπει τη χρήση τιμών χαρακτηριστικών/σειρών χρόνου σε ετικέτες κλειδιών)", + "key-name": "Όνομα κλειδιού", + "key-name-required": "Απαιτείται όνομα κλειδιού", + "key-type": "Τύπος κλειδιού", + "key-type-attribute": "Χαρακτηριστικό", + "key-type-timeseries": "Σειρά χρόνου", + "label-keys-list": "Λίστα κλειδιών για χρήση στις ετικέτες", + "no-label-keys": "Δεν έχουν διαμορφωθεί κλειδιά", + "add-label-key": "Προσθήκη νέου κλειδιού", + "line-width": "Πάχος γραμμής", + "color": "Χρώμα", + "data-is-hidden-by-default": "Τα δεδομένα είναι κρυφά από προεπιλογή", + "disable-data-hiding": "Απενεργοποίηση απόκρυψης δεδομένων", + "remove-from-legend": "Αφαίρεση κλειδιού δεδομένων από το υπόμνημα", + "exclude-from-stacking": "Εξαίρεση από στοίβαξη (διαθέσιμο σε λειτουργία \"Στοίβαξη\")", + "line-settings": "Ρυθμίσεις γραμμής", + "show-line": "Εμφάνιση γραμμής", + "fill-line": "Συμπλήρωση γραμμής", + "fill-line-opacity": "Αδιαφάνεια συμπλήρωσης", + "points-settings": "Ρυθμίσεις σημείων", + "show-points": "Εμφάνιση σημείων", + "points-line-width": "Πάχος γραμμής σημείων", + "points-radius": "Ακτίνα σημείων", + "point-shape": "Σχήμα σημείου", + "point-shape-circle": "Κύκλος", + "point-shape-cross": "Σταυρός", + "point-shape-diamond": "Ρόμβος", + "point-shape-square": "Τετράγωνο", + "point-shape-triangle": "Τρίγωνο", + "point-shape-custom": "Προσαρμοσμένη συνάρτηση", + "point-shape-draw-function": "Συνάρτηση σχεδίασης σχήματος σημείου", + "show-separate-axis": "Εμφάνιση ξεχωριστού άξονα", + "axis-position-left": "Αριστερά", + "axis-position-right": "Δεξιά", + "thresholds": "Όρια", + "no-thresholds": "Δεν έχουν διαμορφωθεί όρια", + "add-threshold": "Προσθήκη ορίου", + "show-values-for-comparison": "Εμφάνιση ιστορικών τιμών για σύγκριση", + "comparison-values-label": "Ετικέτα ιστορικών τιμών", + "comparison-line-color": "Χρώμα γραμμής σύγκρισης", + "threshold-settings": "Ρυθμίσεις ορίων", + "use-as-threshold": "Χρήση τιμής κλειδιού ως όριο", + "threshold-line-width": "Πάχος γραμμής ορίου", + "threshold-color": "Χρώμα ορίου", + "common-pie-settings": "Κοινές ρυθμίσεις πίτας", + "radius": "Ακτίνα", + "inner-radius": "Εσωτερική ακτίνα", + "tilt": "Κλίση", + "common-pie-settings-range-error": "Η τιμή πρέπει να είναι στο εύρος από 0 έως 1", + "stroke-settings": "Ρυθμίσεις περιγράμματος", + "width-pixels": "Πλάτος (pixel)", + "show-labels": "Εμφάνιση ετικετών", + "animation-settings": "Ρυθμίσεις κίνησης", + "animated-pie": "Ενεργοποίηση κίνησης πίτας (πειραματικό)", + "border-settings": "Ρυθμίσεις περιγράμματος", + "border-width": "Πλάτος περιγράμματος", + "border-color": "Χρώμα περιγράμματος", + "legend-settings": "Ρυθμίσεις υπομνήματος", + "display-legend": "Εμφάνιση υπομνήματος", + "labels-font-color": "Χρώμα γραμματοσειράς ετικετών", + "series": "Σειρές", + "add-series": "Προσθήκη σειράς", + "series-settings": "Ρυθμίσεις σειράς", + "remove-series": "Αφαίρεση σειράς", + "no-series": "Δεν έχουν διαμορφωθεί σειρές", + "no-series-error": "Πρέπει να καθοριστεί τουλάχιστον μία σειρά", + "chart-appearance": "Εμφάνιση γραφήματος", + "vertical-grid-lines": "Κάθετες γραμμές πλέγματος", + "horizontal-grid-lines": "Οριζόντιες γραμμές πλέγματος", + "chart-background": "Φόντο γραφήματος", + "grid-lines-color": "Χρώμα γραμμών πλέγματος", + "border": "Περίγραμμα", + "axis": "Άξονας", + "vertical-axis": "Κάθετος άξονας", + "ticks": "Διαβαθμίσεις", + "horizontal-axis": "Οριζόντιος άξονας", + "shape-empty-circle": "Κενός κύκλος", + "shape-circle": "Κύκλος", + "shape-rect": "Ορθογώνιο", + "shape-round-rect": "Στρογγυλεμένο ορθογώνιο", + "shape-triangle": "Τρίγωνο", + "shape-diamond": "Ρόμβος", + "shape-pin": "Καρφίτσα", + "shape-arrow": "Βέλος", + "shape-none": "Κανένα", + "line-type-solid": "Συνεχής", + "line-type-dashed": "Διακεκομμένη", + "line-type-dotted": "Διαστιγμένη", + "label-position-top": "Πάνω", + "label-position-bottom": "Κάτω", + "label-position-outside": "Εξωτερικά", + "label-position-inside": "Εσωτερικά", + "fill": "Γέμισμα", + "fill-type-none": "Κανένα", + "fill-type-solid": "Συμπαγές", + "fill-type-opacity": "Αδιαφάνεια", + "fill-type-gradient": "Διαβάθμιση", + "background": "Φόντο", + "opacity": "Αδιαφάνεια", + "gradient-stops": "Στάσεις διαβάθμισης", + "gradient-start": "αρχή", + "gradient-end": "τέλος", + "animation": { + "animation": "Κίνηση", + "animation-threshold": "Όριο κίνησης", + "animation-duration": "Διάρκεια κίνησης", + "animation-easing": "Ομαλότητα κίνησης", + "animation-delay": "Καθυστέρηση κίνησης", + "update-animation-duration": "Διάρκεια κίνησης κατά την ενημέρωση", + "update-animation-easing": "Ομαλότητα κίνησης κατά την ενημέρωση", + "update-animation-delay": "Καθυστέρηση κίνησης κατά την ενημέρωση" + }, + "chart-axis": { + "scale": "Κλίμακα", + "scale-min": "ελάχιστο", + "scale-max": "μέγιστο", + "scale-auto": "Αυτόματα" + }, + "bar": { + "show-border": "Εμφάνιση περιγράμματος", + "border-width": "Πλάτος περιγράμματος", + "border-radius": "Ακτίνα περιγράμματος", + "bar-width": "Πλάτος μπάρας", + "label": "Ετικέτα", + "label-hint": "Εμφάνιση ετικέτας πάνω στη μπάρα.", + "series-label-hint": "Εμφάνιση ετικέτας με την τιμή πάνω στη μπάρα.", + "label-background": "Φόντο ετικέτας" + } + }, + "color": { + "color-settings": "Ρυθμίσεις χρώματος", + "color-type-constant": "Σταθερό", + "color-type-gradient": "Διαβάθμιση", + "color-type-range": "Εύρος", + "color-type-function": "Συνάρτηση", + "color": "Χρώμα", + "value-range": "Εύρος τιμής", + "from": "Από", + "to": "Προς", + "color-function": "Συνάρτηση χρώματος", + "copy-color-settings-from": "Αντιγραφή ρυθμίσεων χρώματος από", + "copy-from": "Αντιγραφή από", + "settings-type": "Τύπος ρυθμίσεων", + "basic-mode": "Βασική", + "advanced-mode": "Προχωρημένη", + "entity-alias": "Ψευδώνυμο οντότητας", + "entity-attribute": "Χαρακτηριστικό οντότητας", + "gradient-color": "Χρώμα διαβάθμισης", + "gradient-color-min": "Χρώμα", + "gradient-start": "Αρχικό χρώμα διαβάθμισης", + "gradient-start-min": "Έναρξη", + "gradient-end": "Τελικό χρώμα διαβάθμισης", + "gradient-end-min": "Τέλος", + "start-value": "Τιμή εκκίνησης", + "end-value": "Τιμή λήξης", + "gradient-type": "Τύπος διαβάθμισης" + }, + "dashboard-state": { + "dashboard-state-settings": "Ρυθμίσεις κατάστασης πίνακα ελέγχου", + "dashboard-state": "Αναγνωριστικό κατάστασης πίνακα ελέγχου", + "autofill-state-layout": "Αυτόματη συμπλήρωση ύψους διάταξης κατάστασης από προεπιλογή", + "default-margin": "Προεπιλεγμένο περιθώριο widget", + "default-background-color": "Προεπιλεγμένο χρώμα φόντου", + "sync-parent-state-params": "Συγχρονισμός παραμέτρων κατάστασης με τον γονικό πίνακα" + }, "date-range-navigator": { + "date-range-picker-settings": "Ρυθμίσεις επιλογέα εύρους ημερομηνιών", + "hide-date-range-picker": "Απόκρυψη επιλογέα εύρους ημερομηνιών", + "picker-one-panel": "Επιλογέας εύρους ημερομηνιών με ένα πάνελ", + "picker-auto-confirm": "Αυτόματη επιβεβαίωση επιλογέα εύρους ημερομηνιών", + "picker-show-template": "Εμφάνιση προτύπου επιλογέα εύρους ημερομηνιών", + "first-day-of-week": "Πρώτη ημέρα της εβδομάδας", + "interval-settings": "Ρυθμίσεις διαστήματος", + "hide-interval": "Απόκρυψη διαστήματος", + "initial-interval": "Αρχικό διάστημα", + "interval-hour": "Ώρα", + "interval-day": "Ημέρα", + "interval-week": "Εβδομάδα", + "interval-two-weeks": "2 εβδομάδες", + "interval-month": "Μήνας", + "interval-three-months": "3 μήνες", + "interval-six-months": "6 μήνες", + "step-settings": "Ρυθμίσεις βήματος", + "hide-step-size": "Απόκρυψη μεγέθους βήματος", + "initial-step-size": "Αρχικό μέγεθος βήματος", + "hide-labels": "Απόκρυψη ετικετών", + "use-session-storage": "Χρήση αποθήκευσης συνεδρίας", "localizationMap": { "Sun": "Κυρ", "Mon": "Δευ", - "Tue": "Τρι", + "Tue": "Τρ", "Wed": "Τετ", "Thu": "Πεμ", "Fri": "Παρ", @@ -2513,7 +7217,7 @@ "Feb": "Φεβ", "Mar": "Μαρ", "Apr": "Απρ", - "May": "Μάιος", + "May": "Μαϊ", "Jun": "Ιουν", "Jul": "Ιουλ", "Aug": "Αυγ", @@ -2536,65 +7240,1958 @@ "Date Range Template": "Πρότυπο εύρους ημερομηνιών", "Today": "Σήμερα", "Yesterday": "Χθες", - "This Week": "Αυτή την εβοδομάδα", - "Last Week": "Την προηγούμενη εβδομάδα", - "This Month": "Αυτόν τον μήνα", - "Last Month": "Τον προηγούμενο μήνα", + "This Week": "Αυτή την εβδομάδα", + "Last Week": "Προηγούμενη εβδομάδα", + "This Month": "Αυτός ο μήνας", + "Last Month": "Προηγούμενος μήνας", "Year": "Έτος", - "This Year": "Αυτό το χρόνο", - "Last Year": "Τον προηγούμενο χρόνο", + "This Year": "Αυτό το έτος", + "Last Year": "Προηγούμενο έτος", "Date picker": "Επιλογέας ημερομηνίας", "Hour": "Ώρα", "Day": "Ημέρα", "Week": "Εβδομάδα", - "2 weeks": "2 Εβδομάδες", + "2 weeks": "2 εβδομάδες", "Month": "Μήνας", - "3 months": "3 Μήνες", - "6 months": "6 Μήνες", + "3 months": "3 μήνες", + "6 months": "6 μήνες", "Custom interval": "Προσαρμοσμένο διάστημα", "Interval": "Διάστημα", "Step size": "Μέγεθος βήματος", - "Ok": "Ok" + "Ok": "ΟΚ" + } + }, + "doughnut": { + "doughnut-appearance": "Εμφάνιση δακτυλίου", + "layout": "Διάταξη", + "layout-default": "Προεπιλογή", + "layout-with-total": "Με συνολικό άθροισμα", + "central-total-value": "Κεντρική συνολική τιμή", + "doughnut-card-style": "Στυλ κάρτας δακτυλίου" + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Ρυθμίσεις δεδομένων ιεραρχίας", + "relations-query-function": "Συνάρτηση αναζήτησης σχέσεων κόμβου", + "has-children-function": "Συνάρτηση ύπαρξης παιδιών κόμβου", + "node-state-settings": "Ρυθμίσεις κατάστασης κόμβου", + "node-opened-function": "Συνάρτηση προεπιλεγμένου ανοιχτού κόμβου", + "node-disabled-function": "Συνάρτηση απενεργοποιημένου κόμβου", + "display-settings": "Ρυθμίσεις εμφάνισης", + "node-icon-function": "Συνάρτηση εικονιδίου κόμβου", + "node-text-function": "Συνάρτηση κειμένου κόμβου", + "sort-settings": "Ρυθμίσεις ταξινόμησης", + "nodes-sort-function": "Συνάρτηση ταξινόμησης κόμβων" + }, + "edge": { + "display-default-title": "Εμφάνιση προεπιλεγμένου τίτλου" + }, + "gateway": { + "general-settings": "Γενικές ρυθμίσεις", + "widget-title": "Τίτλος widget", + "default-archive-file-name": "Προεπιλεγμένο όνομα αρχείου αρχειοθέτησης", + "device-type-for-new-gateway": "Τύπος συσκευής για νέο gateway", + "messages-settings": "Ρυθμίσεις μηνυμάτων", + "save-config-success-message": "Μήνυμα για επιτυχή αποθήκευση ρυθμίσεων gateway", + "device-name-exists-message": "Μήνυμα όταν η συσκευή με το καταχωρημένο όνομα υπάρχει ήδη", + "gateway-title": "Φόρμα gateway", + "read-only": "Μόνο για ανάγνωση", + "events-title": "Τίτλος φόρμας συμβάντων gateway", + "events-filter": "Φίλτρο συμβάντων", + "event-key-contains": "Το κλειδί συμβάντος περιέχει...", + "show-connector": "Εμφάνιση για τον connector", + "connector-state-param-key": "Κλειδί παραμέτρου κατάστασης connector", + "message": "Μήνυμα", + "level": "Επίπεδο", + "created-time": "Ώρα δημιουργίας" + }, + "gauge": { + "default-color": "Προεπιλεγμένο χρώμα", + "radial-gauge-settings": "Ρυθμίσεις κυκλικού δείκτη", + "ticks-settings": "Ρυθμίσεις δεικτών", + "min-value": "Ελάχιστη τιμή", + "max-value": "Μέγιστη τιμή", + "min-value-short": "ελάχ", + "max-value-short": "μέγ", + "start-ticks-angle": "Γωνία εκκίνησης δεικτών", + "ticks-angle": "Γωνία δεικτών", + "major-ticks": "Κύριοι δείκτες", + "major-ticks-count": "Αριθμός κύριων δεικτών", + "major-ticks-color": "Χρώμα κύριων δεικτών", + "minor-ticks": "Δευτερεύοντες δείκτες", + "minor-ticks-count": "Αριθμός δευτερευόντων δεικτών", + "minor-ticks-color": "Χρώμα δευτερευόντων δεικτών", + "tick-numbers-font": "Γραμματοσειρά αριθμών δεικτών", + "unit-title-settings": "Ρυθμίσεις τίτλου μονάδας", + "show-unit-title": "Τίτλος μονάδας", + "unit-title": "Τίτλος μονάδας", + "title-font": "Γραμματοσειρά τίτλου", + "units-settings": "Ρυθμίσεις μονάδων", + "units-font": "Γραμματοσειρά μονάδων", + "value-box-settings": "Ρυθμίσεις πλαισίου τιμής", + "show-value-box": "Εμφάνιση πλαισίου τιμής", + "value-box": "Πλαίσιο τιμής", + "value-int": "Αριθμός ψηφίων για ακέραιο μέρος της τιμής", + "value-text": "Κείμενο τιμής", + "value-text-shadow": "Σκίαση κειμένου τιμής", + "value-font": "Γραμματοσειρά κειμένου τιμής", + "rect-stroke-color-start": "Χρώμα περιγράμματος παραλληλογράμμου - αρχή διαβάθμισης", + "rect-stroke-color-end": "Χρώμα περιγράμματος παραλληλογράμμου - τέλος διαβάθμισης", + "background-color": "Χρώμα φόντου", + "shadow-color": "Χρώμα σκιάς", + "value-box-rect-stroke-color": "Χρώμα περιγράμματος πλαισίου τιμής", + "value-box-rect-stroke-color-end": "Χρώμα περιγράμματος πλαισίου τιμής - τέλος διαβάθμισης", + "value-box-background-color": "Χρώμα φόντου πλαισίου τιμής", + "value-box-shadow-color": "Χρώμα σκιάς πλαισίου τιμής", + "plate-settings": "Ρυθμίσεις βάσης", + "show-plate-border": "Περίγραμμα βάσης", + "plate-color": "Χρώμα βάσης", + "needle-settings": "Ρυθμίσεις δείκτη", + "needle-circle-size": "Μέγεθος κύκλου δείκτη", + "needle-color": "Χρώμα δείκτη", + "needle-color-start": "Χρώμα δείκτη - αρχή διαβάθμισης", + "needle-color-end": "Χρώμα δείκτη - τέλος διαβάθμισης", + "needle-color-shadow-up": "Χρώμα σκιάς άνω μισού δείκτη", + "needle-color-shadow-down": "Σκίαση κάτω μέρους", + "highlights-settings": "Ρυθμίσεις επισημάνσεων", + "highlights-width": "Πλάτος επισημάνσεων", + "highlights": "Επισημάνσεις", + "highlight-from": "Από", + "highlight-to": "Έως", + "highlight-color": "Χρώμα", + "no-highlights": "Δεν έχουν οριστεί επισημάνσεις", + "add-highlight": "Προσθήκη επισήμανσης", + "animation-settings": "Ρυθμίσεις κίνησης", + "enable-animation": "Κίνηση", + "animation-duration-rule": "Διάρκεια και κανόνας κίνησης", + "animation-duration": "Διάρκεια κίνησης", + "animation-rule": "Κανόνας κίνησης", + "animation-linear": "Γραμμική", + "animation-quad": "Διτετραγωνική", + "animation-quint": "Πενταγωνική", + "animation-cycle": "Κυκλική", + "animation-bounce": "Αναπήδηση", + "animation-elastic": "Ελαστική", + "animation-dequad": "Αποδιτετραγωνική", + "animation-dequint": "Αποπενταγωνική", + "animation-decycle": "Αποκυκλική", + "animation-debounce": "Αποαναπήδηση", + "animation-delastic": "Αποελαστική", + "linear-gauge-settings": "Ρυθμίσεις γραμμικού δείκτη", + "bar-stroke": "Περίγραμμα μπάρας", + "bar-stroke-width": "Πλάτος περιγράμματος μπάρας", + "bar-stroke-color": "Χρώμα περιγράμματος μπάρας", + "bar-background-color": "Χρώμα φόντου μπάρας - αρχή διαβάθμισης", + "bar-background-color-end": "Χρώμα φόντου μπάρας - τέλος διαβάθμισης", + "progress-bar-color": "Χρώμα γραμμής προόδου", + "progress-bar": "Γραμμή προόδου", + "progress-bar-color-start": "Χρώμα γραμμής προόδου - αρχή διαβάθμισης", + "progress-bar-color-end": "Χρώμα γραμμής προόδου - τέλος διαβάθμισης", + "major-ticks-names": "Ονόματα κύριων δεικτών", + "show-stroke-ticks": "Εμφάνιση περιγράμματος δεικτών", + "major-ticks-font": "Γραμματοσειρά κύριων δεικτών", + "border-color": "Χρώμα περιγράμματος", + "border-width": "Πλάτος περιγράμματος", + "needle-circle": "Κύκλος δείκτη", + "needle-circle-color": "Χρώμα κύκλου δείκτη", + "animation-target": "Στόχος κίνησης", + "animation-target-needle": "Δείκτης", + "animation-target-plate": "Βάση", + "common-settings": "Γενικές ρυθμίσεις δείκτη", + "gauge-type": "Τύπος δείκτη", + "gauge-type-arc": "Τόξο", + "gauge-type-donut": "Ντόνατ", + "gauge-type-horizontal-bar": "Οριζόντια μπάρα", + "gauge-type-vertical-bar": "Κάθετη μπάρα", + "donut-start-angle": "Γωνία έναρξης (μοίρες)", + "bar-settings": "Ρυθμίσεις μπάρας δείκτη", + "relative-bar-width": "Σχετικό πλάτος μπάρας", + "neon-glow-brightness": "Φωτεινότητα νεον εφέ (0-100)", + "neon-glow-brightness-hint": "0 - απενεργοποίηση εφέ", + "stripes-thickness": "Πάχος γραμμών", + "stripes-thickness-hint": "0 - χωρίς γραμμές", + "rounded-line-cap": "Στρογγυλεμένο άκρο γραμμής", + "bar-color-settings": "Ρυθμίσεις χρώματος μπάρας", + "use-precise-level-color-values": "Χρήση ακριβών επιπέδων χρώματος", + "bar-colors": "Χρώματα μπάρας, από χαμηλότερο προς υψηλότερο", + "color": "Χρώμα", + "no-bar-colors": "Δεν έχουν οριστεί χρώματα μπάρας", + "add-bar-color": "Προσθήκη χρώματος μπάρας", + "from": "Από", + "to": "Έως", + "fixed-level-colors": "Χρώματα μπάρας με χρήση ορίων", + "gauge-title-settings": "Ρυθμίσεις τίτλου δείκτη", + "show-gauge-title": "Εμφάνιση τίτλου δείκτη", + "gauge-title": "Τίτλος δείκτη", + "gauge-title-font": "Γραμματοσειρά τίτλου δείκτη", + "unit-title-and-timestamp-settings": "Ρυθμίσεις τίτλου μονάδων και χρονικής σήμανσης", + "show-timestamp": "Χρονική σήμανση", + "timestamp-format": "Μορφή χρονικής σήμανσης", + "label-font": "Γραμματοσειρά ετικέτας κάτω από την τιμή", + "value-settings": "Ρυθμίσεις τιμής", + "show-value": "Εμφάνιση κειμένου τιμής", + "min-max-settings": "Ρυθμίσεις ετικετών ελάχιστης/μέγιστης τιμής", + "show-min-max": "Εμφάνιση ελάχιστης και μέγιστης τιμής", + "min-max-font": "Γραμματοσειρά ετικετών ελάχιστης και μέγιστης τιμής", + "show-ticks": "Εμφάνιση δεικτών", + "tick-width": "Πλάτος δείκτη", + "tick-color": "Χρώμα δείκτη", + "tick-values": "Τιμές δεικτών", + "no-tick-values": "Δεν έχουν οριστεί τιμές δεικτών", + "add-tick-value": "Προσθήκη τιμής δείκτη", + "gauge-appearance": "Εμφάνιση δείκτη", + "units-title": "Τίτλος μονάδων", + "value": "Τιμή", + "ticks": "Δείκτες", + "arrow-and-scale-color": "Προεπιλεγμένο χρώμα δείκτη και κλίμακας", + "scale-settings": "Ρυθμίσεις κλίμακας", + "scale": "Κλίμακα", + "scale-color": "Χρώματα κλίμακας", + "compass-appearance": "Εμφάνιση πυξίδας", + "label": "Ετικέτα", + "labels": "Ετικέτες", + "label-style": "Στιλ ετικέτας", + "simple-gauge-type": "Τύπος", + "gauge-bar-background": "Φόντο μπάρας δείκτη", + "bar-color": "Χρώμα μπάρας", + "min-and-max-value": "Ελάχιστη και μέγιστη τιμή", + "min-and-max-label": "Ετικέτες ελάχιστης και μέγιστης τιμής", + "font": "Γραμματοσειρά", + "tick-width-and-color": "Πλάτος και χρώμα δείκτη", + "min-max-validation-text": "Η μέγιστη τιμή πρέπει να είναι μεγαλύτερη από την ελάχιστη" + }, + "gpio": { + "pin": "Ακροδέκτης", + "label": "Ετικέτα", + "row": "Γραμμή", + "column": "Στήλη", + "color": "Χρώμα", + "panel-settings": "Ρυθμίσεις πίνακα", + "background-color": "Χρώμα φόντου", + "gpio-switches": "Διακόπτες GPIO", + "no-gpio-switches": "Δεν έχουν διαμορφωθεί διακόπτες GPIO", + "add-gpio-switch": "Προσθήκη διακόπτη GPIO", + "gpio-status-request": "Αίτημα κατάστασης GPIO", + "method-name": "Όνομα μεθόδου", + "method-body": "Σώμα μεθόδου", + "gpio-status-change-request": "Αίτημα αλλαγής κατάστασης GPIO", + "parse-gpio-status-function": "Συνάρτηση ανάλυσης κατάστασης GPIO", + "gpio-leds": "LEDs GPIO", + "no-gpio-leds": "Δεν έχουν διαμορφωθεί LEDs GPIO", + "add-gpio-led": "Προσθήκη LED GPIO" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "Η παράμετρος χαρακτηριστικού δεν μπορεί να χρησιμοποιηθεί σε αυτό το widget", + "blocked-location": "Η γεωτοποθεσία είναι αποκλεισμένη στο πρόγραμμα περιήγησής σας", + "claim-device": "Διεκδίκηση συσκευής", + "claim-failed": "Αποτυχία διεκδίκησης της συσκευής!", + "claim-not-found": "Η συσκευή δεν βρέθηκε!", + "claim-successful": "Η συσκευή διεκδικήθηκε με επιτυχία!", + "date": "Ημερομηνία", + "device-name": "Όνομα συσκευής", + "device-name-required": "Το όνομα συσκευής είναι υποχρεωτικό", + "discard-changes": "Απόρριψη αλλαγών", + "entity-attribute-required": "Απαιτείται χαρακτηριστικό οντότητας", + "entity-coordinate-required": "Απαιτούνται και τα δύο πεδία, γεωγραφικό πλάτος και μήκος", + "entity-timeseries-required": "Απαιτείται χρονοσειρά οντότητας", + "get-location": "Λήψη τρέχουσας τοποθεσίας", + "invalid-date": "Μη έγκυρη ημερομηνία", + "latitude": "Γεωγραφικό πλάτος", + "longitude": "Γεωγραφικό μήκος", + "min-value-error": "Ελάχιστη τιμή είναι {{value}}", + "max-value-error": "Μέγιστη τιμή είναι {{value}}", + "not-allowed-entity": "Η επιλεγμένη οντότητα δεν μπορεί να έχει κοινόχρηστα χαρακτηριστικά", + "no-attribute-selected": "Δεν έχει επιλεγεί χαρακτηριστικό", + "no-datakey-selected": "Δεν έχει επιλεγεί κλειδί δεδομένων", + "no-coordinate-specified": "Δεν έχει καθοριστεί κλειδί δεδομένων για γεωγραφικό πλάτος/μήκος", + "no-entity-selected": "Δεν έχει επιλεγεί οντότητα", + "no-image": "Καμία εικόνα", + "no-support-geolocation": "Το πρόγραμμα περιήγησής σας δεν υποστηρίζει γεωτοποθεσία", + "no-support-web-camera": "Το πρόγραμμα περιήγησής σας δεν υποστηρίζει κάμερες", + "enable-https-use-widget": "Ενεργοποιήστε το HTTPS για να χρησιμοποιήσετε αυτό το widget", + "no-found-your-camera": "Δεν βρέθηκε η κάμερά σας", + "no-permission-camera": "Η άδεια απορρίφθηκε από τον χρήστη / Αυτός ο ιστότοπος δεν έχει άδεια χρήσης της κάμερας", + "no-timeseries-selected": "Δεν έχει επιλεγεί χρονοσειρά", + "secret-key": "Μυστικό κλειδί", + "secret-key-required": "Το μυστικό κλειδί είναι υποχρεωτικό", + "switch-attribute-value": "Αλλαγή τιμής χαρακτηριστικού οντότητας", + "switch-camera": "Εναλλαγή κάμερας", + "switch-timeseries-value": "Αλλαγή τιμής χρονοσειράς οντότητας", + "take-photo": "Λήψη φωτογραφίας", + "time": "Ώρα", + "timeseries-not-allowed": "Η παράμετρος χρονοσειράς δεν μπορεί να χρησιμοποιηθεί σε αυτό το widget", + "update-failed": "Η ενημέρωση απέτυχε", + "update-successful": "Η ενημέρωση ήταν επιτυχής", + "update-attribute": "Ενημέρωση χαρακτηριστικού", + "update-timeseries": "Ενημέρωση χρονοσειράς", + "value": "Τιμή", + "general-settings": "Γενικές ρυθμίσεις", + "widget-title": "Τίτλος widget", + "claim-button-label": "Ετικέτα κουμπιού διεκδίκησης", + "show-secret-key-field": "Εμφάνιση πεδίου 'Μυστικό κλειδί'", + "labels-settings": "Ρυθμίσεις ετικετών", + "show-labels": "Εμφάνιση ετικετών", + "device-name-label": "Ετικέτα για το πεδίο εισαγωγής ονόματος συσκευής", + "secret-key-label": "Ετικέτα για το πεδίο εισαγωγής μυστικού κλειδιού", + "messages-settings": "Ρυθμίσεις μηνυμάτων", + "claim-device-success-message": "Μήνυμα επιτυχούς διεκδίκησης συσκευής", + "claim-device-not-found-message": "Μήνυμα όταν δεν βρεθεί η συσκευή", + "claim-device-failed-message": "Μήνυμα αποτυχίας διεκδίκησης συσκευής", + "claim-device-name-required-message": "Μήνυμα σφάλματος 'Απαιτείται όνομα συσκευής'", + "claim-device-secret-key-required-message": "Μήνυμα σφάλματος 'Απαιτείται μυστικό κλειδί'", + "show-label": "Εμφάνιση ετικέτας", + "label": "Ετικέτα", + "required": "Υποχρεωτικό", + "required-error-message": "Μήνυμα σφάλματος 'Υποχρεωτικό'", + "show-result-message": "Εμφάνιση μηνύματος αποτελέσματος", + "integer-field-settings": "Ρυθμίσεις πεδίου ακέραιου αριθμού", + "min-value": "Ελάχιστη τιμή", + "max-value": "Μέγιστη τιμή", + "double-field-settings": "Ρυθμίσεις πεδίου δεκαδικού αριθμού", + "text-field-settings": "Ρυθμίσεις πεδίου κειμένου", + "min-length": "Ελάχιστο μήκος", + "max-length": "Μέγιστο μήκος", + "checkbox-settings": "Ρυθμίσεις πλαισίου ελέγχου", + "true-label": "Ετικέτα όταν είναι επιλεγμένο", + "false-label": "Ετικέτα όταν δεν είναι επιλεγμένο", + "image-input-settings": "Ρυθμίσεις εισόδου εικόνας", + "display-preview": "Εμφάνιση προεπισκόπησης", + "display-clear-button": "Εμφάνιση κουμπιού εκκαθάρισης", + "display-apply-button": "Εμφάνιση κουμπιού εφαρμογής", + "display-discard-button": "Εμφάνιση κουμπιού απόρριψης", + "datetime-field-settings": "Ρυθμίσεις πεδίου ημερομηνίας/ώρας", + "display-time-input": "Εμφάνιση πεδίου ώρας", + "latitude-key-name": "Όνομα κλειδιού γεωγραφικού πλάτους", + "longitude-key-name": "Όνομα κλειδιού γεωγραφικού μήκους", + "show-get-location-button": "Εμφάνιση κουμπιού 'Λήψη τρέχουσας τοποθεσίας'", + "use-high-accuracy": "Χρήση υψηλής ακρίβειας", + "location-fields-settings": "Ρυθμίσεις πεδίων τοποθεσίας", + "latitude-label": "Ετικέτα γεωγραφικού πλάτους", + "longitude-label": "Ετικέτα γεωγραφικού μήκους", + "input-fields-alignment": "Στοίχιση πεδίων εισόδου", + "input-fields-alignment-column": "Στήλη (προεπιλογή)", + "input-fields-alignment-row": "Γραμμή", + "layout": "Διάταξη", + "row-gap": "Κενό μεταξύ γραμμών σε pixel", + "column-gap": "Κενό μεταξύ στηλών σε pixel", + "latitude-field-required": "Απαιτείται πεδίο γεωγραφικού πλάτους", + "longitude-field-required": "Απαιτείται πεδίο γεωγραφικού μήκους", + "attribute-settings": "Ρυθμίσεις χαρακτηριστικού", + "widget-mode": "Λειτουργία widget", + "widget-mode-update-attribute": "Ενημέρωση χαρακτηριστικού", + "widget-mode-update-timeseries": "Ενημέρωση χρονοσειράς", + "attribute-scope": "Εύρος χαρακτηριστικού", + "attribute-scope-server": "Χαρακτηριστικό διακομιστή", + "attribute-scope-shared": "Κοινόχρηστο χαρακτηριστικό", + "value-required": "Απαιτείται τιμή", + "image-settings": "Ρυθμίσεις εικόνας", + "image-format": "Μορφή εικόνας", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Ποιότητα εικόνας για συμπίεση τύπου jpeg/webp", + "max-image-width": "Μέγιστο πλάτος εικόνας", + "max-image-height": "Μέγιστο ύψος εικόνας", + "action-buttons": "Κουμπιά ενεργειών", + "show-action-buttons": "Εμφάνιση κουμπιών ενεργειών", + "update-all-values": "Ενημέρωση όλων των τιμών, όχι μόνο των τροποποιημένων", + "save-button-label": "Ετικέτα κουμπιού 'ΑΠΟΘΗΚΕΥΣΗ'", + "reset-button-label": "Ετικέτα κουμπιού 'ΑΝΑΚΛΗΣΗ'", + "group-settings": "Ρυθμίσεις ομάδας", + "show-group-title": "Εμφάνιση τίτλου για ομάδα πεδίων που σχετίζονται με διαφορετικές οντότητες", + "group-title": "Τίτλος ομάδας", + "fields-alignment": "Στοίχιση πεδίων", + "fields-alignment-row": "Γραμμή (προεπιλογή)", + "fields-alignment-column": "Στήλη", + "fields-in-row": "Αριθμός πεδίων ανά γραμμή", + "option-value": "Τιμή (γράψτε 'null' για κενή επιλογή)", + "option-label": "Ετικέτα", + "hide-input-field": "Απόκρυψη πεδίου εισόδου", + "datakey-type": "Τύπος κλειδιού δεδομένων", + "datakey-type-server": "Χαρακτηριστικό διακομιστή (προεπιλογή)", + "datakey-type-shared": "Κοινόχρηστο χαρακτηριστικό", + "datakey-type-timeseries": "Χρονοσειρά", + "datakey-value-type": "Τύπος τιμής κλειδιού δεδομένων", + "datakey-value-type-string": "Συμβολοσειρά", + "datakey-value-type-double": "Δεκαδικός", + "datakey-value-type-integer": "Ακέραιος", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "Λογική (πλαίσιο ελέγχου)", + "datakey-value-type-boolean-switch": "Λογική (διακόπτης)", + "datakey-value-type-date-time": "Ημερομηνία & Ώρα", + "datakey-value-type-date": "Ημερομηνία", + "datakey-value-type-time": "Ώρα", + "datakey-value-type-select": "Επιλογή", + "datakey-value-type-radio": "Κουμπί επιλογής", + "datakey-value-type-color": "Χρώμα", + "value-is-required": "Απαιτείται τιμή", + "ability-to-edit-attribute": "Δυνατότητα επεξεργασίας χαρακτηριστικού", + "ability-to-edit-attribute-editable": "Επεξεργάσιμο (προεπιλογή)", + "ability-to-edit-attribute-disabled": "Απενεργοποιημένο", + "ability-to-edit-attribute-readonly": "Μόνο για ανάγνωση", + "disable-on-datakey-name": "Απενεργοποίηση όταν άλλη τιμή κλειδιού δεδομένων είναι false (εισάγετε όνομα)", + "field-appearance": "Εμφάνιση πεδίου", + "appearance-fill": "Συμπληρωμένο", + "appearance-outline": "Περίγραμμα", + "subscript-sizing": "Μέγεθος υποσελίδας", + "subscript-sizing-fixed": "Σταθερό", + "subscript-sizing-dynamic": "Δυναμικό", + "slide-toggle-settings": "Ρυθμίσεις εναλλαγής διαφάνειας", + "slide-toggle-label-position": "Θέση ετικέτας διακόπτη", + "slide-toggle-label-position-after": "Μετά", + "slide-toggle-label-position-before": "Πριν", + "select-options": "Επιλογές επιλογής", + "no-select-options": "Δεν έχουν ρυθμιστεί επιλογές", + "add-select-option": "Προσθήκη επιλογής", + "numeric-field-settings": "Ρυθμίσεις αριθμητικού πεδίου", + "step-interval": "Βήμα μεταξύ τιμών", + "error-messages": "Μηνύματα σφάλματος", + "min-value-error-message": "Μήνυμα σφάλματος για 'Ελάχιστη τιμή'", + "max-value-error-message": "Μήνυμα σφάλματος για 'Μέγιστη τιμή'", + "invalid-date-error-message": "Μήνυμα σφάλματος για 'Μη έγκυρη ημερομηνία'", + "invalid-JSON-error-message": "Μήνυμα σφάλματος για 'Μη έγκυρο JSON'", + "icon-settings": "Ρυθμίσεις εικονιδίου", + "dialog-editor-settings": "Ρυθμίσεις επεξεργαστή διαλόγου", + "use-custom-icon": "Χρήση προσαρμοσμένου εικονιδίου", + "input-cell-icon": "Εικονίδιο πριν από το πεδίο εισόδου", + "value-conversion-settings": "Ρυθμίσεις μετατροπής τιμής", + "get-value-settings": "Ρυθμίσεις λήψης τιμής", + "use-get-value-function": "Χρήση συνάρτησης getValue", + "get-value-function": "Συνάρτηση getValue", + "set-value-settings": "Ρυθμίσεις ορισμού τιμής", + "use-set-value-function": "Χρήση συνάρτησης setValue", + "set-value-function": "Συνάρτηση setValue", + "json-invalid": "Η τιμή JSON έχει μη έγκυρη μορφή", + "title": "Τίτλος", + "cancel-button-label": "Ετικέτα κουμπιού 'Ακύρωση'", + "radio-button-settings": "Ρυθμίσεις κουμπιών επιλογής", + "color": "Χρώμα", + "columns": "Στήλες", + "radio-options": "Επιλογές κουμπιών επιλογής", + "no-radio-options": "Δεν έχουν ρυθμιστεί επιλογές κουμπιών", + "add-radio-option": "Προσθήκη επιλογής κουμπιού", + "radio-label-position": "Θέση ετικέτας", + "radio-label-position-before": "Πριν", + "radio-label-position-after": "Μετά" + }, + "invalid-qr-code-text": "Μη έγκυρο κείμενο εισόδου για τον QR κώδικα. Η είσοδος πρέπει να είναι τύπου string", + "qr-code": { + "use-qr-code-text-function": "Χρήση συνάρτησης κειμένου QR κώδικα", + "qr-code-text-pattern": "Μοτίβο κειμένου QR κώδικα (π.χ. '${entityName} | ${keyName} - some text.')", + "qr-code-text-pattern-hint": "Το μοτίβο κειμένου QR κώδικα χρησιμοποιεί την τιμή του πρώτου εντοπισμένου κλειδιού στις οντότητες του ψευδωνύμου.", + "qr-code-text-pattern-required": "Απαιτείται μοτίβο κειμένου QR κώδικα.", + "qr-code-text-function": "Συνάρτηση κειμένου QR κώδικα" + }, + "label-widget": { + "label-pattern": "Μοτίβο", + "label-pattern-hint": "Υπόδειξη: π.χ. 'Κείμενο ${keyName} μονάδες.' ή ${#<key index>} μονάδες", + "label-pattern-required": "Απαιτείται μοτίβο", + "label-position": "Θέση (ποσοστό σε σχέση με το φόντο)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Χρώμα φόντου", + "font-settings": "Ρυθμίσεις γραμματοσειράς", + "background-image": "Εικόνα φόντου", + "labels": "Ετικέτες", + "no-labels": "Δεν έχουν ρυθμιστεί ετικέτες", + "add-label": "Προσθήκη ετικέτας" + }, + "navigation": { + "title": "Τίτλος", + "navigation-path": "Διαδρομή πλοήγησης", + "filter-type": "Τύπος φίλτρου", + "filter-type-all": "Όλα τα στοιχεία", + "filter-type-include": "Συμπερίληψη στοιχείων", + "filter-type-exclude": "Εξαίρεση στοιχείων", + "items": "Στοιχεία", + "enter-urls-to-filter": "Εισαγάγετε διευθύνσεις URL για φιλτράρισμα..." + }, + "persistent-table": { + "rpc-id": "Αναγνωριστικό RPC", + "message-type": "Τύπος μηνύματος", + "method": "Μέθοδος", + "params": "Παράμετροι", + "created-time": "Χρόνος δημιουργίας", + "expiration-time": "Χρόνος λήξης", + "retries": "Προσπάθειες", + "status": "Κατάσταση", + "filter": "Φίλτρο", + "refresh": "Ανανέωση", + "add": "Προσθήκη RPC αιτήματος", + "details": "Λεπτομέρειες", + "delete": "Διαγραφή", + "delete-request-title": "Διαγραφή μόνιμου RPC αιτήματος", + "delete-request-text": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το αίτημα;", + "details-title": "Λεπτομέρειες RPC ID:", + "additional-info": "Πρόσθετες πληροφορίες", + "response": "Απάντηση", + "any-status": "Οποιαδήποτε κατάσταση", + "rpc-status-list": "Λίστα καταστάσεων RPC", + "no-request-prompt": "Δεν υπάρχει διαθέσιμο αίτημα για εμφάνιση", + "send-request": "Αποστολή αιτήματος", + "add-title": "Δημιουργία μόνιμου RPC αιτήματος", + "method-error": "Η μέθοδος είναι υποχρεωτική.", + "timeout-error": "Η ελάχιστη τιμή χρονικού ορίου είναι 5000 (5 δευτερόλεπτα).", + "white-space-error": "Τα κενά δεν επιτρέπονται.", + "rpc-status": { + "QUEUED": "ΣΕ ΟΥΡΑ", + "SENT": "ΑΠΕΣΤΑΛΜΕΝΟ", + "DELIVERED": "ΠΑΡΑΔΟΘΗΚΕ", + "SUCCESSFUL": "ΕΠΙΤΥΧΕΣ", + "TIMEOUT": "ΛΗΞΗ ΧΡΟΝΟΥ", + "EXPIRED": "ΕΛΗΞΕ", + "FAILED": "ΑΠΕΤΥΧΕ" + }, + "rpc-search-status-all": "ΟΛΑ", + "message-types": { + "false": "Δύο κατευθύνσεων", + "true": "Μονής κατεύθυνσης" + }, + "general-settings": "Γενικές ρυθμίσεις", + "enable-filter": "Ενεργοποίηση φίλτρου", + "enable-sticky-header": "Εμφάνιση κεφαλίδας κατά την κύλιση", + "enable-sticky-action": "Εμφάνιση στήλης ενεργειών κατά την κύλιση", + "display-request-details": "Εμφάνιση λεπτομερειών αιτήματος", + "allow-send-request": "Να επιτρέπεται η αποστολή RPC αιτήματος", + "allow-delete-request": "Να επιτρέπεται η διαγραφή αιτήματος", + "columns-settings": "Ρυθμίσεις στηλών", + "display-columns": "Στήλες για εμφάνιση", + "column": "Στήλη", + "no-columns-found": "Δεν βρέθηκαν στήλες", + "no-columns-matching": "Δεν βρέθηκε η '{{column}}'." + }, + "range-chart": { + "chart": "Διάγραμμα", + "data-zoom": "Μεγέθυνση δεδομένων", + "range-chart-appearance": "Εμφάνιση διαγράμματος εύρους", + "range-colors": "Χρώματα εύρους", + "out-of-range-color": "Χρώμα εκτός εύρους", + "show-range-thresholds": "Εμφάνιση ορίων εύρους", + "range-thresholds-settings": "Ρυθμίσεις ορίων εύρους", + "fill-area": "Γέμισμα περιοχής", + "fill-area-opacity": "Διαφάνεια γεμίσματος περιοχής", + "range-chart-style": "Στυλ διαγράμματος εύρους" + }, + "rpc": { + "value-settings": "Ρυθμίσεις τιμής", + "initial-value": "Αρχική τιμή", + "retrieve-value-settings": "Ρυθμίσεις ανάκτησης τιμής on/off", + "retrieve-value-method": "Ανάκτηση τιμής μέσω μεθόδου", + "retrieve-value-method-none": "Χωρίς ανάκτηση", + "retrieve-value-method-rpc": "Κλήση RPC μεθόδου για ανάκτηση τιμής", + "retrieve-value-method-attribute": "Εγγραφή σε χαρακτηριστικό", + "retrieve-value-method-timeseries": "Εγγραφή σε χρονοσειρά", + "attribute-value-key": "Κλειδί χαρακτηριστικού", + "timeseries-value-key": "Κλειδί χρονοσειράς", + "get-value-method": "RPC μέθοδος ανάκτησης τιμής", + "parse-value-function": "Συνάρτηση ανάλυσης τιμής", + "update-value-settings": "Ρυθμίσεις ενημέρωσης τιμής", + "set-value-method": "RPC μέθοδος ορισμού τιμής", + "convert-value-function": "Συνάρτηση μετατροπής τιμής", + "rpc-settings": "Ρυθμίσεις RPC", + "request-timeout": "Χρονικό όριο RPC αιτήματος (ms)", + "persistent-rpc-settings": "Ρυθμίσεις επίμονου RPC", + "request-persistent": "Επίμονο RPC αίτημα", + "persistent-polling-interval": "Διάστημα ερωτήσεων (ms) για απάντηση επίμονου RPC", + "common-settings": "Γενικές ρυθμίσεις", + "switch-title": "Τίτλος διακόπτη", + "show-on-off-labels": "Εμφάνιση ετικετών on/off", + "slide-toggle-label": "Ετικέτα διακόπτη εναλλαγής", + "label-position": "Θέση ετικέτας", + "label-position-before": "Πριν", + "label-position-after": "Μετά", + "slider-color": "Χρώμα διακόπτη", + "slider-color-primary": "Πρωτεύον", + "slider-color-accent": "Έμφαση", + "slider-color-warn": "Προειδοποίηση", + "button-style": "Στυλ κουμπιού", + "button-raised": "Ανασηκωμένο κουμπί", + "button-primary": "Πρωτεύον χρώμα", + "button-background-color": "Χρώμα φόντου κουμπιού", + "button-text-color": "Χρώμα κειμένου κουμπιού", + "widget-title": "Τίτλος γραφικού στοιχείου", + "button-label": "Ετικέτα κουμπιού", + "device-attribute-scope": "Πεδίο χαρακτηριστικού συσκευής", + "server-attribute": "Χαρακτηριστικό διακομιστή", + "shared-attribute": "Κοινόχρηστο χαρακτηριστικό", + "device-attribute-parameters": "Παράμετροι χαρακτηριστικού συσκευής", + "is-one-way-command": "Είναι μονόδρομη εντολή", + "rpc-method": "RPC μέθοδος", + "rpc-method-params": "Παράμετροι RPC μεθόδου", + "show-rpc-error": "Εμφάνιση σφάλματος εκτέλεσης RPC εντολής", + "led-title": "Τίτλος LED", + "led-color": "Χρώμα LED", + "check-status-settings": "Ρυθμίσεις ελέγχου κατάστασης", + "perform-rpc-status-check": "Εκτέλεση ελέγχου κατάστασης συσκευής μέσω RPC", + "retrieve-led-status-value-method": "Ανάκτηση τιμής κατάστασης LED μέσω μεθόδου", + "led-status-value-attribute": "Χαρακτηριστικό συσκευής με τιμή κατάστασης LED", + "led-status-value-timeseries": "Χρονοσειρά συσκευής με τιμή κατάστασης LED", + "check-status-method": "RPC μέθοδος ελέγχου κατάστασης συσκευής", + "parse-led-status-value-function": "Συνάρτηση ανάλυσης τιμής κατάστασης LED", + "knob-title": "Τίτλος περιστροφικού ελεγκτή", + "min-value": "Ελάχιστη τιμή", + "max-value": "Μέγιστη τιμή" + }, + "maps": { + "map-type": { + "type": "Τύπος χάρτη", + "map": "Χάρτης", + "image": "Εικόνα" + }, + "image": { + "image-source": "Πηγή εικόνας", + "image-source-image": "Εικόνα", + "image-source-entity-key": "Κλειδί οντότητας", + "source-entity-alias": "Ψευδώνυμο πηγής οντότητας", + "image-url-key": "Κλειδί URL εικόνας", + "image-url-key-required": "Το κλειδί URL εικόνας είναι υποχρεωτικό" + }, + "control": { + "map-controls": "Χειριστήρια χάρτη", + "position": "Θέση", + "position-topleft": "Πάνω αριστερά", + "position-topright": "Πάνω δεξιά", + "position-bottomleft": "Κάτω αριστερά", + "position-bottomright": "Κάτω δεξιά", + "zoom-actions": "Ενέργειες εστίασης", + "zoom-scroll": "Κύλιση", + "zoom-double-click": "Διπλό κλικ", + "zoom-control-buttons": "Κουμπιά ελέγχου", + "scale": "Κλίμακα", + "scale-metric": "Μέτρικο", + "scale-imperial": "Αγγλοσαξονικό", + "switch-to-drag-mode-using-button": "Εναλλαγή σε λειτουργία μεταφοράς μέσω κουμπιού" + }, + "timeline": { + "control-panel": "Πίνακας ελέγχου χρονολογίου", + "time-step": "Βήμα χρόνου", + "speed-options": "Επιλογές ταχύτητας", + "timestamp": "Χρονική σήμανση", + "snap-to-real-location": "Σύμπτωση με την πραγματική τοποθεσία", + "location-snap-filter-function": "Συνάρτηση φίλτρου τοποθέτησης", + "no-trips-data-available": "Δεν υπάρχουν διαθέσιμα δεδομένα διαδρομών" + }, + "map-action": { + "map-action-buttons": "Κουμπιά ενεργειών χάρτη", + "label": "Ετικέτα", + "icon": "Εικονίδιο", + "color": "Χρώμα", + "action": "Ενέργεια", + "add-button": "Προσθήκη κουμπιού", + "no-action-buttons-configured": "Δεν έχουν διαμορφωθεί κουμπιά ενεργειών", + "remove-action-button": "Αφαίρεση κουμπιού ενέργειας", + "map-action-button": "Κουμπί ενέργειας χάρτη", + "button-requires": "Το κουμπί απαιτεί ετικέτα ή εικονίδιο" + }, + "common": { + "common-map-settings": "Κοινές ρυθμίσεις χάρτη", + "fit-map-bounds": "Προσαρμογή ορίων χάρτη για κάλυψη όλων των δεικτών", + "default-map-center-position": "Προεπιλεγμένη κεντρική θέση χάρτη", + "default-map-zoom-level": "Προεπιλεγμένο επίπεδο μεγέθυνσης", + "entities-limit": "Όριο φορτωμένων οντοτήτων" + }, + "layer": { + "label": "Ετικέτα", + "layer": "Επίπεδο", + "layers": "Επίπεδα", + "map-layers": "Επίπεδα χάρτη", + "add-layer": "Προσθήκη επιπέδου", + "layer-settings": "Ρυθμίσεις επιπέδου", + "remove-layer": "Αφαίρεση επιπέδου", + "no-layers": "Δεν υπάρχουν διαμορφωμένα επίπεδα", + "roadmap": "Οδικός χάρτης", + "satellite": "Δορυφορικός", + "hybrid": "Υβριδικός", + "reference": { + "reference-layer": "Επίπεδο αναφοράς", + "no-layer": "Χωρίς επίπεδο", + "openstreetmap-hybrid": "OpenStreetMap Υβριδικός", + "world-edition-hybrid": "Παγκόσμια Έκδοση Υβριδική", + "enhanced-contrast-hybrid": "Υβριδικός με ενισχυμένη αντίθεση" + }, + "provider": { + "provider": "Πάροχος", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "Οδικός χάρτης", + "satellite": "Δορυφορικός", + "hybrid": "Υβριδικός", + "terrain": "Ανάγλυφο" + }, + "here": { + "title": "HERE", + "normal-day": "Κανονική ημέρα", + "normal-night": "Κανονική νύχτα", + "hybrid-day": "Υβριδική ημέρα", + "terrain-day": "Ανάγλυφο ημέρας" + }, + "tencent": { + "title": "Tencent", + "normal": "Κανονικό", + "satellite": "Δορυφορικό", + "terrain": "Ανάγλυφο" + }, + "custom": { + "title": "Προσαρμοσμένο", + "tile-url": "Tile URL" + } + }, + "credentials": { + "credentials": "Διαπιστευτήρια", + "api-key": "API Key" + } + }, + "overlays": { + "overlays": "Επικαλύψεις", + "overlays-hint": "Διαμορφώστε πηγές δεδομένων, εμφάνιση, συμπεριφορά, επιλογές επεξεργασίας και ομαδοποίηση για οντότητες του χάρτη", + "trips": "Διαδρομές", + "markers": "Δείκτες", + "polygons": "Πολύγωνα", + "circles": "Κύκλοι" + }, + "data-layer": { + "source": "Πηγή", + "filter": "Φίλτρο", + "additional-data-keys": "Πρόσθετα κλειδιά δεδομένων", + "additional-datasources": "Πρόσθετες πηγές δεδομένων", + "additional-datasources-hint": "Πηγή δεδομένων για πρόσβαση σε ιδιότητες ή τηλεμετρία από οντότητες που δεν εμφανίζονται στο χάρτη, χρησιμοποιήσιμη σε λειτουργίες επικαλύψεων.", + "more-datasources": "Περισσότερες πηγές δεδομένων", + "data-keys": "Κλειδιά δεδομένων", + "add-datasource": "Προσθήκη πηγής δεδομένων", + "no-datasources": "Δεν έχουν διαμορφωθεί πηγές δεδομένων", + "remove-datasource": "Αφαίρεση πηγής δεδομένων", + "behavior": "Συμπεριφορά", + "on-click": "Με κλικ", + "on-click-hint": "Ενέργεια που εκτελείται όταν ο χρήστης κάνει κλικ στο στοιχείο του χάρτη.", + "groups": "Ομάδες", + "groups-hint": "Λίστα ονομάτων ομάδων που έχουν εκχωρηθεί στην επικάλυψη, χρησιμοποιείται για την εναλλαγή της ορατότητάς της στον χάρτη.", + "color": "Χρώμα", + "color-settings": "Ρυθμίσεις χρώματος", + "color-type-constant": "Σταθερό", + "color-type-range": "Εύρος", + "color-type-function": "Συνάρτηση", + "color-range-source-key": "Κλειδί πηγής εύρους χρώματος", + "color-range-source-key-required": "Απαιτείται κλειδί πηγής εύρους χρώματος", + "color-range": "Εύρος χρώματος", + "color-function": "Συνάρτηση χρώματος", + "label": "Ετικέτα", + "tooltip": "Υπόδειξη", + "pattern-type-pattern": "Πρότυπο", + "pattern-type-function": "Συνάρτηση", + "label-pattern": "Ετικέτα (παραδείγματα προτύπων: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "label-function": "Συνάρτηση ετικέτας", + "tooltip-pattern": "Υπόδειξη (π.χ. 'Text ${keyName} units.' ή Link text)", + "tooltip-function": "Συνάρτηση υπόδειξης", + "tooltip-trigger": "Ενεργοποίηση υπόδειξης", + "tooltip-trigger-click": "Εμφάνιση υπόδειξης με κλικ", + "tooltip-trigger-hover": "Εμφάνιση υπόδειξης με αιώρηση", + "auto-close-tooltips": "Αυτόματο κλείσιμο υποδείξεων", + "tooltip-offset": "Μετατόπιση υπόδειξης", + "tooltip-offset-horizontal": "Οριζόντια", + "tooltip-offset-vertical": "Κάθετη", + "tooltip-tag-actions": "Ενέργειες ετικετών", + "add-tooltip-tag-action": "Προσθήκη ενέργειας ετικέτας", + "edit-tooltip-tag-action": "Επεξεργασία ενέργειας ετικέτας", + "remove-tooltip-tag-action": "Αφαίρεση ενέργειας ετικέτας", + "action-add": "Προσθήκη", + "action-edit": "Επεξεργασία", + "action-move": "Μετακίνηση", + "action-remove": "Αφαίρεση", + "edit-instruments": "Εργαλεία", + "persist-location-attribute-scope": "Εύρος της ιδιότητας για αποθήκευση τοποθεσίας", + "enable-snapping": "Ενεργοποίηση στοίχισης για ακριβές σχέδιο", + "enable-snapping-hint": "Ευθυγραμμίζει αυτόματα τα νέα σημεία με υπάρχοντα σχήματα για ευκολότερη και ακριβέστερη σχεδίαση.", + "drag-drop-mode": "Λειτουργία μεταφοράς και απόθεσης", + "trip": { + "no-trips": "Δεν έχουν διαμορφωθεί διαδρομές", + "add-trip": "Προσθήκη διαδρομής", + "trip-configuration": "Διαμόρφωση διαδρομής", + "remove-trip": "Αφαίρεση διαδρομής" + }, + "marker": { + "marker": "Δείκτης", + "latitude-key": "Κλειδί γεωγραφικού πλάτους", + "longitude-key": "Κλειδί γεωγραφικού μήκους", + "x-pos-key": "Κλειδί θέσης X", + "y-pos-key": "Κλειδί θέσης Y", + "latitude-key-required": "Απαιτείται κλειδί γεωγραφικού πλάτους", + "longitude-key-required": "Απαιτείται κλειδί γεωγραφικού μήκους", + "x-pos-key-required": "Απαιτείται κλειδί θέσης X", + "y-pos-key-required": "Απαιτείται κλειδί θέσης Y", + "no-markers": "Δεν έχουν διαμορφωθεί δείκτες", + "add-marker": "Προσθήκη δείκτη", + "marker-configuration": "Διαμόρφωση δείκτη", + "remove-marker": "Αφαίρεση δείκτη", + "marker-type": "Τύπος δείκτη", + "marker-type-shape": "Σχήμα", + "marker-type-icon": "Εικονίδιο", + "marker-type-image": "Εικόνα", + "shape": "Σχήμα", + "icon": "Εικονίδιο", + "image": "Εικόνα", + "marker-shapes": "Σχήματα δείκτη", + "marker-icon": "Εικονίδιο δείκτη", + "marker-appearance": "Εμφάνιση δείκτη", + "marker-image": "Εικόνα δείκτη", + "marker-image-type-image": "Εικόνα", + "marker-image-type-function": "Συνάρτηση", + "custom-marker-image-size": "Προσαρμοσμένο μέγεθος εικόνας δείκτη", + "marker-image-function": "Συνάρτηση εικόνας δείκτη", + "marker-images": "Εικόνες δείκτη", + "marker-offset": "Μετατόπιση δείκτη", + "offset-horizontal": "Οριζόντια", + "offset-vertical": "Κάθετη", + "rotate-marker": "Περιστροφή δείκτη", + "offset-angle": "Γωνία μετατόπισης", + "position-conversion": "Μετατροπή θέσης", + "position-conversion-function": "Συνάρτηση μετατροπής θέσης, πρέπει να επιστρέφει x,y συντεταγμένες από 0 έως 1 ως δεκαδικούς", + "clustering": { + "use-map-markers-clustering": "Χρήση ομαδοποίησης δεικτών", + "zoom-on-cluster-click": "Εστίαση με κλικ σε ομάδα", + "max-zoom": "Μέγιστο επίπεδο εστίασης για ένταξη σε ομάδα (0 - 18)", + "max-radius": "Μέγιστη ακτίνα κάλυψης ομάδας", + "zoom-animation": "Κινούμενη εικόνα κατά την εστίαση", + "bounds-on-cluster-mouse-over": "Όρια δεικτών όταν αιωρείται ο δείκτης πάνω σε ομάδα", + "spiderfy-max-zoom-level": "Εξάπλωση ομάδας στο μέγιστο επίπεδο εστίασης (για εμφάνιση όλων των δεικτών)", + "load-optimization": "Βελτιστοποίηση φόρτωσης", + "chunked-load": "Χρήση τμηματικής φόρτωσης δεικτών για αποφυγή παγώματος", + "lazy-load": "Χρήση τεμπέλικης φόρτωσης για προσθήκη δεικτών", + "use-cluster-marker-color-function": "Χρήση συνάρτησης χρώματος για δείκτες ομάδας", + "marker-color-function": "Συνάρτηση χρώματος δείκτη" + }, + "edit": "Επεξεργασία δείκτη", + "remove-marker-for": "Αφαίρεση δείκτη για '{{entityName}}'", + "place-marker": "Τοποθέτηση δείκτη", + "place-marker-hint": "Κάντε κλικ για τοποθέτηση δείκτη", + "place-marker-hint-with-entity": "Κάντε κλικ για τοποθέτηση οντότητας '{{entityName}}'" + }, + "path": { + "path": "Διαδρομή", + "path-decorator": "Διακοσμητής διαδρομής", + "decorator-symbol": "Σύμβολο διακοσμητή", + "decorator-symbol-arrow-head": "Βέλος", + "decorator-symbol-dash": "Παύλα", + "decorator-arrangement": "Διάταξη διακοσμητή", + "decorator-offset": "Αρχή", + "decorator-end-offset": "Τέλος", + "decorator-repeat": "Επανάληψη" + }, + "points": { + "points": "Σημεία", + "point-tooltip": "Υπόδειξη σημείου" + }, + "shape": { + "fill": "Γέμισμα", + "fill-type-color": "Χρώμα", + "fill-type-stripe": "Ρίγα", + "fill-type-image": "Εικόνα", + "color": "Χρώμα", + "stripe": "Ρίγα", + "image": "Εικόνα", + "stroke": "Περίγραμμα", + "fill-image": "Εικόνα γεμίσματος", + "fill-image-type-image": "Εικόνα", + "fill-image-type-function": "Συνάρτηση", + "preserve-aspect-ratio": "Διατήρηση αναλογίας", + "opacity": "Διαφάνεια", + "angle": "Γωνία περιστροφής", + "scale": "Κλίμακα", + "fill-image-function": "Συνάρτηση γεμίσματος σχήματος", + "fill-images": "Εικόνες γεμίσματος σχήματος", + "stripe-pattern": "Μοτίβο ρίγας", + "first-stripe": "Πρώτη ρίγα", + "second-stripe": "Δεύτερη ρίγα" + }, + "polygon": { + "polygon-key": "Κλειδί πολυγώνου", + "polygon-key-required": "Απαιτείται κλειδί πολυγώνου", + "no-polygons": "Δεν έχουν διαμορφωθεί πολύγωνα", + "add-polygon": "Προσθήκη πολυγώνου", + "polygon-configuration": "Διαμόρφωση πολυγώνου", + "remove-polygon": "Αφαίρεση πολυγώνου", + "edit": "Επεξεργασία πολυγώνου", + "remove-polygon-for": "Αφαίρεση πολυγώνου για '{{entityName}}'", + "cut": "Αποκοπή περιοχής πολυγώνου", + "rotate": "Περιστροφή πολυγώνου", + "draw-rectangle": "Σχεδίαση ορθογωνίου", + "draw-polygon": "Σχεδίαση πολυγώνου", + "polygon-place-first-point-cut-hint": "Κάντε κλικ για τοποθέτηση του πρώτου σημείου", + "continue-polygon-cut-hint": "Κάντε κλικ για να συνεχίσετε τη σχεδίαση", + "finish-polygon-cut-hint": "Κάντε κλικ στον πρώτο δείκτη για να ολοκληρώσετε και να αποθηκεύσετε", + "polygon-place-first-point-hint": "Πολύγωνο: κάντε κλικ για τοποθέτηση πρώτου σημείου", + "polygon-place-first-point-hint-with-entity": "Πολύγωνο για '{{entityName}}': κάντε κλικ για τοποθέτηση πρώτου σημείου", + "continue-polygon-hint": "Πολύγωνο: κάντε κλικ για να συνεχίσετε τη σχεδίαση", + "continue-polygon-hint-with-entity": "Πολύγωνο για '{{entityName}}': κάντε κλικ για να συνεχίσετε τη σχεδίαση", + "finish-polygon-hint": "Πολύγωνο: κάντε κλικ στον πρώτο δείκτη για ολοκλήρωση", + "finish-polygon-hint-with-entity": "Πολύγωνο για '{{entityName}}': κάντε κλικ στον πρώτο δείκτη για ολοκλήρωση και αποθήκευση", + "rectangle-place-first-point-hint": "Ορθογώνιο: κάντε κλικ για τοποθέτηση πρώτου σημείου", + "rectangle-place-first-point-hint-with-entity": "Ορθογώνιο για '{{entityName}}': κάντε κλικ για τοποθέτηση πρώτου σημείου", + "finish-rectangle-hint": "Ορθογώνιο: κάντε κλικ για ολοκλήρωση", + "finish-rectangle-hint-with-entity": "Ορθογώνιο για '{{entityName}}': κάντε κλικ για ολοκλήρωση και αποθήκευση" + }, + "circle": { + "circle-key": "Κλειδί κύκλου", + "circle-key-required": "Απαιτείται κλειδί κύκλου", + "no-circles": "Δεν έχουν διαμορφωθεί κύκλοι", + "add-circle": "Προσθήκη κύκλου", + "circle-configuration": "Διαμόρφωση κύκλου", + "remove-circle": "Αφαίρεση κύκλου", + "edit": "Επεξεργασία κύκλου", + "remove-circle-for": "Αφαίρεση κύκλου για '{{entityName}}'", + "draw-circle": "Σχεδίαση κύκλου", + "place-circle-center-hint-with-entity": "Κύκλος για '{{entityName}}': κάντε κλικ για τοποθέτηση κέντρου", + "place-circle-center-hint": "Κύκλος: κάντε κλικ για τοποθέτηση κέντρου", + "finish-circle-hint-with-entity": "Κύκλος για '{{entityName}}': κάντε κλικ για ολοκλήρωση και αποθήκευση", + "finish-circle-hint": "Κύκλος: κάντε κλικ για ολοκλήρωση σχεδίασης" + }, + "select-entity": "Επιλέξτε οντότητα", + "select-entity-hint": "Υπόδειξη: μετά την επιλογή κάντε κλικ στον χάρτη για ορισμό θέσης" + }, + "select-entity": "Επιλέξτε οντότητα", + "select-entity-hint": "Υπόδειξη: μετά την επιλογή κάντε κλικ στον χάρτη για ορισμό θέσης", + "tooltips": { + "placeMarker": "Κάντε κλικ για τοποθέτηση οντότητας '{{entityName}}'", + "firstVertex": "Πολύγωνο για '{{entityName}}': κάντε κλικ για τοποθέτηση πρώτου σημείου", + "firstVertex-cut": "Κάντε κλικ για τοποθέτηση πρώτου σημείου", + "continueLine": "Πολύγωνο για '{{entityName}}': κάντε κλικ για να συνεχίσετε τη σχεδίαση", + "continueLine-cut": "Κάντε κλικ για να συνεχίσετε τη σχεδίαση", + "finishLine": "Κάντε κλικ σε υπάρχον δείκτη για ολοκλήρωση", + "finishPoly": "Πολύγωνο για '{{entityName}}': κάντε κλικ στον πρώτο δείκτη για ολοκλήρωση και αποθήκευση", + "finishPoly-cut": "Κάντε κλικ στον πρώτο δείκτη για ολοκλήρωση και αποθήκευση", + "finishRect": "Πολύγωνο για '{{entityName}}': κάντε κλικ για ολοκλήρωση και αποθήκευση", + "startCircle": "Κύκλος για '{{entityName}}': κάντε κλικ για τοποθέτηση κέντρου", + "finishCircle": "Κύκλος για '{{entityName}}': κάντε κλικ για ολοκλήρωση κύκλου", + "placeCircleMarker": "Κάντε κλικ για τοποθέτηση δείκτη κύκλου" + }, + "actions": { + "finish": "Ολοκλήρωση", + "cancel": "Ακύρωση", + "removeLastVertex": "Αφαίρεση τελευταίου σημείου" + }, + "buttonTitles": { + "drawMarkerButton": "Τοποθέτηση οντότητας", + "drawPolyButton": "Δημιουργία πολυγώνου", + "drawLineButton": "Δημιουργία πολυγραμμής", + "drawCircleButton": "Δημιουργία κύκλου", + "drawRectButton": "Δημιουργία ορθογωνίου", + "editButton": "Λειτουργία επεξεργασίας", + "dragButton": "Λειτουργία μεταφοράς και απόθεσης", + "cutButton": "Αποκοπή περιοχής πολυγώνου", + "deleteButton": "Αφαίρεση", + "drawCircleMarkerButton": "Δημιουργία δείκτη κύκλου", + "rotateButton": "Περιστροφή πολυγώνου" + }, + "map-provider-settings": "Ρυθμίσεις παρόχου χάρτη", + "map-provider": "Πάροχος χάρτη", + "map-provider-google": "Χάρτες Google", + "map-provider-openstreet": "Χάρτες OpenStreet", + "map-provider-here": "Χάρτες HERE", + "map-provider-image": "Χάρτης εικόνας", + "map-provider-tencent": "Χάρτες Tencent", + "openstreet-provider": "Πάροχος χάρτη OpenStreet", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Προεπιλογή)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Χρήση προσαρμοσμένου παρόχου", + "custom-provider-tile-url": "Προσαρμοσμένο URL πλακιδίων παρόχου", + "google-maps-api-key": "Κλειδί API Google Maps", + "default-map-type": "Προεπιλεγμένος τύπος χάρτη", + "google-map-type-roadmap": "Οδικός χάρτης", + "google-map-type-satelite": "Δορυφορικός", + "google-map-type-hybrid": "Υβριδικός", + "google-map-type-terrain": "Τοπογραφικός", + "map-layer": "Επίπεδο χάρτη", + "here-map-normal-day": "HERE.normalDay (Προεπιλογή)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Διαπιστευτήρια", + "here-app-id": "HERE app id", + "here-app-code": "HERE app code", + "here-api-key": "HERE API key", + "here-use-new-version-api-3": "Χρήση API έκδοσης 3", + "tencent-maps-api-key": "Κλειδί API Tencent Maps", + "tencent-map-type-roadmap": "Οδικός χάρτης", + "tencent-map-type-satelite": "Δορυφορικός", + "tencent-map-type-hybrid": "Υβριδικός", + "image-map-background": "Φόντο χάρτη εικόνας", + "image-map-background-from-entity-attribute": "Λήψη φόντου χάρτη εικόνας από χαρακτηριστικό οντότητας", + "image-url-source-entity-alias": "Ψευδώνυμο οντότητας προέλευσης URL εικόνας", + "image-url-source-entity-attribute": "Χαρακτηριστικό οντότητας προέλευσης URL εικόνας", + "common-map-settings": "Κοινές ρυθμίσεις χάρτη", + "x-pos-key-name": "Όνομα κλειδιού θέσης X", + "y-pos-key-name": "Όνομα κλειδιού θέσης Y", + "latitude-key-name": "Όνομα κλειδιού γεωγραφικού πλάτους", + "longitude-key-name": "Όνομα κλειδιού γεωγραφικού μήκους", + "default-map-zoom-level": "Προεπιλεγμένο επίπεδο εστίασης χάρτη (0 - 20)", + "default-map-center-position": "Προεπιλεγμένη κεντρική θέση χάρτη (0,0)", + "disable-scroll-zooming": "Απενεργοποίηση εστίασης με κύλιση", + "disable-double-click-zooming": "Απενεργοποίηση εστίασης με διπλό κλικ", + "disable-zoom-control-buttons": "Απενεργοποίηση κουμπιών ελέγχου εστίασης", + "fit-map-bounds": "Προσαρμογή ορίων χάρτη ώστε να περιλαμβάνουν όλους τους δείκτες", + "use-default-map-center-position": "Χρήση προεπιλεγμένης κεντρικής θέσης χάρτη", + "entities-limit": "Όριο οντοτήτων προς φόρτωση", + "markers-settings": "Ρυθμίσεις δεικτών", + "marker-offset-x": "Οριζόντια μετατόπιση δείκτη σε σχέση με το πλάτος του δείκτη", + "marker-offset-y": "Κατακόρυφη μετατόπιση δείκτη σε σχέση με το ύψος του δείκτη", + "position-function": "Συνάρτηση μετατροπής θέσης, επιστρέφει συντεταγμένες x,y από 0 έως 1", + "draggable-marker": "Μετακινούμενος δείκτης", + "label": "Ετικέτα", + "show-label": "Εμφάνιση ετικέτας", + "use-label-function": "Χρήση συνάρτησης ετικέτας", + "label-pattern": "Ετικέτα (παραδείγματα: '${entityName}', '${entityName}: (Text ${keyName} units.)')", + "label-function": "Συνάρτηση ετικέτας", + "tooltip": "Υπόδειξη", + "show-tooltip": "Εμφάνιση υπόδειξης", + "show-tooltip-action": "Ενέργεια για εμφάνιση υπόδειξης", + "show-tooltip-action-click": "Εμφάνιση υπόδειξης με κλικ (Προεπιλογή)", + "show-tooltip-action-hover": "Εμφάνιση υπόδειξης με αιώρηση", + "auto-close-tooltips": "Αυτόματο κλείσιμο υποδείξεων", + "use-tooltip-function": "Χρήση συνάρτησης υπόδειξης", + "tooltip-pattern": "Υπόδειξη (π.χ. 'Text ${keyName} units.' ή Link text)", + "tooltip-function": "Συνάρτηση υπόδειξης", + "tooltip-offset-x": "Οριζόντια μετατόπιση υπόδειξης ως προς το άγκιστρο δείκτη", + "tooltip-offset-y": "Κατακόρυφη μετατόπιση υπόδειξης ως προς το άγκιστρο δείκτη", + "color": "Χρώμα", + "use-color-function": "Χρήση συνάρτησης χρώματος", + "color-function": "Συνάρτηση χρώματος", + "marker-image": "Εικόνα δείκτη", + "use-marker-image-function": "Χρήση συνάρτησης εικόνας δείκτη", + "custom-marker-image": "Προσαρμοσμένη εικόνα δείκτη", + "custom-marker-image-size": "Μέγεθος προσαρμοσμένης εικόνας δείκτη (px)", + "marker-image-function": "Συνάρτηση εικόνας δείκτη", + "marker-images": "Εικόνες δείκτη", + "polygon-settings": "Ρυθμίσεις πολυγώνου", + "show-polygon": "Εμφάνιση πολυγώνου", + "polygon-key-name": "Όνομα κλειδιού πολυγώνου", + "enable-polygon-edit": "Ενεργοποίηση επεξεργασίας πολυγώνου", + "polygon-label": "Ετικέτα πολυγώνου", + "show-polygon-label": "Εμφάνιση ετικέτας πολυγώνου", + "use-polygon-label-function": "Χρήση συνάρτησης ετικέτας πολυγώνου", + "polygon-label-pattern": "Ετικέτα πολυγώνου (παραδείγματα: '${entityName}', '${entityName}: (Text ${keyName} units.)')", + "polygon-label-function": "Συνάρτηση ετικέτας πολυγώνου", + "polygon-tooltip": "Υπόδειξη πολυγώνου", + "show-polygon-tooltip": "Εμφάνιση υπόδειξης πολυγώνου", + "auto-close-polygon-tooltips": "Αυτόματο κλείσιμο υποδείξεων πολυγώνου", + "use-polygon-tooltip-function": "Χρήση συνάρτησης υπόδειξης πολυγώνου", + "polygon-tooltip-pattern": "Υπόδειξη (π.χ. 'Text ${keyName} units.' ή Link text)", + "polygon-tooltip-function": "Συνάρτηση υπόδειξης πολυγώνου", + "polygon-color": "Χρώμα πολυγώνου", + "polygon-opacity": "Αδιαφάνεια πολυγώνου", + "use-polygon-color-function": "Χρήση συνάρτησης χρώματος πολυγώνου", + "polygon-color-function": "Συνάρτηση χρώματος πολυγώνου", + "polygon-stroke": "Περίγραμμα πολυγώνου", + "stroke-color": "Χρώμα περιγράμματος", + "stroke-opacity": "Αδιαφάνεια περιγράμματος", + "stroke-weight": "Πάχος περιγράμματος", + "use-polygon-stroke-color-function": "Χρήση συνάρτησης χρώματος περιγράμματος πολυγώνου", + "polygon-stroke-color-function": "Συνάρτηση χρώματος περιγράμματος πολυγώνου", + "circle-settings": "Ρυθμίσεις κύκλου", + "show-circle": "Εμφάνιση κύκλου", + "circle-key-name": "Όνομα κλειδιού κύκλου", + "enable-circle-edit": "Ενεργοποίηση επεξεργασίας κύκλου", + "circle-label": "Ετικέτα κύκλου", + "show-circle-label": "Εμφάνιση ετικέτας κύκλου", + "use-circle-label-function": "Χρήση συνάρτησης ετικέτας κύκλου", + "circle-label-pattern": "Ετικέτα κύκλου (παραδείγματα: '${entityName}', '${entityName}: (Text ${keyName} units.)')", + "circle-label-function": "Συνάρτηση ετικέτας κύκλου", + "circle-tooltip": "Υπόδειξη κύκλου", + "show-circle-tooltip": "Εμφάνιση υπόδειξης κύκλου", + "auto-close-circle-tooltips": "Αυτόματο κλείσιμο υποδείξεων κύκλου", + "use-circle-tooltip-function": "Χρήση συνάρτησης υπόδειξης κύκλου", + "circle-tooltip-pattern": "Υπόδειξη (π.χ. 'Text ${keyName} units.' ή Link text)", + "circle-tooltip-function": "Συνάρτηση υπόδειξης κύκλου", + "circle-fill-color": "Χρώμα γεμίσματος κύκλου", + "circle-fill-color-opacity": "Αδιαφάνεια χρώματος γεμίσματος κύκλου", + "use-circle-fill-color-function": "Χρήση συνάρτησης γεμίσματος χρώματος κύκλου", + "circle-fill-color-function": "Συνάρτηση γεμίσματος χρώματος κύκλου", + "circle-stroke": "Περίγραμμα κύκλου", + "use-circle-stroke-color-function": "Χρήση συνάρτησης χρώματος περιγράμματος κύκλου", + "circle-stroke-color-function": "Συνάρτηση χρώματος περιγράμματος κύκλου", + "markers-clustering-settings": "Ρυθμίσεις ομαδοποίησης δεικτών", + "use-map-markers-clustering": "Χρήση ομαδοποίησης δεικτών στον χάρτη", + "zoom-on-cluster-click": "Εστίαση κατά το κλικ σε ομάδα", + "max-cluster-zoom": "Μέγιστο επίπεδο εστίασης για ένταξη δείκτη σε ομάδα (0 - 18)", + "max-cluster-radius-pixels": "Μέγιστη ακτίνα που θα καλύπτει μια ομάδα (σε pixels)", + "cluster-zoom-animation": "Εμφάνιση κινούμενης μετάβασης δεικτών κατά την εστίαση", + "show-markers-bounds-on-cluster-mouse-over": "Εμφάνιση ορίων δεικτών κατά την αιώρηση πάνω από ομάδα", + "spiderfy-max-zoom-level": "Εμφάνιση όλων των δεικτών ομάδας στο μέγιστο επίπεδο εστίασης", + "load-optimization": "Βελτιστοποίηση φόρτωσης", + "cluster-chunked-loading": "Χρήση τμηματικής φόρτωσης για αποφυγή παγώματος σελίδας", + "cluster-markers-lazy-load": "Χρήση lazy load για προσθήκη δεικτών", + "editor-settings": "Ρυθμίσεις επεξεργαστή", + "enable-snapping": "Ενεργοποίηση ευθυγράμμισης για ακριβή σχεδίαση", + "init-draggable-mode": "Εκκίνηση χάρτη σε λειτουργία μεταφοράς", + "hide-all-edit-buttons": "Απόκρυψη όλων των κουμπιών επεξεργασίας", + "hide-draw-buttons": "Απόκρυψη κουμπιών σχεδίασης", + "hide-edit-buttons": "Απόκρυψη κουμπιών επεξεργασίας", + "hide-remove-button": "Απόκρυψη κουμπιού αφαίρεσης", + "route-map-settings": "Ρυθμίσεις χάρτη διαδρομής", + "trip-animation-settings": "Ρυθμίσεις κινούμενης διαδρομής", + "normalization-step": "Βήμα κανονικοποίησης δεδομένων (ms)", + "tooltip-background-color": "Χρώμα φόντου υπόδειξης", + "tooltip-font-color": "Χρώμα γραμματοσειράς υπόδειξης", + "tooltip-opacity": "Αδιαφάνεια υπόδειξης (0-1)", + "auto-close-tooltip": "Αυτόματο κλείσιμο υπόδειξης", + "rotation-angle": "Ορισμός επιπλέον γωνίας περιστροφής για δείκτη (μοίρες)", + "path-settings": "Ρυθμίσεις διαδρομής", + "path-color": "Χρώμα διαδρομής", + "use-path-color-function": "Χρήση συνάρτησης χρώματος διαδρομής", + "path-color-function": "Συνάρτηση χρώματος διαδρομής", + "path-decorator": "Διακοσμητικό διαδρομής", + "use-path-decorator": "Χρήση διακοσμητικού διαδρομής", + "decorator-symbol": "Σύμβολο διακοσμητικού", + "decorator-symbol-arrow-head": "Βέλος", + "decorator-symbol-dash": "Παύλα", + "decorator-symbol-size": "Μέγεθος συμβόλου διακοσμητικού (px)", + "use-path-decorator-custom-color": "Χρήση προσαρμοσμένου χρώματος διακοσμητικού", + "decorator-custom-color": "Προσαρμοσμένο χρώμα διακοσμητικού", + "decorator-offset": "Μετατόπιση διακοσμητικού", + "end-decorator-offset": "Τελική μετατόπιση διακοσμητικού", + "decorator-repeat": "Επανάληψη διακοσμητικού", + "points-settings": "Ρυθμίσεις σημείων", + "show-points": "Εμφάνιση σημείων", + "point-color": "Χρώμα σημείου", + "point-size": "Μέγεθος σημείου (px)", + "use-point-color-function": "Χρήση συνάρτησης χρώματος σημείου", + "point-color-function": "Συνάρτηση χρώματος σημείου", + "use-point-as-anchor": "Χρήση σημείου ως άγκυρας", + "point-as-anchor-function": "Συνάρτηση σημείου ως άγκυρας", + "independent-point-tooltip": "Αυτόνομη υπόδειξη σημείου", + "clustering-markers": "Ομαδοποίηση δεικτών", + "use-icon-create-function": "Χρήση συνάρτησης χρώματος δεικτών", + "marker-color-function": "Συνάρτηση χρώματος δείκτη" + }, + "markdown": { + "use-markdown-text-function": "Χρήση συνάρτησης τιμής markdown/HTML", + "markdown-text-function": "Συνάρτηση τιμής markdown/HTML", + "markdown-text-pattern": "Πρότυπο markdown/HTML (markdown ή HTML με μεταβλητές, π.χ. '${entityName} ή ${keyName} - κάποιο κείμενο.')", + "apply-default-markdown-style": "Εφαρμογή προεπιλεγμένου στυλ markdown", + "markdown-css": "CSS για markdown/HTML" + }, + "simple-card": { + "label": "Ετικέτα", + "label-position": "Θέση ετικέτας", + "label-position-left": "Αριστερά", + "label-position-top": "Πάνω" + }, + "single-switch": { + "behavior": "Συμπεριφορά", + "layout": "Διάταξη", + "layout-right": "Δεξιά", + "layout-left": "Αριστερά", + "layout-centered": "Κεντραρισμένο", + "auto-scale": "Αυτόματη κλιμάκωση", + "label": "Ετικέτα", + "icon": "Εικονίδιο", + "switch-color": "Χρώμα διακόπτη", + "on": "Ενεργό", + "off": "Ανενεργό", + "disabled": "Απενεργοποιημένο", + "tumbler-color": "Χρώμα εναλλασσόμενου διακόπτη", + "on-label": "Ετικέτα ενεργοποίησης", + "off-label": "Ετικέτα απενεργοποίησης", + "switch": "Διακόπτης" + }, + "slider": { + "behavior": "Συμπεριφορά", + "initial-value": "Αρχική τιμή", + "initial-value-hint": "Ενέργεια για λήψη της αρχικής τιμής του ρυθμιστικού.", + "on-value-change": "Κατά την αλλαγή τιμής", + "on-value-change-hint": "Ενέργεια που ενεργοποιείται όταν αλλάζει η τιμή του ρυθμιστικού.", + "layout": "Διάταξη", + "layout-default": "Προεπιλεγμένη", + "layout-extended": "Εκτεταμένη", + "layout-simplified": "Απλοποιημένη", + "auto-scale": "Αυτόματη κλιμάκωση", + "icon": "Εικονίδιο", + "value": "Τιμή", + "range": "Εύρος", + "min": "ελάχιστο", + "max": "μέγιστο", + "range-ticks": "Ενδείξεις εύρους", + "tick-marks": "Σημάδια ένδειξης", + "colors": "Χρώματα", + "main": "Κύριο", + "background": "Φόντο", + "left-icon": "Αριστερό εικονίδιο", + "right-icon": "Δεξί εικονίδιο", + "slider": "Ρυθμιστικό" + }, + "value-card": { + "layout": "Διάταξη", + "layout-square": "Τετράγωνο", + "layout-vertical": "Κατακόρυφο", + "layout-centered": "Κεντραρισμένο", + "layout-simplified": "Απλοποιημένο", + "layout-horizontal": "Οριζόντιο", + "layout-horizontal-reversed": "Αντίστροφα οριζόντιο", + "label": "Ετικέτα", + "icon": "Εικονίδιο", + "value": "Τιμή", + "date": "Ημερομηνία", + "value-card-style": "Στυλ κάρτας τιμής", + "auto-scale": "Αυτόματη κλιμάκωση" + }, + "label-card": { + "auto-scale": "Αυτόματη κλιμάκωση", + "label": "Ετικέτα", + "icon": "Εικονίδιο", + "label-card-style": "Στυλ κάρτας ετικέτας" + }, + "label-value-card": { + "value": "Τιμή", + "label-value-card-style": "Στυλ κάρτας ετικέτας και τιμής" + }, + "liquid-level-card": { + "layout-simple": "Απλό", + "layout-percentage": "Ποσοστό", + "layout-absolute": "Απόλυτο", + "layout": "Διάταξη", + "background-overlay": "Επικάλυψη φόντου τιμής", + "total-volume": "Συνολικός όγκος", + "total-volume-units": "Μονάδες συνολικού όγκου", + "tank": "Δοχείο", + "shape": "Σχήμα", + "datasource-units": "Μονάδες πηγής", + "widget-units": "Μονάδες γραφικού στοιχείου", + "decimals": "Δεκαδικά", + "liquid": "Υγρό", + "liquid-color": "Χρώμα υγρού", + "value": "Τιμή", + "value-font": "Γραμματοσειρά τιμής", + "level": "Επίπεδο", + "last-update": "Τελευταία ενημέρωση", + "shape-by-attribute": "Ορισμός σχήματος δοχείου με βάση το όνομα γνωρίσματος", + "tooltip-background": "Χρώμα φόντου", + "background-blur": "Θόλωση φόντου", + "tank-color": "Χρώμα δοχείου", + "static": "Στατικό", + "see-examples": "Δείτε παραδείγματα", + "attribute": "Γνώρισμα", + "shape-type": "Τύπος", + "v-oval": "Κατακόρυφο οβάλ", + "v-cylinder": "Κατακόρυφος κύλινδρος", + "v-capsule": "Κατακόρυφη κάψουλα", + "rectangle": "Ορθογώνιο", + "h-oval": "Οριζόντιο οβάλ", + "h-ellipse": "Οριζόντια έλλειψη", + "h-dish-ends": "Οριζόντιο με λοξές άκρες", + "h-cylinder": "Οριζόντιος κύλινδρος", + "h-capsule": "Οριζόντια κάψουλα", + "h-elliptical_2_1": "Οριζόντια 2:1 έλλειψη", + "icon": "Εικονίδιο κάρτας", + "title": "Τίτλος κάρτας", + "units": "Μονάδες", + "color-and-font": "Χρώμα και γραμματοσειρά", + "shape-attribute-name": "Όνομα γνωρίσματος", + "total-volume-required": "Απαιτείται συνολικός όγκος.", + "attribute-name-required": "Απαιτείται όνομα γνωρίσματος.", + "attribute-key-not-set": "Το κλειδί του γνωρίσματος '{{attributeName}}' δεν έχει οριστεί", + "attribute-key-invalid": "Το κλειδί του γνωρίσματος '{{attributeName}}' δεν είναι έγκυρο" + }, + "aggregated-value-card": { + "subtitle": "Υπότιτλος", + "chart": "Διάγραμμα", + "values": "Τιμές", + "value-appearance": "Εμφάνιση τιμής", + "position": "Θέση", + "position-center": "Κέντρο", + "position-right-top": "Δεξιά επάνω", + "position-right-bottom": "Δεξιά κάτω", + "position-left-top": "Αριστερά επάνω", + "position-left-bottom": "Αριστερά κάτω", + "font": "Γραμματοσειρά", + "color": "Χρώμα", + "arrow": "Βέλος", + "display-up-down-arrow": "Εμφάνιση βέλους ανόδου/καθόδου", + "add-value": "Προσθήκη τιμής", + "remove-value": "Αφαίρεση τιμής", + "no-values": "Δεν έχουν διαμορφωθεί τιμές", + "aggregation": "Συσσώρευση", + "aggregated-value-card-style": "Στυλ κάρτας συσσωρευμένης τιμής", + "auto-scale": "Αυτόματη κλιμάκωση" + }, + "value-chart-card": { + "layout": "Διάταξη", + "layout-left": "Αριστερά", + "layout-right": "Δεξιά", + "auto-scale": "Αυτόματη κλιμάκωση", + "icon": "Εικονίδιο", + "value": "Τιμή", + "chart": "Διάγραμμα", + "value-chart-card-style": "Στυλ κάρτας τιμής με διάγραμμα" + }, + "progress-bar": { + "layout": "Διάταξη", + "layout-default": "Προεπιλογή", + "layout-simplified": "Απλοποιημένο", + "auto-scale": "Αυτόματη κλιμάκωση", + "icon": "Εικονίδιο", + "value": "Τιμή", + "range": "Εύρος", + "min": "ελάχιστο", + "max": "μέγιστο", + "range-ticks": "Διαχωριστικά εύρους", + "bar": "Μπάρα", + "bar-color": "Χρώμα μπάρας", + "bar-background": "Φόντο μπάρας", + "progress-bar-card-style": "Στυλ κάρτας γραμμής προόδου" + }, + "notification": { + "max-notification-display": "Μέγιστος αριθμός ειδοποιήσεων για εμφάνιση", + "counter": "Μετρητής", + "counter-hint": "Ο μετρητής θα εμφανίζεται εάν είναι ενεργοποιημένος ο \"Τίτλος γραφικού στοιχείου\"", + "icon": "Εικονίδιο", + "counter-value": "Τιμή", + "counter-color": "Χρώμα", + "notification-button": "Κουμπιά ειδοποίησης", + "button-view-all": "Προβολή όλων", + "button-filter": "Φίλτρο", + "type-filter": "Φίλτρο τύπου", + "button-mark-read": "Σήμανση όλων ως αναγνωσμένων", + "notification-types": "Τύποι ειδοποιήσεων", + "notification-type": "Τύπος ειδοποίησης", + "search-type": "Τύπος αναζήτησης", + "any-type": "Οποιοσδήποτε τύπος" + }, + "alarm-count": { + "alarm-count-card-style": "Στυλ κάρτας πλήθους συναγερμών" + }, + "entity-count": { + "entity-count-card-style": "Στυλ κάρτας πλήθους οντοτήτων" + }, + "count": { + "layout": "Διάταξη", + "layout-column": "Στήλη", + "layout-row": "Γραμμή", + "label": "Ετικέτα", + "icon": "Εικονίδιο", + "icon-background": "Φόντο εικονιδίου", + "value": "Τιμή", + "chevron": "Βέλος", + "auto-scale": "Αυτόματη κλιμάκωση" + }, + "table": { + "common-table-settings": "Κοινές ρυθμίσεις πίνακα", + "enable-search": "Ενεργοποίηση αναζήτησης", + "enable-sticky-header": "Πάντα εμφάνιση κεφαλίδας", + "enable-sticky-action": "Πάντα εμφάνιση στήλης ενεργειών", + "hidden-cell-button-display-mode": "Λειτουργία εμφάνισης κουμπιών σε κρυφά κελιά", + "show-empty-space-hidden-action": "Εμφάνιση κενού χώρου αντί κουμπιού σε κρυφό κελί", + "dont-reserve-space-hidden-action": "Χωρίς κράτηση χώρου για κρυφές ενέργειες", + "display-timestamp": "Χρονική σήμανση", + "display-pagination": "Εμφάνιση σελιδοποίησης", + "default-page-size": "Προεπιλεγμένο μέγεθος σελίδας", + "page-step-settings": "Ρυθμίσεις βήματος σελίδας", + "page-step-count": "Αριθμός βημάτων", + "page-step-increment": "Αύξηση βήματος", + "page-step-count-format-message": "Πρέπει να είναι ακέραιος αριθμός από 1 έως 100.", + "page-step-increment-format-message": "Πρέπει να είναι ακέραιος αριθμός, τουλάχιστον 1.", + "use-entity-label-tab-name": "Χρήση ετικέτας οντότητας στην καρτέλα", + "hide-empty-lines": "Απόκρυψη κενών γραμμών", + "row-style": "Στυλ γραμμής", + "use-row-style-function": "Χρήση συνάρτησης στυλ γραμμής", + "row-style-function": "Συνάρτηση στυλ γραμμής", + "cell-style": "Στυλ κελιού", + "use-cell-style-function": "Χρήση συνάρτησης στυλ κελιού", + "cell-style-function": "Συνάρτηση στυλ κελιού", + "cell-content": "Περιεχόμενο κελιού", + "use-cell-content-function": "Χρήση συνάρτησης περιεχομένου κελιού", + "cell-content-function": "Συνάρτηση περιεχομένου κελιού", + "show-latest-data-column": "Εμφάνιση στήλης τελευταίων δεδομένων", + "latest-data-column-order": "Σειρά στήλης τελευταίων δεδομένων", + "entities-table-title": "Τίτλος πίνακα οντοτήτων", + "enable-select-column-display": "Ενεργοποίηση επιλογής στηλών για εμφάνιση", + "display-entity-name": "Εμφάνιση στήλης ονόματος οντότητας", + "entity-name-column-title": "Τίτλος στήλης ονόματος οντότητας", + "display-entity-label": "Εμφάνιση στήλης ετικέτας οντότητας", + "entity-label-column-title": "Τίτλος στήλης ετικέτας οντότητας", + "display-entity-type": "Εμφάνιση στήλης τύπου οντότητας", + "default-sort-order": "Προεπιλεγμένη σειρά ταξινόμησης", + "custom-title": "Προσαρμοσμένος τίτλος κεφαλίδας", + "column-width": "Πλάτος στήλης (px ή %)", + "default-column-visibility": "Προεπιλεγμένη ορατότητα στήλης", + "column-visibility-visible": "Ορατή", + "column-visibility-hidden": "Κρυφή", + "column-visibility-hidden-mobile": "Κρυφή σε κινητά", + "column-selection-to-display": "Επιλογή στηλών για εμφάνιση", + "column-selection-to-display-enabled": "Ενεργοποιημένο", + "column-selection-to-display-disabled": "Απενεργοποιημένο", + "alarms-table-title": "Τίτλος πίνακα συναγερμών", + "enable-alarms-selection": "Ενεργοποίηση επιλογής συναγερμών", + "enable-alarms-search": "Ενεργοποίηση αναζήτησης συναγερμών", + "enable-alarm-filter": "Ενεργοποίηση φίλτρου συναγερμών", + "display-alarm-details": "Εμφάνιση λεπτομερειών συναγερμού", + "allow-alarms-ack": "Να επιτρέπεται επιβεβαίωση συναγερμών", + "allow-alarms-clear": "Να επιτρέπεται εκκαθάριση συναγερμών", + "display-alarm-activity": "Εμφάνιση δραστηριότητας συναγερμών", + "allow-alarms-assign": "Να επιτρέπεται ανάθεση συναγερμών", + "columns": "Στήλες", + "column-settings": "Ρυθμίσεις στηλών", + "remove-column": "Αφαίρεση στήλης", + "add-column": "Προσθήκη στήλης", + "no-columns": "Καμία στήλη δεν έχει ρυθμιστεί", + "columns-to-display": "Στήλες προς εμφάνιση", + "table-header": "Κεφαλίδα πίνακα", + "header-buttons": "Κουμπιά κεφαλίδας", + "table-buttons": "Κουμπιά πίνακα", + "pagination": "Σελιδοποίηση", + "rows": "Γραμμές", + "timeseries-column-error": "Πρέπει να καθοριστεί τουλάχιστον μία στήλη χρονικών σειρών", + "alarm-column-error": "Πρέπει να καθοριστεί τουλάχιστον μία στήλη συναγερμών", + "table-tabs": "Καρτέλες πίνακα", + "show-cell-actions-menu-mobile": "Εμφάνιση μενού ενεργειών κελιού σε κινητά", + "disable-sorting": "Απενεργοποίηση ταξινόμησης" + }, + "latest-chart": { + "total": "Σύνολο", + "auto-scale": "Αυτόματη κλιμάκωση", + "clockwise-layout": "Διάταξη δεξιόστροφα", + "sort-series": "Ταξινόμηση σειρών κατά ετικέτα", + "tooltip-value-type-absolute": "Απόλυτο", + "tooltip-value-type-percentage": "Ποσοστό" + }, + "pie-chart": { + "pie-chart-appearance": "Εμφάνιση διαγράμματος πίτας", + "label": "Ετικέτα", + "border": "Περίγραμμα", + "radius": "Ακτίνα", + "pie-chart-card-style": "Στυλ κάρτας πίτας" + }, + "radar-chart": { + "radar-appearance": "Εμφάνιση ραντάρ", + "shape": "Σχήμα", + "shape-polygon": "Πολύγωνο", + "shape-circle": "Κύκλος", + "color": "Χρώμα", + "line": "Γραμμή", + "points": "Σημεία", + "points-label": "Ετικέτα σημείων", + "radar-axis": "Άξονας ραντάρ", + "axis-label": "Ετικέτα άξονα", + "ticks-label": "Ετικέτα διαβαθμίσεων", + "radar-chart-style": "Στυλ διαγράμματος ραντάρ" + }, + "time-series-chart": { + "chart": "Διάγραμμα", + "chart-style": "Στυλ διαγράμματος", + "data-zoom": "Εστίαση δεδομένων", + "stack-mode": "Λειτουργία στοίβαξης", + "stack-mode-hint": "Οι σειρές με την ίδια μονάδα στοιβάζονται μεταξύ τους στο διάγραμμα.", + "axes": "Άξονες", + "y-axes": "Άξονες Υ", + "line-type": "Τύπος γραμμής", + "line-width": "Πλάτος γραμμής", + "type-line": "Γραμμή", + "type-bar": "Ράβδος", + "type-point": "Σημείο", + "no-aggregation-bar-width-strategy": "Στρατηγική πλάτους ράβδου για μη συγκεντρωμένα δεδομένα", + "no-aggregation-bar-width-strategy-group": "Ομαδοποίηση", + "no-aggregation-bar-width-strategy-separate": "Διαχωρισμός", + "bar-group-width": "Πλάτος ομάδας ράβδων", + "bar-width": "Πλάτος ράβδου", + "bar-width-relative": "Ποσοστό παραθύρου χρόνου", + "bar-width-absolute": "Απόλυτο (ms)", + "comparison": { + "comparison": "Σύγκριση", + "comparison-hint": "Η σύγκριση λειτουργεί μόνο με ιστορικά δεδομένα!", + "show": "Εμφάνιση", + "settings": "Ρυθμίσεις σύγκρισης", + "show-values-for-comparison": "Εμφάνιση ιστορικών δεδομένων για σύγκριση", + "comparison-values-label": "Ετικέτα κλειδιού σύγκρισης", + "comparison-values-label-auto": "Αυτόματο", + "comparison-data-color": "Χρώμα δεδομένων σύγκρισης" + }, + "threshold": { + "thresholds": "Όρια", + "source": "Πηγή", + "key-value": "Κλειδί / Τιμή", + "no-thresholds": "Δεν έχουν ρυθμιστεί όρια", + "add-threshold": "Προσθήκη ορίου", + "type-constant": "Σταθερό", + "type-latest-key": "Κλειδί", + "type-entity": "Οντότητα", + "threshold-settings": "Ρυθμίσεις ορίου", + "remove-threshold": "Αφαίρεση ορίου", + "threshold-value-required": "Απαιτείται τιμή ορίου.", + "key-required": "Απαιτείται κλειδί.", + "entity-key-required": "Απαιτείται κλειδί οντότητας.", + "line-appearance": "Εμφάνιση γραμμής", + "line-color": "Χρώμα γραμμής", + "start-symbol": "Σύμβολο αρχής", + "end-symbol": "Σύμβολο τέλους", + "symbol-size": "μέγεθος", + "label": "Ετικέτα", + "label-position-start": "Αρχή", + "label-position-middle": "Μέση", + "label-position-end": "Τέλος", + "label-position-inside-start": "Εσωτερικά στην αρχή", + "label-position-inside-start-top": "Εσωτερικά στην αρχή - πάνω", + "label-position-inside-start-bottom": "Εσωτερικά στην αρχή - κάτω", + "label-position-inside-middle": "Εσωτερικά στο μέσο", + "label-position-inside-middle-top": "Εσωτερικά στο μέσο - πάνω", + "label-position-inside-middle-bottom": "Εσωτερικά στο μέσο - κάτω", + "label-position-inside-end": "Εσωτερικά στο τέλος", + "label-position-inside-end-top": "Εσωτερικά στο τέλος - πάνω", + "label-position-inside-end-bottom": "Εσωτερικά στο τέλος - κάτω", + "label-background": "Φόντο ετικέτας" + }, + "state": { + "states": "Καταστάσεις", + "label": "Ετικέτα", + "ticks-value": "Τιμή διαβαθμίσεων", + "source": "Πηγή", + "value-range": "Τιμή / Εύρος", + "no-states": "Δεν έχουν ρυθμιστεί καταστάσεις", + "add-state": "Προσθήκη κατάστασης", + "type-constant": "Σταθερό", + "type-range": "Εύρος", + "from": "Από", + "to": "Έως", + "remove-state": "Αφαίρεση κατάστασης" + }, + "grid": { + "grid": "Πλέγμα", + "background-color": "Χρώμα φόντου", + "border": "Περίγραμμα" + }, + "axis": { + "axes": "Άξονες", + "x-axis": "Άξονας Χ", + "y-axis": "Άξονας Υ", + "y-axis-settings": "Ρυθμίσεις άξονα Υ", + "comparison-x-axis-settings": "Ρυθμίσεις άξονα Χ για σύγκριση", + "remove-y-axis": "Αφαίρεση άξονα Υ", + "id": "Αναγνωριστικό", + "label": "Ετικέτα", + "position": "Θέση", + "position-left": "Αριστερά", + "position-right": "Δεξιά", + "position-top": "Πάνω", + "position-bottom": "Κάτω", + "tick-labels": "Ετικέτες διαβαθμίσεων", + "ticks-formatter-function": "Συνάρτηση μορφοποίησης διαβαθμίσεων", + "ticks-generator-function": "Συνάρτηση δημιουργίας διαβαθμίσεων", + "show-ticks": "Εμφάνιση διαβαθμίσεων", + "show-line": "Εμφάνιση γραμμής", + "show-split-lines": "Εμφάνιση γραμμών διαχωρισμού", + "show-split-lines-x-axis-hint": "Αν ενεργοποιηθεί, θα εμφανίζονται κατακόρυφες γραμμές στο διάγραμμα.", + "show-split-lines-y-axis-hint": "Αν ενεργοποιηθεί, θα εμφανίζονται οριζόντιες γραμμές στο διάγραμμα.", + "ticks-interval": "Διάστημα διαβαθμίσεων", + "ticks-interval-hint": "Ορισμός διαστήματος διαχωρισμού για τον άξονα.", + "split-number": "Αριθμός διαχωρισμών", + "split-number-hint": "Αριθμός διαχωρισμών στον άξονα.", + "min": "Ελάχιστο", + "max": "Μέγιστο", + "show": "Εμφάνιση", + "add-y-axis": "Προσθήκη άξονα Υ" + }, + "series": { + "legend-settings": "Ρυθμίσεις υπόμνησης", + "show-in-legend": "Εμφάνιση στην υπόμνηση", + "show-in-legend-hint": "Εμφάνιση ονόματος και δεδομένων σειράς στην υπόμνηση.", + "hidden-by-default": "Απόκρυψη από προεπιλογή", + "hidden-by-default-hint": "Κάνει τη σειρά κρυφή στην υπόμνηση από προεπιλογή.", + "series-type": "Τύπος σειράς", + "type": "Τύπος", + "type-line": "Γραμμή", + "type-bar": "Ράβδος", + "line": { + "line": "Γραμμή", + "show-line": "Εμφάνιση γραμμής", + "step-line": "Βηματική γραμμή", + "step-type-start": "Αρχή", + "step-type-middle": "Μέση", + "step-type-end": "Τέλος", + "smooth-line": "Ομαλή γραμμή" + }, + "point": { + "points": "Σημεία", + "show-points": "Εμφάνιση σημείων", + "point-label": "Ετικέτα σημείου", + "point-label-hint": "Εμφάνιση ετικέτας με τιμή πάνω από το σημείο της σειράς.", + "point-label-background": "Φόντο ετικέτας σημείου", + "point-shape": "Σχήμα σημείου", + "point-size": "Μέγεθος σημείου" + } + } + }, + "wind-speed-direction": { + "layout": "Διάταξη", + "layout-default": "Προεπιλεγμένη", + "layout-advanced": "Προηγμένη", + "layout-simplified": "Απλοποιημένη", + "values": "Τιμές", + "wind-direction": "Κατεύθυνση ανέμου", + "center-value": "Κεντρική τιμή", + "icon": "Εικονίδιο", + "arrow": "Βέλος", + "ticks": "Διαβαθμίσεις", + "labels-type": "Τύπος ετικετών", + "directional-names": "Κατευθυντικά ονόματα", + "degrees": "Μοίρες", + "major-ticks": "Κύριες διαβαθμίσεις", + "minor-ticks": "Δευτερεύουσες διαβαθμίσεις", + "wind-speed-direction-card-style": "Στυλ κάρτας ταχύτητας και κατεύθυνσης ανέμου", + "ticks-color": "Χρώμα διαβαθμίσεων", + "ticks-labels-type": "Τύπος ετικετών διαβαθμίσεων", + "arrow-color": "Χρώμα βέλους" + }, + "value-source": { + "value-source": "Πηγή τιμής", + "predefined-value": "Σταθερή", + "entity-attribute": "Γνώρισμα οντότητας", + "value": "Τιμή", + "value-required": "Απαιτείται τιμή.", + "key-required": "Απαιτείται κλειδί.", + "entity-key-required": "Απαιτείται κλειδί οντότητας.", + "source-entity-alias": "Ψευδώνυμο πηγαίας οντότητας", + "source-entity-attribute": "Γνώρισμα πηγαίας οντότητας", + "type-constant": "Σταθερή", + "type-latest-key": "Κλειδί", + "type-entity": "Οντότητα" + }, + "rpc-state": { + "initial-state": "Αρχική κατάσταση", + "initial-state-hint": "Ενέργεια για λήψη της αρχικής κατάστασης (On/Off) του στοιχείου.", + "disabled-state": "Απενεργοποιημένη κατάσταση", + "disabled-state-hint": "Διαμόρφωση συνθήκης κατά την οποία το στοιχείο είναι απενεργοποιημένο.", + "turn-on": "Ενεργοποίηση", + "turn-on-hint": "Ενέργεια που ενεργοποιείται όταν ο διακόπτης τίθεται σε 'On'", + "turn-off": "Απενεργοποίηση", + "turn-off-hint": "Ενέργεια που ενεργοποιείται όταν ο διακόπτης τίθεται σε 'Off'", + "on": "Ενεργό", + "off": "Ανενεργό", + "disabled": "Απενεργοποιημένο" + }, + "value-action": { + "do-nothing": "Καμία ενέργεια", + "execute-rpc": "Εκτέλεση RPC", + "get-attribute": "Λήψη γνωρίσματος", + "set-attribute": "Ορισμός γνωρίσματος", + "get-time-series": "Λήψη χρονικής σειράς", + "get-alarm-status": "Λήψη κατάστασης συναγερμού", + "get-dashboard-state": "Λήψη ταυτότητας κατάστασης ταμπλό", + "get-dashboard-state-object": "Λήψη αντικειμένου κατάστασης ταμπλό", + "add-time-series": "Προσθήκη χρονικής σειράς", + "execute-rpc-text": "Εκτέλεση μεθόδου RPC '{{methodName}}'", + "get-time-series-text": "Χρήση χρονικής σειράς '{{key}}'", + "get-attribute-text": "Χρήση γνωρίσματος '{{key}}'", + "get-alarm-status-text": "Χρήση κατάστασης συναγερμού", + "get-dashboard-state-text": "Χρήση κατάστασης ταμπλό", + "get-dashboard-state-object-text": "Χρήση αντικειμένου κατάστασης ταμπλό", + "when-dashboard-state-is-text": "Όταν η ταυτότητα κατάστασης ταμπλό είναι '{{state}}'", + "when-dashboard-state-function-is-text": "Όταν f(ταυτότητα κατάστασης ταμπλό) είναι '{{state}}'", + "when-dashboard-state-object-function-is-text": "Όταν f(αντικείμενο κατάστασης ταμπλό) είναι '{{state}}'", + "set-attribute-to-value-text": "Ορισμός γνωρίσματος '{{key}}' σε: {{value}}", + "add-time-series-value-text": "Προσθήκη τιμής χρονικής σειράς '{{key}}': {{value}}", + "set-attribute-text": "Ορισμός γνωρίσματος '{{key}}'", + "add-time-series-text": "Προσθήκη χρονικής σειράς '{{key}}'", + "action": "Ενέργεια", + "value": "Τιμή", + "init-value-hint": "Τιμή που θα οριστεί έως ότου η συσκευή στείλει δεδομένα.", + "method": "Μέθοδος", + "method-name-required": "Απαιτείται όνομα μεθόδου.", + "request-timeout-ms": "Χρονικό όριο RPC (ms)", + "request-timeout-required": "Απαιτείται χρονικό όριο.", + "min-request-timeout-error": "Η τιμή χρονικού ορίου πρέπει να είναι τουλάχιστον 5000 ms (5 δευτερόλεπτα).", + "request-persistent": "Επίμονη RPC αίτηση", + "persistent-polling-interval": "Διάστημα ερωτήσεων για επίμονη RPC (ms)", + "persistent-polling-interval-hint": "Διάστημα (ms) για ερώτηση επίμονης RPC εντολής", + "persistent-polling-interval-required": "Απαιτείται διάστημα ερωτήσεων.", + "min-persistent-polling-interval-error": "Η τιμή διαστήματος ερωτήσεων πρέπει να είναι τουλάχιστον 1000 ms (1 δευτερόλεπτο).", + "attribute-scope": "Εμβέλεια γνωρίσματος", + "attribute-key": "Κλειδί γνωρίσματος", + "attribute-key-required": "Απαιτείται κλειδί γνωρίσματος.", + "time-series-key": "Κλειδί χρονικής σειράς", + "time-series-key-required": "Απαιτείται κλειδί χρονικής σειράς.", + "action-result-converter": "Μετατροπέας αποτελέσματος ενέργειας", + "converter-none": "Κανένας", + "converter-function": "Συνάρτηση", + "converter-constant": "Σταθερά", + "converter-value": "Τιμή", + "parse-value-function": "Συνάρτηση ανάλυσης τιμής", + "state-when-result-is": "'{{state}}' όταν το αποτέλεσμα είναι", + "parameters": "Παράμετροι", + "convert-value-function": "Συνάρτηση μετατροπής τιμής", + "error": { + "target-entity-is-not-set": "Η οντότητα στόχος δεν έχει οριστεί!", + "failed-to-perform-action": "Απέτυχε η εκτέλεση της ενέργειας {{ actionLabel }}.", + "invalid-attribute-scope": "Η εμβέλεια γνωρίσματος {{scope}} δεν υποστηρίζεται από την οντότητα {{entityType}}." + } + }, + "widget-font": { + "font-settings": "Ρυθμίσεις γραμματοσειράς", + "font-family": "Οικογένεια γραμματοσειράς", + "size": "Μέγεθος", + "relative-font-size": "Σχετικό μέγεθος γραμματοσειράς (%)", + "font-style": "Στυλ", + "font-style-normal": "Κανονικό", + "font-style-italic": "Πλάγιο", + "font-style-oblique": "Λοξό", + "font-weight": "Βάρος", + "font-weight-normal": "Κανονικό", + "font-weight-bold": "Έντονο", + "font-weight-bolder": "Εντονότερο", + "font-weight-lighter": "Ελαφρύτερο", + "color": "Χρώμα", + "shadow-color": "Χρώμα σκιάς", + "preview": "Προεπισκόπηση", + "line-height": "Ύψος γραμμής", + "auto": "Αυτόματο" + }, + "home": { + "no-data-available": "Δεν υπάρχουν διαθέσιμα δεδομένα" + }, + "system-info": { + "cpu": "Επεξεργαστής", + "ram": "RAM", + "disk": "Δίσκος", + "cpu-warning-text": "Υψηλή χρήση επεξεργαστή. Βελτιστοποιήστε την απόδοση του συστήματος για να αποφύγετε αποτυχία.", + "cpu-critical-text": "Κρίσιμα υψηλή χρήση επεξεργαστή. Βελτιστοποιήστε την απόδοση του συστήματος για να αποφύγετε αποτυχία.", + "ram-warning-text": "Χαμηλή διαθεσιμότητα RAM. Βελτιστοποιήστε ή αυξήστε τη RAM για να αποφύγετε αποτυχία.", + "ram-critical-text": "Κρίσιμα χαμηλή RAM. Βελτιστοποιήστε ή αυξήστε τη RAM για να αποφύγετε αποτυχία.", + "disk-warning-text": "Χαμηλός διαθέσιμος χώρος δίσκου. Απελευθερώστε ή αυξήστε τον αποθηκευτικό χώρο.", + "disk-critical-text": "Κρίσιμα χαμηλός χώρος δίσκου. Απελευθερώστε ή αυξήστε τον αποθηκευτικό χώρο." + }, + "cluster-info": { + "service-id": "Αναγνωριστικό υπηρεσίας", + "service-type": "Τύπος υπηρεσίας", + "no-data": "Δεν υπάρχουν δεδομένα" + }, + "transport-messages": { + "title": "Μηνύματα μεταφοράς", + "info": "Όλα τα μηνύματα που ελήφθησαν από συσκευές" + }, + "activity": { + "title": "Δραστηριότητα" + }, + "documentation": { + "title": "Τεκμηρίωση", + "add-link": "Προσθήκη συνδέσμου", + "add-link-title": "Προσθήκη συνδέσμου τεκμηρίωσης", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "link": "Σύνδεσμος", + "link-required": "Απαιτείται σύνδεσμος.", + "columns": "Στήλες" + }, + "quick-links": { + "title": "Γρήγοροι σύνδεσμοι", + "add-link": "Προσθήκη συνδέσμου", + "add-link-title": "Προσθήκη γρήγορου συνδέσμου", + "quick-link": "Γρήγορος σύνδεσμος", + "quick-link-required": "Απαιτείται γρήγορος σύνδεσμος.", + "no-links-matching": "Δεν βρέθηκαν σύνδεσμοι που να ταιριάζουν με '{{name}}'.", + "columns": "Στήλες" + }, + "recent-dashboards": { + "title": "Πίνακες ελέγχου", + "last": "Τελευταία προβολή", + "starred": "Με αστέρι", + "name": "Όνομα", + "last-viewed": "Τελευταία προβολή", + "no-last-viewed-dashboards": "Δεν υπάρχουν πίνακες ελέγχου με τελευταία προβολή" + }, + "configured-features": { + "title": "Διαμορφωμένα χαρακτηριστικά", + "info": "Κατάσταση χαρακτηριστικών που απαιτούν ρύθμιση", + "email-feature": "Email", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2FA", + "feature-configured": "Το χαρακτηριστικό έχει ρυθμιστεί.\nΚάντε κλικ για ρύθμιση", + "feature-not-configured": "Το χαρακτηριστικό δεν έχει ρυθμιστεί.\nΚάντε κλικ για ρύθμιση" + }, + "version-info": { + "title": "Έκδοση", + "contact-us": "Επικοινωνήστε μαζί μας", + "current-version": "Τρέχουσα έκδοση", + "current": "Τρέχουσα", + "available-version": "Διαθέσιμη έκδοση", + "available": "Διαθέσιμη", + "upgrade": "Αναβάθμιση", + "version-is-up-to-date": "Η έκδοση είναι ενημερωμένη" + }, + "usage-info": { + "title": "Χρήση", + "entities": "Οντότητες", + "api-calls": "Κλήσεις API" + }, + "functions": { + "title": "Λειτουργίες", + "pe-feature-tooltip": "Μόνο στην ThingsBoard\nProfessional Edition", + "switch-to-pe": "Μετάβαση σε PE", + "alarms": "Συναγερμοί", + "dashboards": "Πίνακες ελέγχου", + "entities-and-relations": "Οντότητες & Σχέσεις", + "profiles": "Προφίλ", + "advanced-features": "Προηγμένα χαρακτηριστικά", + "notification-center": "Κέντρο ειδοποιήσεων", + "api-usage": "Χρήση API", + "customers": "Πελάτες", + "customers-hierarchy": "Ιεραρχία πελατών", + "roles-and-permissions": "Ρόλοι & Δικαιώματα", + "groups": "Ομάδες", + "integrations": "Ενσωματώσεις", + "solution-templates": "Πρότυπα λύσεων", + "scheduler": "Προγραμματιστής", + "white-labeling": "Λευκή ετικετοποίηση" + }, + "devices": { + "view-docs": "Προβολή τεκμηρίωσης", + "inactive": "Ανενεργό", + "active": "Ενεργό", + "total": "Σύνολο" + }, + "alarms": { + "critical": "Κρίσιμο", + "assigned-to-me": "Ανατέθηκε σε μένα", + "total": "Σύνολο" + }, + "getting-started": { + "get-started": "Ξεκινήστε", + "finish": "Ολοκλήρωση", + "done-welcome-title": "Καλωσήρθατε", + "done-welcome-text": "Τα καταφέρατε υπέροχα!", + "sys-admin": { + "step1": { + "title": "Δημιουργία Μισθωτή & Διαχειριστή Μισθωτή", + "content": "

    Ένας μισθωτής είναι ένα άτομο ή ένας οργανισμός που κατέχει ή παράγει συσκευές και περιουσιακά στοιχεία. Ο μισθωτής μπορεί να έχει πολλούς διαχειριστές, πελάτες, συσκευές και περιουσιακά στοιχεία.

    Ο Διαχειριστής Μισθωτή μπορεί να δημιουργήσει και να διαχειριστεί συσκευές, περιουσιακά στοιχεία, πελάτες και πίνακες ελέγχου στον λογαριασμό του μισθωτή.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-create-tenant": "Πώς να δημιουργήσετε Μισθωτή & Διαχειριστή Μισθωτή" + }, + "step2": { + "title": "Διαμόρφωση λειτουργίας: Mail server", + "content": "

    Η διαμόρφωση του mail server είναι απαραίτητη για ενεργοποίηση χρήστη, ανάκτηση κωδικού και αποστολή ειδοποιήσεων.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-configure-mail-server": "Πώς να διαμορφώσετε τον Mail server" + }, + "step3": { + "title": "Διαμόρφωση λειτουργίας: Πάροχος SMS", + "content": "

    Ρυθμίστε τους παρόχους SMS για ειδοποίηση πελατών μέσω SMS.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-configure-sms-provider": "Πώς να διαμορφώσετε τον πάροχο SMS" + }, + "step4": { + "title": "Διαμόρφωση λειτουργίας: Λευκή ετικετοποίηση", + "content": "

    Προσαρμόστε το λογότυπο και το χρωματικό σχήμα της εταιρείας ή του προϊόντος σας χωρίς κώδικα ή επανεκκίνηση της υπηρεσίας.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    " + }, + "step5": { + "title": "Διαμόρφωση λειτουργίας: 2FA", + "content": "

    Ενισχύστε την ασφάλεια των λογαριασμών πλατφόρμας με έλεγχο ταυτότητας δύο παραγόντων.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    " + }, + "step6": { + "title": "Διαμόρφωση λειτουργίας: OAuth 2", + "content": "

    Απλοποιήστε τη σύνδεση για τους χρήστες με τη λειτουργία SSO μέσω OAuth 2.0.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    " + } + }, + "tenant-admin": { + "step1": { + "title": "Δημιουργία συσκευής", + "content": "

    Ας καταχωρήσουμε την πρώτη σας συσκευή στην πλατφόρμα μέσω του UI. Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-create-device": "Πώς να δημιουργήσετε Συσκευή" + }, + "step2": { + "title": "Σύνδεση συσκευής", + "content-before": "

    Για να συνδέσετε τη συσκευή, πρέπει να αποκτήσετε τα διαπιστευτήρια της. Συνιστούμε τη χρήση του προεπιλεγμένου αυτόματα παραγόμενου διακριτικού πρόσβασης.

    • Μεταβείτε στον πίνακα συσκευών
    • Κάντε κλικ στη σειρά της συσκευής για να ανοίξετε τις λεπτομέρειες
    • Πατήστε το κουμπί \"Αντιγραφή διακριτικού πρόσβασης\"

    Χρησιμοποιήστε απλές εντολές για αποστολή δεδομένων μέσω HTTP. Μην ξεχάσετε να αντικαταστήσετε το $ACCESS_TOKEN με το διακριτικό της συσκευής σας:

    ", + "ubuntu": { + "install-curl": "Εγκατάσταση cURL για Ubuntu:" + }, + "macos": { + "install-curl": "Εγκατάσταση cURL για MacOS:" + }, + "windows": { + "install-curl": "Από τα Windows 10 b17063, το cURL είναι διαθέσιμο από προεπιλογή." + }, + "replace-access-token": "Αντικαταστήστε το $ACCESS_TOKEN με το διακριτικό της συσκευής σας:", + "content-after": "

    Μπορείτε επίσης να χρησιμοποιήσετε άλλα πρωτόκολλα όπως MQTT, CoAP, κ.λπ.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-connect-device": "Πώς να συνδέσετε Συσκευή" + }, + "step3": { + "title": "Δημιουργία πίνακα ελέγχου", + "content": "

    Δημιουργήστε έναν πίνακα ελέγχου για να απεικονίσετε δεδομένα από οντότητες όπως συσκευές, περιουσιακά στοιχεία, κ.λπ.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-create-dashboard": "Πώς να δημιουργήσετε Πίνακα ελέγχου" + }, + "step4": { + "title": "Διαμόρφωση κανόνων συναγερμού", + "alarm-rules": "Κανόνες συναγερμού", + "content": "

    Ας ενεργοποιήσουμε συναγερμό όταν η θερμοκρασία φτάσει τους 25°C. Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-configure-alarm-rules": "Πώς να διαμορφώσετε Κανόνες συναγερμού" + }, + "step5": { + "title": "Δημιουργία συναγερμού", + "content-before": "

    Για να ενεργοποιήσετε τον συναγερμό, στείλτε νέα τιμή τηλεμετρίας 26°C ή μεγαλύτερη.

    ", + "replace-access-token": "Αντικαταστήστε το $ACCESS_TOKEN με το διακριτικό της συσκευής σας:", + "content-after": "

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    ", + "how-to-create-alarm": "Πώς να δημιουργήσετε Συναγερμό" + }, + "step6": { + "title": "Δημιουργία πελάτη και διαμοιρασμός πίνακα ελέγχου", + "content": "

    Δημιουργώντας πίνακες ελέγχου τελικού χρήστη, ένας πελάτης μπορεί να δει μόνο τις δικές του συσκευές. Τα δεδομένα άλλων πελατών θα είναι κρυφά.

    Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

    " + } } } }, "icon": { "icon": "Εικονίδιο", - "select-icon": "Επιλογή εικονιδίου ", - "material-icons": "Υλικά εικονίδια", - "show-all": "Προβολή όλων των εικονιδίων" - }, - "subscription": { - "entity-limit-text": "Ωστόσο, μπορείτε να αναβαθμίσετε το πρόγραμμα εγγραφής σας για να αυξήσετε τα όριά σας.", - "upgrade-your-plan": "Αναβάθμιση του σχεδίου συνδρομής", - "white-labeling-feature": "Εμφάνιση χαρακτηριστικού", - "white-labeling-text-full": "Αλλάξτε το διακριτικό τίτλο της πλατφόρμας με το λογότυπο της εταιρείας ή του προϊόντος σας και το συνδυασμό χρωμάτων σε 2 λεπτά.

    Αφαιρέστε το \"Powered By\" στο κάτω μέρος του dashboard.
    Δεν απαιτείται κώδικας ή επανεκκίνηση υπηρεσίας. Επιτρέψτε και στους πελάτες σας να αλλάξουν το περιβάλλον τους.", - "enable-white-labeling": "Ενεργοποίηση εμφάνισης χαρακτηριστικού τώρα αναβαθμίζοντας το σχέδιο εγγραφής σας!", - "read-more": "Διαβάστε περισσότερα", - "white-labeling-video-text": "Δείτε το εκπαιδευτικό βίντεο παρακάτω για να δείτε πώς λειτουργεί αυτό το χαρακτηριστικό!" + "icons": "Εικονίδια", + "select-icon": "Επιλέξτε εικονίδιο", + "material-icons": "Material εικονίδια", + "show-all": "Εμφάνιση όλων των εικονιδίων", + "search-icon": "Αναζήτηση εικονιδίου", + "no-icons-found": "Δεν βρέθηκαν εικονίδια για '{{iconSearch}}'" }, - "subscription-error": { - "title": "Παραβίαση συνδρομής", - "warning-title": "Προειδοποίηση συνδρομής", - "limit-reached": { - "device-count": "Έχετε φτάσει τις μέγιστες συσκευές ({{value}}) που επιτρέπονται από το πρόγραμμα εγγραφής σας!", - "asset-count": "Έχετε φτάσει τα μέγιστα assets ({{value}}) που επιτρέπονται από το πρόγραμμα εγγραφής σας!" - }, - "feature-disabled": { - "white-labeling": "Η εμφάνιση χαρακτηριστικού δεν επιτρέπεται από το σχέδιο εγγραφής σας!" - } + "phone-input": { + "phone-input-label": "Αριθμός τηλεφώνου", + "phone-input-required": "Απαιτείται αριθμός τηλεφώνου", + "phone-input-validation": "Ο αριθμός τηλεφώνου δεν είναι έγκυρος ή δεν είναι δυνατός", + "phone-input-pattern": "Μη έγκυρος αριθμός τηλεφώνου. Πρέπει να είναι σε μορφή E.164, π.χ. {{phoneNumber}}", + "phone-input-hint": "Αριθμός τηλεφώνου σε μορφή E.164, π.χ. {{phoneNumber}}" }, "custom": { "widget-action": { - "action-cell-button": "Κουμπί ενέργειας κελιών", - "row-click": "Κλικ στη σειρά", - "polygon-click": "Κλικ στο πολύγωνο", - "marker-click": "Κλικ στο δείκτη", - "tooltip-tag-action": "Ετικέτα εργαλείου ενέργειας", - "node-selected": "Στον επιλεγμένο κόμβο", - "element-click": "Κλικ στο στοιχείο HTML" + "action-cell-button": "Κουμπί ενέργειας κελιού", + "row-click": "Κατά το κλικ στη σειρά", + "cell-click": "Κατά το κλικ στο κελί", + "polygon-click": "Κατά το κλικ στο πολύγωνο", + "marker-click": "Κατά το κλικ στο δείκτη", + "circle-click": "Κατά το κλικ στον κύκλο", + "tooltip-tag-action": "Ενέργεια ετικέτας συμβουλής", + "node-selected": "Κατά την επιλογή κόμβου", + "element-click": "Κατά το κλικ στο HTML στοιχείο", + "pie-slice-click": "Κατά το κλικ στο κομμάτι", + "row-double-click": "Κατά το διπλό κλικ στη σειρά", + "cell-double-click": "Κατά το διπλό κλικ στο κελί", + "card-click": "Κατά το κλικ στην κάρτα", + "click": "Κατά το κλικ" } }, + "paginator": { + "items-per-page": "Στοιχεία ανά σελίδα:", + "first-page-label": "Πρώτη σελίδα", + "last-page-label": "Τελευταία σελίδα", + "next-page-label": "Επόμενη σελίδα", + "previous-page-label": "Προηγούμενη σελίδα", + "items-per-page-separator": "από" + }, "language": { + "language": "Γλώσσα" } } 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 e3f67fe8f7..a7396ad49e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -721,6 +721,7 @@ "state-entity-parameter-name": "State entity parameter name", "default-state-entity": "Default state entity", "default-entity-parameter-name": "By default", + "query-options": "Query options", "max-relation-level": "Max relation level", "unlimited-level": "Unlimited level", "state-entity": "Dashboard state entity", @@ -924,7 +925,12 @@ "view-statistics": "View statistics" }, "api-limit": { - "cassandra-queries": "Cassandra queries", + "cassandra-write-queries-core": "Rest API Cassandra write queries", + "cassandra-read-queries-core": "Rest API and WS telemetry Cassandra read queries", + "cassandra-write-queries-rule-engine": "Rule Engine telemetry Cassandra write queries", + "cassandra-read-queries-rule-engine": "Rule Engine telemetry Cassandra read queries", + "cassandra-write-queries-monolith": "Monolith telemetry Cassandra write queries", + "cassandra-read-queries-monolith": "Monolith telemetry Cassandra read queries", "entity-version-creation": "Entity version creation", "entity-version-load": "Entity version load", "notification-requests": "Notification requests", @@ -1064,7 +1070,7 @@ "delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 calculated field} other {# calculated fields} }?", "delete-multiple-text": "Be careful, after the confirmation all selected calculated fields will be removed and all related data will become unrecoverable.", "test-with-this-message": "Test with this message", - "use-message-timestamp": "Use message timestamp", + "use-latest-timestamp": "Use latest timestamp", "hint": { "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", "arguments-empty": "Arguments should not be empty.", @@ -1081,9 +1087,86 @@ "decimals-range": "Decimals by default should be a number between 0 and 15.", "expression": "Default expression demonstrates how to transform a temperature from Fahrenheit to Celsius.", "arguments-entity-not-found": "Argument target entity not found.", - "use-message-timestamp": "If enabled, the calculated value will be persisted using the timestamp of the telemetry that triggered the calculation, instead of the server time." + "use-latest-timestamp": "If enabled, the calculated value will be persisted using the most recent timestamp from the arguments telemetry, instead of the server time." } }, + "ai-models": { + "ai-models": "AI models", + "ai-model": "AI model", + "model": "Model", + "name": "Name", + "ai-provider": "AI provider", + "no-found": "No AI models found", + "list": "{ count, plural, =1 {One model} other {List of # models} }", + "selected-fields": "{ count, plural, =1 {1 model} other {# models} } selected", + "add": "Add model", + "delete-model-title": "Are you sure you want to delete the model '{{modelName}}'?", + "delete-model-text": "Be careful, after the confirmation the model and all related data will become unrecoverable.", + "delete-models-title": "Are you sure you want to delete { count, plural, =1 {1 model} other {# models} }?", + "delete-models-text": "Be careful, after the confirmation all selected models will be removed and all related data will become unrecoverable.", + "ai-providers": { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "google-ai-gemini": "Google AI Gemini", + "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "mistral-ai": "Mistral AI", + "anthropic": "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "github-models": "GitHub Models" + }, + "name-required": "Name is required.", + "name-max-length": "Name must be 255 characters or less.", + "provider": "Provider", + "api-key": "API key", + "api-key-required": "API key is required.", + "project-id": "Project ID", + "project-id-required": "Project ID is required", + "location": "Location", + "location-required": "Location is required.", + "service-account-key-file": "Service account key file", + "service-account-key-file-required": "Service account key file is required.", + "no-file": "No file selected.", + "drop-file": "Drop a file or click to select a file to upload.", + "personal-access-token": "Personal access token", + "personal-access-token-required": "Personal access token is required.", + "configuration": "Configuration", + "model-id": "Model ID", + "model-id-required": "Model ID is required.", + "deployment-name": "Deployment name", + "deployment-name-required": "Deployment name is required", + "set": "Set", + "region": "Region", + "region-required": "Region is required.", + "access-key-id": "Access key ID", + "access-key-id-required": "Access key ID is required.", + "secret-access-key": "Secret access key", + "secret-access-key-required": "Secret access key is required.", + "temperature": "Temperature", + "temperature-hint": "Adjusts the level of randomness in the model's output. Higher values increase randomness, while lower values decrease it.", + "temperature-min": "Must be 0 or greater.", + "top-p": "Top P", + "top-p-hint": "Creates a pool of the most probable tokens for the model to choose from. Higher values create a larger and more diverse pool, while lower values create a smaller one.", + "top-p-min-max": "Must be greater than 0 and up to 1.", + "top-k": "Top K", + "top-k-hint": "Restricts the model's choices to a fixed set of the \"K\" most likely tokens.", + "top-k-min": "Must be 0 or greater.", + "presence-penalty": "Presence penalty", + "presence-penalty-hint": "Applies a fixed penalty to the likelihood of a token if it has already appeared in the text.", + "frequency-penalty": "Frequency penalty", + "frequency-penalty-hint": "Applies a penalty to a token's likelihood that increases based on its frequency in the text.", + "max-output-tokens": "Maximum output tokens", + "max-output-tokens-min": "Must be greater than 0.", + "max-output-tokens-hint": "Sets the maximum number of tokens that the \nmodel can generate in a single response.", + "endpoint": "Endpoint", + "endpoint-required": "Endpoint is required.", + "service-version": "Service version", + "check-connectivity": "Check connectivity", + "check-connectivity-success": "Test request was successful", + "check-connectivity-failed": "Test request failed", + "no-model-matching": "No models matching '{{entity}}' were found.", + "model-required": "Model is required.", + "no-model-text": "No models found." + }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", "html-message": "You have unsaved changes.
    Are you sure you want to leave this page?", @@ -2550,6 +2633,8 @@ "type-current-user-owner": "Current User Owner", "type-calculated-field": "Calculated field", "type-calculated-fields": "Calculated fields", + "type-ai-model": "AI model", + "type-ai-models": "AI models", "type-widgets-bundle": "Widgets bundle", "type-widgets-bundles": "Widgets bundles", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", @@ -2579,6 +2664,8 @@ "type-tb-resources": "Resources", "list-of-tb-resources": "{ count, plural, =1 {One resource} other {List of # resources} }", "type-ota-package": "OTA package", + "type-ota-packages": "OTA packages", + "list-of-ota-packages": "{ count, plural, =1 {One OTA package} other {List of # OTA packages} }", "type-rpc": "RPC", "type-queue": "Queue", "type-queue-stats": "Queue statistics", @@ -3437,6 +3524,7 @@ "power-button-background": "Power button background", "value-box-background": "Value box background", "value-units": "Value units", + "enable-units-scale": "Enable units on scale", "filtration-mode": "Filtration mode", "filtration-mode-hint": "Integer value indication the current filtration mode.", "filtration-mode-update": "Filtration mode update state", @@ -3723,9 +3811,9 @@ }, "mobile": { "add-application": "Add application", - "app-id": "App ID", - "app-id-required": "App ID is required", - "app-id-pattern": "Invalid format App ID", + "app-id": "App Site Association ID", + "app-id-required": "App Site Association ID is required", + "app-id-pattern": "Invalid format App Site Association ID", "app-store-link": "App Store link", "app-store-link-required": "App Store link is required", "application-details": "Application details", @@ -3759,6 +3847,8 @@ "mobile-package-max-length": "Application package should be less than 256", "mobile-package-required": "Application package is required.", "mobile-package-pattern": "Application package invalid format", + "mobile-package-title": "Application title", + "mobile-package-title-max-length": "Application title should be less than 256", "no-application": "No applications found", "no-bundles": "No bundles found", "platform-type": "Platform type", @@ -3842,17 +3932,13 @@ "prepare-environment-text": "Flutter ThingsBoard Mobile Application requires Flutter SDK. Follow instructions to set up Flutter SDK.", "get-source-code-title": "Get app source code", "get-source-code-text": "You can get Flutter ThingsBoard Mobile Application source code by cloning it from the GitHub repository:", - "configure-api-title": "Configure ThingsBoard API endpoint", - "configure-api-text": "Open the flutter_thingsboard_app project in your editor/IDE. Edit:", - "configure-api-hint": "Set the value of the thingsBoardApiEndpoint constant to match the API endpoint of your ThingsBoard server instance. Do not use “localhost” or “127.0.0.1” hostnames.", + "configure-app-settings-title": "Configure app settings", + "configure-app-settings-text": "Download the configuration file and place it into the root directory of the project you cloned in the previous step.", + "download-file": "Download file", "run-app-title": "Run the app", "run-app-text": "Run the app as described in your IDE.\nIf using the terminal, run the app with the following command:", "more-information": "Detailed information may be found in our Getting Started documentation.", - "getting-started": "Getting Started", - "configure-package-title": "Configure application package", - "configure-package-text": "You can manually change the Application Package or use third party CLI tool.", - "configure-package-text-install": "To install the Rename CLI Tool, execute the following command:", - "configure-package-run-commands": "Run these commands in the root directory of your project:" + "getting-started": "Getting Started" } }, "notification": { @@ -3876,6 +3962,7 @@ "new-platform-version-trigger-settings": "New platform version trigger settings", "rate-limits-trigger-settings": "Exceeded rate limits trigger settings", "task-processing-failure-trigger-settings": "Task processing failure trigger settings", + "resources-shortage-trigger-settings": "Resources shortage trigger settings", "at-least-one-should-be-selected": "At least one should be selected", "basic-settings": "Basic settings", "button-text": "Button text", @@ -3890,6 +3977,7 @@ "create-new": "Create new", "created": "Created", "customize-messages": "Customize messages", + "cpu-threshold": "CPU threshold", "delete-notification-text": "Be careful, after the confirmation the notification will become unrecoverable.", "delete-notification-title": "Are you sure you want to delete the notification?", "delete-notifications-text": "Be careful, after the confirmation notifications will become unrecoverable.", @@ -3956,6 +4044,7 @@ "input-fields-support-templatization": "Input fields support templatization.", "link": "Link", "link-required": "Link is required", + "link-max-length": "Link should be less than or equal to {{ length }} characters", "link-type": { "dashboard": "Open dashboard", "link": "Open URL link" @@ -4005,6 +4094,7 @@ "only-rule-chain-lifecycle-failures": "Only rule chain lifecycle failures", "only-rule-node-lifecycle-failures": "Only rule node lifecycle failures", "platform-users": "Platform users", + "ram-threshold": "RAM threshold", "rate-limits": "Rate limits", "rate-limits-hint": "If the field is empty, the trigger will be applied to all rate limits", "recipient": "Recipient", @@ -4070,6 +4160,7 @@ "start-from-scratch": "Start from scratch", "status": "Status", "stop-escalation-alarm-status-become": "Stop the escalation on the alarm status become:", + "storage-threshold": "Storage threshold", "subject": "Subject", "subject-required": "Subject is required", "subject-max-length": "Subject should be less than or equal to {{ length }} characters", @@ -4091,7 +4182,8 @@ "rate-limits": "Exceeded rate limits", "edge-communication-failure": "Edge communication failure", "edge-connection": "Edge connection", - "task-processing-failure": "Task processing failure" + "task-processing-failure": "Task processing failure", + "resources-shortage": "Resources shortage" }, "templates": "Templates", "notification-templates": "Notifications / Templates", @@ -4115,6 +4207,7 @@ "edge-connection": "Edge connection", "edge-communication-failure": "Edge communication failure", "task-processing-failure": "Task processing failure", + "resources-shortage": "Resources shortage", "trigger": "Trigger", "trigger-required": "Trigger is required" }, @@ -4156,6 +4249,7 @@ "checksum-copied-message": "Package checksum has been copied to clipboard", "change-firmware": "Change of the firmware may cause update of { count, plural, =1 {1 device} other {# devices} }.", "change-software": "Change of the software may cause update of { count, plural, =1 {1 device} other {# devices} }.", + "change-ota-setting-title": "Are you sure you want to change OTA settings?", "chose-compatible-device-profile": "The uploaded package will be available only for devices with the chosen profile.", "chose-firmware-distributed-device": "Choose firmware that will be distributed to the devices", "chose-software-distributed-device": "Choose software that will be distributed to the devices", @@ -4351,6 +4445,7 @@ "add-relation-filter": "Add relation filter", "any-relation": "Any relation", "relation-filters": "Relation filters", + "relation-filter": "Relation filter", "additional-info": "Additional info (JSON)", "invalid-additional-info": "Unable to parse additional info json.", "no-relations-text": "No relations found", @@ -5341,6 +5436,36 @@ "html-text-description": "Allows you to use HTML tags for formatting, links and images in your mai body.", "dynamic-text-description": "Allows to use Plain Text or HTML body type dynamically based on templatization feature.", "after-template-evaluation-hint": "After template evaluation value should be true for HTML, and false for Plain text." + }, + "ai": { + "ai-model": "AI model", + "model": "Model", + "ai-model-hint": "Select the pre-configured AI model to process requests sent by this rule node, or use \"Create new\" to configure a new one.", + "prompt-settings": "Prompt settings", + "prompt-settings-hint": "The optional system prompt sets the AI's general role and constraints, while the user prompt defines the specific task to perform. Both fields also support templatization.", + "system-prompt": "System prompt", + "system-prompt-max-length": "System prompt must be 10000 characters or less.", + "system-prompt-blank": "System prompt must not be blank.", + "user-prompt": "User prompt", + "user-prompt-required": "User prompt is required.", + "user-prompt-max-length": "User prompt must be 10000 characters or less.", + "user-prompt-blank": "User prompt must not be blank.", + "response-format": "Response format", + "response-text": "Text", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-format-hint-TEXT": "Allows the model to generate arbitrary text, which may or may not be a valid JSON object. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-format-hint-JSON": "The model is required to generate a response that is a valid JSON. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-format-hint-JSON_SCHEMA": "The model is required to generate a JSON that matches the specific structure and data types defined in the provided schema. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-json-schema-hint": "While any valid JSON Schema can be entered, this rule node only supports a limited subset of its features. See node documentation for details.", + "response-json-schema-required": "JSON Schema is required", + "advanced-settings": "Advanced settings", + "timeout": "Timeout", + "timeout-hint": "Maximum time to wait for a response \nfrom the AI model before the request is terminated.", + "timeout-required": "Timeout is required", + "timeout-validation": "Must be from 1 second to 10 minutes.", + "force-acknowledgement": "Force acknowledgement", + "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message." } }, "timezone": { @@ -5662,7 +5787,10 @@ "too-small-value-zero": "The value must be bigger than 0", "too-small-value-one": "The value must be bigger than 1", "queue-size-is-limited-by-system-configuration": "The size of the queue is also limited by the system configuration.", - "cassandra-tenant-limits-configuration": "Cassandra query for tenant", + "cassandra-write-tenant-core-limits-configuration": "Rest API Cassandra write queries", + "cassandra-read-tenant-core-limits-configuration": "Rest API and WS telemetry Cassandra read queries", + "cassandra-write-tenant-rule-engine-limits-configuration": "Rule Engine telemetry Cassandra write queries", + "cassandra-read-tenant-rule-engine-limits-configuration": "Rule Engine telemetry Cassandra read queries", "ws-limit-max-sessions-per-tenant": "Sessions per tenant maximum number", "ws-limit-max-sessions-per-customer": "Sessions per customer maximum number", "ws-limit-max-sessions-per-regular-user": "Sessions per regular user maximum number", @@ -5675,9 +5803,9 @@ "ws-limit-updates-per-session": "WS updates per session", "rate-limits": { "add-limit": "Add limit", + "and-also-less-than": "and also less than", "advanced-settings": "Advanced settings", "edit-limit": "Edit limit", - "but-less-than": "but less than", "calculated-field-debug-event-rate-limit": "Calculated field debug events", "edit-calculated-field-debug-event-rate-limit": "Edit calculated field debug events rate limits", "edit-transport-tenant-msg-title": "Edit transport tenant messages rate limits", @@ -5695,7 +5823,10 @@ "edit-tenant-rest-limits-title": "Edit REST requests for tenant rate limits", "edit-customer-rest-limits-title": "Edit REST requests for customer rate limits", "edit-ws-limit-updates-per-session-title": "Edit WS updates per session rate limits", - "edit-cassandra-tenant-limits-configuration-title": "Edit Cassandra query for tenant rate limits", + "edit-cassandra-write-tenant-core-limits-configuration": "Edit Rest API Cassandra write queries", + "edit-cassandra-read-tenant-core-limits-configuration": "Edit Rest API and WS telemetry Cassandra read queries", + "edit-cassandra-write-tenant-rule-engine-limits-configuration": "Edit Rule Engine telemetry Cassandra write queries", + "edit-cassandra-read-tenant-rule-engine-limits-configuration": "Edit Rule Engine telemetry Cassandra read queries", "edit-tenant-entity-export-rate-limit-title": "Edit entity version creation rate limits", "edit-tenant-entity-import-rate-limit-title": "Edit entity version load rate limits", "edit-tenant-notification-request-rate-limit-title": "Edit notification requests rate limits", @@ -5717,6 +5848,7 @@ "per-seconds": "Per seconds", "per-seconds-required": "Time rate is required.", "per-seconds-min": "Minimum value is 1.", + "per-seconds-duplicate": "Duplicate time rate. Each time interval must be unique.", "rate-limits": "Rate limits", "remove-limit": "Remove limit", "transport-tenant-msg": "Transport tenant messages", @@ -5865,6 +5997,7 @@ "date": "Date", "show-date-time-interval": "Show date time interval", "show-date-time-interval-hint": "Show date time interval according to the data aggregation.", + "hide-zero-tooltip-values": "Hide zero values", "background-color": "Background color", "background-blur": "Background blur" }, @@ -5924,7 +6057,7 @@ "energy-density": "Energy density", "force": "Force", "frequency": "Frequency", - "fuel-efficiency": "fuel efficiency", + "fuel-efficiency": "Fuel efficiency", "heat-capacity": "Heat capacity", "illuminance": "Illuminance", "inductance": "Inductance", @@ -5992,9 +6125,9 @@ "foot-us": "Foot (US survey)", "yard": "Yard", "mile": "Mile", - "nautical-mile": "Nautical Mile", - "astronomical-unit": "Astronomical Unit", - "reciprocal-metre": "Reciprocal Metre", + "nautical-mile": "Nautical mile", + "astronomical-unit": "Astronomical unit", + "reciprocal-metre": "Reciprocal metre", "meter-per-meter": "Meter per meter", "steradian": "Steradian", "thou": "Thou", @@ -6024,24 +6157,24 @@ "quarter": "Quarter", "slug": "Slug", "carat": "Carat", - "cubic-millimeter": "Cubic Millimeter", - "cubic-centimeter": "Cubic Centimeter", - "cubic-meter": "Cubic Meter", - "cubic-kilometer": "Cubic Kilometer", + "cubic-millimeter": "Cubic millimeter", + "cubic-centimeter": "Cubic centimeter", + "cubic-meter": "Cubic meter", + "cubic-kilometer": "Cubic kilometer", "microliter": "Microliter", "milliliter": "Milliliter", "liter": "Liter", "hectoliter": "Hectolitre", - "cubic-inch": "Cubic Inch", - "cubic-foot": "Cubic Foot", - "cubic-yard": "Cubic Yard", - "fluid-ounce": "Fluid Ounce", - "fluid-ounce-per-second": "Fluid Ounce per second", + "cubic-inch": "Cubic inch", + "cubic-foot": "Cubic foot", + "cubic-yard": "Cubic yard", + "fluid-ounce": "Fluid ounce", + "fluid-ounce-per-second": "Fluid ounce per second", "pint": "Pint", "quart": "Quart", "gallon": "Gallon", - "oil-barrels": "Oil Barrel", - "cubic-meter-per-kilogram": "Cubic Meter per Kilogram", + "oil-barrels": "Oil barrel", + "cubic-meter-per-kilogram": "Cubic meter per kilogram", "gill": "Gill", "hogshead": "Hogshead", "teaspoon": "Teaspoon", @@ -6052,14 +6185,16 @@ "rankine": "Rankine", "fahrenheit": "Fahrenheit", "percent": "Percent", - "meter-per-second": "Meter per Second", - "kilometer-per-hour": "Kilometer per Hour", - "foot-per-second": "Foot per Second", - "foot-per-minute": "Foot per Minute", - "mile-per-hour": "Mile per Hour", + "meter-per-second": "Meter per second", + "kilometer-per-hour": "Kilometer per hour", + "foot-per-second": "Foot per second", + "foot-per-minute": "Foot per minute", + "mile-per-hour": "Mile per hour", "knot": "Knot", - "inch-per-hour": "Inch per Hour", + "inch-per-second": "Inch per second", + "inch-per-hour": "Inch per hour", "millimeters-per-minute": "Millimeters per minute", + "meter-per-minute": "Meter per minute", "kilometer-per-hour-squared": "Kilometer per hour squared", "foot-per-second-squared": "Foot per second squared", "pascal": "Pascal", @@ -6075,18 +6210,18 @@ "inch-pounds": "Inch-pounds", "newton-per-meter": "Newton per meter", "atmospheres": "Atmospheres", - "pounds-per-square-inch": "Pounds per Square Inch", - "kilopound-per-square-inch": "Kilopound per Square Inch", + "pounds-per-square-inch": "Pounds per square inch", + "kilopound-per-square-inch": "Kilopound per square inch", "torr": "Torr", - "inches-of-mercury": "Inches of Mercury", - "pascal-per-square-meter": "Pascal per Square Meter", - "pound-per-square-inch": "Pound per Square Inch", - "newton-per-square-meter": "Newton per Square Meter", - "kilogram-force-per-square-meter": "Kilogram-force per Square Meter", - "pascal-per-square-centimeter": "Pascal per Square Centimeter", - "ton-force-per-square-inch": "Ton-force per Square Inch", - "kilonewton-per-square-meter": "Kilonewton per Square Meter", - "newton-per-square-millimeter": "Newton per Square Millimeter", + "inches-of-mercury": "Inches of mercury", + "pascal-per-square-meter": "Pascal per square meter", + "pound-per-square-inch": "Pound per square inch", + "newton-per-square-meter": "Newton per square meter", + "kilogram-force-per-square-meter": "Kilogram-force per square meter", + "pascal-per-square-centimeter": "Pascal per square centimeter", + "ton-force-per-square-inch": "Ton-force per square inch", + "kilonewton-per-square-meter": "Kilonewton per square meter", + "newton-per-square-millimeter": "Newton per square millimeter", "microjoule": "Microjoule", "millijoule": "Millijoule", "joule": "Joule", @@ -6100,29 +6235,31 @@ "megawatt-hour": "Megawatt-hour", "gigawatt-hour": "Gigawatt-hour", "electron-volts": "Electron volts", - "joules-per-coulomb": "Joules per Coulomb", - "british-thermal-unit": "British Thermal Units", + "joules-per-coulomb": "Joules per coulomb", + "british-thermal-unit": "British thermal units", + "thousand-british-thermal-unit": "Thousand British thermal units", + "million-british-thermal-unit": "Million British thermal units", "foot-pound": "Foot-pound", "calorie": "Calorie", - "small-calorie": "Small Calorie", + "small-calorie": "Small calorie", "kilocalorie": "Kilocalorie", - "joule-per-kelvin": "Joule per Kelvin", - "joule-per-kilogram-kelvin": "Joule per Kilogram-Kelvin", - "joule-per-kilogram": "Joule per Kilogram", - "watt-per-meter-kelvin": "Watt per Meter-Kelvin", - "joule-per-cubic-meter": "Joule per Cubic Meter", + "joule-per-kelvin": "Joule per kelvin", + "joule-per-kilogram-kelvin": "Joule per kilogram-kelvin", + "joule-per-kilogram": "Joule per kilogram", + "watt-per-meter-kelvin": "Watt per meter-kelvin", + "joule-per-cubic-meter": "Joule per cubic meter", "therm": "Therm", - "electric-dipole-moment": "Electric Dipole Moment", - "magnetic-dipole-moment": "Magnetic Dipole Moment", + "electric-dipole-moment": "Electric dipole moment", + "magnetic-dipole-moment": "Magnetic dipole moment", "debye": "Debye", - "coulomb-per-square-meter-per-volt": "Coulomb per Square Meter per Volt", + "coulomb-per-square-meter-per-volt": "Coulomb per square meter per volt", "milliwatt": "Milliwatt", "microwatt": "Microwatt", "watt": "Watt", "kilowatt": "Kilowatt", "megawatt": "Megawatt", "gigawatt": "Gigawatt", - "metric-horsepower": "Metric Horsepower", + "metric-horsepower": "Metric horsepower", "milliwatt-per-square-centimeter": "Milliwatts per square centimeter", "watt-per-square-centimeter": "Watts per square centimeter", "kilowatt-per-square-centimeter": "Kilowatts per square centimeter", @@ -6132,30 +6269,37 @@ "watt-per-square-inch": "Watts per square inch", "kilowatt-per-square-inch": "Kilowatts per square inch", "horsepower": "Horsepower", - "btu-per-hour": "British thermal units/hour", - "btu-per-second": "British thermal units/second", - "foot-pound-per-second": "foot-pound per second", + "btu-per-hour": "British thermal units per hour", + "btu-per-second": "British thermal units per second", + "btu-per-day": "British thermal units per day", + "mbtu-per-hour": "Thousand British thermal units per hour", + "mbtu-per-second": "Thousand British thermal units per second", + "mbtu-per-day": "Thousand British thermal units per day", + "mmbtu-per-hour": "Million British thermal units per hour", + "mmbtu-per-second": "Million British thermal units per second", + "mmbtu-per-day": "Million British thermal units per day", + "foot-pound-per-second": "Foot-pound per second", "coulomb": "Coulomb", "millicoulomb": "Millicoulombs", "microcoulomb": "Microcoulomb", "nanocoulomb": "Nanocoulomb", "picocoulomb": "Picocoulomb", "coulomb-per-meter": "Coulomb per meter", - "coulomb-per-cubic-meter": "Coulomb per Cubic Meter", - "coulomb-per-square-meter": "Coulomb per Square Meter", - "square-millimeter": "Square Millimeter", - "square-centimeter": "Square Centimeter", - "square-meter": "Square Meter", + "coulomb-per-cubic-meter": "Coulomb per cubic meter", + "coulomb-per-square-meter": "Coulomb per square meter", + "square-millimeter": "Square millimeter", + "square-centimeter": "Square centimeter", + "square-meter": "Square meter", "hectare": "Hectare", - "square-kilometer": "Square Kilometer", - "square-inch": "Square Inch", - "square-foot": "Square Foot", - "square-yard": "Square Yard", + "square-kilometer": "Square kilometer", + "square-inch": "Square inch", + "square-foot": "Square foot", + "square-yard": "Square yard", "acre": "Acre", - "square-mile": "Square Mile", + "square-mile": "Square mile", "are": "Are", "barn": "Barn", - "circular-inch": "Circular Inch", + "circular-inch": "Circular inch", "milliampere-hour": "Milliampere-hour", "ampere-hours": "Ampere-hours", "kiloampere-hours": "Kiloampere-hours", @@ -6168,11 +6312,11 @@ "megaampere": "Megaampere", "gigaampere": "Gigaampere", "microampere-per-square-centimeter": "Microampere per square centimeter", - "ampere-per-square-meter": "Ampere per Square Meter", - "ampere-per-meter": "Ampere per Meter", + "ampere-per-square-meter": "Ampere per square meter", + "ampere-per-meter": "Ampere per meter", "oersted": "Oersted", - "bohr-magneton": "Bohr Magneton", - "ampere-meter-squared": "Ampere-Meter Squared", + "bohr-magneton": "Bohr magneton", + "ampere-meter-squared": "Ampere-meter squared", "nanovolt": "Nanovolt", "picovolt": "Picovolt", "millivolt": "Millivolts", @@ -6180,14 +6324,14 @@ "volt": "Volt", "kilovolt": "Kilovolt", "megavolt": "Megavolt", - "dbmV": "dBmV", - "dbm": "dBm", - "volt-meter": "Volt-Meter", - "kilovolt-meter": "Kilovolt-Meter", - "megavolt-meter": "Megavolt-Meter", - "microvolt-meter": "Microvolt-Meter", - "millivolt-meter": "Millivolt-Meter", - "nanovolt-meter": "Nanovolt-Meter", + "dbmV": "Decibel volt", + "dbm": "Decibel-milliwatts", + "volt-meter": "Volt-meter", + "kilovolt-meter": "Kilovolt-meter", + "megavolt-meter": "Megavolt-meter", + "microvolt-meter": "Microvolt-meter", + "millivolt-meter": "Millivolt-meter", + "nanovolt-meter": "Nanovolt-meter", "ohm": "Ohm", "microohm": "Microohm", "milliohm": "Milliohm", @@ -6200,7 +6344,7 @@ "megahertz": "Megahertz", "gigahertz": "Gigahertz", "terahertz": "Terahertz", - "rpm": "Revolutions Per Minute", + "rpm": "Revolutions per minute", "candela-per-square-meter": "Candela per square meter", "candela": "Candela", "lumen": "Lumen", @@ -6212,17 +6356,17 @@ "lumens-per-watt": "Lumens per watt", "mole": "Mole", "nanomole": "Nanomole", - "micromole": "MicroMole", + "micromole": "Micromole", "millimole": "Millimole", "kilomole": "Kilomole", - "mole-per-cubic-meter": "Mole per Cubic Meter", - "rssi": "RSSI", - "ppm": "Parts Per Million", - "ppb": "Parts Per Billion", - "micrograms-per-cubic-meter": "Micrograms per Cubic Meter", - "aqi": "AQI", + "mole-per-cubic-meter": "Mole per cubic meter", + "rssi": "Received signal strength indicator", + "ppm": "Parts per million", + "ppb": "Parts per billion", + "micrograms-per-cubic-meter": "Micrograms per cubic meter", + "aqi": "Aqi", "gram-per-cubic-meter": "Gram per cubic meter", - "gram-per-kilogram": "Specific Humidity", + "gram-per-kilogram": "Specific humidity", "millimeters-per-second": "Millimeters per second", "neper": "Neper", "bel": "Bel", @@ -6233,7 +6377,7 @@ "gray": "Gray", "sievert": "Sievert", "roentgen": "Roentgen", - "cps": "Counts per Second", + "cps": "Counts per second", "rad": "Rad", "rem": "Rem", "dps": "Disintegrations per second", @@ -6243,10 +6387,10 @@ "curies-per-liter": "Curies per liter", "becquerels-per-second": "Becquerels per second", "curies-per-second": "Curies per second", - "gy-per-second": "Gray per Second", - "watt-per-steradian": "Watt per Steradian", - "watt-per-square-metre-steradian": "Watt per Square Metre-Steradian", - "ph-level": "pH Level", + "gy-per-second": "Gray per second", + "watt-per-steradian": "Watt per steradian", + "watt-per-square-metre-steradian": "Watt per square metre-steradian", + "ph-level": "Ph level", "turbidity": "Turbidity", "mg-per-liter": "Milligrams per liter", "microsiemens-per-centimeter": "Microsiemens per centimeter", @@ -6272,9 +6416,9 @@ "milligrams-per-deciliter": "Milligrams per deciliter", "g-force": "G-force", "kilonewton": "Kilonewton", - "kilogram-force": "Kilogram-Force", - "pound-force": "Pound-Force", - "kilopound-force": "Kilopound-Force", + "kilogram-force": "Kilogram-force", + "pound-force": "Pound-force", + "kilopound-force": "Kilopound-force", "dyne": "Dyne", "poundal": "Poundal", "kip": "Kip", @@ -6284,7 +6428,7 @@ "atmosphere": "Atmosphere", "millibars": "Millibars", "inch-of-mercury": "One inch of mercury", - "richter-scale": "Richter Scale", + "richter-scale": "Richter scale", "nanosecond": "Nanosecond", "microsecond": "Microsecond", "millisecond": "Millisecond", @@ -6295,12 +6439,12 @@ "week": "Week", "month": "Month", "year": "Year", - "cubic-foot-per-minute": "Cubic Foot Per Minute", - "cubic-meters-per-hour": "Cubic Meters Per Hour", - "cubic-meters-per-second": "Cubic Meters Per Second", - "liter-per-second": "Liter Per Second", - "liter-per-minute": "Liter Per Minute", - "gallons-per-minute": "Gallons Per Minute", + "cubic-foot-per-minute": "Cubic foot per minute", + "cubic-meters-per-hour": "Cubic meters per hour", + "cubic-meters-per-second": "Cubic meters per second", + "liter-per-second": "Liter per second", + "liter-per-minute": "Liter per minute", + "gallons-per-minute": "Gallons per minute", "cubic-foot-per-second": "Cubic foot per second", "milliliters-per-minute": "Milliliters per minute", "cubic-decimeter-per-second": "Cubic decimeter per second", @@ -6345,7 +6489,7 @@ "megafarad": "Megafarad", "gigafarad": "Gigafarad", "terfarad": "Terfarad", - "farad-per-meter": "Farad per Meter", + "farad-per-meter": "Farad per meter", "tesla": "Tesla", "gauss": "Gauss", "kilogauss": "Kilogauss", @@ -6354,7 +6498,7 @@ "nanotesla": "Nanotesla", "kilotesla": "Kilotesla", "megatesla": "Megatesla", - "millitesla-square-meters": "millitesla square meters", + "millitesla-square-meters": "Millitesla square meters", "gamma": "Gamma", "lambda": "Lambda", "square-meter-per-second": "Square meter per second", @@ -6373,36 +6517,36 @@ "kilogram-per-meter-second": "Kilogram per meter-second", "tesla-square-meters": "Tesla square meters", "maxwell": "Maxwell", - "tesla-per-meter": "Tesla per Meter", - "gauss-per-centimeter": "Gauss per Centimeter", + "tesla-per-meter": "Tesla per meter", + "gauss-per-centimeter": "Gauss per centimeter", "weber": "Weber", "microweber": "Microweber", "milliweber": "Milliweber", - "gauss-square-centimeter": "Gauss-Square Centimeter", - "kilogauss-square-centimeter": "Kilogauss-Square Centimeter", + "gauss-square-centimeter": "Gauss-square centimeter", + "kilogauss-square-centimeter": "Kilogauss-square centimeter", "henry": "Henry", "millihenry": "Millihenry", "microhenry": "Microhenry", "nanohenry": "Nanohenry", - "henry-per-meter": "Henry per Meter", - "tesla-meter-per-ampere": "Tesla Meter per Ampere", - "gauss-per-oersted": "Gauss per Oersted", + "henry-per-meter": "Henry per meter", + "tesla-meter-per-ampere": "Tesla meter per ampere", + "gauss-per-oersted": "Gauss per oersted", "kilogram-per-mole": "Kilogram per mole", "gram-per-mole": "Gram per mole", "milligram-per-mole": "Milligram per mole", - "joule-per-mole": "Joule per Mole", - "joule-per-mole-kelvin": "Joule per Mole-Kelvin", + "joule-per-mole": "Joule per mole", + "joule-per-mole-kelvin": "Joule per mole-kelvin", "millivolts-per-meter": "Millivolts per meter", "volts-per-meter": "Volts per meter", "kilovolts-per-meter": "Kilovolts per meter", "radian-per-second": "Radian per second", "radian-per-second-squared": "Radian per second squared", "revolutions-per-minute-per-second": "Angular acceleration", - "deg-per-second": "deg/s", + "deg-per-second": "Degrees per second", "rotation-per-minute": "Rotation per minute", - "degrees-brix": "Degrees Brix", + "degrees-brix": "Degrees brix", "katal": "Katal", - "katal-per-cubic-metre": "Katal per Cubic Metre", + "katal-per-cubic-metre": "Katal per cubic metre", "paris-inch": "Paris inch" }, "user": { @@ -7947,6 +8091,18 @@ "fill-area-opacity": "Fill area opacity", "range-chart-style": "Range chart style" }, + "knob": { + "behavior": "Behavior", + "initial-value": "Initial value", + "initial-value-hint": "Action to get the initial value of the knob.", + "on-value-change": "On value change", + "on-value-change-hint": "Action triggered when the knob value is changed.", + "range": "Range", + "min": "min", + "max": "max", + "value": "Value", + "fallback-initial-value": "Fallback initial value" + }, "rpc": { "value-settings": "Value settings", "initial-value": "Initial value", @@ -8003,9 +8159,7 @@ "led-status-value-timeseries": "Device time series containing led status value", "check-status-method": "RPC check device status method", "parse-led-status-value-function": "Parse led status value function", - "knob-title": "Knob title", - "min-value": "Minimum value", - "max-value": "Maximum value" + "knob-title": "Knob title" }, "maps": { "map-type": { @@ -8860,7 +9014,11 @@ "radar-axis": "Radar axis", "axis-label": "Axis label", "ticks-label": "Ticks label", - "radar-chart-style": "Radar chart style" + "radar-chart-style": "Radar chart style", + "max-axes-scaling": "Max axes scaling", + "max-axes-scaling-hint": "Choose whether each radar axis has its own maximum value (Separate) or shares the highest value across all axes based on the widget dataset (Common).", + "separate": "Separate", + "common": "Common" }, "time-series-chart": { "chart": "Chart", @@ -9390,6 +9548,8 @@ "lt_LT": "lietuvių (Lietuva)", "lv_LV": "latviešu (Latvija)", "nl_BE": "Nederlands (België)", + "nl_NL": "Nederlands (Nederland)", + "no_NO": "norsk (Norge)", "pl_PL": "polski (Polska)", "pt_BR": "português (Brasil)", "ro_RO": "română (România)", 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 bb6f70c2aa..daab8822fd 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -5640,7 +5640,6 @@ "add-limit": "Agregar límite", "advanced-settings": "Configuraciones avanzadas", "edit-limit": "Editar límite", - "but-less-than": "pero menor que", "calculated-field-debug-event-rate-limit": "Eventos de depuración de campo calculado", "edit-calculated-field-debug-event-rate-limit": "Editar límites de eventos de depuración de campo calculado", "edit-transport-tenant-msg-title": "Editar límites de velocidad de mensajes de transporte del inquilino", @@ -9192,33 +9191,6 @@ "items-per-page-separator": "de" }, "language": { - "language": "Language", - "locales": { - "ar_AE": "العربية (الإمارات العربية المتحدة)", - "ca_ES": "català (Espanya)", - "cs_CZ": "čeština (Česko)", - "da_DK": "dansk (Danmark)", - "de_DE": "Deutsch (Deutschland)", - "el_GR": "Ελληνικά (Ελλάδα)", - "en_US": "English (United States)", - "es_ES": "español (España)", - "fa_IR": "فارسی (ایران)", - "fr_FR": "français (France)", - "it_IT": "italiano (Italia)", - "ja_JP": "日本語 (日本)", - "ka_GE": "ქართული (საქართველო)", - "ko_KR": "한국어 (대한민국)", - "lt_LT": "lietuvių (Lietuva)", - "lv_LV": "latviešu (Latvija)", - "nl_BE": "Nederlands (België)", - "pl_PL": "polski (Polska)", - "pt_BR": "português (Brasil)", - "ro_RO": "română (România)", - "sl_SI": "slovenščina (Slovenija)", - "tr_TR": "Türkçe (Türkiye)", - "uk_UA": "українська (Україна)", - "zh_CN": "中文 (中国)", - "zh_TW": "中文 (台灣)" - } + "language": "Idioma" } -} \ No newline at end of file +} 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 07739906bb..d715c60187 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -1979,7 +1979,7 @@ "alarm-rule-additional-info": "Informations supplémentaires", "edit-alarm-rule-additional-info": "Modifier les informations supplémentaires", "alarm-rule-additional-info-placeholder": "Ajoutez ici vos commentaires ou ajustements à afficher dans les détails de l'alarme", - "alarm-rule-additional-info-hint": "Astuce : utilisez code>${keyName} pour insérer les valeurs des clés utilisées dans la condition.", + "alarm-rule-additional-info-hint": "Astuce : utilisez ${keyName} pour insérer les valeurs des clés utilisées dans la condition.", "alarm-rule-mobile-dashboard": "Tableau de bord mobile", "alarm-rule-mobile-dashboard-hint": "Utilisé comme tableau de bord des détails de l'alarme dans l'application mobile", "alarm-rule-no-mobile-dashboard": "Aucun tableau de bord sélectionné", @@ -4783,7 +4783,7 @@ "from-metadata": "Depuis les métadonnées", "to-template": "À", "to-template-required": "Le champ À est requis", - "mail-address-list-template-hint": "Liste d’adresses séparées par des virgules, utilisez ${metadataKey} pour les métadonnées, $[messageKey] pour le corps du message.", + "mail-address-list-template-hint": "Liste d’adresses séparées par des virgules, utilisez ${metadataKey} pour une valeur depuis les métadonnées, $[messageKey] pour une valeur depuis le corps du message", "cc-template": "Cc", "bcc-template": "Cci", "subject-template": "Objet", @@ -4816,7 +4816,7 @@ "max-response-size": "Taille maximale de réponse (en Ko)", "max-response-size-hint": "Quantité maximale de mémoire utilisée pour le tampon lors du décodage/encodage des messages HTTP comme les charges JSON ou XML", "headers": "En-têtes", - "headers-hint": "Utilisez ${metadataKey} pour une valeur des métadonnées, $[messageKey] pour une valeur du corps du message", + "headers-hint": "Utilisez ${metadataKey} pour une valeur provenant des métadonnées, $[messageKey] pour une valeur provenant du corps du message dans les champs en-tête/valeur", "header": "En-tête", "header-required": "L'en-tête est requis", "value": "Valeur", @@ -4884,7 +4884,7 @@ "pubsub-topic-name": "Nom du sujet", "pubsub-topic-name-required": "Le nom du sujet est requis", "message-attributes": "Attributs du message", - "message-attributes-hint": "Utilisez ${metadataKey} pour une valeur depuis les métadonnées, $[messageKey] depuis le message", + "message-attributes-hint": "Utilisez ${metadataKey} pour une valeur provenant des métadonnées, $[messageKey] pour une valeur provenant du corps du message dans les champs nom/valeur", "connect-timeout": "Délai de connexion (s)", "connect-timeout-required": "Le délai de connexion est requis.", "connect-timeout-range": "Le délai de connexion doit être compris entre 1 et 200.", @@ -4964,7 +4964,7 @@ "proxy-scheme": "Schéma de proxy", "numbers-to-template": "Numéros de téléphone vers modèle", "numbers-to-template-required": "Le modèle de numéros de téléphone est requis", - "numbers-to-template-hint": "Liste de numéros séparés par des virgules. Utilisez ${metadataKey} ou $[messageKey].", + "numbers-to-template-hint": "Numéros de téléphone séparés par des virgules, utilisez ${metadataKey} pour une valeur provenant des métadonnées, $[messageKey] pour une valeur provenant du corps du message", "sms-message-template": "Modèle de message SMS", "sms-message-template-required": "Le modèle de message SMS est requis", "use-system-sms-settings": "Utiliser les paramètres SMS système", @@ -5079,7 +5079,7 @@ "period-value-key": "Clé de la période", "period-value-key-required": "La clé de la période est requise.", "general-pattern-hint": "Utilisez ${metadataKey} pour les métadonnées, $[messageKey] pour le corps du message.", - "alarm-severity-pattern-hint": "Utilisez ${metadataKey} pour les métadonnées, $[messageKey] pour le corps du message. Les niveaux doivent être CRITICAL, MAJOR, etc.", + "alarm-severity-pattern-hint": "Utilisez ${metadataKey} pour une valeur provenant des métadonnées, $[messageKey] pour une valeur provenant du corps du message. La gravité de l'alarme doit être un niveau système (CRITICAL, MAJOR, etc.)", "output-node-name-hint": "Le nom du nœud correspond au type de relation du message de sortie.", "use-server-ts": "Utiliser l’horodatage serveur", "use-server-ts-hint": "Utilise le temps actuel du serveur pour les séries temporelles sans horodatage explicite.", @@ -5640,7 +5640,6 @@ "add-limit": "Ajouter une limite", "advanced-settings": "Paramètres avancés", "edit-limit": "Modifier la limite", - "but-less-than": "mais inférieur à", "calculated-field-debug-event-rate-limit": "Événements de débogage des champs calculés", "edit-calculated-field-debug-event-rate-limit": "Modifier la limite des événements de débogage de champ calculé", "edit-transport-tenant-msg-title": "Modifier les limites de messages de transport du locataire", @@ -6435,8 +6434,8 @@ "tidy": "Nettoyer", "css": "CSS", "settings-form": "Formulaire de paramètres", - "data-key-settings-form": "Formulaire de paramètres de clé de données", - "latest-data-key-settings-form": "Formulaire de paramètres des dernières données", + "data-key-settings-form": "Formulaire des paramètres de clé de données", + "latest-data-key-settings-form": "Formulaire des paramètres de la dernière clé de données", "widget-settings": "Paramètres du widget", "description": "Description", "tags": "Étiquettes", @@ -7687,7 +7686,7 @@ }, "label-widget": { "label-pattern": "Modèle", - "label-pattern-hint": "Astuce : par ex. 'Texte ${keyName} unités.' ou ${#<key index>} unités'", + "label-pattern-hint": "Astuce: par ex. 'Texte ${keyName} unités.' ou ${#<key index>} unités", "label-pattern-required": "Le modèle est requis", "label-position": "Position (pourcentage par rapport à l’arrière-plan)", "x-pos": "X", @@ -9192,33 +9191,6 @@ "items-per-page-separator": "sur" }, "language": { - "language": "Langue", - "locales": { - "ar_AE": "العربية (الإمارات العربية المتحدة)", - "ca_ES": "català (Espanya)", - "cs_CZ": "čeština (Česko)", - "da_DK": "dansk (Danmark)", - "de_DE": "Deutsch (Deutschland)", - "el_GR": "Ελληνικά (Ελλάδα)", - "en_US": "English (United States)", - "es_ES": "español (España)", - "fa_IR": "فارسی (ایران)", - "fr_FR": "français (France)", - "it_IT": "italiano (Italia)", - "ja_JP": "日本語 (日本)", - "ka_GE": "ქართული (საქართველო)", - "ko_KR": "한국어 (대한민국)", - "lt_LT": "lietuvių (Lietuva)", - "lv_LV": "latviešu (Latvija)", - "nl_BE": "Nederlands (België)", - "pl_PL": "polski (Polska)", - "pt_BR": "português (Brasil)", - "ro_RO": "română (România)", - "sl_SI": "slovenščina (Slovenija)", - "tr_TR": "Türkçe (Türkiye)", - "uk_UA": "українська (Україна)", - "zh_CN": "中文 (中国)", - "zh_TW": "中文 (台灣)" - } + "language": "Langue" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json index f976a76052..add8432b40 100644 --- a/ui-ngx/src/assets/locale/locale.constant-it_IT.json +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -5640,7 +5640,6 @@ "add-limit": "Aggiungi limite", "advanced-settings": "Impostazioni avanzate", "edit-limit": "Modifica limite", - "but-less-than": "ma inferiore a", "calculated-field-debug-event-rate-limit": "Eventi di debug del campo calcolato", "edit-calculated-field-debug-event-rate-limit": "Modifica i limiti di eventi di debug del campo calcolato", "edit-transport-tenant-msg-title": "Modifica i limiti di messaggi di trasporto del tenant", @@ -7687,7 +7686,7 @@ }, "label-widget": { "label-pattern": "Modello", - "label-pattern-hint": "Suggerimento: ad esempio, 'Testo ${keyName} unità.' oppure ${#<indice chiave>} unità'", + "label-pattern-hint": "Suggerimento: ad esempio, 'Testo ${keyName} unità.' oppure ${#<key index>} unità'", "label-pattern-required": "Il pattern è obbligatorio", "label-position": "Posizione (percentuale rispetto allo sfondo)", "x-pos": "X", @@ -9192,33 +9191,6 @@ "items-per-page-separator": "di" }, "language": { - "language": "Lingua", - "locales": { - "ar_AE": "العربية (الإمارات العربية المتحدة)", - "ca_ES": "català (Espanya)", - "cs_CZ": "čeština (Česko)", - "da_DK": "dansk (Danmark)", - "de_DE": "Deutsch (Deutschland)", - "el_GR": "Ελληνικά (Ελλάδα)", - "en_US": "English (United States)", - "es_ES": "español (España)", - "fa_IR": "فارسی (ایران)", - "fr_FR": "français (France)", - "it_IT": "italiano (Italia)", - "ja_JP": "日本語 (日本)", - "ka_GE": "ქართული (საქართველო)", - "ko_KR": "한국어 (대한민국)", - "lt_LT": "lietuvių (Lietuva)", - "lv_LV": "latviešu (Latvija)", - "nl_BE": "Nederlands (België)", - "pl_PL": "polski (Polska)", - "pt_BR": "português (Brasil)", - "ro_RO": "română (România)", - "sl_SI": "slovenščina (Slovenija)", - "tr_TR": "Türkçe (Türkiye)", - "uk_UA": "українська (Україна)", - "zh_CN": "中文 (中国)", - "zh_TW": "中文 (台灣)" - } + "language": "Lingua" } -} \ No newline at end of file +} 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 d3cc6f6c47..935ae65eb4 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -5131,7 +5131,6 @@ "add-limit": "Add limit", "advanced-settings": "Advanced settings", "edit-limit": "Edit limit", - "but-less-than": "but less than", "edit-transport-tenant-msg-title": "Edit transport tenant messages rate limits", "edit-transport-tenant-telemetry-msg-title": "Edit transport tenant telemetry messages rate limits", "edit-transport-tenant-telemetry-data-points-title": "Edit transport tenant telemetry data points rate limits", 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 f4951582b5..3c8bfbde60 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_BE.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_BE.json @@ -5164,7 +5164,6 @@ "add-limit": "Limiet toevoegen", "advanced-settings": "Geavanceerde instellingen", "edit-limit": "Limiet bewerken", - "but-less-than": "maar minder dan", "edit-transport-tenant-msg-title": "Snelheidslimieten voor berichten van transporttenants bewerken", "edit-transport-tenant-telemetry-msg-title": "Snelheidslimieten voor telemetrie berichten van transporttenants bewerken", "edit-transport-tenant-telemetry-data-points-title": "Snelheidslimieten voor telemetrie gegevenspunten van transporttenants bewerken", @@ -7197,29 +7196,6 @@ "items-per-page-separator": "van" }, "language": { - "language": "Taal", - "locales": { - "ca_ES": "Catalaans", - "cs_CZ": "Česky", - "da_DK": "Dansk", - "de_DE": "Deutsch", - "el_GR": "Ελληνικά", - "en_US": "Engels", - "es_ES": "Español", - "fa_IR": "فارسي", - "fr_FR": "Français", - "it_IT": "Italiano", - "ja_JP": "日本語", - "ka_GE": "ქართული", - "ko_KR": "한국어", - "lv_LV": "Kanton Latviešu", - "pt_BR": "Português do Brasil", - "ro_RO": "Română", - "sl_SI": "Slovenščina", - "tr_TR": "Türkçe", - "uk_UA": "Українська", - "zh_CN": "简体中文", - "zh_TW": "繁體中文" - } + "language": "Taal" } } diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json new file mode 100644 index 0000000000..379f36b9de --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json @@ -0,0 +1,9197 @@ +{ + "access": { + "unauthorized": "Ongeautoriseerd", + "unauthorized-access": "Ongeautoriseerde toegang", + "unauthorized-access-text": "U moet inloggen om toegang tot deze bron te krijgen!", + "access-forbidden": "Toegang verboden", + "access-forbidden-text": "U heeft geen toegangsrechten tot deze locatie!
    Probeer in te loggen met een andere gebruiker als u nog steeds toegang wilt krijgen tot deze locatie.", + "refresh-token-expired": "Sessie is verlopen", + "refresh-token-failed": "Kan sessie niet vernieuwen", + "permission-denied": "Toestemming geweigerd", + "permission-denied-text": "U heeft geen toestemming om deze handeling uit te voeren!" + }, + "account": { + "account": "Account", + "notification-settings": "Meldingsinstellingen" + }, + "action": { + "activate": "Activeren", + "suspend": "Opschorten", + "save": "Opslaan", + "saveAs": "Opslaan als", + "move": "Verplaatsen", + "cancel": "Annuleren", + "ok": "OK", + "delete": "Verwijderen", + "add": "Toevoegen", + "yes": "Ja", + "no": "Nee", + "update": "Bijwerken", + "remove": "Verwijderen", + "search": "Zoeken", + "clear-search": "Zoekopdracht wissen", + "assign": "Toewijzen", + "unassign": "Toewijzing ongedaan maken", + "share": "Delen", + "make-private": "Privé maken", + "apply": "Toepassen", + "apply-changes": "Wijzigingen toepassen", + "edit-mode": "Bewerkmodus", + "enter-edit-mode": "Bewerkmodus starten", + "decline-changes": "Wijzigingen afwijzen", + "decline": "Afwijzen", + "close": "Sluiten", + "back": "Terug", + "run": "Uitvoeren", + "sign-in": "Inloggen!", + "edit": "Bewerken", + "view": "Bekijken", + "create": "Aanmaken", + "drag": "Slepen", + "refresh": "Vernieuwen", + "undo": "Ongedaan maken", + "copy": "Kopiëren", + "paste": "Plakken", + "copy-reference": "Referentie kopiëren", + "paste-reference": "Referentie plakken", + "import": "Importeren", + "export": "Exporteren", + "share-via": "Delen via {{provider}}", + "select": "Selecteren", + "continue": "Doorgaan", + "discard-changes": "Wijzigingen negeren", + "download": "Downloaden", + "next": "Volgende", + "next-with-label": "Volgende: {{label}}", + "read-more": "Meer lezen", + "hide": "Verbergen", + "test": "Testen", + "done": "Gereed", + "print": "Afdrukken", + "restore": "Herstellen", + "confirm": "Bevestigen", + "more": "Meer", + "less": "Minder", + "skip": "Overslaan", + "send": "Verzenden", + "reset": "Resetten", + "show-more": "Meer weergeven", + "dont-show-again": "Niet opnieuw weergeven", + "see-documentation": "Bekijk documentatie", + "clear": "Wissen", + "upload": "Uploaden", + "delete-anyway": "Toch verwijderen", + "delete-selected": "Geselecteerde verwijderen", + "set": "Instellen" + }, + "aggregation": { + "aggregation": "Aggregatie", + "function": "Functie voor gegevensaggregatie", + "limit": "Maximale waarden", + "group-interval": "Groepeerinterval", + "min": "Min", + "max": "Max", + "avg": "Gemiddelde", + "sum": "Som", + "count": "Aantal", + "none": "Geen" + }, + "admin": { + "settings": "Instellingen", + "general": "Algemeen", + "general-settings": "Algemene instellingen", + "home-settings": "Startpagina-instellingen", + "home": "Startpagina", + "outgoing-mail": "Mailserver", + "outgoing-mail-settings": "Instellingen uitgaande mailserver", + "system-settings": "Systeeminstellingen", + "test-mail-sent": "Testmail is succesvol verzonden!", + "base-url": "Basis-URL", + "base-url-required": "Basis-URL is verplicht.", + "prohibit-different-url": "Gebruik van hostnaam uit clientaanvraagheaders verbieden", + "prohibit-different-url-hint": "Deze instelling moet ingeschakeld zijn in productieomgevingen. Uitschakelen kan beveiligingsrisico’s veroorzaken.", + "device-connectivity": { + "device-connectivity": "Apparaatconnectiviteit", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPS", + "mqtt": "MQTT", + "mqtts": "MQTTS", + "coap": "COAP", + "coaps": "COAPS", + "hint": "Als de velden host of poort leeg zijn, wordt de standaardprotocolwaarde gebruikt.", + "host": "Host", + "port": "Poort", + "port-pattern": "Poort moet een positief geheel getal zijn.", + "port-range": "Poort moet tussen 1 en 65535 liggen." + }, + "mail-from": "Afzenderadres", + "mail-from-required": "Afzenderadres is verplicht.", + "smtp-protocol": "SMTP-protocol", + "smtp-host": "SMTP-host", + "smtp-host-required": "SMTP-host is verplicht.", + "smtp-port": "SMTP-poort", + "smtp-port-required": "U moet een SMTP-poort opgeven.", + "smtp-port-invalid": "Dit lijkt geen geldige SMTP-poort.", + "timeout-msec": "Timeout (msec)", + "timeout-required": "Timeout is verplicht.", + "timeout-invalid": "Dit lijkt geen geldige timeout.", + "enable-tls": "TLS inschakelen", + "tls-version": "TLS-versie", + "enable-proxy": "Proxy inschakelen", + "proxy-host": "Proxyhost", + "proxy-host-required": "Proxyhost is verplicht.", + "proxy-port": "Proxypoort", + "proxy-port-required": "Proxypoort is verplicht.", + "proxy-port-range": "Proxypoort moet tussen 1 en 65535 liggen.", + "proxy-user": "Proxygebruiker", + "proxy-password": "Proxywachtwoord", + "change-password": "Wachtwoord wijzigen", + "send-test-mail": "Testmail verzenden", + "sms-provider": "SMS-provider", + "sms-provider-settings": "Instellingen SMS-provider", + "sms-provider-type": "Type SMS-provider", + "sms-provider-type-required": "Type SMS-provider is verplicht.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID is verplicht", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key is verplicht", + "aws-region": "AWS-regio", + "aws-region-required": "AWS-regio is verplicht", + "number-from": "Afzender telefoonnummer", + "number-from-required": "Afzender telefoonnummer is verplicht.", + "number-to": "Ontvanger telefoonnummer", + "number-to-required": "Ontvanger telefoonnummer is verplicht.", + "phone-number-hint": "Telefoonnummer in E.164-formaat, bijv. +19995550123", + "phone-number-hint-twilio": "Telefoonnummer in E.164-formaat/SID van telefoonnummer/Messaging Service SID, bijv. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Ongeldig telefoonnummer. Moet in E.164-formaat zijn, bijv. +19995550123.", + "phone-number-pattern-twilio": "Ongeldig telefoonnummer. Moet in E.164-formaat/SID van telefoonnummer/Messaging Service SID zijn, bijv. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS-bericht", + "sms-message-required": "SMS-bericht is verplicht.", + "sms-message-max-length": "SMS-bericht mag niet langer zijn dan 1600 tekens", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID is verplicht", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Twilio Account Token is verplicht", + "send-test-sms": "Test-SMS verzenden", + "test-sms-sent": "Test-SMS is succesvol verzonden!", + "security-settings": "Beveiligingsinstellingen", + "password-policy": "Wachtwoordbeleid", + "minimum-password-length": "Minimale wachtwoordlengte", + "minimum-password-length-required": "Minimale wachtwoordlengte is verplicht", + "minimum-password-length-range": "Minimale wachtwoordlengte moet tussen 6 en 50 liggen", + "maximum-password-length": "Maximale wachtwoordlengte", + "maximum-password-length-min": "Maximale wachtwoordlengte moet ten minste 6 zijn", + "maximum-password-length-less-min": "Maximale wachtwoordlengte moet groter zijn dan minimale lengte", + "minimum-uppercase-letters": "Minimaal aantal hoofdletters", + "minimum-uppercase-letters-range": "Minimaal aantal hoofdletters mag niet negatief zijn", + "minimum-lowercase-letters": "Minimaal aantal kleine letters", + "minimum-lowercase-letters-range": "Minimaal aantal kleine letters mag niet negatief zijn", + "minimum-digits": "Minimaal aantal cijfers", + "minimum-digits-range": "Minimaal aantal cijfers mag niet negatief zijn", + "minimum-special-characters": "Minimaal aantal speciale tekens", + "minimum-special-characters-range": "Minimaal aantal speciale tekens mag niet negatief zijn", + "password-expiration-period-days": "Wachtwoordvervalperiode in dagen", + "password-expiration-period-days-range": "Vervaldagen wachtwoord mogen niet negatief zijn", + "password-reuse-frequency-days": "Hergebruikfrequentie wachtwoord in dagen", + "password-reuse-frequency-days-range": "Hergebruikfrequentie wachtwoord in dagen mag niet negatief zijn", + "allow-whitespace": "Spaties toestaan", + "force-reset-password-if-no-valid": "Wachtwoordreset forceren als ongeldig", + "force-reset-password-if-no-valid-hint": "Wees voorzichtig met deze functie: gebruikers met een ongeldig wachtwoord moeten hun wachtwoord via e-mail opnieuw instellen.", + "general-policy": "Algemeen beleid", + "max-failed-login-attempts": "Maximaal aantal mislukte inlogpogingen voordat account wordt vergrendeld", + "minimum-max-failed-login-attempts-range": "Maximum aantal mislukte pogingen mag niet negatief zijn", + "user-lockout-notification-email": "Stuur e-mailmelding bij accountvergrendeling", + "user-activation-token-ttl": "Levensduur activatielink gebruiker in uren", + "user-activation-token-ttl-range": "Levensduur activatielink moet tussen 1 en 24 uur liggen", + "password-reset-token-ttl": "Levensduur wachtwoordherstellink in uren", + "password-reset-token-ttl-range": "Levensduur herstellink moet tussen 1 en 24 uur liggen", + "mobile-secret-key-length": "Lengte mobiele geheime sleutel", + "mobile-secret-key-length-range": "Lengte van mobiele geheime sleutel moet positief zijn", + "domain-name": "Domeinnaam", + "domain-name-unique": "Domeinnaam en protocol moeten uniek zijn.", + "domain-name-max-length": "Domeinnaam moet minder dan 256 tekens bevatten", + "error-verification-url": "Domeinnaam mag geen '/' of ':' bevatten. Voorbeeld: thingsboard.io", + "connection-settings": "Verbindingsinstellingen", + "oauth2": { + "access-token-uri": "Access token URI", + "access-token-uri-required": "Access token URI is verplicht.", + "activate-user": "Gebruiker activeren", + "add-domain": "Domein toevoegen", + "delete-domain": "Domein verwijderen", + "add-provider": "Provider toevoegen", + "delete-provider": "Provider verwijderen", + "allow-user-creation": "Gebruikerscreatie toestaan", + "always-fullscreen": "Altijd volledig scherm", + "authorization-uri": "Autorisatie URI", + "authorization-uri-required": "Autorisatie URI is verplicht.", + "add-client": "OAuth 2.0-client toevoegen", + "client-details": "OAuth 2.0-clientdetails", + "client": "OAuth 2.0-client", + "clients": "OAuth 2.0-clients", + "no-oauth2-clients": "Geen OAuth 2.0-clients gevonden", + "search-oauth2-clients": "Zoek OAuth 2.0-clients", + "delete-client-title": "Weet u zeker dat u de OAuth 2.0-client '{{clientName}}' wilt verwijderen?", + "delete-client-text": "Let op: na bevestiging worden de client en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-mobile-app-title": "Weet u zeker dat u de mobiele applicatie '{{applicationName}}' wilt verwijderen?", + "delete-mobile-app-text": "Let op: na bevestiging worden de mobiele applicatie en alle gerelateerde gegevens onherstelbaar verwijderd.", + "title": "Titel", + "client-title-required": "Titel is verplicht", + "client-title-max-length": "Titel mag maximaal 100 tekens bevatten", + "advanced-settings": "Geavanceerde instellingen", + "domain-details": "Domeindetails", + "no-domains": "Geen domeinen gevonden", + "search-domains": "Zoek domeinen", + "mobile-app-details": "Details mobiele applicatie", + "add-mobile-app": "Mobiele applicatie toevoegen", + "no-mobile-apps": "Geen mobiele applicaties gevonden", + "search-mobile-apps": "Zoek mobiele applicaties", + "send-token": "Token verzenden", + "create-new": "Nieuw aanmaken", + "client-authentication-method": "Authenticatiemethode client", + "client-id": "Client ID", + "client-id-required": "Client ID is verplicht.", + "client-id-max-length": "Client ID mag maximaal 256 tekens bevatten", + "client-secret": "Clientgeheim", + "client-secret-required": "Clientgeheim is verplicht.", + "client-secret-max-length": "Clientgeheim mag maximaal 2049 tekens bevatten", + "custom-setting": "Aangepaste instellingen", + "customer-name-pattern": "Naam patroon klant", + "customer-name-pattern-max-length": "Naam patroon klant mag maximaal 256 tekens bevatten", + "default-dashboard-name": "Standaard dashboardnaam", + "default-dashboard-name-max-length": "Standaard dashboardnaam mag maximaal 256 tekens bevatten", + "delete-domain-text": "Let op: na bevestiging worden het domein en alle providergegevens ontoegankelijk.", + "delete-domain-title": "Weet u zeker dat u het domein '{{domainName}}' wilt verwijderen?", + "delete-registration-text": "Let op: na bevestiging worden de gegevens van de provider ontoegankelijk.", + "delete-registration-title": "Weet u zeker dat u de provider '{{name}}' wilt verwijderen?", + "email-attribute-key": "Attribuutsleutel e-mailadres", + "email-attribute-key-required": "Attribuutsleutel e-mailadres is verplicht.", + "email-attribute-key-max-length": "Attribuutsleutel e-mailadres mag maximaal 32 tekens bevatten", + "first-name-attribute-key": "Attribuutsleutel voornaam", + "first-name-attribute-key-max-length": "Attribuutsleutel voornaam mag maximaal 32 tekens bevatten", + "general": "Algemeen", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Attribuutsleutel achternaam", + "last-name-attribute-key-max-length": "Attribuutsleutel achternaam mag maximaal 32 tekens bevatten", + "login-button-icon": "Pictogram login-knop", + "login-button-label": "Providerlabel", + "login-button-label-placeholder": "Inloggen met $(Provider label)", + "login-button-label-required": "Label is verplicht.", + "login-provider": "Loginprovider", + "mapper": "Mapper", + "new-domain": "Nieuw domein", + "oauth2": "OAuth 2.0", + "password-max-length": "Wachtwoord mag maximaal 256 tekens bevatten", + "redirect-uri-template": "Redirect URI-sjabloon", + "copy-redirect-uri": "Redirect URI kopiëren", + "registration-id": "Registratie-ID", + "registration-id-required": "Registratie-ID is verplicht.", + "registration-id-unique": "Registratie-ID moet uniek zijn binnen het systeem.", + "scope": "Scope", + "scope-required": "Scope is verplicht.", + "tenant-name-pattern": "Naam patroon tenant", + "tenant-name-pattern-required": "Naam patroon tenant is verplicht.", + "tenant-name-pattern-max-length": "Naam patroon tenant mag maximaal 256 tekens bevatten", + "tenant-name-strategy": "Naamstrategie tenant", + "type": "Mappertype", + "uri-pattern-error": "Ongeldig URI-formaat.", + "url": "URL", + "url-pattern": "Ongeldig URL-formaat.", + "url-required": "URL is verplicht.", + "url-max-length": "URL mag maximaal 256 tekens bevatten", + "user-info-uri": "Gebruikersinformatie URI", + "user-info-uri-required": "Gebruikersinformatie URI is verplicht.", + "username-max-length": "Gebruikersnaam mag maximaal 256 tekens bevatten", + "user-name-attribute-name": "Attribuutsleutel gebruikersnaam", + "user-name-attribute-name-required": "Attribuutsleutel gebruikersnaam is verplicht", + "protocol": "Protocol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "OAuth 2.0-instellingen inschakelen", + "disable": "OAuth 2.0-instellingen uitschakelen", + "edge": "Naar Edge propageren", + "edge-enable": "Propagatie naar Edge inschakelen", + "edge-disable": "Propagatie naar Edge uitschakelen", + "domains": "Domeinen", + "mobile-apps": "Mobiele applicaties", + "mobile-package": "Applicatiepakket", + "mobile-package-placeholder": "Bijv.: my.example.app", + "mobile-package-hint": "Voor Android: uw unieke Applicatie-ID. Voor iOS: Product bundle identifier.", + "mobile-package-unique": "Applicatiepakket moet uniek zijn.", + "mobile-package-required": "Applicatiepakket is verplicht.", + "mobile-package-max-length": "Applicatiepakket mag maximaal 256 tekens bevatten", + "mobile-package-spaces": "Applicatiepakket mag geen spaties bevatten", + "mobile-app-secret": "Applicatiegeheim", + "mobile-app-secret-hint": "Base64-gecodeerde tekenreeks van ten minste 512 bits.", + "mobile-app-secret-required": "Applicatiegeheim is verplicht.", + "mobile-app-secret-min-length": "Applicatiegeheim moet ten minste 512 bits zijn.", + "mobile-app-secret-base64": "Applicatiegeheim moet in base64-formaat zijn.", + "invalid-mobile-app-secret": "Applicatiegeheim mag alleen alfanumerieke tekens bevatten en moet tussen 16 en 2048 tekens lang zijn.", + "copy-mobile-app-secret": "Applicatiegeheim kopiëren", + "delete-mobile-app": "Applicatiegegevens verwijderen", + "providers": "Providers", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Alle platforms", + "smtp-provider": "SMTP-provider", + "allowed-platforms": "Toegestane platforms", + "authentication": "Authenticatie", + "basic": "Basis", + "provider": "Provider", + "redirect-url": "Redirect URI", + "domain-name": "Domeinnaam", + "domain-name-required": "Domeinnaam is verplicht", + "redirect-url-template": "Redirect URI-sjabloon", + "microsoft-tenant-id": "Directory (tenant) ID", + "microsoft-tenant-id-required": "Directory (tenant) ID is verplicht", + "token-uri": "Token URI", + "token-uri-required": "Token URI is verplicht", + "redirect-uri": "Redirect URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Aangepast", + "generate-access-token": "Access token genereren", + "update-access-token": "Access token bijwerken", + "access-token-status": "Status access token:", + "token-status-generated": "gegenereerd", + "token-status-not-generated": "niet gegenereerd" + }, + "smpp-provider": { + "smpp-version": "SMPP-versie", + "smpp-host": "SMPP-host", + "smpp-host-required": "SMPP-host is verplicht", + "smpp-port": "SMPP-poort", + "smpp-port-required": "SMPP-poort is verplicht", + "system-id": "Systeem-ID", + "system-id-required": "Systeem-ID is verplicht", + "password": "Wachtwoord", + "password-required": "Wachtwoord is verplicht", + "type-settings": "Type-instellingen", + "source-settings": "Broninstellingen", + "destination-settings": "Bestemmingsinstellingen", + "additional-settings": "Aanvullende instellingen", + "system-type": "Systeemtype", + "bind-type": "Bindtype", + "service-type": "Servicetype", + "source-address": "Bronadres", + "source-ton": "Bron-TON", + "source-npi": "Bron-NPI", + "destination-ton": "Bestemming TON (Type of Number)", + "destination-npi": "Bestemming NPI (Numbering Plan Identification)", + "address-range": "Adresbereik", + "coding-scheme": "Coderingstype", + "bind-type-tx": "Zender", + "bind-type-rx": "Ontvanger", + "bind-type-trx": "Zender/Ontvanger", + "ton-unknown": "Onbekend", + "ton-international": "Internationaal", + "ton-national": "Nationaal", + "ton-network-specific": "Netwerkspecifiek", + "ton-subscriber-number": "Abonneenummer", + "ton-alphanumeric": "Alfanumeriek", + "ton-abbreviated": "Afgekort", + "npi-unknown": "0 - Onbekend", + "npi-isdn": "1 - ISDN/telefoonnummerplan (E163/E164)", + "npi-data-numbering-plan": "3 - Data-nummerplan (X.121)", + "npi-telex-numbering-plan": "4 - Telex-nummerplan (F.69)", + "npi-land-mobile": "6 - Mobiele landtelefoon (E.212)", + "npi-national-numbering-plan": "8 - Nationaal nummerplan", + "npi-private-numbering-plan": "9 - Privénummerplan", + "npi-ermes-numbering-plan": "10 - ERMES-nummerplan (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - WAP-client-ID (te definiëren door WAP Forum)", + "scheme-smsc": "0 - SMSC Standaardalfabet (ASCII voor korte/lange code en GSM voor gratis nummers)", + "scheme-ia5": "1 - IA5 (ASCII voor korte/lange code, Latin 9 voor gratis nummers (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Octet Ongespecificeerd (8-bit binair)", + "scheme-latin-1": "3 - Latijn 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octet Ongespecificeerd (8-bit binair)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Cyrillisch (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latijns/Hebreeuws (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Pictogramcodering", + "scheme-music-codes": "10 - Muziekcodes (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Uitgebreid Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Koreaanse grafische tekenset (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Selecteer wachtrijnaam", + "queue-name": "Naam", + "queue-name-required": "Wachtrijnaam is verplicht!", + "queues": "Wachtrijen", + "queue-partitions": "Partities", + "queue-submit-strategy": "Indienstrategie", + "queue-processing-strategy": "Verwerkingsstrategie", + "queue-configuration": "Wachtrijconfiguratie", + "repository-settings": "Repository-instellingen", + "repository": "Repository", + "repository-url": "Repository-URL", + "repository-url-required": "Repository-URL is verplicht.", + "default-branch": "Standaard branchnaam", + "repository-read-only": "Alleen-lezen", + "show-merge-commits": "Merge commits tonen", + "authentication-settings": "Authenticatie-instellingen", + "auth-method": "Authenticatiemethode", + "auth-method-username-password": "Wachtwoord / access token", + "auth-method-username-password-hint": "GitHub-gebruikers moeten gebruik maken van tokens met schrijfrechten op de repository.", + "auth-method-private-key": "Privésleutel", + "password-access-token": "Wachtwoord / access token", + "change-password-access-token": "Wachtwoord / access token wijzigen", + "private-key": "Privésleutel", + "drop-private-key-file-or": "Sleep een privésleutelbestand hierheen of", + "passphrase": "Wachtwoordzin", + "enter-passphrase": "Voer wachtwoordzin in", + "change-passphrase": "Wachtwoordzin wijzigen", + "check-access": "Toegang controleren", + "check-repository-access-success": "Toegang tot repository is succesvol geverifieerd!", + "delete-repository-settings-title": "Weet u zeker dat u de repository-instellingen wilt verwijderen?", + "delete-repository-settings-text": "Let op: na bevestiging worden de instellingen verwijderd en is versiebeheer niet meer beschikbaar.", + "auto-commit-settings": "Instellingen automatisch vastleggen", + "auto-commit": "Automatisch vastleggen", + "auto-commit-entities": "Entiteiten voor automatisch vastleggen", + "no-auto-commit-entities-prompt": "Geen entiteiten geconfigureerd voor automatisch vastleggen", + "delete-auto-commit-settings-title": "Weet u zeker dat u de instellingen voor automatisch vastleggen wilt verwijderen?", + "delete-auto-commit-settings-text": "Let op: na bevestiging worden de instellingen verwijderd en wordt automatisch vastleggen voor alle entiteiten uitgeschakeld.", + "mobile-app": { + "mobile-app": "Mobiele app", + "mobile-app-qr-code-widget-settings": "Mobiele app QR-code widget instellingen", + "applications": "Applicaties", + "default": "Standaard", + "custom": "Aangepast", + "android": "Android", + "ios": "iOS", + "appearance": "Uiterlijk", + "appearance-on-home-page": "Weergave op startpagina", + "enabled": "Ingeschakeld", + "disabled": "Uitgeschakeld", + "badges": "Badges", + "label": "Label", + "label-required": "Label is verplicht", + "label-max-length": "Label mag maximaal 50 tekens bevatten", + "right": "Rechts", + "left": "Links", + "set": "Instellen", + "preview": "Voorbeeld", + "connect-mobile-app": "Verbind mobiele app", + "use-system-settings": "Gebruik systeeminstellingen" + }, + "2fa": { + "2fa": "Twee-factor-authenticatie", + "available-providers": "Beschikbare providers", + "issuer-name": "Naam uitgever", + "issuer-name-required": "Naam uitgever is verplicht.", + "max-verification-failures-before-user-lockout": "Maximaal aantal verificatiefouten vóór accountvergrendeling", + "max-verification-failures-before-user-lockout-pattern": "Maximaal aantal fouten moet een positief geheel getal zijn.", + "number-of-checking-attempts": "Aantal controlepogingen", + "number-of-checking-attempts-pattern": "Aantal pogingen moet een positief geheel getal zijn.", + "number-of-checking-attempts-required": "Aantal controlepogingen is verplicht.", + "number-of-codes": "Aantal codes", + "number-of-codes-pattern": "Aantal codes moet een positief geheel getal zijn.", + "number-of-codes-required": "Aantal codes is verplicht.", + "provider": "Provider", + "retry-verification-code-period": "Herhaalperiode verificatiecode (sec)", + "retry-verification-code-period-pattern": "Minimale periode is 5 seconden", + "retry-verification-code-period-required": "Herhaalperiode is verplicht.", + "total-allowed-time-for-verification": "Totale toegestane verificatietijd (sec)", + "total-allowed-time-for-verification-pattern": "Minimale toegestane tijd is 60 seconden", + "total-allowed-time-for-verification-required": "Totale verificatietijd is verplicht.", + "use-system-two-factor-auth-settings": "Gebruik systeeminstellingen voor twee-factor-authenticatie", + "verification-code-check-rate-limit": "Beperkingssnelheid controle verificatiecode", + "verification-code-lifetime": "Levensduur verificatiecode (sec)", + "verification-code-lifetime-pattern": "Levensduur moet een positief geheel getal zijn.", + "verification-code-lifetime-required": "Levensduur van verificatiecode is verplicht.", + "verification-message-template": "Sjabloon verificatiebericht", + "verification-limitations": "Verificatiebeperkingen", + "verification-message-template-pattern": "Verificatiebericht moet patroon bevatten: ${code}", + "verification-message-template-required": "Sjabloon verificatiebericht is verplicht.", + "within-time": "Binnen tijd (sec)", + "within-time-pattern": "Tijd moet een positief geheel getal zijn.", + "within-time-required": "Tijd is verplicht." + }, + "jwt": { + "security-settings": "JWT-beveiligingsinstellingen", + "issuer-name": "Naam uitgever", + "issuer-name-required": "Naam uitgever is verplicht.", + "signings-key": "Ondertekeningssleutel", + "signings-key-hint": "Base64-gecodeerde tekenreeks die ten minste 512 bits vertegenwoordigt.", + "signings-key-required": "Ondertekeningssleutel is verplicht.", + "signings-key-min-length": "Ondertekeningssleutel moet minstens 512 bits zijn.", + "signings-key-base64": "Ondertekeningssleutel moet in base64-formaat zijn.", + "expiration-time": "Verlooptijd token (sec)", + "expiration-time-required": "Verlooptijd token is verplicht.", + "expiration-time-max": "Maximaal toegestane tijd is 2147483647 seconden (68 jaar).", + "expiration-time-min": "Minimale tijd is 60 seconden (1 minuut).", + "refresh-expiration-time": "Verlooptijd vernieuwingstoken (sec)", + "refresh-expiration-time-required": "Verlooptijd vernieuwingstoken is verplicht.", + "refresh-expiration-time-max": "Maximaal toegestane tijd is 2147483647 seconden (68 jaar).", + "refresh-expiration-time-min": "Minimale tijd is 900 seconden (15 minuten).", + "refresh-expiration-time-less-token": "Vernieuwingstoken moet een langere duur hebben dan access token.", + "generate-key": "Sleutel genereren", + "info-header": "Alle gebruikers moeten opnieuw inloggen", + "info-message": "Wijziging van de JWT-ondertekeningssleutel maakt alle uitgegeven tokens ongeldig. Alle gebruikers moeten opnieuw inloggen. Dit heeft ook invloed op scripts die gebruikmaken van de REST API/Websockets." + }, + "resources": "Bronnen", + "notifications": "Meldingen", + "notifications-settings": "Meldingsinstellingen", + "slack-api-token": "Slack API-token", + "slack": "Slack", + "slack-settings": "Slack-instellingen", + "mobile-settings": "Mobiele instellingen", + "firebase-service-account-file": "Firebase serviceaccount referenties JSON-bestand", + "select-firebase-service-account-file": "Sleep uw Firebase serviceaccountbestand hierheen of " + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarmen", + "all-alarms": "Alle alarmen", + "select-alarm": "Selecteer alarm", + "no-alarms-matching": "Geen alarmen gevonden voor '{{entity}}'.", + "alarm-required": "Alarm is verplicht", + "alarm-filter": "Alarmfilter", + "filter": "Filter", + "alarm-status": "Alarmstatus", + "alarm-status-list": "Lijst van alarmstatussen", + "any-status": "Elke status", + "search-status": { + "ANY": "Elke", + "ACTIVE": "Actief", + "CLEARED": "Gezuiverd", + "ACK": "Bevestigd", + "UNACK": "Niet bevestigd" + }, + "display-status": { + "ACTIVE_UNACK": "Actief niet bevestigd", + "ACTIVE_ACK": "Actief bevestigd", + "CLEARED_UNACK": "Gezuiverd niet bevestigd", + "CLEARED_ACK": "Gezuiverd bevestigd" + }, + "no-alarms-prompt": "Geen alarmen gevonden", + "created-time": "Aangemaakt op", + "type": "Type", + "severity": "Ernst", + "originator": "Oorsprong", + "originator-type": "Type oorsprong", + "details": "Details", + "originator-label": "Label oorsprong", + "assign": "Toewijzen", + "assignments": "Toewijzingen", + "assignee": "Toegewezen aan", + "assignee-id": "ID van toegewezene", + "assignee-first-name": "Voornaam toegewezene", + "assignee-last-name": "Achternaam toegewezene", + "assignee-email": "E-mail toegewezene", + "unassigned": "Niet toegewezen", + "user-deleted": "Gebruiker verwijderd", + "assignee-not-set": "Alle", + "status": "Status", + "alarm-details": "Alarmdetails", + "start-time": "Starttijd", + "assign-time": "Toewijzingstijd", + "end-time": "Eindtijd", + "ack-time": "Bevestigingstijd", + "clear-time": "Zuiveringstijd", + "duration": "Duur", + "alarm-severity": "Alarmernst", + "alarm-severity-list": "Lijst van alarmernst", + "any-severity": "Elke ernst", + "severity-critical": "Kritiek", + "severity-major": "Groot", + "severity-minor": "Klein", + "severity-warning": "Waarschuwing", + "severity-indeterminate": "Onbepaald", + "acknowledge": "Bevestigen", + "clear": "Zuiveren", + "delete": "Verwijderen", + "search": "Alarmen zoeken", + "selected-alarms": "{ count, plural, =1 {1 alarm} other {# alarmen} } geselecteerd", + "no-data": "Geen gegevens om weer te geven", + "polling-interval": "Polling-interval voor alarmen (sec)", + "polling-interval-required": "Polling-interval voor alarmen is verplicht.", + "min-polling-interval-message": "Minimaal toegestaan polling-interval is 1 seconde.", + "aknowledge-alarms-title": "{ count, plural, =1 {1 alarm bevestigen} other {# alarmen bevestigen} }", + "aknowledge-alarms-text": "Weet u zeker dat u { count, plural, =1 {1 alarm} other {# alarmen} } wilt bevestigen?", + "aknowledge-alarm-title": "Alarm bevestigen", + "aknowledge-alarm-text": "Weet u zeker dat u het alarm wilt bevestigen?", + "selected-alarms-are-acknowledged": "Geselecteerde alarmen zijn al bevestigd", + "clear-alarms-title": "{ count, plural, =1 {1 alarm zuiveren} other {# alarmen zuiveren} }", + "clear-alarms-text": "Weet u zeker dat u { count, plural, =1 {1 alarm} other {# alarmen} } wilt zuiveren?", + "clear-alarm-title": "Alarm zuiveren", + "clear-alarm-text": "Weet u zeker dat u het alarm wilt zuiveren?", + "delete-alarms-title": "{ count, plural, =1 {1 alarm verwijderen} other {# alarmen verwijderen} }", + "delete-alarms-text": "Weet u zeker dat u { count, plural, =1 {1 alarm} other {# alarmen} } wilt verwijderen?", + "selected-alarms-are-cleared": "Geselecteerde alarmen zijn al gezuiverd", + "alarm-status-filter": "Alarmstatusfilter", + "alarm-filter-title": "Alarmfilter", + "assigned": "Toegewezen", + "filter-title": "Filter", + "max-count-load": "Maximaal aantal te laden alarmen (0 - onbeperkt)", + "max-count-load-required": "Maximaal aantal te laden alarmen is verplicht.", + "max-count-load-error-min": "Minimale waarde is 0.", + "fetch-size": "Fetch-grootte", + "fetch-size-required": "Fetch-grootte is verplicht.", + "fetch-size-error-min": "Minimale waarde is 10.", + "alarm-types": "Alarmtypen", + "alarm-type-list": "Lijst van alarmtypen", + "any-type": "Elk type", + "assigned-to-current-user": "Toegewezen aan huidige gebruiker", + "assigned-to-me": "Toegewezen aan mij", + "search-propagated-alarms": "Gepropageerde alarmen zoeken", + "comments": "Alarmopmerkingen", + "show-more": "Meer weergeven", + "additional-info": "Aanvullende info", + "alarm-type": "Alarmtype", + "enter-alarm-type": "Voer alarmtype in", + "no-alarm-types-matching": "Geen overeenkomende alarmtypen gevonden voor '{{entitySubtype}}'.", + "alarm-type-list-empty": "Geen alarmtypen geselecteerd." + }, + "alarm-activity": { + "add": "Voeg een opmerking toe...", + "alarm-comment": "Alarmopmerking", + "comments": "Opmerkingen", + "delete-alarm-comment": "Wilt u deze opmerking verwijderen?", + "refresh": "Vernieuwen", + "oldest-first": "Oudste eerst", + "newest-first": "Nieuwste eerst", + "activity": "Activiteit", + "export": "Exporteren naar CSV", + "author": "Auteur", + "created-date": "Aanmaakdatum", + "edited-date": "Bewerkingsdatum", + "text": "Tekst", + "system": "Systeem" + }, + "alias": { + "add": "Alias toevoegen", + "edit": "Alias bewerken", + "name": "Aliasnaam", + "name-required": "Aliasnaam is verplicht", + "duplicate-alias": "Er bestaat al een alias met dezelfde naam.", + "filter-type-single-entity": "Enkelvoudige entiteit", + "filter-type-entity-list": "Entiteitenlijst", + "filter-type-entity-name": "Entiteitsnaam", + "filter-type-entity-type": "Entiteitstype", + "filter-type-state-entity": "Entiteit uit dashboardstatus", + "filter-type-state-entity-description": "Entiteit afkomstig uit statusparameters van het dashboard", + "filter-type-asset-type": "Assettype", + "filter-type-asset-type-description": "Assets van het type '{{assetTypes}}'", + "filter-type-asset-type-and-name-description": "Assets van het type '{{assetTypes}}' met naam die begint met '{{prefix}}'", + "filter-type-device-type": "Apparaattype", + "filter-type-device-type-description": "Apparaten van het type '{{deviceTypes}}'", + "filter-type-device-type-and-name-description": "Apparaten van het type '{{deviceTypes}}' met naam die begint met '{{prefix}}'", + "filter-type-entity-view-type": "Type entiteitsoverzicht", + "filter-type-entity-view-type-description": "Entiteitsoverzichten van het type '{{entityViewTypes}}'", + "filter-type-entity-view-type-and-name-description": "Entiteitsoverzichten van het type '{{entityViewTypes}}' met naam die begint met '{{prefix}}'", + "filter-type-edge-type": "Edge-type", + "filter-type-edge-type-description": "Edges van het type '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Edges van het type '{{edgeTypes}}' met naam die begint met '{{prefix}}'", + "filter-type-relations-query": "Relatiequery", + "filter-type-relations-query-description": "{{entities}} die een {{relationType}}-relatie {{direction}} {{rootEntity}} hebben", + "filter-type-edge-search-query": "Edge-zoekquery", + "filter-type-edge-search-query-description": "Edges met typen {{edgeTypes}} die een {{relationType}}-relatie {{direction}} {{rootEntity}} hebben", + "filter-type-asset-search-query": "Asset zoekquery", + "filter-type-asset-search-query-description": "Assets met typen {{assetTypes}} die een {{relationType}}-relatie {{direction}} {{rootEntity}} hebben", + "filter-type-device-search-query": "Apparaat zoekquery", + "filter-type-device-search-query-description": "Apparaten met typen {{deviceTypes}} die een {{relationType}}-relatie {{direction}} {{rootEntity}} hebben", + "filter-type-entity-view-search-query": "Entiteitsoverzicht zoekquery", + "filter-type-entity-view-search-query-description": "Entiteitsoverzichten met typen {{entityViewTypes}} die een {{relationType}}-relatie {{direction}} {{rootEntity}} hebben", + "filter-type-apiUsageState": "API-gebruiksstatus", + "entity-filter": "Entiteitenfilter", + "resolve-multiple": "Oplossen als meerdere entiteiten", + "resolve-multiple-hint": "Inschakelen om gegevens van alle gefilterde entiteiten tegelijkertijd weer te geven.\nAls uitgeschakeld, toont de widget alleen gegevens van de geselecteerde entiteit.", + "filter-type": "Filtertype", + "filter-type-required": "Filtertype is verplicht.", + "entity-filter-no-entity-matched": "Geen entiteiten gevonden die overeenkomen met het opgegeven filter.", + "no-entity-filter-specified": "Geen entiteitenfilter opgegeven", + "root-state-entity": "Gebruik dashboardstatus-entiteit als root", + "last-level-relation": "Alleen laatste relatieniveau ophalen", + "root-entity": "Root-entiteit", + "state-entity-parameter-name": "Parameternaam status-entiteit", + "default-state-entity": "Standaard status-entiteit", + "default-entity-parameter-name": "Standaard", + "max-relation-level": "Maximaal relatieniveau", + "unlimited-level": "Ongelimiteerd niveau", + "state-entity": "Dashboardstatus-entiteit", + "all-entities": "Alle entiteiten", + "any-relation": "elke" + }, + "asset": { + "asset": "Asset", + "assets": "Assets", + "management": "Assetbeheer", + "view-assets": "Assets bekijken", + "add": "Asset toevoegen", + "asset-type-max-length": "Assettype mag maximaal 256 tekens bevatten", + "assign-to-customer": "Toewijzen aan klant", + "assign-asset-to-customer": "Asset(s) aan klant toewijzen", + "assign-asset-to-customer-text": "Selecteer de assets die u aan de klant wilt toewijzen", + "no-assets-text": "Geen assets gevonden", + "assign-to-customer-text": "Selecteer de klant waaraan u de asset(s) wilt toewijzen", + "public": "Openbaar", + "assignedToCustomer": "Toegewezen aan klant", + "make-public": "Asset openbaar maken", + "make-private": "Asset privé maken", + "unassign-from-customer": "Toewijzing aan klant ongedaan maken", + "delete": "Asset verwijderen", + "asset-public": "Asset is openbaar", + "asset-type": "Assettype", + "asset-type-required": "Assettype is verplicht.", + "select-asset-type": "Selecteer assettype", + "enter-asset-type": "Voer assetprofiel in", + "any-asset": "Elke asset", + "no-asset-types-matching": "Geen overeenkomende assettypen gevonden voor '{{entitySubtype}}'.", + "asset-type-list-empty": "Geen assettypen geselecteerd.", + "asset-types": "Assettypen", + "name": "Naam", + "name-required": "Naam is verplicht.", + "name-max-length": "Naam mag maximaal 256 tekens bevatten", + "label-max-length": "Label mag maximaal 256 tekens bevatten", + "description": "Beschrijving", + "type": "Type", + "type-required": "Type is verplicht.", + "details": "Details", + "events": "Gebeurtenissen", + "add-asset-text": "Nieuwe asset toevoegen", + "asset-details": "Assetdetails", + "assign-assets": "Assets toewijzen", + "assign-assets-text": "{ count, plural, =1 {1 asset} other {# assets} } aan klant toewijzen", + "assign-asset-to-edge-title": "Asset(s) toewijzen aan Edge", + "assign-asset-to-edge-text": "Selecteer de assets die u aan de Edge wilt toewijzen", + "delete-assets": "Assets verwijderen", + "unassign-assets": "Assets ongedaan maken", + "unassign-assets-action-title": "{ count, plural, =1 {1 asset} other {# assets} } toewijzing aan klant ongedaan maken", + "assign-new-asset": "Nieuwe asset toewijzen", + "delete-asset-title": "Weet u zeker dat u de asset '{{assetName}}' wilt verwijderen?", + "delete-asset-text": "Let op: na bevestiging worden de asset en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-assets-title": "Weet u zeker dat u { count, plural, =1 {1 asset} other {# assets} } wilt verwijderen?", + "delete-assets-action-title": "{ count, plural, =1 {1 asset verwijderen} other {# assets verwijderen} }", + "delete-assets-text": "Let op: na bevestiging worden alle geselecteerde assets verwijderd en alle gerelateerde gegevens onherstelbaar.", + "make-public-asset-title": "Weet u zeker dat u de asset '{{assetName}}' openbaar wilt maken?", + "make-public-asset-text": "Na bevestiging wordt de asset en alle bijbehorende gegevens openbaar en toegankelijk voor anderen.", + "make-private-asset-title": "Weet u zeker dat u de asset '{{assetName}}' privé wilt maken?", + "make-private-asset-text": "Na bevestiging wordt de asset privé en niet meer toegankelijk voor anderen.", + "unassign-asset-title": "Weet u zeker dat u de asset '{{assetName}}' wilt onttrekken?", + "unassign-asset-text": "Na bevestiging wordt de asset ontkoppeld en is deze niet meer toegankelijk voor de klant.", + "unassign-asset": "Asset ontkoppelen", + "unassign-assets-title": "Weet u zeker dat u { count, plural, =1 {1 asset} other {# assets} } wilt onttrekken?", + "unassign-assets-text": "Na bevestiging worden alle geselecteerde assets ontkoppeld en zijn niet meer toegankelijk voor de klant.", + "copyId": "Asset-ID kopiëren", + "idCopiedMessage": "Asset-ID is gekopieerd naar klembord", + "select-asset": "Selecteer asset", + "no-assets-matching": "Geen overeenkomende assets gevonden voor '{{entity}}'.", + "asset-required": "Asset is verplicht", + "name-starts-with": "Assetnaam-expressie", + "help-text": "Gebruik '%' indien nodig: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "search": "Assets zoeken", + "import": "Assets importeren", + "asset-file": "Assetbestand", + "label": "Label", + "assign-asset-to-edge": "Asset(s) toewijzen aan Edge", + "unassign-asset-from-edge": "Asset ontkoppelen", + "unassign-asset-from-edge-title": "Weet u zeker dat u de asset '{{assetName}}' wilt ontkoppelen van Edge?", + "unassign-asset-from-edge-text": "Na bevestiging wordt de asset ontkoppeld en niet meer toegankelijk voor de Edge.", + "unassign-assets-from-edge-title": "Weet u zeker dat u { count, plural, =1 {1 asset} other {# assets} } wilt ontkoppelen?", + "unassign-assets-from-edge-text": "Na bevestiging worden alle geselecteerde assets ontkoppeld en zijn ze niet meer toegankelijk voor de Edge.", + "selected-assets": "{ count, plural, =1 {1 asset} other {# assets} } geselecteerd" + }, + "attribute": { + "attributes": "Attribuuten", + "latest-telemetry": "Laatste telemetrie", + "no-latest-telemetry": "Geen laatste telemetrie", + "attributes-scope": "Bereik van entiteitsattributen", + "scope-telemetry": "Telemetrie", + "scope-latest-telemetry": "Laatste telemetrie", + "scope-client": "Clientattributen", + "scope-server": "Serverattributen", + "scope-shared": "Gedeelde attributen", + "scope-client-short": "Client", + "scope-server-short": "Server", + "scope-shared-short": "Gedeeld", + "scope-latest-short": "Laatste", + "scope-any": "Elke", + "add": "Attribuut toevoegen", + "key": "Sleutel", + "key-max-length": "Sleutel mag maximaal 256 tekens bevatten", + "last-update-time": "Laatste update", + "key-required": "Attribuutsleutel is verplicht.", + "value": "Waarde", + "value-required": "Attribuutwaarde is verplicht.", + "telemetry-key-required": "Telemetriesleutel is verplicht", + "telemetry-value-required": "Telemetrie waarde is verplicht", + "delete-attributes-title": "Weet u zeker dat u { count, plural, =1 {1 attribuut} other {# attributen} } wilt verwijderen?", + "delete-attributes-text": "Let op: na bevestiging worden alle geselecteerde attributen verwijderd.", + "delete-attributes": "Attributen verwijderen", + "enter-attribute-value": "Voer attribuutwaarde in", + "show-on-widget": "Weergeven op widget", + "widget-mode": "Widgetmodus", + "next-widget": "Volgende widget", + "prev-widget": "Vorige widget", + "add-to-dashboard": "Toevoegen aan dashboard", + "add-widget-to-dashboard": "Widget aan dashboard toevoegen", + "selected-attributes": "{ count, plural, =1 {1 attribuut} other {# attributen} } geselecteerd", + "selected-telemetry": "{ count, plural, =1 {1 telemetrie-eenheid} other {# telemetrie-eenheden} } geselecteerd", + "no-attributes-text": "Geen attributen gevonden", + "no-telemetry-text": "Geen telemetrie gevonden", + "copy-key": "Sleutel kopiëren", + "add-telemetry": "Telemetrie toevoegen", + "copy-value": "Waarde kopiëren", + "delete-timeseries": { + "start-time": "Starttijd", + "ends-on": "Eindigt op", + "strategy": "Strategie", + "delete-strategy": "Verwijderstrategie", + "all-data": "Alle gegevens verwijderen", + "all-data-except-latest-value": "Alle gegevens verwijderen behalve laatste waarde", + "latest-value": "Laatste waarde verwijderen", + "all-data-for-time-period": "Alle gegevens voor tijdsperiode verwijderen", + "rewrite-latest-value": "Laatste waarde overschrijven" + } + }, + "api-usage": { + "api-features": "API-functies", + "api-usage": "API-gebruik", + "alarm": "Alarm", + "alarms-created": "Gegenereerde alarmen", + "queue-stats": "Wachtrijstatistieken", + "processing-failures-and-timeouts": "Verwerkingsfouten en time-outs", + "exceptions": "Uitzonderingen", + "alarms-created-daily-activity": "Dagelijkse activiteit gegenereerde alarmen", + "alarms-created-hourly-activity": "Uurlijkse activiteit gegenereerde alarmen", + "alarms-created-monthly-activity": "Maandelijkse activiteit gegenereerde alarmen", + "data-points": "Datapunten", + "data-points-storage-days": "Opslagduur van datapunten (dagen)", + "device-api": "Apparaat-API", + "email": "E-mail", + "email-messages": "E-mailberichten", + "email-messages-daily-activity": "Dagelijkse activiteit e-mailberichten", + "email-messages-monthly-activity": "Maandelijkse activiteit e-mailberichten", + "executions": "Uitvoeringen", + "scripts": "Scripts", + "scripts-hourly-activity": "Uurlijkse activiteit scripts", + "scripts-daily-activity": "Dagelijkse activiteit scripts", + "scripts-monthly-activity": "Maandelijkse activiteit scripts", + "javascript": "JavaScript", + "javascript-executions": "JavaScript-uitvoeringen", + "tbel": "TBEL", + "tbel-executions": "TBEL-uitvoeringen", + "latest-error": "Laatste fout", + "messages": "Berichten", + "notifications": "Meldingen", + "notifications-email-sms": "Meldingen (E-mail/SMS)", + "notifications-hourly-activity": "Uurlijkse meldingsactiviteit", + "permanent-failures": "${entityName} Permanente fouten", + "permanent-timeouts": "${entityName} Permanente time-outs", + "processing-failures": "${entityName} Verwerkingsfouten", + "processing-timeouts": "${entityName} Verwerking time-outs", + "rule-chain": "Rule chain", + "rule-engine": "Rule Engine", + "rule-engine-daily-activity": "Dagelijkse activiteit Rule Engine", + "rule-engine-executions": "Rule Engine-uitvoeringen", + "rule-engine-hourly-activity": "Uurlijkse activiteit Rule Engine", + "rule-engine-monthly-activity": "Maandelijkse activiteit Rule Engine", + "rule-engine-statistics": "Rule Engine-statistieken", + "rule-node": "Rule node", + "sms": "SMS", + "sms-messages": "SMS-berichten", + "sms-messages-daily-activity": "Dagelijkse activiteit SMS-berichten", + "sms-messages-monthly-activity": "Maandelijkse activiteit SMS-berichten", + "successful": "${entityName} Succesvol", + "telemetry": "Telemetrie", + "telemetry-persistence": "Telemetrie-opslag", + "telemetry-persistence-daily-activity": "Dagelijkse activiteit telemetrie-opslag", + "telemetry-persistence-hourly-activity": "Uurlijkse activiteit telemetrie-opslag", + "telemetry-persistence-monthly-activity": "Maandelijkse activiteit telemetrie-opslag", + "transport": "Transport", + "transport-daily-activity": "Dagelijkse activiteit transport", + "transport-data-points": "Transport datapunten", + "transport-hourly-activity": "Uurlijkse activiteit transport", + "transport-messages": "Transportberichten", + "transport-monthly-activity": "Maandelijkse activiteit transport", + "view-details": "Bekijk details", + "view-statistics": "Bekijk statistieken" + }, + "api-limit": { + "cassandra-queries": "Cassandra-query's", + "entity-version-creation": "Versiecreatie van entiteit", + "entity-version-load": "Versieladen van entiteit", + "notification-requests": "Meldingsverzoeken", + "notification-requests-per-rule": "Meldingsverzoeken per regel", + "rest-api-requests": "REST API-verzoeken", + "rest-api-requests-per-customer": "REST API-verzoeken per klant", + "transport-messages": "Transportberichten", + "transport-messages-per-device": "Transportberichten per apparaat", + "transport-messages-per-gateway": "Transportberichten per gateway", + "transport-messages-per-gateway-device": "Transportberichten per gateway-apparaat", + "ws-updates-per-session": "WS-updates per sessie", + "edge-events": "Edge-gebeurtenissen", + "edge-events-per-edge": "Edge-gebeurtenissen per edge", + "edge-uplink-messages": "Edge uplink-berichten", + "edge-uplink-messages-per-edge": "Edge uplink-berichten per edge" + }, + "audit-log": { + "audit": "Audit", + "audit-logs": "Auditlogs", + "timestamp": "Tijdstempel", + "entity-type": "Entiteitstype", + "entity-name": "Entiteitsnaam", + "user": "Gebruiker", + "type": "Type", + "status": "Status", + "details": "Details", + "type-added": "Toegevoegd", + "type-deleted": "Verwijderd", + "type-updated": "Bijgewerkt", + "type-attributes-updated": "Attributen bijgewerkt", + "type-attributes-deleted": "Attributen verwijderd", + "type-rpc-call": "RPC-aanroep", + "type-credentials-updated": "Inloggegevens bijgewerkt", + "type-assigned-to-customer": "Toegewezen aan klant", + "type-unassigned-from-customer": "Ontkoppeld van klant", + "type-assigned-to-edge": "Toegewezen aan Edge", + "type-unassigned-from-edge": "Ontkoppeld van Edge", + "type-activated": "Geactiveerd", + "type-suspended": "Opgeschort", + "type-credentials-read": "Inloggegevens gelezen", + "type-attributes-read": "Attributen gelezen", + "type-relation-add-or-update": "Relatie bijgewerkt", + "type-relation-delete": "Relatie verwijderd", + "type-relations-delete": "Alle relaties verwijderd", + "type-alarm-ack": "Alarm bevestigd", + "type-alarm-clear": "Alarm gewist", + "type-alarm-delete": "Alarm verwijderd", + "type-alarm-assign": "Alarm toegewezen", + "type-alarm-unassign": "Alarm ontkoppeld", + "type-added-comment": "Opmerking toegevoegd", + "type-updated-comment": "Opmerking bijgewerkt", + "type-deleted-comment": "Opmerking verwijderd", + "type-login": "Inloggen", + "type-logout": "Uitloggen", + "type-lockout": "Geblokkeerd", + "status-success": "Succes", + "status-failure": "Mislukt", + "audit-log-details": "Details auditlogboek", + "no-audit-logs-prompt": "Geen logs gevonden", + "action-data": "Actiegegevens", + "failure-details": "Foutdetails", + "search": "Auditlogs zoeken", + "clear-search": "Zoekopdracht wissen", + "type-assigned-from-tenant": "Toegewezen vanuit tenant", + "type-assigned-to-tenant": "Toegewezen aan tenant", + "type-provision-success": "Apparaat geprovisioneerd", + "type-provision-failure": "Provisioning apparaat mislukt", + "type-timeseries-updated": "Telemetrie bijgewerkt", + "type-timeseries-deleted": "Telemetrie verwijderd", + "type-sms-sent": "SMS verzonden" + }, + "debug-settings": { + "label": "Debugconfiguratie", + "on-failure": "Alleen fouten (24/7)", + "all-messages": "Alle berichten ({{time}})", + "failures": "Fouten", + "entity": "entiteit", + "hint": { + "main-limited": "Er worden niet meer dan {{msg}} {{entity}} debugberichten per {{time}} opgeslagen.", + "on-failure": "Alleen foutmeldingen loggen.", + "all-messages": "Alle debugberichten loggen." + } + }, + "calculated-fields": { + "expression": "Expressie", + "no-found": "Geen berekende velden gevonden", + "list": "{ count, plural, =1 {Eén berekend veld} other {Lijst van # berekende velden} }", + "selected-fields": "{ count, plural, =1 {1 berekend veld} other {# berekende velden} } geselecteerd", + "type": { + "simple": "Eenvoudig", + "script": "Script" + }, + "arguments": "Argumenten", + "decimals-by-default": "Standaard aantal decimalen", + "debugging": "Debuggen berekend veld", + "argument-name": "Argumentnaam", + "datasource": "Databron", + "add-argument": "Argument toevoegen", + "test-script-function": "Scriptfunctie testen", + "no-arguments": "Geen argumenten geconfigureerd", + "argument-settings": "Argumentinstellingen", + "argument-current": "Huidige entiteit", + "argument-current-tenant": "Huidige tenant", + "argument-device": "Apparaat", + "argument-asset": "Asset", + "argument-customer": "Klant", + "argument-tenant": "Huidige tenant", + "argument-type": "Argumenttype", + "see-debug-events": "Debuggebeurtenissen bekijken", + "attribute": "Attribuut", + "copy-argument-name": "Argumentnaam kopiëren", + "timeseries-key": "Tijdreeks-sleutel", + "device-name": "Apparaatnaam", + "latest-telemetry": "Laatste telemetrie", + "rolling": "Tijdreeks rolling", + "attribute-scope": "Attributenbereik", + "server-attributes": "Serverattributen", + "client-attributes": "Clientattributen", + "shared-attributes": "Gedeelde attributen", + "attribute-key": "Attribuutsleutel", + "default-value": "Standaardwaarde", + "limit": "Maximale waarden", + "time-window": "Tijdsvenster", + "customer-name": "Klantnaam", + "asset-name": "Assetnaam", + "timeseries": "Tijdreeks", + "output": "Uitvoer", + "create": "Nieuw berekend veld aanmaken", + "file": "Berekend veld-bestand", + "invalid-file-error": "Ongeldig bestandsformaat. Zorg ervoor dat het een geldig JSON-bestand is.", + "import": "Berekend veld importeren", + "export": "Berekend veld exporteren", + "export-failed-error": "Kan berekend veld niet exporteren: {{error}}", + "output-type": "Uitvoertype", + "delete-title": "Weet u zeker dat u het berekend veld '{{title}}' wilt verwijderen?", + "delete-text": "Let op: na bevestiging worden het berekend veld en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-multiple-title": "Weet u zeker dat u { count, plural, =1 {1 berekend veld} other {# berekende velden} } wilt verwijderen?", + "delete-multiple-text": "Let op: na bevestiging worden alle geselecteerde berekende velden verwijderd en zijn de gegevens onherstelbaar.", + "test-with-this-message": "Test met dit bericht", + "hint": { + "arguments-simple-with-rolling": "Berekend veld van het type eenvoudig mag geen sleutels met tijdreeks rolling bevatten.", + "arguments-empty": "Argumenten mogen niet leeg zijn.", + "expression-required": "Expressie is verplicht.", + "expression-invalid": "Expressie is ongeldig", + "expression-max-length": "Lengte van de expressie moet minder dan 255 tekens zijn.", + "argument-name-required": "Argumentnaam is verplicht.", + "argument-name-pattern": "Ongeldige argumentnaam.", + "argument-name-duplicate": "Er bestaat al een argument met deze naam.", + "argument-name-max-length": "Argumentnaam moet minder dan 256 tekens zijn.", + "argument-name-forbidden": "Argumentnaam is gereserveerd en mag niet worden gebruikt.", + "argument-type-required": "Argumenttype is verplicht.", + "max-args": "Maximum aantal argumenten bereikt.", + "decimals-range": "Standaard aantal decimalen moet tussen 0 en 15 liggen.", + "expression": "Standaardexpressie toont hoe je temperatuur converteert van Fahrenheit naar Celsius.", + "arguments-entity-not-found": "Doelentiteit van argument niet gevonden." + } + }, + "confirm-on-exit": { + "message": "U heeft niet-opgeslagen wijzigingen. Weet u zeker dat u deze pagina wilt verlaten?", + "html-message": "U heeft niet-opgeslagen wijzigingen.
    Weet u zeker dat u deze pagina wilt verlaten?", + "title": "Niet-opgeslagen wijzigingen" + }, + "contact": { + "country": "Land", + "country-required": "Land is verplicht.", + "city": "Stad", + "state": "Staat / Provincie", + "postal-code": "Postcode", + "postal-code-invalid": "Ongeldig postcodeformaat.", + "address": "Adres", + "address2": "Adresregel 2", + "phone": "Telefoon", + "email": "E-mail", + "no-address": "Geen adres", + "no-country-found": "Geen landen gevonden.", + "no-country-matching": "Geen overeenkomstig land gevonden voor '{{country}}'.", + "state-max-length": "Lengte van staat mag maximaal 256 tekens zijn", + "phone-max-length": "Telefoonnummer mag maximaal 256 tekens zijn", + "city-max-length": "Opgegeven stad mag maximaal 256 tekens zijn" + }, + "common": { + "name": "Naam", + "type": "Type", + "general": "Algemeen", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "data": "Gegevens", + "timestamp": "Tijdstempel", + "enter-username": "Voer gebruikersnaam in", + "enter-password": "Voer wachtwoord in", + "enter-search": "Voer zoekopdracht in", + "created-time": "Aanmaaktijd", + "disabled": "Uitgeschakeld", + "loading": "Laden...", + "proceed": "Doorgaan", + "open-details-page": "Details bekijken", + "not-found": "Niet gevonden", + "value": "Waarde", + "documentation": "Documentatie", + "time-left": "{{time}} resterend", + "output": "Uitvoer", + "suffix": { + "s": "s", + "ms": "ms" + }, + "hint": { + "name-required": "Naam is verplicht.", + "name-pattern": "Naam is ongeldig.", + "name-max-length": "Naam mag maximaal 256 tekens bevatten.", + "title-required": "Titel is verplicht.", + "title-pattern": "Titel is ongeldig.", + "title-max-length": "Titel mag maximaal 256 tekens bevatten.", + "key-required": "Sleutel is verplicht.", + "key-pattern": "Sleutel is ongeldig.", + "key-max-length": "Sleutel mag maximaal 256 tekens bevatten." + }, + "required-fields": "Verplichte velden ontbreken" + }, + "content-type": { + "json": "Json", + "text": "Tekst", + "binary": "Binair (Base64)" + }, + "color": { + "color": "Kleur" + }, + "customer": { + "customer": "Klant", + "customers": "Klanten", + "management": "Klantbeheer", + "dashboard": "Klantdashboard", + "dashboards": "Klantdashboards", + "devices": "Klantapparaten", + "entity-views": "Klantentiteitsoverzichten", + "assets": "Klantassets", + "public-dashboards": "Openbare dashboards", + "public-devices": "Openbare apparaten", + "public-assets": "Openbare assets", + "public-entity-views": "Openbare entiteitsoverzichten", + "add": "Klant toevoegen", + "delete": "Klant verwijderen", + "manage-customer-users": "Klantgebruikers beheren", + "manage-customer-devices": "Klantapparaten beheren", + "manage-customer-dashboards": "Klantdashboards beheren", + "manage-public-devices": "Openbare apparaten beheren", + "manage-public-dashboards": "Openbare dashboards beheren", + "manage-customer-assets": "Klantassets beheren", + "manage-customer-edges": "Klantedges beheren", + "manage-public-assets": "Openbare assets beheren", + "add-customer-text": "Nieuwe klant toevoegen", + "no-customers-text": "Geen klanten gevonden", + "customer-details": "Klantdetails", + "delete-customer-title": "Weet u zeker dat u de klant '{{customerTitle}}' wilt verwijderen?", + "delete-customer-text": "Let op: na bevestiging worden de klant en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-customers-title": "Weet u zeker dat u { count, plural, =1 {1 klant} other {# klanten} } wilt verwijderen?", + "delete-customers-action-title": "{ count, plural, =1 {1 klant verwijderen} other {# klanten verwijderen} }", + "delete-customers-text": "Let op: na bevestiging worden alle geselecteerde klanten en hun gegevens onherstelbaar verwijderd.", + "manage-users": "Gebruikers beheren", + "manage-assets": "Assets beheren", + "manage-devices": "Apparaten beheren", + "manage-dashboards": "Dashboards beheren", + "title": "Titel", + "title-required": "Titel is verplicht.", + "title-max-length": "Titel mag maximaal 256 tekens bevatten", + "description": "Beschrijving", + "details": "Details", + "events": "Gebeurtenissen", + "copyId": "Klant-ID kopiëren", + "idCopiedMessage": "Klant-ID is gekopieerd naar klembord", + "select-customer": "Selecteer klant", + "no-customers-matching": "Geen klanten gevonden voor '{{entity}}'.", + "customer-required": "Klant is verplicht", + "select-default-customer": "Selecteer standaardklant", + "default-customer": "Standaardklant", + "default-customer-required": "Standaardklant is vereist om dashboard op tenantniveau te debuggen", + "search": "Klanten zoeken", + "selected-customers": "{ count, plural, =1 {1 klant} other {# klanten} } geselecteerd", + "edges": "Klant edge-instanties", + "manage-edges": "Edges beheren" + }, + "css-size": { + "size-value-required": "Waarde van grootte is verplicht", + "invalid-size-value": "Ongeldige groottewaarde" + }, + "date": { + "last-update-n-ago": "Laatste update N geleden", + "last-update-n-ago-text": "Laatste update {{ agoText }}", + "custom-date": "Aangepaste datum", + "format": "Formaat", + "preview": "Voorbeeld", + "auto": "Auto", + "time-granularity-formats": "Tijdgranulariteit-formaten", + "unit-year": "Jaren", + "unit-month": "Maanden", + "unit-day": "Dagen", + "unit-hour": "Uren", + "unit-minute": "Minuten", + "unit-second": "Seconden", + "unit-millisecond": "Milliseconden" + }, + "datetime": { + "date-from": "Datum vanaf", + "time-from": "Tijd vanaf", + "date-to": "Datum tot", + "time-to": "Tijd tot", + "from": "Van", + "to": "Tot" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboards", + "management": "Dashboardbeheer", + "view-dashboards": "Dashboards bekijken", + "add": "Dashboard toevoegen", + "assign-dashboard-to-customer": "Dashboard(s) toewijzen aan klant", + "assign-dashboard-to-customer-text": "Selecteer de dashboards om toe te wijzen aan de klant", + "assign-to-customer-text": "Selecteer de klant om het dashboard toe te wijzen", + "assign-to-customer": "Toewijzen aan klant", + "unassign-from-customer": "Ontkoppelen van klant", + "make-public": "Dashboard openbaar maken", + "make-private": "Dashboard privé maken", + "manage-assigned-customers": "Toegewezen klanten beheren", + "assigned-customers": "Toegewezen klanten", + "assign-to-customers": "Dashboard(s) toewijzen aan klanten", + "assign-to-customers-text": "Selecteer de klanten om dashboard(s) toe te wijzen", + "unassign-from-customers": "Dashboard(s) ontkoppelen van klanten", + "unassign-from-customers-text": "Selecteer de klanten om dashboard(s) te ontkoppelen", + "no-dashboards-text": "Geen dashboards gevonden", + "no-widgets": "Geen widgets geconfigureerd", + "add-widget": "Nieuwe widget toevoegen", + "add-widget-button-text": "Widget toevoegen", + "title": "Titel", + "image": "Dashboardafbeelding", + "mobile-app-settings": "Mobiele app-instellingen", + "mobile-order": "Volgorde in mobiele applicatie", + "mobile-hide": "Verberg dashboard in mobiele applicatie", + "update-image": "Dashboardafbeelding bijwerken", + "take-screenshot": "Schermafbeelding maken", + "select-widget-title": "Selecteer widget", + "select-widget-value": "{{title}}: selecteer widget", + "select-widget-subtitle": "Beschikbare widgettypes", + "delete": "Dashboard verwijderen", + "title-required": "Titel is verplicht.", + "title-max-length": "Titel mag maximaal 256 tekens bevatten", + "description": "Beschrijving", + "details": "Details", + "dashboard-details": "Dashboarddetails", + "add-dashboard-text": "Nieuw dashboard toevoegen", + "assign-dashboards": "Dashboards toewijzen", + "assign-new-dashboard": "Nieuw dashboard toewijzen", + "assign-dashboards-text": "{ count, plural, =1 {1 dashboard} other {# dashboards} } toewijzen aan klanten", + "unassign-dashboards-action-text": "{ count, plural, =1 {1 dashboard} other {# dashboards} } ontkoppelen van klanten", + "delete-dashboards": "Dashboards verwijderen", + "unassign-dashboards": "Dashboards ontkoppelen", + "unassign-dashboards-action-title": "{ count, plural, =1 {1 dashboard} other {# dashboards} } ontkoppelen van klant", + "delete-dashboard-title": "Weet u zeker dat u het dashboard '{{dashboardTitle}}' wilt verwijderen?", + "delete-dashboard-text": "Let op: na bevestiging worden het dashboard en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-dashboards-title": "Weet u zeker dat u { count, plural, =1 {1 dashboard} other {# dashboards} } wilt verwijderen?", + "delete-dashboards-action-title": "{ count, plural, =1 {1 dashboard verwijderen} other {# dashboards verwijderen} }", + "delete-dashboards-text": "Let op: na bevestiging worden alle geselecteerde dashboards en gerelateerde gegevens onherstelbaar verwijderd.", + "unassign-dashboard-title": "Weet u zeker dat u het dashboard '{{dashboardTitle}}' wilt ontkoppelen?", + "unassign-dashboard-text": "Na bevestiging wordt het dashboard ontkoppeld en is het niet langer toegankelijk voor de klant.", + "unassign-dashboard": "Dashboard ontkoppelen", + "unassign-dashboards-title": "Weet u zeker dat u { count, plural, =1 {1 dashboard} other {# dashboards} } wilt ontkoppelen?", + "unassign-dashboards-text": "Na bevestiging worden alle geselecteerde dashboards ontkoppeld en niet langer toegankelijk voor de klant.", + "public-dashboard-title": "Dashboard is nu openbaar", + "public-dashboard-text": "Uw dashboard {{dashboardTitle}} is nu openbaar en bereikbaar via deze link:", + "public-dashboard-notice": "Let op: Vergeet niet om gerelateerde apparaten openbaar te maken om toegang tot gegevens te geven.", + "make-private-dashboard-title": "Weet u zeker dat u het dashboard '{{dashboardTitle}}' privé wilt maken?", + "make-private-dashboard-text": "Na bevestiging wordt het dashboard privé en niet langer toegankelijk voor anderen.", + "make-private-dashboard": "Dashboard privé maken", + "socialshare-text": "'{{dashboardTitle}}' aangedreven door ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' aangedreven door ThingsBoard", + "select-dashboard": "Selecteer dashboard", + "no-dashboards-matching": "Geen dashboards gevonden voor '{{entity}}'.", + "dashboard-required": "Dashboard is verplicht.", + "select-existing": "Bestaand dashboard selecteren", + "create-new": "Nieuw dashboard aanmaken", + "new-dashboard-title": "Titel nieuw dashboard", + "open-dashboard": "Dashboard openen", + "set-background": "Achtergrond instellen", + "background-color": "Achtergrondkleur", + "background-image": "Achtergrondafbeelding", + "background-size-mode": "Formaatmodus achtergrond", + "no-image": "Geen afbeelding geselecteerd", + "empty-image": "Geen afbeelding", + "drop-image": "Sleep een afbeelding hierheen of klik om een bestand te selecteren.", + "maximum-upload-file-size": "Maximale uploadgrootte: {{ size }}", + "cannot-upload-file": "Kan bestand niet uploaden", + "settings": "Instellingen", + "move-all-widgets": "Alle widgets verplaatsen", + "move-by": "Verplaatsen met", + "cols": "kolommen", + "rows": "rijen", + "layout": "Lay-out", + "layout-type-default": "Standaard", + "layout-type-scada": "SCADA", + "layout-type-divider": "Scheiding", + "layout-settings-type": "Lay-outinstellingen: {{ type }} breakpoint", + "columns-count": "Aantal kolommen", + "columns-count-required": "Aantal kolommen is verplicht.", + "min-columns-count-message": "Minimaal toegestane aantal kolommen is 10.", + "max-columns-count-message": "Maximaal toegestane aantal kolommen is 1000.", + "min-layout-width": "Minimale lay-outbreedte", + "columns-suffix": "kolommen", + "widgets-margins": "Marge tussen widgets", + "margin-required": "Margenwaarde is verplicht.", + "min-margin-message": "Minimale waarde is 0.", + "max-margin-message": "Maximale waarde is 50.", + "horizontal-margin": "Horizontale marge", + "horizontal-margin-required": "Horizontale margenwaarde is verplicht.", + "min-horizontal-margin-message": "Minimale horizontale marge is 0.", + "max-horizontal-margin-message": "Maximale horizontale marge is 50.", + "vertical-margin": "Verticale marge", + "vertical-margin-required": "Verticale margenwaarde is verplicht.", + "min-vertical-margin-message": "Minimale verticale marge is 0.", + "max-vertical-margin-message": "Maximale verticale marge is 50.", + "apply-outer-margin": "Marge toepassen aan zijkanten van de lay-out", + "autofill-height": "Automatisch lay-outhoogte vullen", + "mobile-layout": "Mobiele lay-outinstellingen", + "mobile-row-height": "Mobiele rijhoogte", + "mobile-row-height-required": "Mobiele rijhoogte is verplicht.", + "min-mobile-row-height-message": "Minimale mobiele rijhoogte is 5 pixels.", + "max-mobile-row-height-message": "Maximale mobiele rijhoogte is 200 pixels.", + "row-height": "Rijhoogte", + "row-height-required": "Rijhoogte is verplicht.", + "min-row-height-message": "Minimale rijhoogte is 5 pixels.", + "max-row-height-message": "Maximale rijhoogte is 200 pixels.", + "display-first-in-mobile-view": "Eerst tonen in mobiele weergave", + "title-settings": "Titelinstellingen", + "display-title": "Dashboardtitel weergeven", + "title-color": "Titelkleur", + "toolbar-settings": "Werkbalkinstellingen", + "hide-toolbar": "Werkbalk verbergen", + "toolbar-always-open": "Werkbalk altijd geopend houden", + "display-dashboards-selection": "Dashboardsselectie weergeven", + "display-entities-selection": "Entiteitenselectie weergeven", + "display-filters": "Filters weergeven", + "display-dashboard-timewindow": "Tijdvenster weergeven", + "display-dashboard-export": "Exporteren weergeven", + "display-update-dashboard-image": "Dashboardafbeelding bijwerken weergeven", + "dashboard-logo-settings": "Dashboardlogo-instellingen", + "display-dashboard-logo": "Logo weergeven in volledig scherm", + "dashboard-logo-image": "Afbeelding dashboardlogo", + "advanced-settings": "Geavanceerde instellingen", + "dashboard-css": "Dashboard CSS", + "import": "Dashboard importeren", + "export": "Dashboard exporteren", + "export-failed-error": "Kan dashboard niet exporteren: {{error}}", + "export-prompt": "Dashboardafbeeldingen en bronnen insluiten", + "create-new-dashboard": "Nieuw dashboard aanmaken", + "dashboard-file": "Dashboardbestand", + "invalid-dashboard-file-error": "Kan dashboard niet importeren: Ongeldige datastructuur.", + "dashboard-import-missing-aliases-title": "Configureer aliassen die worden gebruikt in geïmporteerd dashboard", + "create-new-widget": "Nieuwe widget aanmaken", + "import-widget": "Widget importeren", + "widget-file": "Widgetbestand", + "invalid-widget-file-error": "Kan widget niet importeren: Ongeldige datastructuur.", + "widget-import-missing-aliases-title": "Configureer aliassen die worden gebruikt in geïmporteerde widget", + "open-toolbar": "Dashboardwerkbalk openen", + "close-toolbar": "Werkbalk sluiten", + "configuration-error": "Configuratiefout", + "alias-resolution-error-title": "Fout in dashboard aliassen", + "invalid-aliases-config": "Kan geen apparaten vinden die overeenkomen met sommige aliasfilters.
    Neem contact op met uw beheerder om dit probleem op te lossen.", + "select-devices": "Apparaten selecteren", + "assignedToCustomer": "Toegewezen aan klant", + "assignedToCustomers": "Toegewezen aan klanten", + "public": "Openbaar", + "copyId": "Dashboard-ID kopiëren", + "idCopiedMessage": "Dashboard-ID is gekopieerd naar klembord", + "public-link": "Openbare link", + "copy-public-link": "Openbare link kopiëren", + "public-link-copied-message": "Openbare dashboardlink is gekopieerd naar klembord", + "manage-states": "Dashboardtoestanden beheren", + "states": "Dashboardtoestanden", + "states-short": "Toestanden", + "search-states": "Zoek dashboardtoestanden", + "selected-states": "{ count, plural, =1 {1 dashboardtoestand} other {# dashboardtoestanden} } geselecteerd", + "edit-state": "Dashboardtoestand bewerken", + "delete-state": "Dashboardtoestand verwijderen", + "add-state": "Dashboardtoestand toevoegen", + "no-states-text": "Geen toestanden gevonden", + "state": "Dashboardtoestand", + "state-name": "Naam", + "state-name-required": "Naam van dashboardtoestand is verplicht.", + "state-id": "Toestands-ID", + "state-id-required": "Toestands-ID is verplicht.", + "state-id-exists": "Er bestaat al een dashboardtoestand met dezelfde ID.", + "is-root-state": "Hoofdtoestand", + "delete-state-title": "Dashboardtoestand verwijderen", + "delete-state-text": "Weet u zeker dat u de dashboardtoestand met naam '{{stateName}}' wilt verwijderen?", + "show-details": "Details tonen", + "hide-details": "Details verbergen", + "select-state": "Selecteer doeltoestand", + "state-controller": "Toestandscontroller", + "state-controller-default": "statisch (verouderd)", + "search": "Dashboards zoeken", + "selected-dashboards": "{ count, plural, =1 {1 dashboard} other {# dashboards} } geselecteerd", + "home-dashboard": "Startdashboard", + "home-dashboard-hide-toolbar": "Werkbalk van startdashboard verbergen", + "unassign-dashboard-from-edge-text": "Na bevestiging wordt het dashboard ontkoppeld en is het niet meer toegankelijk voor de edge.", + "unassign-dashboards-from-edge-title": "Weet u zeker dat u { count, plural, =1 {1 dashboard} other {# dashboards} } wilt ontkoppelen?", + "unassign-dashboards-from-edge-text": "Na bevestiging worden alle geselecteerde dashboards ontkoppeld en niet meer toegankelijk voor de edge.", + "assign-dashboard-to-edge": "Dashboard(s) toewijzen aan edge", + "assign-dashboard-to-edge-text": "Selecteer de dashboards om toe te wijzen aan de edge", + "non-existent-dashboard-state-error": "Dashboardtoestand met ID \"{{ stateId }}\" is niet gevonden", + "edit-mode": "Bewerkmodus", + "duplicate-state-action": "Toestand dupliceren", + "breakpoint-value": "Breakpoint ({{ value }})", + "breakpoints-id": { + "default": "Standaard", + "xs": "Mobiel (xs)", + "sm": "Tablet (sm)", + "md": "Laptop (md)", + "lg": "Desktop (lg)", + "xl": "Desktop (xl)" + }, + "view-format-type-grid": "Raster", + "view-format-type-list": "Lijst", + "view-format": "Weergaveformaat" + }, + "datakey": { + "settings": "Instellingen", + "general": "Algemeen", + "advanced": "Geavanceerd", + "key": "Sleutel", + "keys": "Sleutels", + "label": "Label", + "color": "Kleur", + "units": "Symbool weer te geven naast waarde", + "decimals": "Aantal decimalen na de komma", + "data-generation-func": "Functie voor gegevensgeneratie", + "use-data-post-processing-func": "Gebruik functie voor gegevensnabewerking", + "configuration": "Configuratie van gegevenssleutel", + "timeseries": "Tijdreeks", + "attributes": "Attributen", + "entity-field": "Entiteitsveld", + "alarm": "Alarmvelden", + "timeseries-required": "Entiteit tijdreeks is verplicht.", + "timeseries-or-attributes-required": "Tijdreeks/attributen van entiteit zijn vereist.", + "alarm-fields-timeseries-or-attributes-required": "Alarmvelden of entiteit tijdreeks/attributen zijn vereist.", + "maximum-timeseries-or-attributes": "Maximaal { count, plural, =1 {1 tijdreeks/attribuut toegestaan.} other {# tijdreeksen/attributen toegestaan} }", + "alarm-fields-required": "Alarmvelden zijn verplicht.", + "function-types": "Functietypes", + "function-type": "Functietype", + "function-types-required": "Functietypes zijn verplicht.", + "data-keys": "Gegevenssleutels", + "data-key": "Gegevenssleutel", + "data-keys-required": "Gegevenssleutels zijn verplicht.", + "data-key-required": "Gegevenssleutel is verplicht.", + "alarm-keys": "Alarmgegevenssleutels", + "alarm-key": "Alarmgegevenssleutel", + "alarm-key-functions": "Alarm sleutel functies", + "alarm-key-function": "Alarm sleutel functie", + "latest-keys": "Laatste gegevenssleutels", + "latest-key": "Laatste gegevenssleutel", + "latest-key-functions": "Functies voor laatste sleutel", + "latest-key-function": "Functie voor laatste sleutel", + "timeseries-keys": "Tijdreeksgegevenssleutels", + "timeseries-key": "Tijdreeksgegevenssleutel", + "timeseries-key-functions": "Functies voor tijdreekssleutel", + "timeseries-key-function": "Functie voor tijdreekssleutel", + "maximum-function-types": "Maximaal { count, plural, =1 {1 functietype toegestaan.} other {# functietypes toegestaan} }", + "time-description": "tijdstempel van de huidige waarde;", + "value-description": "de huidige waarde;", + "prev-value-description": "resultaat van vorige functieaanroep;", + "time-prev-description": "tijdstempel van vorige waarde;", + "prev-orig-value-description": "oorspronkelijke vorige waarde;", + "aggregation": "Aggregatie", + "aggregation-type-hint-common": "Om prestatie redenen, is aggregatie alleen beschikbaar voor vaste tijdsintervallen zoals \"huidige dag\", \"huidige maand\", enz., niet voor schuivende vensters zoals 'laatste 30 minuten'.", + "aggregation-type-none-hint": "Gebruik laatste waarde.", + "aggregation-type-min-hint": "Zoek minimale waarde binnen geselecteerd tijdvenster.", + "aggregation-type-max-hint": "Zoek maximale waarde binnen geselecteerd tijdvenster.", + "aggregation-type-avg-hint": "Bereken gemiddelde waarde binnen geselecteerd tijdvenster.", + "aggregation-type-sum-hint": "Sommeer alle waarden binnen geselecteerd tijdvenster.", + "aggregation-type-count-hint": "Aantal gegevenspunten binnen geselecteerd tijdvenster.", + "delta-calculation": "Delta-berekening", + "enable-delta-calculation": "Delta-berekening inschakelen", + "enable-delta-calculation-hint": "Bij inschakeling wordt waarde berekend op basis van geaggregeerde gegevens in gekozen tijdsvenster en vergelijkingsperiode. Alleen beschikbaar voor historische vensters.", + "delta-calculation-result": "Resultaat delta-berekening", + "delta-calculation-result-previous-value": "Vorige waarde", + "delta-calculation-result-delta-absolute": "Delta (absoluut)", + "delta-calculation-result-delta-percent": "Delta (procent)", + "source": "Bron", + "latest": "Laatste", + "latest-value": "Laatste waarde", + "delta": "delta", + "percent": "procent", + "absolute": "absoluut" + }, + "datasource": { + "type": "Type gegevensbron", + "name": "Naam", + "label": "Label", + "add-datasource-prompt": "Voeg gegevensbron toe" + }, + "details": { + "details": "Details", + "edit-mode": "Bewerkmodus", + "edit-json": "JSON bewerken", + "toggle-edit-mode": "Schakel bewerkmodus" + }, + "device": { + "device": "Apparaat", + "device-required": "Apparaat is verplicht.", + "devices": "Apparaten", + "management": "Apparaatbeheer", + "view-devices": "Apparaten bekijken", + "device-alias": "Apparaatalias", + "device-type-max-length": "Apparaattype moet minder dan 256 tekens zijn", + "aliases": "Apparaataliasen", + "no-alias-matching": "'{{alias}}' niet gevonden.", + "no-aliases-found": "Geen aliassen gevonden.", + "no-key-matching": "'{{key}}' niet gevonden.", + "no-keys-found": "Geen sleutels gevonden.", + "create-new-alias": "Een nieuwe aanmaken!", + "create-new-key": "Een nieuwe aanmaken!", + "duplicate-alias-error": "Duplicaat alias gevonden '{{alias}}'.
    Apparaataliasen moeten uniek zijn binnen het dashboard.", + "configure-alias": "Alias '{{alias}}' configureren", + "no-devices-matching": "Geen apparaten gevonden die overeenkomen met '{{entity}}'.", + "alias": "Alias", + "alias-required": "Apparaatalias is verplicht.", + "remove-alias": "Apparaatalias verwijderen", + "add-alias": "Apparaatalias toevoegen", + "name-starts-with": "Apparaatnaam expressie", + "help-text": "Gebruik '%' indien nodig: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "device-list": "Apparatenlijst", + "use-device-name-filter": "Filter gebruiken", + "device-list-empty": "Geen apparaten geselecteerd.", + "device-name-filter-required": "Apparaatnaamfilter is verplicht.", + "device-name-filter-no-device-matched": "Geen apparaten gevonden die beginnen met '{{device}}'.", + "add": "Apparaat toevoegen", + "assign-to-customer": "Toewijzen aan klant", + "assign-device-to-customer": "Appara(a)t(en) toewijzen aan klant", + "assign-device-to-customer-text": "Selecteer de apparaten om toe te wijzen aan de klant", + "make-public": "Apparaat openbaar maken", + "make-private": "Apparaat privé maken", + "no-devices-text": "Geen apparaten gevonden", + "assign-to-customer-text": "Selecteer de klant om het apparaat toe te wijzen", + "device-details": "Apparaatdetails", + "add-device-text": "Nieuw apparaat toevoegen", + "credentials": "Referenties", + "manage-credentials": "Referenties beheren", + "delete": "Apparaat verwijderen", + "assign-devices": "Apparaten toewijzen", + "assign-devices-text": "{ count, plural, =1 {1 apparaat} other {# apparaten} } toewijzen aan klant", + "delete-devices": "Apparaten verwijderen", + "unassign-from-customer": "Ontkoppelen van klant", + "unassign-devices": "Apparaten ontkoppelen", + "unassign-devices-action-title": "{ count, plural, =1 {1 apparaat} other {# apparaten} } ontkoppelen van klant", + "unassign-device-from-edge-title": "Weet u zeker dat u het apparaat '{{deviceName}}' wilt ontkoppelen?", + "unassign-device-from-edge-text": "Na bevestiging wordt het apparaat ontkoppeld en is het niet meer toegankelijk voor de edge.", + "unassign-devices-from-edge": "Apparaten ontkoppelen van edge", + "assign-new-device": "Nieuw apparaat toewijzen", + "make-public-device-title": "Weet u zeker dat u het apparaat '{{deviceName}}' openbaar wilt maken?", + "make-public-device-text": "Na bevestiging wordt het apparaat en alle bijbehorende gegevens openbaar en toegankelijk voor anderen.", + "make-private-device-title": "Weet u zeker dat u het apparaat '{{deviceName}}' privé wilt maken?", + "make-private-device-text": "Na bevestiging wordt het apparaat en alle bijbehorende gegevens privé en niet toegankelijk voor anderen.", + "view-credentials": "Referenties bekijken", + "delete-device-title": "Weet u zeker dat u het apparaat '{{deviceName}}' wilt verwijderen?", + "delete-device-text": "Let op: na bevestiging wordt het apparaat en alle bijbehorende gegevens onherstelbaar verwijderd.", + "delete-devices-title": "Weet u zeker dat u { count, plural, =1 {1 apparaat} other {# apparaten} } wilt verwijderen?", + "delete-devices-action-title": "{ count, plural, =1 {1 apparaat verwijderen} other {# apparaten verwijderen} }", + "delete-devices-text": "Let op: na bevestiging worden alle geselecteerde apparaten en gegevens onherstelbaar verwijderd.", + "unassign-device-title": "Weet u zeker dat u het apparaat '{{deviceName}}' wilt ontkoppelen?", + "unassign-device-text": "Na bevestiging wordt het apparaat ontkoppeld en is het niet meer toegankelijk voor de klant.", + "unassign-device": "Apparaat ontkoppelen", + "unassign-devices-title": "Weet u zeker dat u { count, plural, =1 {1 apparaat} other {# apparaten} } wilt ontkoppelen?", + "unassign-devices-text": "Na bevestiging worden alle geselecteerde apparaten ontkoppeld en niet meer toegankelijk voor de klant.", + "device-credentials": "Apparaatreferenties", + "loading-device-credentials": "Apparaatreferenties laden...", + "credentials-type": "Type referentie", + "access-token": "Access token", + "access-token-required": "Access token is verplicht.", + "access-token-invalid": "Lengte access token moet tussen 1 en 32 tekens zijn.", + "certificate-pem-format": "Certificaat in PEM-formaat", + "certificate-pem-format-required": "Certificaat is verplicht.", + "copy-access-token": "Access token kopiëren", + "copy-certificate": "Certificaat kopiëren", + "copy-client-id": "Client-ID kopiëren", + "copy-user-name": "Gebruikersnaam kopiëren", + "copy-password": "Wachtwoord kopiëren", + "generate-client-id": "Genereer Client-ID", + "generate-user-name": "Genereer gebruikersnaam", + "generate-password": "Genereer wachtwoord", + "generate-access-token": "Genereer Access Token", + "lwm2m-security-config": { + "identity": "Clientidentiteit", + "identity-required": "Clientidentiteit is verplicht.", + "identity-tooltip": "De PSK-identificatie is een willekeurige identificatie tot 128 bytes volgens [RFC7925]. Moet eerst naar een tekenreeks worden omgezet en vervolgens in UTF-8 worden gecodeerd.", + "client-key": "Clientsleutel", + "client-key-required": "Clientsleutel is verplicht.", + "client-key-tooltip-prk": "RPK publieke sleutel of ID moet voldoen aan [RFC7250] en gecodeerd zijn in Base64-formaat!", + "client-key-tooltip-psk": "PSK-sleutel moet voldoen aan [RFC4279] en in HexDec-formaat zijn: 32, 64 of 128 tekens!", + "endpoint": "Endpoint clientnaam", + "endpoint-required": "Endpoint clientnaam is verplicht.", + "client-public-key": "Publieke client sleutel", + "client-public-key-hint": "Als leeg, wordt vertrouwd certificaat gebruikt", + "client-public-key-tooltip": "X509 publieke sleutel moet DER-gecodeerd X509v3-formaat zijn, EC-algoritme ondersteunen en Base64-gecodeerd zijn!", + "mode": "Beveiligingsconfiguratie-modus", + "client-tab": "Clientbeveiliging", + "client-certificate": "Clientcertificaat", + "bootstrap-tab": "Bootstrap-client", + "bootstrap-server": "Bootstrap-server", + "lwm2m-server": "LwM2M-server", + "client-publicKey-or-id": "Client publieke sleutel of ID", + "client-publicKey-or-id-required": "Client publieke sleutel of ID is verplicht.", + "client-publicKey-or-id-tooltip-psk": "PSK-ID max. 128 bytes volgens [RFC7925], omzetten naar string en UTF-8 coderen.", + "client-publicKey-or-id-tooltip-rpk": "RPK moet [RFC7250] volgen en Base64-gecodeerd zijn!", + "client-publicKey-or-id-tooltip-x509": "X509 moet DER-gecodeerd X509v3 zijn met EC-algoritme en Base64-gecodeerd!", + "client-secret-key": "Clientgeheime sleutel", + "client-secret-key-required": "Clientgeheime sleutel is verplicht.", + "client-secret-key-tooltip-psk": "PSK moet [RFC4279] volgen en HexDec (32, 64, 128 tekens) zijn!", + "client-secret-key-tooltip-prk": "RPK geheime sleutel moet in PKCS_8 (DER, [RFC5958]) zijn en Base64-gecodeerd!", + "client-secret-key-tooltip-x509": "X509 geheime sleutel moet in PKCS_8 (DER, [RFC5958]) zijn en Base64-gecodeerd!" + }, + "client-id": "Client-ID", + "client-id-pattern": "Bevat ongeldig teken.", + "user-name": "Gebruikersnaam", + "user-name-required": "Gebruikersnaam is verplicht.", + "client-id-or-user-name-necessary": "Client-ID en/of gebruikersnaam vereist", + "password": "Wachtwoord", + "secret": "Geheim", + "secret-required": "Geheim is verplicht.", + "device-type": "Apparaatprofiel", + "device-type-required": "Apparaattype is verplicht.", + "select-device-type": "Selecteer apparaattype", + "enter-device-type": "Voer apparaatprofiel in", + "any-device": "Elk apparaat", + "no-device-types-matching": "Geen apparaatprofielen gevonden voor '{{entitySubtype}}'.", + "device-type-list-empty": "Geen apparaatprofielen geselecteerd!", + "device-profile-type-list-empty": "Minstens één apparaatprofiel is vereist.", + "device-types": "Apparaattypes", + "name": "Naam", + "name-required": "Naam is verplicht.", + "name-max-length": "Naam moet minder dan 256 tekens zijn", + "label-max-length": "Label moet minder dan 256 tekens zijn", + "description": "Beschrijving", + "label": "Label", + "events": "Gebeurtenissen", + "details": "Details", + "copyId": "Apparaat-ID kopiëren", + "copyAccessToken": "Access token kopiëren", + "copy-mqtt-authentication": "MQTT-referenties kopiëren", + "idCopiedMessage": "Apparaat-ID is gekopieerd", + "accessTokenCopiedMessage": "Access token is gekopieerd", + "mqtt-authentication-copied-message": "MQTT-authenticatie is gekopieerd", + "assignedToCustomer": "Toegewezen aan klant", + "unable-delete-device-alias-title": "Kan apparaatalias niet verwijderen", + "unable-delete-device-alias-text": "Alias '{{deviceAlias}}' kan niet worden verwijderd omdat deze wordt gebruikt door de volgende widget(s):
    {{widgetsList}}", + "is-gateway": "Is gateway", + "overwrite-activity-time": "Overschrijf activiteitsmoment verbonden apparaat", + "device-filter": "Apparaatfilter", + "device-filter-title": "Apparaatfilter", + "filter-title": "Filter", + "device-state": "Apparaatstatus", + "state": "Status", + "any": "Elke", + "active": "Actief", + "inactive": "Inactief", + "public": "Openbaar", + "device-public": "Apparaat is openbaar", + "select-device": "Selecteer apparaat", + "import": "Apparaat importeren", + "device-file": "Apparaatbestand", + "search": "Apparaten zoeken", + "selected-devices": "{ count, plural, =1 {1 apparaat} other {# apparaten} } geselecteerd", + "device-configuration": "Apparaatconfiguratie", + "transport-configuration": "Transportconfiguratie", + "wizard": { + "device-details": "Apparaatdetails" + }, + "unassign-devices-from-edge-title": "Weet u zeker dat u { count, plural, =1 {1 apparaat} other {# apparaten} } wilt ontkoppelen?", + "unassign-devices-from-edge-text": "Na bevestiging worden alle geselecteerde apparaten ontkoppeld en zijn ze niet meer toegankelijk voor de edge.", + "time": "Tijd", + "connectivity": { + "check-connectivity": "Controleer connectiviteit", + "device-created-check-connectivity": "Apparaat aangemaakt. Laten we connectiviteit controleren!", + "loading-check-connectivity-command": "Commando's voor connectiviteitscontrole laden...", + "use-following-instructions": "Gebruik onderstaande instructies om telemetrie te verzenden namens het apparaat via shell", + "execute-following-command": "Voer het volgende commando uit", + "install-curl-windows": "Vanaf Windows 10 b17063 is cURL standaard beschikbaar", + "install-curl-macos": "Vanaf Mac OS X 10.2 6C115 (Jaguar) is cURL standaard beschikbaar", + "install-mqtt-windows": "Gebruik de instructies om mosquitto_pub te downloaden, installeren, configureren en uitvoeren", + "install-coap-client": "Gebruik de instructies om coap-client te downloaden, installeren, configureren en uitvoeren", + "install-necessary-client-tools": "Installeer noodzakelijke clienthulpmiddelen", + "mqtts-x509-command": "Gebruik de volgende documentatie om apparaat te verbinden via MQTT met X509-autorisatie", + "coaps-x509-command": "Gebruik de volgende documentatie om apparaat te verbinden via CoAP over DTLS met X509-autorisatie", + "snmp-command": "Gebruik de volgende documentatie om apparaat te verbinden via SNMP.", + "sparkplug-command": "Gebruik de volgende documentatie om apparaat te verbinden via MQTT Sparkplug.", + "lwm2m-command": "Gebruik de volgende documentatie om apparaat te verbinden via LWM2M." + } + }, + "dynamic-form": { + "property": { + "properties": "Eigenschappen", + "property": "Eigenschap", + "id": "Id", + "name": "Naam", + "type": "Type", + "type-text": "Tekst", + "type-password": "Wachtwoord", + "type-textarea": "Tekstgebied", + "type-number": "Nummer", + "type-switch": "Schakelaar", + "type-select": "Selectie", + "type-radios": "Keuzerondjes", + "type-datetime": "Datum/tijd", + "type-image": "Afbeelding", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "Markdown", + "type-color": "Kleur", + "type-color-settings": "Kleurinstellingen", + "type-font": "Lettertype", + "type-units": "Eenheden", + "type-icon": "Pictogram", + "type-fieldset": "Veldenset", + "type-array": "Array", + "type-html-section": "HTML-sectie", + "group-title": "Groepstitel", + "no-properties": "Geen eigenschappen geconfigureerd", + "add-property": "Eigenschap toevoegen", + "property-settings": "Eigenschapsinstellingen", + "remove-property": "Eigenschap verwijderen", + "default-value": "Standaardwaarde", + "value-required": "Waarde vereist", + "number-settings": "Nummerinstellingen", + "min": "Min", + "max": "Max", + "step": "Stap", + "selected-options-limit": "Limiet geselecteerde opties", + "advanced-ui-settings": "Geavanceerde UI-instellingen", + "disable-on-property": "Uitschakelen op eigenschap", + "display-condition-function": "Weergavevoorwaarde functie", + "sub-label": "Sublabel", + "vertical-divider-after": "Verticale scheiding na", + "input-field-suffix": "Achtervoegsel invoerveld", + "property-row-classes": "Rijklassen eigenschap", + "property-field-classes": "Veldklassen eigenschap", + "not-unique-property-ids-error": "Eigenschaps-Ids moeten uniek zijn!", + "enable-multiple-select": "Meerdere selecties inschakelen", + "allow-empty-select-option": "Lege optie toestaan", + "select-options": "Selectieopties", + "not-unique-select-option-value-error": "Waarden van selectieopties moeten uniek zijn!", + "value": "Waarde", + "label": "Label", + "add-option": "Optie toevoegen", + "no-options": "Geen opties geconfigureerd", + "remove-option": "Optie verwijderen", + "textarea-rows": "Rijen tekstgebied", + "help-id": "Help-id", + "buttons-direction": "Knoppenrichting", + "direction-row": "Rij", + "direction-column": "Kolom", + "radio-button-options": "Opties keuzerondjes", + "datetime-type": "Datum/tijd veldtype", + "datetime-type-date": "Datum", + "datetime-type-time": "Tijd", + "datetime-type-datetime": "Datum/tijd", + "enable-clear-button": "Wis-knop inschakelen", + "html-section-settings": "HTML-sectie instellingen", + "html-section-classes": "HTML-sectie klassen", + "html-section-content": "HTML-sectie inhoud", + "array-item": "Array-item", + "item-type": "Itemtype", + "item-name": "Itemnaam", + "no-items": "Geen items" + }, + "clear-form": "Formulier wissen", + "clear-form-prompt": "Weet u zeker dat u alle formulier-eigenschappen wilt verwijderen?", + "import-form": "Formulier importeren uit JSON", + "export-form": "Formulier exporteren naar JSON", + "json-file": "JSON-bestand", + "json-content": "JSON-inhoud", + "invalid-form-json-file-error": "Kan formulier niet importeren uit JSON: Ongeldige formulierstructuur in JSON." + }, + "asset-profile": { + "asset-profile": "Assetprofiel", + "asset-profiles": "Assetprofielen", + "all-asset-profiles": "Alles", + "add": "Assetprofiel toevoegen", + "edit": "Assetprofiel bewerken", + "asset-profile-details": "Assetprofiel details", + "no-asset-profiles-text": "Geen assetprofielen gevonden", + "search": "Assetprofielen zoeken", + "selected-asset-profiles": "{ count, plural, =1 {1 assetprofiel} other {# assetprofielen} } geselecteerd", + "no-asset-profiles-matching": "Geen overeenkomend assetprofiel gevonden voor '{{entity}}'.", + "asset-profile-required": "Assetprofiel is verplicht", + "idCopiedMessage": "Assetprofiel-ID is gekopieerd naar klembord", + "set-default": "Assetprofiel als standaard instellen", + "delete": "Assetprofiel verwijderen", + "copyId": "Assetprofiel-ID kopiëren", + "name-max-length": "Naam moet minder dan 256 tekens zijn", + "new-device-profile-name": "Naam van assetprofiel", + "new-device-profile-name-required": "Naam van assetprofiel is verplicht.", + "name": "Naam", + "name-required": "Naam is verplicht.", + "image": "Assetprofielafbeelding", + "description": "Beschrijving", + "default": "Standaard", + "default-rule-chain": "Standaard regelketen", + "default-edge-rule-chain": "Standaard edge-regelketen", + "default-edge-rule-chain-hint": "Wordt op edge gebruikt als regelketen voor verwerking van inkomende gegevens voor assets van dit profiel", + "mobile-dashboard": "Mobiel dashboard", + "mobile-dashboard-hint": "Wordt door mobiele app gebruikt als detaildashboard voor asset", + "select-queue-hint": "Selecteer uit een keuzelijst.", + "delete-asset-profile-title": "Weet u zeker dat u het assetprofiel '{{assetProfileName}}' wilt verwijderen?", + "delete-asset-profile-text": "Wees voorzichtig, na bevestiging worden het assetprofiel en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-asset-profiles-title": "Weet u zeker dat u { count, plural, =1 {1 assetprofiel} other {# assetprofielen} } wilt verwijderen?", + "delete-asset-profiles-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde assetprofielen en gerelateerde gegevens onherstelbaar verwijderd.", + "set-default-asset-profile-title": "Weet u zeker dat u '{{assetProfileName}}' als standaard wilt instellen?", + "set-default-asset-profile-text": "Na bevestiging wordt het assetprofiel als standaard gemarkeerd en gebruikt voor nieuwe assets zonder gespecificeerd profiel.", + "no-asset-profiles-found": "Geen assetprofielen gevonden.", + "create-new-asset-profile": "Nieuwe aanmaken!", + "create-asset-profile": "Nieuw assetprofiel aanmaken", + "import": "Assetprofiel importeren", + "export": "Assetprofiel exporteren", + "export-failed-error": "Kan assetprofiel niet exporteren: {{error}}", + "asset-profile-file": "Assetprofielbestand", + "invalid-asset-profile-file-error": "Kan assetprofiel niet importeren: Ongeldige datastructuur voor assetprofiel." + }, + "device-profile": { + "device-profile": "Apparaatprofiel", + "device-profiles": "Apparaatprofielen", + "all-device-profiles": "Alles", + "add": "Apparaatprofiel toevoegen", + "edit": "Apparaatprofiel bewerken", + "device-profile-details": "Apparaatprofiel details", + "no-device-profiles-text": "Geen apparaatprofielen gevonden", + "search": "Apparaatprofielen zoeken", + "selected-device-profiles": "{ count, plural, =1 {1 apparaatprofiel} other {# apparaatprofielen} } geselecteerd", + "no-device-profiles-matching": "Geen overeenkomend apparaatprofiel gevonden voor '{{entity}}'.", + "device-profile-required": "Apparaatprofiel is verplicht", + "idCopiedMessage": "Apparaatprofiel-ID is gekopieerd naar klembord", + "set-default": "Instellen als standaard apparaatprofiel", + "delete": "Apparaatprofiel verwijderen", + "copyId": "Apparaatprofiel-ID kopiëren", + "name-max-length": "Naam moet minder dan 256 tekens zijn", + "name": "Naam", + "name-required": "Naam is verplicht.", + "type": "Profieltype", + "type-required": "Profieltype is verplicht.", + "type-default": "Standaard", + "image": "Apparaatprofielafbeelding", + "transport-type": "Transporttype", + "transport-type-required": "Transporttype is verplicht.", + "transport-type-default": "Standaard", + "transport-type-default-hint": "Ondersteunt basis MQTT, HTTP en CoAP transport", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Maakt geavanceerde MQTT-instellingen mogelijk", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Maakt geavanceerde CoAP-instellingen mogelijk", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M transporttype", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Specificeer SNMP transportconfiguratie", + "transport-type-http": "HTTP", + "description": "Beschrijving", + "default": "Standaard", + "profile-configuration": "Profielconfiguratie", + "transport-configuration": "Transportconfiguratie", + "default-rule-chain": "Standaard regelketen", + "default-edge-rule-chain": "Standaard edge regelketen", + "default-edge-rule-chain-hint": "Gebruikt op edge voor verwerking van gegevens voor apparaten met dit profiel", + "mobile-dashboard": "Mobiel dashboard", + "mobile-dashboard-hint": "Gebruikt door mobiele app als apparaatoverzicht", + "select-queue-hint": "Selecteer uit de keuzelijst.", + "delete-device-profile-title": "Weet u zeker dat u het apparaatprofiel '{{deviceProfileName}}' wilt verwijderen?", + "delete-device-profile-text": "Let op: het apparaatprofiel en gerelateerde gegevens, inclusief OTA-updates, worden onherstelbaar verwijderd.", + "delete-device-profiles-title": "Weet u zeker dat u { count, plural, =1 {1 apparaatprofiel} other {# apparaatprofielen} } wilt verwijderen?", + "delete-device-profiles-text": "Let op: alle geselecteerde profielen en gerelateerde gegevens inclusief OTA-updates worden onherstelbaar verwijderd.", + "set-default-device-profile-title": "Wilt u '{{deviceProfileName}}' instellen als standaardprofiel?", + "set-default-device-profile-text": "Na bevestiging wordt dit profiel gebruikt voor nieuwe apparaten zonder specifiek profiel.", + "no-device-profiles-found": "Geen apparaatprofielen gevonden.", + "create-new-device-profile": "Nieuwe aanmaken!", + "mqtt-device-topic-filters": "MQTT apparaattopicfilters", + "mqtt-device-topic-filters-unique": "MQTT apparaattopicfilters moeten uniek zijn.", + "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN) node.", + "mqtt-device-topic-filters-spark-plug-hint": "Sta verbindingen toe van EoN-nodes met Sparkplug B payload en topicstructuur.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "SparkPlug-metingen als attributen opslaan.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "SparkPlug-metingen worden opgeslagen als attributen; overige als telemetrie.", + "mqtt-device-payload-type": "MQTT apparaatgegevensformaat", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Compatibiliteit met ander formaat inschakelen", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Bij inschakelen wordt standaard Protobuf gebruikt. Bij fout wordt JSON geprobeerd. Handig tijdens firmware-overgangen. Kan prestaties beïnvloeden.", + "mqtt-use-json-format-for-default-downlink-topics": "Gebruik JSON voor standaard downlink topics", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Indien ingeschakeld, worden JSON payloads gebruikt voor standaard topics zoals v1/devices/me/attributes. Dit heeft geen invloed op v2-topics.", + "mqtt-send-ack-on-validation-exception": "Stuur PUBACK bij validatiefout", + "mqtt-send-ack-on-validation-exception-hint": "Standaard sluit platform sessie. Indien ingeschakeld, wordt PUBACK gestuurd bij fout.", + "snmp-add-mapping": "SNMP-mapping toevoegen", + "snmp-mapping-not-configured": "Geen mapping geconfigureerd van OID naar telemetrie", + "snmp-timseries-or-attribute-name": "Naam tijdreeks/attribuut voor mapping", + "snmp-timseries-or-attribute-type": "Type tijdreeks/attribuut voor mapping", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Payloadtype is verplicht.", + "coap-device-type": "CoAP apparaattype", + "coap-device-payload-type": "CoAP payload", + "coap-device-type-required": "CoAP apparaattype is verplicht.", + "coap-device-type-default": "Standaard", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Ondersteuning voor enkelvoudige [+] en meervoudige [#] wildcards.", + "telemetry-topic-filter": "Telemetrie topicfilter", + "telemetry-topic-filter-required": "Telemetrie topicfilter is verplicht.", + "attributes-topic-filter": "Attributen publiceer topicfilter", + "attributes-subscribe-topic-filter": "Attributen subscribe topicfilter", + "attributes-topic-filter-required": "Attributen publiceer topicfilter is verplicht.", + "attributes-subscribe-topic-filter-required": "Attributen subscribe topic is verplicht", + "telemetry-proto-schema": "Telemetrie Protobuf-schema", + "telemetry-proto-schema-required": "Telemetrie Protobuf-schema is verplicht.", + "attributes-proto-schema": "Attributen Protobuf-schema", + "attributes-proto-schema-required": "Attributen Protobuf-schema is verplicht.", + "rpc-response-proto-schema": "RPC respons Protobuf-schema", + "rpc-response-proto-schema-required": "RPC respons Protobuf-schema is verplicht.", + "rpc-response-topic-filter": "RPC respons topicfilter", + "rpc-response-topic-filter-required": "RPC respons topicfilter is verplicht.", + "rpc-request-proto-schema": "RPC verzoek Protobuf-schema", + "rpc-request-proto-schema-required": "RPC verzoek Protobuf-schema is verplicht.", + "rpc-request-proto-schema-hint": "RPC-verzoekbericht moet altijd de volgende velden bevatten: string method = 1; int32 requestId = 2; en params = 3 van elk gegevenstype.", + "not-valid-pattern-topic-filter": "Ongeldig patroon voor topicfilter", + "not-valid-single-character": "Ongeldig gebruik van enkelvoudig wildcard-teken", + "not-valid-multi-character": "Ongeldig gebruik van meervoudig wildcard-teken", + "single-level-wildcards-hint": "[+] is geschikt voor elk niveau in topicfilter. Bijv.: v1/devices/+/telemetry of +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] kan het hele topicfilter vervangen en moet het laatste teken zijn. Bijv.: # of v1/devices/me/#.", + "alarm-rules": "Alarmregels", + "alarm-rules-with-count": "Alarmregels ({{count}})", + "no-alarm-rules": "Geen alarmregels geconfigureerd", + "add-alarm-rule": "Alarmregel toevoegen", + "edit-alarm-rule": "Alarmregel bewerken", + "alarm-type": "Alarmtype", + "alarm-type-required": "Alarmtype is verplicht.", + "alarm-type-unique": "Alarmtype moet uniek zijn binnen het apparaatprofiel.", + "alarm-type-max-length": "Alarmtype moet minder dan 256 tekens zijn", + "create-alarm-pattern": "Maak {{alarmType}} alarm aan", + "create-alarm-rules": "Alarmregels aanmaken", + "no-create-alarm-rules": "Geen aanmaakvoorwaarden geconfigureerd", + "add-create-alarm-rule-prompt": "Voeg een aanmaakvoorwaarde toe", + "clear-alarm-rule": "Wis alarmregel", + "no-clear-alarm-rule": "Geen wisvoorwaarde geconfigureerd", + "add-create-alarm-rule": "Aanmaakvoorwaarde toevoegen", + "add-clear-alarm-rule": "Wisvoorwaarde toevoegen", + "select-alarm-severity": "Selecteer alarmernst", + "alarm-severity-required": "Alarmernst is verplicht.", + "condition-duration": "Voorwaarde duur", + "condition-duration-value": "Duurwaarde", + "condition-duration-time-unit": "Tijdeenheid", + "condition-duration-value-range": "Duurwaarde moet tussen 1 en 2147483647 liggen.", + "condition-duration-value-pattern": "Duurwaarde moet een geheel getal zijn.", + "condition-duration-value-required": "Duurwaarde is verplicht.", + "condition-duration-time-unit-required": "Tijdeenheid is verplicht.", + "advanced-settings": "Geavanceerde instellingen", + "alarm-rule-additional-info": "Aanvullende informatie", + "edit-alarm-rule-additional-info": "Bewerk aanvullende informatie", + "alarm-rule-additional-info-placeholder": "Voer hier opmerkingen of aanpassingen in die onder 'Aanvullende informatie' in de alarmdetails worden weergegeven", + "alarm-rule-additional-info-hint": "Hint: gebruik ${keyName} om waarden van attributen of telemetrie te vervangen in de regelvoorwaarde.", + "alarm-rule-mobile-dashboard": "Mobiel dashboard", + "alarm-rule-mobile-dashboard-hint": "Gebruikt door mobiele applicatie als dashboard voor alarmdetails", + "alarm-rule-no-mobile-dashboard": "Geen dashboard geselecteerd", + "propagate-alarm": "Alarm doorsturen naar gerelateerde entiteiten", + "alarm-rule-relation-types-list": "Relatietypes", + "alarm-rule-relation-types-list-hint": "Definieer relatietypes om gerelateerde entiteiten te filteren. Indien leeg, wordt het alarm naar alle gerelateerde entiteiten doorgezet.", + "propagate-alarm-to-owner": "Alarm doorsturen naar eigenaar (Klant of Tenant)", + "propagate-alarm-to-tenant": "Alarm doorsturen naar Tenant", + "alarm-rule-condition": "Alarmregelvoorwaarde", + "enter-alarm-rule-condition-prompt": "Voeg een alarmregelvoorwaarde toe", + "edit-alarm-rule-condition": "Bewerk alarmregelvoorwaarde", + "device-provisioning": "Apparaatvoorziening", + "provision-strategy": "Voorzieningsstrategie", + "provision-strategy-required": "Voorzieningsstrategie is verplicht.", + "provision-strategy-disabled": "Uitgeschakeld", + "provision-strategy-created-new": "Nieuwe apparaten toestaan", + "provision-strategy-check-pre-provisioned": "Controleren op vooraf geconfigureerde apparaten", + "provision-device-key": "Voorzieningssleutel", + "provision-device-key-required": "Voorzieningssleutel is verplicht.", + "copy-provision-key": "Voorzieningssleutel kopiëren", + "provision-key-copied-message": "Voorzieningssleutel is gekopieerd naar klembord", + "provision-device-secret": "Voorzieningsgeheim", + "provision-device-secret-required": "Voorzieningsgeheim is verplicht.", + "copy-provision-secret": "Voorzieningsgeheim kopiëren", + "provision-secret-copied-message": "Voorzieningsgeheim is gekopieerd naar klembord", + "provision-strategy-x509": { + "certificate-chain": "X509 certificaatketen", + "certificate-chain-hint": "X.509-certificaatstrategie wordt gebruikt om apparaten te voorzien via clientcertificaten in tweerichtings-TLS-communicatie.", + "allow-create-new-devices": "Nieuwe apparaten aanmaken", + "allow-create-new-devices-hint": "Indien geselecteerd, worden nieuwe apparaten aangemaakt en wordt het clientcertificaat gebruikt als apparaatreferentie.", + "certificate-value": "Certificaat in PEM-indeling", + "certificate-value-required": "Certificaat in PEM-indeling is verplicht", + "cn-regex-variable": "CN reguliere expressievariabele", + "cn-regex-variable-required": "CN reguliere expressievariabele is verplicht", + "cn-regex-variable-hint": "Nodig om de apparaatnaam te extraheren uit de algemene naam (CN) van het X509-certificaat van het apparaat." + }, + "condition": "Voorwaarde", + "condition-type": "Voorwaardetype", + "condition-type-simple": "Eenvoudig", + "condition-type-duration": "Duur", + "condition-during": "Gedurende {{during}}", + "condition-during-dynamic": "Gedurende \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Herhaling", + "condition-type-required": "Voorwaardetype is verplicht.", + "condition-repeating-value": "Aantal gebeurtenissen", + "condition-repeating-value-range": "Aantal gebeurtenissen moet tussen 1 en 2147483647 liggen.", + "condition-repeating-value-pattern": "Aantal gebeurtenissen moet een geheel getal zijn.", + "condition-repeating-value-required": "Aantal gebeurtenissen is verplicht.", + "condition-repeat-times": "Herhaalt { count, plural, =1 {1 keer} other {# keer} }", + "condition-repeat-times-dynamic": "Herhaalt \"{ attribute }\" ({ count, plural, =1 {1 keer} other {# keer} })", + "schedule-type": "Plannertype", + "schedule-type-required": "Plannertype is verplicht.", + "schedule": "Planning", + "edit-schedule": "Alarmplanning bewerken", + "schedule-any-time": "Altijd actief", + "schedule-specific-time": "Actief op specifiek tijdstip", + "schedule-custom": "Aangepast", + "schedule-day": { + "monday": "Maandag", + "tuesday": "Dinsdag", + "wednesday": "Woensdag", + "thursday": "Donderdag", + "friday": "Vrijdag", + "saturday": "Zaterdag", + "sunday": "Zondag" + }, + "schedule-days": "Dagen", + "schedule-time": "Tijd", + "schedule-time-from": "Van", + "schedule-time-to": "Tot", + "schedule-days-of-week-required": "Minimaal één weekdag moet worden geselecteerd.", + "create-device-profile": "Nieuw apparaatprofiel aanmaken", + "import": "Apparaatprofiel importeren", + "export": "Apparaatprofiel exporteren", + "export-failed-error": "Kan apparaatprofiel niet exporteren: {{error}}", + "device-profile-file": "Apparaatprofielbestand", + "invalid-device-profile-file-error": "Kan apparaatprofiel niet importeren: Ongeldige datastructuur.", + "power-saving-mode": "Energie besparende modus", + "power-saving-mode-type": { + "default": "Gebruik energiebesparende modus van profiel", + "psm": "Power Saving Mode (PSM)", + "drx": "Discontinuous Reception (DRX)", + "edrx": "Extended Discontinuous Reception (eDRX)" + }, + "edrx-cycle": "eDRX-cyclus", + "edrx-cycle-required": "eDRX-cyclus is verplicht.", + "edrx-cycle-pattern": "eDRX-cyclus moet een positief geheel getal zijn.", + "edrx-cycle-min": "Minimaal aantal seconden voor eDRX-cyclus is {{ min }}.", + "paging-transmission-window": "Paging transmissievenster", + "paging-transmission-window-required": "Paging transmissievenster is verplicht.", + "paging-transmission-window-pattern": "Paging transmissievenster moet een positief geheel getal zijn.", + "paging-transmission-window-min": "Minimaal aantal seconden voor transmissievenster is {{ min }}.", + "psm-activity-timer": "PSM Activiteitstimer", + "psm-activity-timer-required": "PSM activiteitstimer is verplicht.", + "psm-activity-timer-pattern": "PSM activiteitstimer moet een positief geheel getal zijn.", + "psm-activity-timer-min": "Minimaal aantal seconden voor activiteitstimer is {{ min }}.", + "lwm2m": { + "object-list": "Objectlijst", + "object-list-empty": "Geen objecten geselecteerd.", + "no-objects-found": "Geen objecten gevonden.", + "no-objects-matching": "Geen objecten gevonden die overeenkomen met '{{object}}'.", + "model-tab": "LWM2M-model", + "add-new-instances": "Nieuwe instanties toevoegen", + "instances-list": "Instantielijst", + "instances-list-required": "Instantielijst is verplicht.", + "instance-id-pattern": "Instantie-ID moet een positief geheel getal zijn.", + "instance-id-max": "Maximale waarde voor instantie-ID is {{max}}.", + "instance": "Instantie", + "resource-label": "#ID Resourcenaam", + "observe-label": "Observeer", + "attribute-label": "Attribuut", + "telemetry-label": "Telemetrie", + "edit-observe-select": "Om observatie te bewerken, selecteer telemetrie of attribuut", + "edit-attributes-select": "Om attributen te bewerken, selecteer telemetrie of attribuut", + "no-attributes-set": "Geen attributen ingesteld", + "key-name": "Sleutelnaam", + "key-name-required": "Sleutelnaam is verplicht", + "attribute-name": "Naam attribuut", + "attribute-name-required": "Naam attribuut is verplicht.", + "attribute-value": "Attribuutwaarde", + "attribute-value-required": "Attribuutwaarde is verplicht.", + "attribute-value-pattern": "Attribuutwaarde moet een positief geheel getal zijn.", + "edit-attributes": "Attributen bewerken: {{ name }}", + "view-attributes": "Attributen bekijken: {{ name }}", + "add-attribute": "Attribuut toevoegen", + "edit-attribute": "Attribuut bewerken", + "view-attribute": "Attribuut bekijken", + "remove-attribute": "Attribuut verwijderen", + "delete-server-text": "Wees voorzichtig, na bevestiging wordt de serverconfiguratie onherroepelijk verwijderd.", + "delete-server-title": "Weet je zeker dat je de server wilt verwijderen?", + "mode": "Beveiligingsconfiguratiemodus", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrapserver (ShortId...)", + "lwm2m-server-legend": "LwM2M-server (ShortId...)", + "server": "Server", + "short-id": "Korte server-ID", + "short-id-tooltip": "Korte server-ID. Wordt gebruikt om serverobjectinstanties te koppelen.\nDeze identifier identificeert uniek elke LwM2M-server geconfigureerd voor de LwM2M-client.\nMoet worden ingesteld als de Bootstrap-Server-resource 'false' is.\nWaarden ID:0 en ID:65535 mogen niet worden gebruikt.", + "short-id-tooltip-bootstrap": "Korte server-ID. Vereist voor associatie van objectinstanties.\nMag niet 0 of 65535 zijn.", + "short-id-required": "Korte server-ID is verplicht.", + "short-id-range": "Korte server-ID moet tussen {{ min }} en {{ max }} liggen.", + "short-id-pattern": "Korte server-ID moet een positief geheel getal zijn.", + "lifetime": "Registratieduur client", + "lifetime-required": "Registratieduur is verplicht.", + "lifetime-pattern": "Registratieduur moet een positief geheel getal zijn.", + "default-min-period": "Minimale periode tussen meldingen (s)", + "default-min-period-tooltip": "Standaard minimumperiode voor observatie indien geen waarde opgegeven is.", + "default-min-period-required": "Minimale periode is verplicht.", + "default-min-period-pattern": "Minimale periode moet een positief geheel getal zijn.", + "notification-storing": "Notificaties opslaan bij uitgeschakeld of offline", + "binding": "Binding", + "binding-type": { + "u": "U: Client is bereikbaar via UDP-binding.", + "m": "M: Client is bereikbaar via MQTT-binding.", + "h": "H: Client is bereikbaar via HTTP-binding.", + "t": "T: Client is bereikbaar via TCP-binding.", + "s": "S: Client is bereikbaar via SMS-binding.", + "n": "N: Client MOET antwoorden via Non-IP-binding (sinds LWM2M 1.1).", + "uq": "UQ: UDP in wachtrijmodus (niet ondersteund sinds LWM2M 1.1)", + "uqs": "UQS: UDP + SMS actief; UDP in wachtrijmodus (niet ondersteund sinds LWM2M 1.1)", + "tq": "TQ: TCP in wachtrijmodus (niet ondersteund sinds LWM2M 1.1)", + "tqs": "TQS: TCP + SMS actief; TCP in wachtrijmodus (niet ondersteund sinds LWM2M 1.1)", + "sq": "SQ: SMS in wachtrijmodus (niet ondersteund sinds LWM2M 1.1)" + }, + "binding-tooltip": "Bindingmodus zoals gedefinieerd in resource /1/x/7 van het LwM2M-serverobject.\nGeeft ondersteunde bindingen aan in de client. Deze waarde moet overeenkomen met waarde in resource /3/0/16.\nSlechts één binding kan gebruikt worden per sessie.", + "bootstrap-server": "Bootstrapserver", + "lwm2m-server": "LwM2M-server", + "include-bootstrap-server": "Bootstrapserver-updates insluiten", + "bootstrap-update-title": "Er is al een Bootstrapserver geconfigureerd. Weet je zeker dat je de updates wilt uitsluiten?", + "bootstrap-update-text": "Wees voorzichtig, na bevestiging wordt de configuratie van de Bootstrapserver verwijderd.", + "server-host": "Host", + "server-host-required": "Host is verplicht.", + "server-port": "Poort", + "server-port-required": "Poort is verplicht.", + "server-port-pattern": "Poort moet een positief geheel getal zijn.", + "server-port-range": "Poort moet tussen 1 en 65535 liggen.", + "server-public-key": "Server openbare sleutel", + "server-public-key-required": "Server openbare sleutel is verplicht.", + "client-hold-off-time": "Wachttijd client", + "client-hold-off-time-required": "Wachttijd client is verplicht.", + "client-hold-off-time-pattern": "Wachttijd client moet een positief geheel getal zijn.", + "client-hold-off-time-tooltip": "Wachttijd van de client, alleen te gebruiken met een Bootstrap-server", + "account-after-timeout": "Account na time-out", + "account-after-timeout-required": "Account na time-out is verplicht.", + "account-after-timeout-pattern": "Account na time-out moet een positief geheel getal zijn.", + "account-after-timeout-tooltip": "Accountinstelling van de Bootstrap-server na de opgegeven time-out.", + "server-type": "Servertype", + "add-new-server-title": "Nieuwe serverconfiguratie toevoegen", + "add-server-config": "Serverconfiguratie toevoegen", + "add-lwm2m-server-config": "LwM2M-server toevoegen", + "no-config-servers": "Geen servers geconfigureerd", + "others-tab": "Overige instellingen", + "client-strategy": "Clientstrategie bij verbinding", + "client-strategy-label": "Strategie", + "client-strategy-only-observe": "Alleen Observe-verzoek na eerste verbinding", + "client-strategy-read-all": "Lees alle bronnen & Observe-verzoek na registratie", + "fw-update": "Firmware-update", + "fw-update-strategy": "Firmware-updatestrategie", + "fw-update-strategy-data": "Firmware als binair bestand pushen via Object 19, Resource 0 (Data)", + "fw-update-strategy-package": "Firmware als binair bestand pushen via Object 5, Resource 0 (Package)", + "fw-update-strategy-package-uri": "Unieke CoAP-URL automatisch genereren om firmwarepakket te downloaden en pushen via Object 5, Resource 1 (Package URI)", + "sw-update": "Software-update", + "sw-update-strategy": "Software-updatestrategie", + "sw-update-strategy-package": "Binaire bestanden pushen via Object 9, Resource 2 (Package)", + "sw-update-strategy-package-uri": "Unieke CoAP-URL automatisch genereren om softwarepakket te downloaden en pushen via Object 9, Resource 3 (Package URI)", + "fw-update-resource": "Firmware-update CoAP-resource", + "fw-update-resource-required": "Firmware-update CoAP-resource is verplicht.", + "sw-update-resource": "Software-update CoAP-resource", + "sw-update-resource-required": "Software-update CoAP-resource is verplicht.", + "config-json-tab": "Json Config Profielapparaat", + "attributes-name": { + "min-period": "Minimale periode", + "max-period": "Maximale periode", + "greater-than": "Groter dan", + "less-than": "Kleiner dan", + "step": "Stap", + "min-evaluation-period": "Minimale evaluatieperiode", + "max-evaluation-period": "Maximale evaluatieperiode" + }, + "default-object-id": "Standaard objectversie (Attribuut)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1" + } + }, + "snmp": { + "add-communication-config": "Communicatieconfiguratie toevoegen", + "add-mapping": "Mapping toevoegen", + "authentication-passphrase": "Authenticatie wachtzin", + "authentication-passphrase-required": "Authenticatie wachtzin is verplicht.", + "authentication-protocol": "Authenticatieprotocol", + "authentication-protocol-required": "Authenticatieprotocol is verplicht.", + "communication-configs": "Communicatieconfiguraties", + "community": "Community string", + "community-required": "Community string is verplicht.", + "context-name": "Contextnaam", + "data-key": "Gegevenssleutel", + "data-key-required": "Gegevenssleutel is verplicht.", + "data-type": "Gegevenstype", + "data-type-required": "Gegevenstype is verplicht.", + "engine-id": "Engine ID", + "host": "Host", + "host-required": "Host is verplicht.", + "oid": "OID", + "oid-pattern": "Ongeldig OID-formaat.", + "oid-required": "OID is verplicht.", + "please-add-communication-config": "Voeg een communicatieconfiguratie toe", + "please-add-mapping-config": "Voeg een mappingconfiguratie toe", + "port": "Poort", + "port-format": "Ongeldig poortformaat.", + "port-required": "Poort is verplicht.", + "privacy-passphrase": "Privacy wachtzin", + "privacy-passphrase-required": "Privacy wachtzin is verplicht.", + "privacy-protocol": "Privacyprotocol", + "privacy-protocol-required": "Privacyprotocol is verplicht.", + "protocol-version": "Protocolversie", + "protocol-version-required": "Protocolversie is verplicht.", + "querying-frequency": "Queryfrequentie, ms", + "querying-frequency-invalid-format": "Queryfrequentie moet een positief geheel getal zijn.", + "querying-frequency-required": "Queryfrequentie is verplicht.", + "retries": "Pogingen", + "retries-invalid-format": "Aantal pogingen moet een positief geheel getal zijn.", + "retries-required": "Aantal pogingen is verplicht.", + "scope": "Scope", + "scope-required": "Scope is verplicht.", + "security-name": "Beveiligingsnaam", + "security-name-required": "Beveiligingsnaam is verplicht.", + "timeout-ms": "Time-out, ms", + "timeout-ms-invalid-format": "Time-out moet een positief geheel getal zijn.", + "timeout-ms-required": "Time-out is verplicht.", + "user-name": "Gebruikersnaam", + "user-name-required": "Gebruikersnaam is verplicht." + } + }, + "dialog": { + "close": "Dialoog sluiten", + "error-message-title": "Foutmelding:", + "error-details-title": "Foutdetails" + }, + "direction": { + "column": "Kolom", + "row": "Rij" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Edge-instanties", + "instances": "Instanties", + "edge-file": "Edge-bestand", + "name-max-length": "Naam mag niet langer zijn dan 256 tekens", + "label-max-length": "Label mag niet langer zijn dan 256 tekens", + "type-max-length": "Type mag niet langer zijn dan 256 tekens", + "management": "Edgebeheer", + "no-edges-matching": "Geen edge-instanties gevonden die overeenkomen met '{{entity}}'.", + "add": "Edge toevoegen", + "no-edges-text": "Geen edge-instanties gevonden", + "edge-details": "Edge-details", + "add-edge-text": "Nieuwe edge toevoegen", + "delete": "Edge verwijderen", + "delete-edge-title": "Weet je zeker dat je de edge '{{edgeName}}' wilt verwijderen?", + "delete-edge-text": "Wees voorzichtig, na bevestiging wordt de edge en alle gerelateerde gegevens permanent verwijderd.", + "delete-edges-title": "Weet je zeker dat je { count, plural, =1 {1 edge} other {# edge-instanties} } wilt verwijderen?", + "delete-edges-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde edge-instanties verwijderd inclusief alle gerelateerde gegevens.", + "name": "Naam", + "name-starts-with": "Edge-naam begint met", + "name-required": "Naam is verplicht.", + "description": "Beschrijving", + "details": "Details", + "events": "Gebeurtenissen", + "copy-id": "Kopieer Edge-ID", + "id-copied-message": "Edge-ID is gekopieerd naar klembord", + "sync": "Edge synchroniseren", + "edge-required": "Edge is verplicht", + "edge-type": "Edge-type", + "edge-type-required": "Edge-type is verplicht.", + "event-action": "Gebeurtenisactie", + "entity-id": "Entiteit-ID", + "select-edge-type": "Selecteer edge-type", + "assign-to-customer": "Toewijzen aan klant", + "assign-to-customer-text": "Selecteer de klant aan wie je de edge(s) wilt toewijzen", + "assign-edge-to-customer": "Wijs Edge(s) toe aan klant", + "assign-edge-to-customer-text": "Selecteer de edge-instanties die je aan de klant wilt toewijzen", + "assignedToCustomer": "Toegewezen aan klant", + "edge-public": "Edge is openbaar", + "assigned-to-customer": "Toegewezen aan: {{customerTitle}}", + "unassign-from-customer": "Toewijzing aan klant ongedaan maken", + "unassign-edge-title": "Weet je zeker dat je de edge '{{edgeName}}' wilt ontkoppelen?", + "unassign-edge-text": "Na bevestiging wordt de edge ontkoppeld en is deze niet meer toegankelijk voor de klant.", + "unassign-edges-title": "Weet je zeker dat je { count, plural, =1 {1 edge} other {# edge-instanties} } wilt ontkoppelen?", + "unassign-edges-text": "Na bevestiging worden alle geselecteerde edge-instanties ontkoppeld en zijn ze niet meer toegankelijk voor de klant.", + "make-public": "Maak edge openbaar", + "make-public-edge-title": "Weet je zeker dat je de edge '{{edgeName}}' openbaar wilt maken?", + "make-public-edge-text": "Na bevestiging wordt de edge en alle gerelateerde gegevens openbaar gemaakt.", + "make-private": "Maak edge privé", + "public": "Openbaar", + "make-private-edge-title": "Weet je zeker dat je de edge '{{edgeName}}' privé wilt maken?", + "make-private-edge-text": "Na bevestiging wordt de edge en alle gerelateerde gegevens privé gemaakt.", + "import": "Edge importeren", + "install-connect-instructions": "Installatie- en verbindingsinstructies", + "install-connect-instructions-edge-created": "Edge aangemaakt! Raadpleeg installatie- en verbindingsinstructies", + "loading-edge-instructions": "Edge-instructies laden...", + "label": "Label", + "load-entity-error": "Kan gegevens niet laden. Entiteit is verwijderd.", + "assign-new-edge": "Nieuwe edge toewijzen", + "unassign-from-edge": "Ontkoppelen van edge", + "edge-key": "Edge-sleutel", + "copy-edge-key": "Kopieer Edge-sleutel", + "edge-key-copied-message": "Edge-sleutel is gekopieerd naar klembord", + "edge-secret": "Edge-geheim", + "copy-edge-secret": "Kopieer Edge-geheim", + "edge-secret-copied-message": "Edge-geheim is gekopieerd naar klembord", + "manage-assets": "Beheer assets", + "manage-devices": "Beheer apparaten", + "manage-entity-views": "Beheer entiteitweergaven", + "manage-dashboards": "Beheer dashboards", + "manage-rulechains": "Beheer rule chains", + "assets": "Edge-assets", + "devices": "Edge-apparaten", + "entity-views": "Edge-entiteitweergaven", + "dashboard": "Edge-dashboard", + "dashboards": "Edge-dashboards", + "rulechain-templates": "Rule chain-sjablonen", + "edge-rulechain-templates": "Edge rule chain-sjablonen", + "rulechains": "Edge rule chains", + "search": "Zoek edges", + "selected-edges": "{ count, plural, =1 {1 edge} other {# edges} } geselecteerd", + "any-edge": "Elke edge", + "no-edge-types-matching": "Geen edge-types gevonden die overeenkomen met '{{entitySubtype}}'.", + "edge-type-list-empty": "Geen edge-types geselecteerd.", + "edge-types": "Edge-types", + "enter-edge-type": "Voer edge-type in", + "deployed": "Ingezet", + "pending": "In afwachting", + "downlinks": "Downlinks", + "no-downlinks-prompt": "Geen downlinks gevonden", + "sync-process-started-successfully": "Synchronisatieproces succesvol gestart!", + "missing-related-rule-chains-title": "Edge mist gerelateerde rule chain(s)", + "missing-related-rule-chains-text": "Aan edge toegewezen rule chain(s) gebruiken knooppunten die berichten doorsturen naar niet-toegewezen rule chain(s).

    Lijst van ontbrekende rule chain(s):
    {{missingRuleChains}}", + "widget-datasource-error": "Deze widget ondersteunt alleen EDGE-entiteit als gegevensbron", + "upgrade-instructions": "Upgrade-instructies", + "connected": "Verbonden", + "disconnected": "Verbinding verbroken" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Asset", + "type-device": "Apparaat", + "type-device-profile": "Apparaatprofiel", + "type-asset-profile": "Assetprofiel", + "type-entity-view": "Entiteitsweergave", + "type-alarm": "Alarm", + "type-rule-chain": "Regelketen", + "type-rule-chain-metadata": "Regelketen Metadata", + "type-edge": "Edge", + "type-user": "Gebruiker", + "type-tenant": "Tenant", + "type-tenant-profile": "Tenantprofiel", + "type-customer": "Klant", + "type-relation": "Relatie", + "type-widgets-bundle": "Widgetbundel", + "type-widgets-type": "Widgettype", + "type-admin-settings": "Beheerinstellingen", + "type-ota-package": "OTA-pakket", + "type-queue": "Wachtrij", + "action-type-added": "Toegevoegd", + "action-type-deleted": "Verwijderd", + "action-type-updated": "Bijgewerkt", + "action-type-post-attributes": "Attribuut verzonden", + "action-type-attributes-updated": "Attributen bijgewerkt", + "action-type-attributes-deleted": "Attributen verwijderd", + "action-type-timeseries-updated": "Tijdreeks bijgewerkt", + "action-type-credentials-updated": "Referenties bijgewerkt", + "action-type-assigned-to-customer": "Toegewezen aan klant", + "action-type-unassigned-from-customer": "Ontkoppeld van klant", + "action-type-relation-add-or-update": "Relatie toegevoegd/bijgewerkt", + "action-type-relation-deleted": "Relatie verwijderd", + "action-type-rpc-call": "RPC-oproep", + "action-type-alarm-ack": "Alarm bevestigd", + "action-type-alarm-clear": "Alarm gewist", + "action-type-alarm-assigned": "Alarm toegewezen", + "action-type-alarm-unassigned": "Alarm ontkoppeld", + "action-type-assigned-to-edge": "Toegewezen aan edge", + "action-type-unassigned-from-edge": "Ontkoppeld van edge", + "action-type-credentials-request": "Referentieaanvraag", + "action-type-entity-merge-request": "Entiteitsmerge-aanvraag" + }, + "error": { + "unable-to-connect": "Kan geen verbinding maken met de server! Controleer uw internetverbinding.", + "unhandled-error-code": "Onbehandelde foutcode: {{errorCode}}", + "unknown-error": "Onbekende fout" + }, + "entity": { + "entity": "Entiteit", + "entities": "Entiteiten", + "entities-count": "Aantal entiteiten", + "alarms-count": "Aantal alarmen", + "aliases": "Entiteit-aliasen", + "aliases-short": "Aliassen", + "entity-alias": "Entiteit-alias", + "unable-delete-entity-alias-title": "Kan entiteit-alias niet verwijderen", + "unable-delete-entity-alias-text": "Entiteit-alias '{{entityAlias}}' kan niet worden verwijderd omdat het wordt gebruikt door de volgende widget(s):
    {{widgetsList}}", + "duplicate-alias-error": "Dubbele alias gevonden '{{alias}}'.
    Entiteit-aliasen moeten uniek zijn binnen het dashboard.", + "missing-entity-filter-error": "Filter ontbreekt voor alias '{{alias}}'.", + "configure-alias": "Configureer alias '{{alias}}'", + "alias": "Alias", + "alias-required": "Entiteit-alias is verplicht.", + "remove-alias": "Verwijder entiteit-alias", + "add-alias": "Voeg entiteit-alias toe", + "edit-alias": "Bewerk entiteit-alias", + "entity-list": "Entiteitenlijst", + "entity-type": "Entiteittype", + "entity-types": "Entiteittypes", + "entity-type-list": "Lijst met entiteittypes", + "any-entity": "Elke entiteit", + "add-entity-type": "Voeg entiteittype toe", + "enter-entity-type": "Voer entiteittype in", + "no-entities-matching": "Geen entiteiten gevonden die overeenkomen met '{{entity}}'.", + "no-entities-text": "Geen entiteiten gevonden", + "no-entity-types-matching": "Geen entiteittypes gevonden die overeenkomen met '{{entityType}}'.", + "name-starts-with": "Naamexpressie", + "help-text": "Gebruik '%' indien nodig: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "use-entity-name-filter": "Gebruik filter", + "entity-list-empty": "Geen entiteiten geselecteerd.", + "entity-type-list-required": "Minstens één entiteittype moet worden geselecteerd.", + "entity-name-filter-required": "Entiteitnaamfilter is vereist.", + "entity-name-filter-no-entity-matched": "Geen entiteiten gevonden die beginnen met '{{entity}}'.", + "all-subtypes": "Alle", + "select-entities": "Selecteer entiteiten", + "no-aliases-found": "Geen aliassen gevonden.", + "no-alias-matching": "'{{alias}}' niet gevonden.", + "create-new-alias": "Maak een nieuwe!", + "create-new": "Nieuw aanmaken", + "key": "Sleutel", + "key-name": "Sleutelnaam", + "no-keys-found": "Geen sleutels gevonden.", + "no-key-matching": "'{{key}}' niet gevonden.", + "create-new-key": "Maak een nieuwe!", + "type": "Type", + "type-required": "Entiteittype is vereist.", + "type-device": "Apparaat", + "type-devices": "Apparaten", + "list-of-devices": "{ count, plural, =1 {Een apparaat} other {Lijst van # apparaten} }", + "device-name-starts-with": "Apparaten met namen die beginnen met '{{prefix}}'", + "type-device-profile": "Apparaatprofiel", + "type-device-profiles": "Apparaatprofielen", + "clear-selected-profiles": "Geselecteerde profielen wissen", + "list-of-device-profiles": "{ count, plural, =1 {Een apparaatprofiel} other {Lijst van # apparaatprofielen} }", + "device-profile-name-starts-with": "Apparaatprofielen met namen die beginnen met '{{prefix}}'", + "type-asset-profile": "Assetprofiel", + "type-asset-profiles": "Assetprofielen", + "list-of-asset-profiles": "{ count, plural, =1 {Een assetprofiel} other {Lijst van # assetprofielen} }", + "asset-profile-name-starts-with": "Assetprofielen met namen die beginnen met '{{prefix}}'", + "type-asset": "Asset", + "type-assets": "Assets", + "list-of-assets": "{ count, plural, =1 {Een asset} other {Lijst van # assets} }", + "asset-name-starts-with": "Assets met namen die beginnen met '{{prefix}}'", + "type-entity-view": "Entiteitsweergave", + "type-entity-views": "Entiteitsweergaven", + "list-of-entity-views": "{ count, plural, =1 {Een entiteitsweergave} other {Lijst van # entiteitsweergaven} }", + "entity-view-name-starts-with": "Entiteitsweergaven met namen die beginnen met '{{prefix}}'", + "type-rule": "Regel", + "type-rules": "Regels", + "list-of-rules": "{ count, plural, =1 {Een regel} other {Lijst van # regels} }", + "rule-name-starts-with": "Regels met namen die beginnen met '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "list-of-plugins": "{ count, plural, =1 {Een plugin} other {Lijst van # plugins} }", + "plugin-name-starts-with": "Plugins met namen die beginnen met '{{prefix}}'", + "type-tenant": "Tenant", + "type-tenants": "Tenants", + "list-of-tenants": "{ count, plural, =1 {Een tenant} other {Lijst van # tenants} }", + "tenant-name-starts-with": "Tenants met namen die beginnen met '{{prefix}}'", + "type-tenant-profile": "Tenantprofiel", + "type-tenant-profiles": "Tenantprofielen", + "list-of-tenant-profiles": "{ count, plural, =1 {Een tenantprofiel} other {Lijst van # tenantprofielen} }", + "tenant-profile-name-starts-with": "Tenantprofielen met namen die beginnen met '{{prefix}}'", + "type-customer": "Klant", + "type-customers": "Klanten", + "list-of-customers": "{ count, plural, =1 {Eén klant} other {Lijst van # klanten} }", + "customer-name-starts-with": "Klanten met namen die beginnen met '{{prefix}}'", + "type-user": "Gebruiker", + "type-users": "Gebruikers", + "list-of-users": "{ count, plural, =1 {Eén gebruiker} other {Lijst van # gebruikers} }", + "user-name-starts-with": "Gebruikers met namen die beginnen met '{{prefix}}'", + "type-dashboard": "Dashboard", + "type-dashboards": "Dashboards", + "list-of-dashboards": "{ count, plural, =1 {Eén dashboard} other {Lijst van # dashboards} }", + "dashboard-name-starts-with": "Dashboards met namen die beginnen met '{{prefix}}'", + "type-alarm": "Alarm", + "type-alarms": "Alarmen", + "list-of-alarms": "{ count, plural, =1 {Eén alarm} other {Lijst van # alarmen} }", + "alarm-name-starts-with": "Alarmen met namen die beginnen met '{{prefix}}'", + "type-rulechain": "Regelketen", + "type-rulechains": "Regelketens", + "list-of-rulechains": "{ count, plural, =1 {Eén regelketen} other {Lijst van # regelketens} }", + "rulechain-name-starts-with": "Regelketens met namen die beginnen met '{{prefix}}'", + "type-rulenode": "Regelknooppunt", + "type-rulenodes": "Regelknooppunten", + "list-of-rulenodes": "{ count, plural, =1 {Eén regelknooppunt} other {Lijst van # regelknooppunten} }", + "rulenode-name-starts-with": "Regelknooppunten met namen die beginnen met '{{prefix}}'", + "type-current-customer": "Huidige klant", + "type-current-tenant": "Huidige tenant", + "type-current-user": "Huidige gebruiker", + "type-current-user-owner": "Eigenaar huidige gebruiker", + "type-calculated-field": "Berekend veld", + "type-calculated-fields": "Berekende velden", + "type-widgets-bundle": "Widget-bundel", + "type-widgets-bundles": "Widget-bundels", + "list-of-widgets-bundles": "{ count, plural, =1 {Eén widget-bundel} other {Lijst van # widget-bundels} }", + "type-widget": "Widget", + "type-widgets": "Widgets", + "list-of-widgets": "{ count, plural, =1 {Eén widget} other {Lijst van # widgets} }", + "search": "Zoek entiteiten", + "selected-entities": "{ count, plural, =1 {1 entiteit} other {# entiteiten} } geselecteerd", + "entity-name": "Entiteitnaam", + "entity-label": "Entiteitlabel", + "details": "Entiteitdetails", + "no-entities-prompt": "Geen entiteiten gevonden", + "no-data": "Geen gegevens om weer te geven", + "columns-to-display": "Kolommen om weer te geven", + "type-api-usage-state": "API-gebruikstoestand", + "type-edge": "Edge", + "type-edges": "Edges", + "list-of-edges": "{ count, plural, =1 {Eén edge} other {Lijst van # edges} }", + "edge-name-starts-with": "Edges met namen die beginnen met '{{prefix}}'", + "version-conflict": { + "message": "Wilt u de bestaande versie overschrijven of de wijzigingen negeren en de laatste versie laden?", + "link": "U kunt uw versie van de {{entityType}} downloaden via deze", + "overwrite": "Versie overschrijven", + "discard": "Wijzigingen negeren" + }, + "type-tb-resource": "Resource", + "type-tb-resources": "Resources", + "list-of-tb-resources": "{ count, plural, =1 {Eén resource} other {Lijst van # resources} }", + "type-ota-package": "OTA-pakket", + "type-rpc": "RPC", + "type-queue": "Wachtrij", + "type-queue-stats": "Wachtrijstatistieken", + "type-queues-stats": "Wachtrijenstatistieken", + "type-notification": "Melding", + "type-notification-rule": "Meldingsregel", + "type-notification-rules": "Meldingsregels", + "list-of-notification-rules": "{ count, plural, =1 {Eén meldingsregel} other {Lijst van # meldingsregels} }", + "type-notification-target": "Meldingsontvanger", + "type-notification-targets": "Meldingsontvangers", + "list-of-notification-targets": "{ count, plural, =1 {Eén meldingsontvanger} other {Lijst van # meldingsontvangers} }", + "type-notification-request": "Meldingsverzoek", + "type-notification-template": "Meldingssjabloon", + "type-notification-templates": "Meldingssjablonen", + "list-of-notification-templates": "{ count, plural, =1 {Eén meldingssjabloon} other {Lijst van # meldingssjablonen} }", + "link": "link", + "type-oauth2-client": "OAuth 2.0-client", + "type-oauth2-clients": "OAuth 2.0-clients", + "list-of-oauth2-clients": "{ count, plural, =1 {Eén OAuth 2.0-client} other {Lijst van # OAuth 2.0-clients} }", + "type-domain": "Domein", + "type-domains": "Domeinen", + "list-of-domains": "{ count, plural, =1 {Eén domein} other {Lijst van # domeinen} }", + "type-mobile-app": "Mobiele applicatie", + "type-mobile-apps": "Mobiele applicaties", + "list-of-mobile-apps": "{ count, plural, =1 {Eén mobiele applicatie} other {Lijst van # mobiele applicaties} }", + "type-mobile-app-bundle": "Mobiele bundel", + "type-mobile-app-bundles": "Mobiele bundels", + "list-of-mobile-app-bundles": "{ count, plural, =1 {Eén mobiele bundel} other {Lijst van # mobiele bundels} }" + }, + "entity-field": { + "created-time": "Aangemaakt op", + "name": "Naam", + "type": "Type", + "first-name": "Voornaam", + "last-name": "Achternaam", + "email": "E-mail", + "title": "Titel", + "country": "Land", + "state": "Staat / Provincie", + "city": "Stad", + "address": "Adres", + "address2": "Adres 2", + "zip": "Postcode", + "phone": "Telefoon", + "label": "Label", + "queue-name": "Naam wachtrij", + "service-id": "Service-ID", + "owner-name": "Naam eigenaar", + "owner-type": "Eigenaartype" + }, + "entity-view": { + "entity-view": "Entity view", + "entity-view-required": "Entity view is vereist.", + "entity-views": "Entity views", + "management": "Beheer van Entity Views", + "view-entity-views": "Bekijk Entity Views", + "entity-view-alias": "Entity view-alias", + "aliases": "Entity view-aliasen", + "no-alias-matching": "'{{alias}}' niet gevonden.", + "no-aliases-found": "Geen aliasen gevonden.", + "no-key-matching": "'{{key}}' niet gevonden.", + "no-keys-found": "Geen sleutels gevonden.", + "create-new-alias": "Maak een nieuwe aan!", + "create-new-key": "Maak een nieuwe aan!", + "duplicate-alias-error": "Dubbele alias gevonden '{{alias}}'.
    Entity view-aliasen moeten uniek zijn binnen het dashboard.", + "configure-alias": "Configureer alias '{{alias}}'", + "no-entity-views-matching": "Geen entity views gevonden die overeenkomen met '{{entity}}'.", + "public": "Openbaar", + "alias": "Alias", + "alias-required": "Entity view-alias is vereist.", + "remove-alias": "Verwijder entity view-alias", + "add-alias": "Voeg entity view-alias toe", + "name-starts-with": "Entity view naamexpressie", + "help-text": "Gebruik '%' naar behoefte: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "Entity view-lijst", + "use-entity-view-name-filter": "Gebruik filter", + "entity-view-list-empty": "Geen entity views geselecteerd.", + "entity-view-name-filter-required": "Entity view naamfilter is vereist.", + "entity-view-name-filter-no-entity-view-matched": "Geen entity views gevonden die beginnen met '{{entityView}}'.", + "add": "Voeg entity view toe", + "entity-view-public": "Entity view is openbaar", + "assign-to-customer": "Toewijzen aan klant", + "assign-entity-view-to-customer": "Wijs entity view(s) toe aan klant", + "assign-entity-view-to-customer-text": "Selecteer de entity views die u aan de klant wilt toewijzen", + "no-entity-views-text": "Geen entity views gevonden", + "assign-to-customer-text": "Selecteer de klant om de entity view(s) aan toe te wijzen", + "entity-view-details": "Details van entity view", + "add-entity-view-text": "Voeg nieuwe entity view toe", + "delete": "Verwijder entity view", + "assign-entity-views": "Wijs entity views toe", + "assign-entity-views-text": "{ count, plural, =1 {1 entity view} other {# entity views} } toewijzen aan klant", + "delete-entity-views": "Verwijder entity views", + "make-public": "Maak entity view openbaar", + "make-private": "Maak entity view privé", + "unassign-from-customer": "Ontkoppel van klant", + "unassign-entity-views": "Ontkoppel entity views", + "unassign-entity-views-action-title": "{ count, plural, =1 {1 entity view} other {# entity views} } ontkoppelen van klant", + "assign-new-entity-view": "Wijs nieuwe entity view toe", + "delete-entity-view-title": "Weet u zeker dat u de entity view '{{entityViewName}}' wilt verwijderen?", + "delete-entity-view-text": "Wees voorzichtig, na bevestiging wordt de entity view en alle gerelateerde gegevens permanent verwijderd.", + "delete-entity-views-title": "Weet u zeker dat u { count, plural, =1 {1 entity view} other {# entity views} } wilt verwijderen?", + "delete-entity-views-action-title": "Verwijder { count, plural, =1 {1 entity view} other {# entity views} }", + "delete-entity-views-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde entity views verwijderd en worden gerelateerde gegevens permanent verwijderd.", + "make-public-entity-view-title": "Weet u zeker dat u de entity view '{{entityViewName}}' openbaar wilt maken?", + "make-public-entity-view-text": "Na bevestiging wordt de entity view en alle gegevens openbaar en toegankelijk voor anderen.", + "make-private-entity-view-title": "Weet u zeker dat u de entity view '{{entityViewName}}' privé wilt maken?", + "make-private-entity-view-text": "Na bevestiging wordt de entity view en alle gegevens privé en niet langer toegankelijk voor anderen.", + "unassign-entity-view-title": "Weet u zeker dat u de entity view '{{entityViewName}}' wilt ontkoppelen?", + "unassign-entity-view-text": "Na bevestiging wordt de entity view ontkoppeld en niet langer toegankelijk voor de klant.", + "unassign-entity-view": "Ontkoppel entity view", + "unassign-entity-views-title": "Weet u zeker dat u { count, plural, =1 {1 entity view} other {# entity views} } wilt ontkoppelen?", + "unassign-entity-views-text": "Na bevestiging worden alle geselecteerde entity views ontkoppeld en niet langer toegankelijk voor de klant.", + "entity-view-type": "Entity view-type", + "entity-view-type-required": "Entity view-type is vereist.", + "select-entity-view-type": "Selecteer entity view-type", + "enter-entity-view-type": "Voer entity view-type in", + "any-entity-view": "Elke entity view", + "no-entity-view-types-matching": "Geen entity view-types gevonden die overeenkomen met '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Geen entity view-types geselecteerd.", + "entity-view-types": "Entity view-types", + "created-time": "Aangemaakt op", + "name": "Naam", + "name-required": "Naam is vereist.", + "name-max-length": "Naam moet minder dan 256 tekens zijn", + "type-max-length": "Entity view-type moet minder dan 256 tekens zijn", + "description": "Beschrijving", + "events": "Gebeurtenissen", + "details": "Details", + "copyId": "Kopieer entity view-ID", + "idCopiedMessage": "Entity view-ID is gekopieerd naar klembord", + "assignedToCustomer": "Toegewezen aan klant", + "unable-entity-view-device-alias-title": "Kan entity view-alias niet verwijderen", + "unable-entity-view-device-alias-text": "Device-alias '{{entityViewAlias}}' kan niet worden verwijderd omdat deze wordt gebruikt door de volgende widget(s):
    {{widgetsList}}", + "select-entity-view": "Selecteer entity view", + "start-ts": "Starttijd", + "end-ts": "Eindtijd", + "date-limits": "Datumbeperkingen", + "client-attributes": "Clientattributen", + "shared-attributes": "Gedeelde attributen", + "server-attributes": "Serverattributen", + "timeseries": "Tijdreeks", + "client-attributes-placeholder": "Clientattributen", + "shared-attributes-placeholder": "Gedeelde attributen", + "server-attributes-placeholder": "Serverattributen", + "timeseries-placeholder": "Tijdreeks", + "target-entity": "Doelentiteit", + "attributes-propagation": "Attributenpropagatie", + "attributes-propagation-hint": "Entity View zal automatisch gespecificeerde attributen kopiëren van de Doelentiteit telkens wanneer u deze entity view opslaat of bijwerkt. Omwille van prestatie-redenen worden attributen van de doelentiteit niet automatisch gesynchroniseerd bij elke wijziging. U kunt automatische propagatie inschakelen door een \"copy to view\" regelnode te configureren in uw regelketen en \"Post attributes\" en \"Attributes Updated\" berichten te koppelen aan de nieuwe regelnode.", + "timeseries-data": "Tijdreeksgegevens", + "timeseries-data-hint": "Configureer tijdreeksgegevenssleutels van de doelentiteit die toegankelijk zullen zijn voor de entity view. Deze tijdreeksgegevens zijn alleen-lezen.", + "search": "Zoek entity views", + "selected-entity-views": "{ count, plural, =1 {1 entity view} other {# entity views} } geselecteerd", + "assign-entity-view-to-edge": "Wijs entity view(s) toe aan Edge", + "assign-entity-view-to-edge-text": "Selecteer de entity views die u aan de edge wilt toewijzen", + "unassign-entity-view-from-edge-title": "Weet u zeker dat u de entity view '{{entityViewName}}' wilt ontkoppelen?", + "unassign-entity-view-from-edge-text": "Na bevestiging wordt de entity view ontkoppeld en niet langer toegankelijk door de edge.", + "unassign-entity-views-from-edge-action-title": "{ count, plural, =1 {1 entity view} other {# entity views} } ontkoppelen van edge", + "unassign-entity-view-from-edge": "Ontkoppel entity view", + "unassign-entity-views-from-edge-title": "Weet u zeker dat u { count, plural, =1 {1 entity view} other {# entity views} } wilt ontkoppelen?", + "unassign-entity-views-from-edge-text": "Na bevestiging worden alle geselecteerde entity views ontkoppeld en niet langer toegankelijk door de edge." + }, + "event": { + "event-type": "Gebeurtenistype", + "events-filter": "Gebeurtenissenfilter", + "clean-events": "Gebeurtenissen wissen", + "type-error": "Fout", + "type-lc-event": "Levenscyclusgebeurtenis", + "type-stats": "Statistieken", + "type-debug-rule-node": "Debug", + "type-debug-rule-chain": "Debug", + "type-debug-calculated-field": "Debug", + "arguments": "Argumenten", + "result": "Resultaat", + "no-events-prompt": "Geen gebeurtenissen gevonden", + "error": "Fout", + "alarm": "Alarm", + "event-time": "Gebeurtenistijd", + "server": "Server", + "body": "Inhoud", + "method": "Methode", + "type": "Type", + "metadata": "Metadata", + "message": "Bericht", + "message-id": "Bericht-ID", + "copy-message-id": "Kopieer bericht-ID", + "message-type": "Berichttype", + "data-type": "Gegevenstype", + "relation-type": "Relatietype", + "data": "Gegevens", + "event": "Gebeurtenis", + "status": "Status", + "success": "Succes", + "failed": "Mislukt", + "messages-processed": "Verwerkte berichten", + "max-messages-processed": "Maximum verwerkte berichten", + "min-messages-processed": "Minimum verwerkte berichten", + "errors-occurred": "Fouten opgetreden", + "max-errors-occurred": "Maximum fouten opgetreden", + "min-errors-occurred": "Minimum fouten opgetreden", + "min-value": "Minimale waarde is 0.", + "all-events": "Alle", + "has-error": "Heeft fout", + "entity-id": "Entiteit-ID", + "copy-entity-id": "Kopieer entiteit-ID", + "entity-type": "Entiteittype", + "clear-filter": "Filter wissen", + "clear-request-title": "Alle gebeurtenissen wissen", + "clear-request-text": "Weet u zeker dat u alle gebeurtenissen wilt wissen?", + "started": "Gestart", + "updated": "Bijgewerkt", + "stopped": "Gestopt" + }, + "extension": { + "extensions": "Extensies", + "selected-extensions": "{ count, plural, =1 {1 extensie} other {# extensies} } geselecteerd", + "type": "Type", + "key": "Sleutel", + "value": "Waarde", + "id": "Id", + "extension-id": "Extensie-id", + "extension-type": "Extensietype", + "transformer-json": "JSON *", + "unique-id-required": "Huidige extensie-id bestaat al.", + "delete": "Verwijder extensie", + "add": "Voeg extensie toe", + "edit": "Bewerk extensie", + "delete-extension-title": "Weet u zeker dat u de extensie '{{extensionId}}' wilt verwijderen?", + "delete-extension-text": "Wees voorzichtig, na bevestiging wordt de extensie en alle gerelateerde gegevens onherroepelijk verwijderd.", + "delete-extensions-title": "Weet u zeker dat u { count, plural, =1 {1 extensie} other {# extensies} } wilt verwijderen?", + "delete-extensions-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde extensies verwijderd.", + "converters": "Converters", + "converter-id": "Converter-id", + "configuration": "Configuratie", + "converter-configurations": "Converterconfiguraties", + "token": "Beveiligingstoken", + "add-converter": "Voeg converter toe", + "add-config": "Voeg converterconfiguratie toe", + "device-name-expression": "Apparaatnaamexpressie", + "device-type-expression": "Apparaattype-expressie", + "custom": "Aangepast", + "to-double": "Naar double", + "transformer": "Transformer", + "json-required": "Transformer JSON is verplicht.", + "json-parse": "Kan transformer JSON niet parseren.", + "attributes": "Attributen", + "add-attribute": "Voeg attribuut toe", + "add-map": "Voeg mappingelement toe", + "timeseries": "Tijdreeks", + "add-timeseries": "Voeg tijdreeks toe", + "field-required": "Veld is verplicht", + "brokers": "Brokers", + "add-broker": "Voeg broker toe", + "host": "Host", + "port": "Poort", + "port-range": "Poort moet tussen 1 en 65535 liggen.", + "ssl": "Ssl", + "credentials": "Referenties", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "retry-interval": "Herhaalinterval in milliseconden", + "anonymous": "Anoniem", + "basic": "Basis", + "pem": "PEM", + "ca-cert": "CA-certificaatbestand *", + "private-key": "Privésleutelbestand *", + "cert": "Certificaatbestand *", + "no-file": "Geen bestand geselecteerd.", + "drop-file": "Sleep een bestand of klik om een bestand te selecteren en te uploaden.", + "mapping": "Mapping", + "topic-filter": "Onderwerpfilter", + "converter-type": "Convertertype", + "converter-json": "Json", + "json-name-expression": "Apparaatnaam JSON-expressie", + "topic-name-expression": "Apparaatnaam topic-expressie", + "json-type-expression": "Apparaattype JSON-expressie", + "topic-type-expression": "Apparaattype topic-expressie", + "attribute-key-expression": "Attribuutsleutelexpressie", + "attr-json-key-expression": "Attribuutsleutel JSON-expressie", + "attr-topic-key-expression": "Attribuutsleutel topic-expressie", + "request-id-expression": "Verzoek-id-expressie", + "request-id-json-expression": "Verzoek-id JSON-expressie", + "request-id-topic-expression": "Verzoek-id topic-expressie", + "response-topic-expression": "Antwoord topic-expressie", + "value-expression": "Waarde-expressie", + "topic": "Onderwerp", + "timeout": "Timeout in milliseconden", + "converter-json-required": "Converter JSON is verplicht.", + "converter-json-parse": "Kan converter JSON niet parseren.", + "filter-expression": "Filterexpressie", + "connect-requests": "Verbindingsverzoeken", + "add-connect-request": "Voeg verbindingsverzoek toe", + "disconnect-requests": "Verbrekingsverzoeken", + "add-disconnect-request": "Voeg verbreekverzoek toe", + "attribute-requests": "Attribuutverzoeken", + "add-attribute-request": "Voeg attribuutverzoek toe", + "attribute-updates": "Attribuutupdates", + "add-attribute-update": "Voeg attribuutupdate toe", + "server-side-rpc": "Server-side RPC", + "add-server-side-rpc-request": "Voeg server-side RPC-verzoek toe", + "device-name-filter": "Apparaatnaamfilter", + "attribute-filter": "Attribuutfilter", + "method-filter": "Methodfilter", + "request-topic-expression": "Verzoekonderwerp-expressie", + "response-timeout": "Antwoord-timeout in milliseconden", + "topic-expression": "Onderwerp-expressie", + "client-scope": "Clientscope", + "add-device": "Voeg apparaat toe", + "opc-server": "Servers", + "opc-add-server": "Voeg server toe", + "opc-add-server-prompt": "Voeg een server toe", + "opc-application-name": "Applicatienaam", + "opc-application-uri": "Applicatie-URI", + "opc-scan-period-in-seconds": "Scanperiode in seconden", + "opc-security": "Beveiliging", + "opc-identity": "Identiteit", + "opc-keystore": "Sleutellocatie", + "opc-type": "Type", + "opc-keystore-type": "Type", + "opc-keystore-location": "Locatie *", + "opc-keystore-password": "Wachtwoord", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Sleutelwachtwoord", + "opc-device-node-pattern": "Apparaatnodepatroon", + "opc-device-name-pattern": "Apparaatnaam patroon", + "modbus-server": "Servers/slaves", + "modbus-add-server": "Voeg server/slave toe", + "modbus-add-server-prompt": "Voeg server/slave toe", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Automatisch opnieuw verbinden", + "modbus-rtu-over-tcp": "RTU via TCP", + "modbus-port-name": "Seriële poortnaam", + "modbus-encoding": "Codering", + "modbus-parity": "Pariteit", + "modbus-baudrate": "Baudrate", + "modbus-databits": "Databits", + "modbus-stopbits": "Stopbits", + "modbus-databits-range": "Databits moeten tussen 7 en 8 liggen.", + "modbus-stopbits-range": "Stopbits moeten tussen 1 en 2 liggen.", + "modbus-unit-id": "Unit-ID", + "modbus-unit-id-range": "Unit-ID moet tussen 1 en 247 liggen.", + "modbus-device-name": "Apparaatnaam", + "modbus-poll-period": "Pollingperiode (ms)", + "modbus-attributes-poll-period": "Attribuut pollingperiode (ms)", + "modbus-timeseries-poll-period": "Tijdreeks pollingperiode (ms)", + "modbus-poll-period-range": "Pollingperiode moet een positieve waarde zijn.", + "modbus-tag": "Tag", + "modbus-function": "Functie", + "modbus-register-address": "Registeradres", + "modbus-register-address-range": "Registeradres moet tussen 0 en 65535 liggen.", + "modbus-register-bit-index": "Bitindex", + "modbus-register-bit-index-range": "Bitindex moet tussen 0 en 15 liggen.", + "modbus-register-count": "Registeraantal", + "modbus-register-count-range": "Registeraantal moet een positieve waarde zijn.", + "modbus-byte-order": "Bytevolgorde", + "sync": { + "status": "Status", + "sync": "Synchroniseren", + "not-sync": "Niet gesynchroniseerd", + "last-sync-time": "Laatste synchronisatietijd", + "not-available": "Niet beschikbaar" + }, + "export-extensions-configuration": "Exporteer extensieconfiguratie", + "import-extensions-configuration": "Importeer extensieconfiguratie", + "import-extensions": "Importeer extensies", + "import-extension": "Importeer extensie", + "export-extension": "Exporteer extensie", + "file": "Extensiebestand", + "invalid-file-error": "Ongeldig extensiebestand" + }, + "feature": { + "advanced-features": "Geavanceerde functies" + }, + "filter": { + "add": "Filter toevoegen", + "edit": "Filter bewerken", + "name": "Filternaam", + "name-required": "Filternaam is vereist.", + "duplicate-filter": "Er bestaat al een filter met dezelfde naam.", + "filters": "Filters", + "unable-delete-filter-title": "Kan filter niet verwijderen", + "unable-delete-filter-text": "Filter '{{filter}}' kan niet worden verwijderd omdat deze wordt gebruikt door de volgende widget(s):
    {{widgetsList}}", + "duplicate-filter-error": "Duplicaatfilter gevonden '{{filter}}'.
    Filters moeten uniek zijn binnen het dashboard.", + "missing-key-filters-error": "Sleutelfilters ontbreken voor filter '{{filter}}'.", + "filter": "Filter", + "editable": "Bewerkbaar", + "no-filters-found": "Geen filters gevonden.", + "no-filter-text": "Geen filter opgegeven", + "add-filter-prompt": "Voeg een filter toe", + "no-filter-matching": "'{{filter}}' niet gevonden.", + "create-new-filter": "Maak een nieuwe aan!", + "create-new": "Maak nieuw", + "filter-required": "Filter is vereist.", + "operation": { + "operation": "Operatie", + "equal": "gelijk aan", + "not-equal": "niet gelijk aan", + "starts-with": "begint met", + "ends-with": "eindigt met", + "contains": "bevat", + "not-contains": "bevat niet", + "greater": "groter dan", + "less": "kleiner dan", + "greater-or-equal": "groter of gelijk aan", + "less-or-equal": "kleiner of gelijk aan", + "and": "en", + "or": "of", + "in": "in", + "not-in": "niet in" + }, + "ignore-case": "negeer hoofdlettergebruik", + "value": "Waarde", + "remove-filter": "Verwijder filter", + "duplicate-filter-action": "Dupliceer filter", + "preview": "Filtervoorbeeld", + "no-filters": "Geen filters geconfigureerd", + "add-filter": "Filter toevoegen", + "add-complex-filter": "Complex filter toevoegen", + "add-complex": "Voeg complex toe", + "complex-filter": "Complex filter", + "edit-complex-filter": "Complex filter bewerken", + "edit-filter-user-params": "Filtergebruikersparameters bewerken", + "filter-user-params": "Filtergebruikersparameters", + "user-parameters": "Gebruikersparameters", + "display-label": "Label om weer te geven", + "order-priority": "Volgordeprioriteit van veld", + "key-filter": "Sleutelfilter", + "key-filters": "Sleutelfilters", + "key-name": "Sleutelnaam", + "key-name-required": "Sleutelnaam is vereist.", + "key-type": { + "key-type": "Sleuteltype", + "attribute": "Attribuut", + "timeseries": "Tijdreeks", + "entity-field": "Entiteitsveld", + "constant": "Constante", + "client-attribute": "Clientattribuut", + "server-attribute": "Serverattribuut", + "shared-attribute": "Gedeeld attribuut" + }, + "value-type": { + "value-type": "Waarde type", + "string": "Tekenreeks", + "numeric": "Numeriek", + "boolean": "Boolean", + "date-time": "Datumtijd" + }, + "value-type-required": "Waarde type is vereist.", + "key-value-type-change-title": "Weet u zeker dat u het waardetype wilt wijzigen?", + "key-value-type-change-message": "Als u bevestigt, worden alle ingevoerde sleutelfilters verwijderd.", + "no-key-filters": "Geen sleutelfilters geconfigureerd", + "add-key-filter": "Sleutelfilter toevoegen", + "remove-key-filter": "Sleutelfilter verwijderen", + "edit-key-filter": "Sleutelfilter bewerken", + "date": "Datum", + "time": "Tijd", + "current-tenant": "Huidige huurder", + "current-customer": "Huidige klant", + "current-user": "Huidige gebruiker", + "current-device": "Huidig apparaat", + "default-value": "Standaardwaarde", + "default-comma-separated-values": "Standaard komma-gescheiden waarden", + "dynamic-source-type": "Dynamische bron type", + "dynamic-value": "Dynamische waarde", + "no-dynamic-value": "Geen dynamische waarde", + "source-attribute": "Bronattribuut", + "switch-to-dynamic-value": "Overschakelen naar dynamische waarde", + "switch-to-default-value": "Overschakelen naar standaardwaarde", + "inherit-owner": "Overnemen van eigenaar", + "source-attribute-not-set": "Als het bronattribuut niet is ingesteld" + }, + "fullscreen": { + "expand": "Vergroot naar volledig scherm", + "exit": "Verlaat volledig scherm", + "toggle": "Schakel volledig schermmodus in", + "fullscreen": "Volledig scherm" + }, + "function": { + "function": "Functie" + }, + "gateway": { + "gateway-name": "Gateway naam", + "gateway-name-required": "Gateway naam is vereist.", + "gateways": "Gateways", + "create-new-gateway": "Maak een nieuwe gateway aan", + "create-new-gateway-text": "Weet u zeker dat u een nieuwe gateway wilt aanmaken met naam: '{{gatewayName}}'?", + "launch-command": "Startcommando", + "no-gateway-found": "Geen gateway gevonden.", + "no-gateway-matching": "'{{item}}' niet gevonden." + }, + "grid": { + "delete-item-title": "Weet u zeker dat u dit item wilt verwijderen?", + "delete-item-text": "Wees voorzichtig, na bevestiging worden dit item en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-items-title": "Weet u zeker dat u { count, plural, =1 {1 item} other {# items} } wilt verwijderen?", + "delete-items-action-title": "Verwijder { count, plural, =1 {1 item} other {# items} }", + "delete-items-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde items verwijderd en zijn alle gerelateerde gegevens onherstelbaar.", + "add-item-text": "Nieuw item toevoegen", + "no-items-text": "Geen items gevonden", + "item-details": "Itemdetails", + "delete-item": "Item verwijderen", + "delete-items": "Items verwijderen", + "scroll-to-top": "Scroll naar boven" + }, + "help": { + "goto-help-page": "Ga naar help pagina", + "show-help": "Toon help" + }, + "home": { + "home": "Home", + "profile": "Profiel", + "logout": "Uitloggen", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Open gebruikersmenu" + }, + "file-input": { + "browse-file": "Blader bestand", + "browse-files": "Blader bestanden" + }, + "image": { + "gallery": "Afbeeldingengalerij", + "search": "Afbeelding zoeken", + "selected-images": "{ count, plural, =1 {1 afbeelding} other {# afbeeldingen} } geselecteerd", + "created-time": "Aangemaakt op", + "name": "Naam", + "name-required": "Naam is vereist.", + "resolution": "Resolutie", + "size": "Grootte", + "system": "Systeem", + "download-image": "Download afbeelding", + "export-image": "Exporteer afbeelding naar JSON", + "import-image": "Importeer afbeelding uit JSON", + "upload-image": "Upload afbeelding", + "edit-image": "Bewerk afbeelding", + "image-details": "Afbeeldingsdetails", + "no-images": "Geen afbeeldingen gevonden", + "delete-image": "Verwijder afbeelding", + "delete-image-title": "Weet u zeker dat u afbeelding '{{imageTitle}}' wilt verwijderen?", + "delete-image-text": "Wees voorzichtig, na bevestiging wordt de afbeelding onherstelbaar verwijderd.", + "delete-images-title": "Weet u zeker dat u { count, plural, =1 {1 afbeelding} other {# afbeeldingen} } wilt verwijderen?", + "delete-images-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde afbeeldingen verwijderd en zijn alle gerelateerde gegevens onherstelbaar.", + "list-mode": "Lijstweergave", + "grid-mode": "Rasterweergave", + "image-preview": "Afbeelding voorbeeld", + "update-image": "Afbeelding bijwerken", + "export-failed-error": "Kan afbeelding niet exporteren: {{error}}", + "image-json-file": "Afbeelding JSON bestand", + "invalid-image-json-file-error": "Kan afbeelding niet importeren uit JSON: Ongeldige afbeelding JSON datastructuur.", + "image-is-in-use": "Afbeelding wordt gebruikt door andere entiteiten", + "images-are-in-use": "Afbeeldingen worden gebruikt door andere entiteiten", + "image-is-in-use-text": "De afbeelding '{{title}}' is niet verwijderd omdat deze wordt gebruikt door de volgende entiteiten:", + "images-are-in-use-text": "Niet alle afbeeldingen zijn verwijderd omdat ze worden gebruikt door andere entiteiten.
    U kunt de verwijzingen bekijken door op de knop Referenties in de overeenkomstige afbeeldingsrij te klikken.
    Als u deze afbeeldingen toch wilt verwijderen, selecteer ze dan in de onderstaande tabel en klik op de knop Geselecteerde verwijderen.", + "delete-image-in-use-text": "Als u de afbeelding toch wilt verwijderen, klik dan op de knop Toch verwijderen.", + "system-entities": "Systeementiteiten:", + "entities": "entiteiten:", + "references": "Referenties", + "include-system-images": "Systeemafbeeldingen opnemen", + "clear-image": "Afbeelding wissen", + "no-image": "Geen afbeelding", + "no-image-selected": "Geen afbeelding geselecteerd", + "browse-from-gallery": "Blader door galerij", + "set-link": "Link instellen", + "image-link": "Afbeeldingslink", + "link": "Link", + "copy-image-link": "Kopieer afbeeldingslink", + "embed-image": "Afbeelding insluiten", + "embed-to-html": "Insluiten in HTML", + "embed-to-html-hint": "Deze functie maakt de link beschikbaar voor elke niet-geautoriseerde gebruiker.", + "embed-to-html-text": "Gebruik de volgende code om een afbeelding in te sluiten in componenten op basis van eenvoudige HTML.
    Dergelijke componenten omvatten HTML-kaartwidgets, celinhoudfuncties, enz.", + "embed-to-angular-template": "Insluiten in Angular HTML-sjabloon", + "embed-to-angular-template-text": "Gebruik de volgende code om een afbeelding in te sluiten in de Angular HTML-sjabloon die wordt gebruikt voor componenten.
    Dergelijke componenten omvatten de Markdown-widget, HTML-sectie in de widget-editor, aangepaste acties, enz." + }, + "image-input": { + "drop-images-or": "Sleep afbeeldingen of", + "drag-and-drop": "Slepen & Neerzetten", + "or": "of", + "browse": "Bladeren", + "no-images": "Geen afbeeldingen geselecteerd", + "images": "afbeeldingen" + }, + "import": { + "no-file": "Geen bestand geselecteerd", + "drop-file": "Sleep een JSON-bestand of klik om een bestand te uploaden.", + "drop-json-file-or": "Sleep een JSON-bestand of", + "drop-file-csv": "Sleep een CSV-bestand of klik om een bestand te uploaden.", + "drop-file-csv-or": "Sleep een CSV-bestand of", + "column-value": "Waarde", + "column-title": "Titel", + "column-example": "Voorbeeldwaarde", + "column-key": "Attribuut/telemetriesleutel", + "credentials": "Referenties", + "csv-delimiter": "CSV-scheidingsteken", + "csv-first-line-header": "Eerste regel bevat kolomnamen", + "csv-update-data": "Attribuut/telemetrie bijwerken", + "details": "Details", + "import-csv-number-columns-error": "Een bestand moet minimaal twee kolommen bevatten", + "import-csv-invalid-format-error": "Ongeldig bestandsformaat. Regel: '{{line}}'", + "column-type": { + "name": "Naam", + "type": "Type", + "label": "Label", + "column-type": "Kolomtype", + "client-attribute": "Clientattribuut", + "shared-attribute": "Gedeeld attribuut", + "server-attribute": "Serverattribuut", + "timeseries": "Tijdreeks", + "entity-field": "Entiteitsveld", + "access-token": "Toegangstoken", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT-client-ID", + "user-name": "MQTT-gebruikersnaam", + "password": "MQTT-wachtwoord" + }, + "lwm2m": { + "client-endpoint": "LwM2M endpoint clientnaam", + "security-config-mode": "LwM2M beveiligingsmodus", + "client-identity": "LwM2M clientidentiteit", + "client-key": "LwM2M clientsleutel", + "client-cert": "LwM2M client publieke sleutel", + "bootstrap-server-security-mode": "LwM2M bootstrap server beveiligingsmodus", + "bootstrap-server-secret-key": "LwM2M bootstrap server geheime sleutel", + "bootstrap-server-public-key-id": "LwM2M bootstrap server publieke sleutel of id", + "lwm2m-server-security-mode": "LwM2M server beveiligingsmodus", + "lwm2m-server-secret-key": "LwM2M server geheime sleutel", + "lwm2m-server-public-key-id": "LwM2M server publieke sleutel of id" + }, + "snmp": { + "host": "SNMP-host", + "port": "SNMP-poort", + "version": "SNMP-versie (v1, v2c of v3)", + "community-string": "SNMP community string" + }, + "isgateway": "Is Gateway", + "activity-time-from-gateway-device": "Activiteitstijd van gatewayapparaat", + "description": "Beschrijving", + "routing-key": "Edge sleutel", + "secret": "Edge geheim" + }, + "stepper-text": { + "select-file": "Selecteer een bestand", + "configuration": "Importconfiguratie", + "column-type": "Selecteer kolomtype", + "creat-entities": "Nieuwe entiteiten aanmaken" + }, + "message": { + "create-entities": "{{count}} nieuwe entiteiten zijn succesvol aangemaakt.", + "update-entities": "{{count}} entiteiten zijn succesvol bijgewerkt.", + "error-entities": "Er is een fout opgetreden bij het aanmaken van {{count}} entiteiten." + } + }, + "scada": { + "symbols": "SCADA-symbolen", + "search": "Zoek symbool", + "selected-symbols": "{ count, plural, =1 {1 symbool} other {# symbolen} } geselecteerd", + "download-symbol": "Download SCADA-symbool", + "export-symbol": "Exporteer SCADA-symbool naar JSON", + "import-symbol": "Importeer SCADA-symbool van JSON", + "upload-symbol": "Upload SCADA-symbool", + "update-symbol": "Update SCADA-symbool", + "edit-symbol": "Bewerk SCADA-symbool", + "symbol-details": "SCADA-symbooldetails", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "Geen symbolen gevonden", + "show-hidden-elements": "Toon verborgen elementen", + "hide-hidden-elements": "Verberg verborgen elementen", + "delete-symbol": "Verwijder SCADA-symbool", + "delete-symbol-title": "Weet u zeker dat u het SCADA-symbool '{{imageTitle}}' wilt verwijderen?", + "delete-symbol-text": "Wees voorzichtig, na bevestiging wordt het SCADA-symbool onherstelbaar verwijderd.", + "delete-symbols-title": "Weet u zeker dat u { count, plural, =1 {1 SCADA-symbool} other {# SCADA-symbolen} } wilt verwijderen?", + "delete-symbols-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde SCADA-symbolen verwijderd en alle gerelateerde gegevens gaan onherroepelijk verloren.", + "include-system-symbols": "Inclusief systeem symbolen", + "symbol-preview": "Symboolvoorbeeld", + "general": "Algemeen", + "tags": "Tags", + "properties": "Eigenschappen", + "title": "Titel", + "description": "Beschrijving", + "search-tags": "Zoek tags", + "widget-size": "Widgetgrootte", + "cols": "kolommen", + "rows": "rijen", + "state-render-function": "Toestandsweergavefunctie", + "preview": "Voorbeeld", + "preview-widget-action-text": "Widgetactie '{{type}}' succesvol uitgevoerd!", + "no-symbol": "Geen SCADA-symbool", + "no-symbol-selected": "Geen SCADA-symbool geselecteerd", + "clear-symbol": "Wis SCADA-symbool", + "browse-symbol-from-gallery": "Blader SCADA-symbool uit galerij", + "zoom-in": "Inzoomen", + "zoom-out": "Uitzoomen", + "create-widget": "Widget maken", + "create-widget-from-symbol": "Widget maken van SCADA-symbool", + "hidden": "verborgen", + "tag": { + "tag": "Tag", + "on-click-action": "Actie bij klikken", + "no-tags": "Geen tags geconfigureerd", + "delete-tag-text": "Weet u zeker dat u tag
    {{tag}} van {{elementType}} element wilt verwijderen?", + "update-tag": "Tag bijwerken", + "enter-tag": "Voer tag in", + "tag-settings": "Taginstellingen", + "remove-tag": "Tag verwijderen", + "add-tag": "Tag toevoegen" + }, + "behavior": { + "behavior": "Gedrag", + "id": "Id", + "name": "Naam", + "type": "Type", + "no-behaviors": "Geen gedragingen geconfigureerd", + "add-behavior": "Gedrag toevoegen", + "type-action": "Actie", + "type-value": "Waarde", + "type-widget-action": "Widgetactie", + "behavior-settings": "Gedragsinstellingen", + "remove-behavior": "Verwijder gedrag", + "hint": "Tip", + "group-title": "Groepstitel", + "value-type": "Waarde type", + "default-value": "Standaardwaarde", + "true-label": "Label voor waar", + "false-label": "Label voor onwaar", + "state-label": "Toestand label", + "default-payload": "Standaardpayload", + "not-unique-behavior-ids-error": "Gedrag-IDs moeten uniek zijn!", + "default-settings": "Standaardinstellingen" + }, + "symbol": { + "symbol": "SCADA-symbool", + "fluid-presence": "Vloeistofaanwezigheid", + "fluid-presence-hint": "Geeft aan of er vloeistof in de pijp aanwezig is.", + "fluid-present": "Vloeistof aanwezig", + "present": "Aanwezig", + "absent": "Afwezig", + "flow-presence": "Stroomaanwezigheid", + "flow-presence-hint": "Geeft aan of vloeistof in de pijp stroomt.", + "flow-present": "Stroom aanwezig", + "flow-direction": "Stroomrichting", + "flow-direction-hint": "Geeft de stroomrichting van de vloeistof aan.", + "forward": "Vooruit", + "reverse": "Achteruit", + "flow-animation-speed": "Stroomsnelheid animatie", + "flow-animation-speed-hint": "Dubbele waarde die de animatiesnelheid van de stroom aangeeft. 1 - normale snelheid, 0 - geen animatie, < 1 - tragere animatie, > 1 - snellere animatie.", + "leak": "Lek", + "leak-hint": "Geeft aan of er een lek aanwezig is in de pijp.", + "leak-present": "Lek aanwezig", + "fluid-color": "Vloeistofkleur", + "pipe-color": "Pijpkleur", + "horizontal-pipe": "Horizontale pijp", + "vertical-pipe": "Verticale pijp", + "horizontal-fluid-color": "Horizontale vloeistofkleur", + "vertical-fluid-color": "Verticale vloeistofkleur", + "left-pipe": "Linkerpijp", + "right-pipe": "Rechterpijp", + "top-pipe": "Bovenpijp", + "bottom-pipe": "Onderpijp", + "left-fluid-color": "Linker vloeistofkleur", + "right-fluid-color": "Rechter vloeistofkleur", + "top-fluid-color": "Boven vloeistofkleur", + "bottom-fluid-color": "Onder vloeistofkleur", + "display": "Weergave", + "display-format": "Weergaveformaat", + "value": "Waarde", + "decimals": "Decimalen", + "units": "Eenheden", + "flow-meter-value-hint": "Dubbele waarde weergegeven op het flowmeterdisplay", + "value-hint": "Dubbele waarde die de huidige waarde aangeeft", + "running": "Actief", + "running-hint": "Geeft aan of het onderdeel actief is.", + "warning-state": "Waarschuwingsstatus", + "warning": "Waarschuwing", + "warning-click": "Waarschuwingsklik", + "warning-state-hint": "Geeft aan of het onderdeel in een waarschuwingsstatus verkeert.", + "critical-state": "Kritieke status", + "critical": "Kritiek", + "critical-click": "Kritieke klik", + "critical-state-hint": "Geeft aan of het onderdeel in een kritieke status verkeert.", + "critical-state-animation": "Kritieke status animatie", + "critical-state-animation-hint": "Knipperende animatie inschakelen wanneer het onderdeel in een kritieke status verkeert.", + "warning-critical-state-animation": "Waarschuwing/kritieke status animatie", + "warning-critical-state-animation-hint": "Knipperende animatie inschakelen bij waarschuwing of kritieke status.", + "animation": "Animatie", + "broken": "Defect", + "broken-hint": "Geeft aan of het onderdeel defect is.", + "on-display-click": "Bij klik op weergave", + "on-display-click-hint": "Actie die wordt uitgevoerd wanneer gebruiker op de weergave klikt.", + "pipe": "Pijp", + "default-border-color": "Standaard randkleur", + "active-border-color": "Actieve randkleur", + "warning-border-color": "Waarschuwingsrandkleur", + "critical-border-color": "Kritieke randkleur", + "background-color": "Achtergrondkleur", + "rotation-animation-speed": "Rotatie animatiesnelheid", + "rotation-animation-speed-hint": "Dubbele waarde voor de rotatie animatiesnelheid. 1 - normale snelheid, 0 - geen animatie, < 1 - tragere animatie, > 1 - snellere animatie.", + "on-click": "Bij klik", + "on-click-hint": "Actie uitgevoerd bij klik op component.", + "connectors-positions": "Connectorposities", + "right-connector": "Rechterconnector", + "right-top-connector": "Rechtsboven connector", + "right-bottom-connector": "Rechtsonder connector", + "left-connector": "Linkerconnector", + "left-top-connector": "Linksboven connector", + "left-bottom-connector": "Linksonder connector", + "top-left-connector": "Boven-links connector", + "top-right-connector": "Boven-rechts connector", + "top-connector": "Bovenconnector", + "bottom-connector": "Onderconnector", + "running-color": "Actieve kleur", + "stopped-color": "Gestopte kleur", + "stopped": "Gestopt", + "warning-color": "Waarschuwingskleur", + "critical-color": "Kritieke kleur", + "opened": "Geopend", + "opened-hint": "Geeft aan of het onderdeel geopend is.", + "open": "Openen", + "open-hint": "Actie uitgevoerd wanneer gebruiker het onderdeel opent.", + "close": "Sluiten", + "close-hint": "Actie uitgevoerd wanneer gebruiker op sluiten klikt.", + "close-state-animation": "Animatie bij gesloten status", + "close-state-animation-hint": "Knipperende animatie inschakelen bij gesloten status van het component.", + "opened-color": "Geopende kleur", + "closed-color": "Gesloten kleur", + "opened-rotation-angle": "Rotatiehoek bij openen", + "closed-rotation-angle": "Rotatiehoek bij sluiten", + "tank-capacity": "Tankcapaciteit", + "tank-capacity-hint": "Dubbele waarde die de totale tankcapaciteit aangeeft.", + "current-volume": "Huidig volume", + "current-volume-hint": "Dubbele waarde die het huidige bezette volume aangeeft.", + "tank-color": "Tankkleur", + "value-box": "Waardeveld", + "value-text": "Waarde tekst", + "scale": "Schaal", + "transparent-mode": "Transparante modus", + "major-ticks": "Hoofdindelingen", + "intervals": "Intervallen", + "major-ticks-color": "Kleur hoofdindelingen", + "normal": "Normaal", + "minor-ticks": "Kleine indelingen", + "minor-ticks-color": "Kleur kleine indelingen", + "temperature": "Temperatuur", + "temperature-hint": "Dubbele waarde die de huidige temperatuur aangeeft.", + "update-temperature": "Temperatuur bijwerken", + "update-temperature-hint": "Actie uitgevoerd bij wijziging van de temperatuur.", + "run": "Starten", + "run-hint": "Actie uitgevoerd bij starten van component.", + "stop": "Stoppen", + "stop-hint": "Actie uitgevoerd bij stoppen van component.", + "temperature-step": "Temperatuurstap", + "heat-pump-color": "Warmtepompkleur", + "power-button-background": "Achtergrond stroomknop", + "value-box-background": "Achtergrond waardeveld", + "value-units": "Waarde-eenheden", + "filtration-mode": "Filtratiemodus", + "filtration-mode-hint": "Geheel getal dat huidige filtratiemodus aangeeft.", + "filtration-mode-update": "Filtratiemodus bijwerken", + "filtration-mode-update-hint": "Actie uitgevoerd bij wijziging van filtratiemodus.", + "filter-mode": "Filter", + "waste-mode": "Afval", + "backwash-mode": "Terugspoelen", + "recirculate-mode": "Recirculeren", + "rinse-mode": "Spoelen", + "closed-mode": "Gesloten", + "sand-filter-color": "Zandfilterkleur", + "mode-box-background": "Achtergrond modusvak", + "border-color": "Randkleur", + "label-color": "Labelkleur", + "water-leak-hint": "Geeft aan of er een lek is.", + "default-color": "Standaardkleur", + "leak-color": "Lek kleur", + "full-value": "Volledige waarde", + "full-value-hint": "Dubbele waarde die de maximale waarde aangeeft.", + "label": "Label", + "icon": "Pictogram", + "button-color": "Knopkleur", + "on-label": "'Aan'-labeltekst", + "off-label": "'Uit'-labeltekst", + "arrow-presence": "Pijl aanwezig", + "arrow-presence-hint": "Geeft aan of er een pijl is in de connector.", + "arrow-present": "Pijl aanwezig", + "arrow-direction": "Stroomrichting", + "arrow-direction-hint": "Geeft stroomrichting aan.", + "flow-animation": "Stroomaanwezigheid", + "flow-animation-hint": "Geeft aan of vloeistof in de connector stroomt.", + "flow": "Stroom", + "flow-line": "Lijn", + "flow-line-style": "Lijnstijl", + "flow-style-hint": "Stel Dash- en Gap-waarden zo in dat hun som zonder rest deelbaar is door 100 voor perfecte animatiesynchronisatie.", + "flow-dash-cap": "Dash eindstijl", + "dash-cap-butt": "Recht", + "dash-cap-round": "Rond", + "dash-cap-square": "Vierkant", + "dash": "Streep", + "gap": "Ruimte", + "main-line": "Hoofdlijn", + "line": "Lijn", + "line-color": "Lijnkleur", + "arrow-color": "Pijlkleur", + "target-value": "Doelwaarde", + "target-value-hint": "Geeft het doelpunt op de schaal aan.", + "min-max-value": "Min en max waarde", + "min-value": "Min", + "max-value": "Max", + "progress-bar": "Voortgangsbalk", + "progress-arrow": "Voortgangspijl", + "warning-scale-color": "Waarschuwingsschaalkleur", + "critical-scale-color": "Kritieke schaalkleur", + "scale-color": "Schaalkleur", + "target": "Doel", + "high-warning-state": "Hoge waarschuwingstoestand", + "show-high-warning-scale": "Toon hoge waarschuwing schaal", + "high-warning-scale": "Hoge waarschuwing schaal", + "high-warning-state-hint": "Dubbele waarde geeft een hoge waarschuwing bereik tot aan een hoge kritieke of maximale waarde aan.", + "low-warning-state": "Lage waarschuwingstoestand", + "show-low-warning-scale": "Toon lage waarschuwing schaal", + "low-warning-scale": "Lage waarschuwing schaal", + "low-warning-state-hint": "Dubbele waarde geeft een lage waarschuwing bereik tot aan een lage kritieke of minimale waarde aan.", + "high-critical-state": "Hoge kritieke toestand", + "show-high-critical-scale": "Toon hoge kritieke schaal", + "high-critical-scale": "Hoge kritieke schaal", + "high-critical-state-hint": "Dubbele waarde geeft een hoog kritiek bereik tot de maximale schaalwaarde aan.", + "low-critical-state": "Lage kritieke toestand", + "show-low-critical-scale": "Toon lage kritieke schaal", + "low-critical-scale": "Lage kritieke schaal", + "low-critical-state-hint": "Dubbele waarde geeft een laag kritiek bereik tot de minimale schaalwaarde aan.", + "filter-color": "Filterkleur", + "colors": "Kleuren", + "indicator-colors": "Indicator kleuren", + "enabled": "Ingeschakeld", + "disabled": "Uitgeschakeld", + "on": "AAN", + "off": "UIT", + "on-off-state": "Aan/Uit-toestand", + "on-off-state-hint": "Geeft aan of het component in AAN of UIT toestand is.", + "on-update-state": "Bijwerken naar AAN", + "on-update-state-hint": "Actie uitgevoerd bij bijwerken naar AAN.", + "off-update-state": "Bijwerken naar UIT", + "off-update-state-hint": "Actie uitgevoerd bij bijwerken naar UIT.", + "voltage": "Spanning", + "input-voltage": "Ingangsspanning", + "input-voltage-hint": "Dubbele waarde geeft ingangsspanning aan.", + "output-voltage": "Uitgangsspanning", + "output-voltage-hint": "Dubbele waarde geeft uitgangsspanning aan.", + "first-phase-voltage": "Eerste fase spanning", + "second-phase-voltage": "Tweede fase spanning", + "third-phase-voltage": "Derde fase spanning", + "phase-voltage-hint": "Dubbele waarde geeft de spanning voor de huidige fase aan", + "voltage-hint": "Dubbele waarde geeft huidige spanning aan", + "current-voltage-color": "Huidige spanningskleur", + "phase-indicator-color": "Fase indicator kleur", + "measured": "Gemeten", + "measured-hint": "Dubbele waarde geeft energieverbruik in kilowattuur aan", + "day-rate": "Dagtarief", + "night-rate": "Nachttarief", + "off-peak-rate": "Laag tarief", + "peak-rate": "Piektarief", + "export-rate": "Exporteertarief", + "operating-mode": "Bedrijfsmodus", + "bypass-mode": "Bypass", + "operating-mode-hint": "Geheel getal dat de huidige bedrijfsmodus aangeeft (0 - UIT, 1 - AAN, 2 - BYPASS)", + "connected": "Verbonden", + "connected-hint": "Geeft aan of component in verbonden toestand is.", + "disconnected": "Verbroken", + "indicator": "Indicator", + "operation-mode": "Bedrijfsmodus", + "operation-mode-hint": "Geeft aan of de omvormer in netmodus of omvormermodus werkt.", + "operation-mode-indicators-color": "Bedrijfsmodus indicator kleur", + "mains-on-mode": "Netstroom ingeschakeld", + "inverter-on-mode": "Omvormer ingeschakeld", + "charging-mode": "Laadmodus", + "charging-mode-hint": "Geheel getal dat de huidige laadmodus aangeeft (1 - Bulk, 2 - Absorptie, 3 - Drijfvermogen)", + "charging-mode-indicators-color": "Laadmodus indicator kleur", + "inverter-faults": "Storingen", + "inverter-fault-indicators-color": "Storing indicator kleur", + "overload-fault": "Overbelasting", + "overload-fault-hint": "Geeft aan of de omvormer overbelast is.", + "low-battery-fault": "Lage batterij", + "low-battery-fault-hint": "Geeft aan of de batterij te ver ontladen is.", + "temperature-fault": "Temperatuur", + "temperature-fault-hint": "Geeft hoge temperatuur in een omvormer aan.", + "triangle": "Driehoek", + "socket": "Stopcontact", + "left-button": "Linkerknop", + "right-button": "Rechterknop", + "alarm-colors": "Alarmkleuren", + "hook-color": "Haakkleur" + } + }, + "item": { + "selected": "Geselecteerd" + }, + "js-func": { + "no-return-error": "Functie moet een waarde retourneren!", + "return-type-mismatch": "Functie moet een waarde van het type '{{type}}' retourneren!", + "tidy": "Netjes", + "mini": "Mini", + "modules": "Modules", + "remove-module": "Module verwijderen", + "no-modules": "Geen modules geconfigureerd", + "add-module": "Module toevoegen", + "module-alias": "Alias", + "invalid-module-alias-name": "Ongeldige aliasnaam", + "module-resource": "JS modulebron", + "not-unique-module-aliases-error": "Module-aliasen moeten uniek zijn!", + "show-module-info": "Toon module-info", + "show-module-source-code": "Toon modulesourcecode", + "module-members": "Moduleleden", + "module-no-members": "Module heeft geen geëxporteerde leden", + "module-load-error": "Fout bij laden van module", + "source-code": "Broncode", + "source-code-load-error": "Fout bij laden van broncode", + "no-js-module-text": "Geen JS-modules gevonden", + "no-js-module-matching": "Geen JS-modules gevonden die overeenkomen met '{{module}}'." + }, + "key-val": { + "key": "Sleutel", + "value": "Waarde", + "remove-entry": "Invoer verwijderen", + "add-entry": "Invoer toevoegen", + "no-data": "Geen invoer" + }, + "layout": { + "layout": "Lay-out", + "layouts": "Lay-outs", + "manage": "Lay-outs beheren", + "settings": "Lay-outinstellingen", + "color": "Kleur", + "main": "Hoofd", + "right": "Rechts", + "left": "Links", + "select": "Selecteer doellay-out", + "percentage-width": "Percentagebreedte (%)", + "fixed-width": "Vaste breedte (px)", + "left-width": "Linkerkolom (%)", + "right-width": "Rechterkolom (%)", + "pick-fixed-side": "Vaste zijde: ", + "layout-fixed-width": "Vaste breedte (px)", + "value-min-error": "Waarde moet groter zijn dan {{min}}{{unit}}", + "value-max-error": "Waarde moet kleiner zijn dan {{max}}{{unit}}", + "layout-fixed-width-required": "Vaste breedte is vereist", + "right-width-percentage-required": "Percentage voor rechts is vereist", + "left-width-percentage-required": "Percentage voor links is vereist", + "divider": "Verdeler", + "right-side": "Lay-out rechterzijde", + "left-side": "Lay-out linkerzijde", + "add-new-breakpoint": "Nieuwe breekpunt toevoegen", + "breakpoint": "Breekpunt", + "breakpoints": "Breekpunten", + "copy-from": "Kopieer van", + "size": "Grootte", + "delete-breakpoint-title": "Weet je zeker dat je het breekpunt '{{name}}' wilt verwijderen?", + "delete-breakpoint-text": "Let op, na bevestiging wordt het breekpunt onherstelbaar verwijderd en worden de instellingen teruggezet naar het standaardbreekpunt." + }, + "legend": { + "direction": "Richting", + "position": "Positie", + "show-values": "Waarden tonen", + "min-option": "Min", + "max-option": "Max", + "average-option": "Gemiddelde", + "total-option": "Totaal", + "latest-option": "Laatste", + "sort-legend": "Sorteer gegevenssleutels in legenda", + "show-max": "Toon maximale waarde", + "show-min": "Toon minimale waarde", + "show-avg": "Toon gemiddelde waarde", + "show-total": "Toon totale waarde", + "show-latest": "Toon laatste waarde", + "settings": "Legenda-instellingen", + "min": "min", + "max": "max", + "avg": "gem", + "total": "totaal", + "latest": "laatste", + "Min": "Min", + "Max": "Max", + "Avg": "Gem", + "Total": "Totaal", + "Latest": "Laatste", + "comparison-time-ago": { + "previousInterval": "(vorig interval)", + "customInterval": "(aangepast interval)", + "days": "(dag geleden)", + "weeks": "(week geleden)", + "months": "(maand geleden)", + "years": "(jaar geleden)" + }, + "column-title": "Kolomtitel", + "label": "Label", + "value": "Waarde" + }, + "login": { + "login": "Inloggen", + "request-password-reset": "Wachtwoord reset aanvragen", + "reset-password": "Wachtwoord opnieuw instellen", + "create-password": "Wachtwoord aanmaken", + "two-factor-authentication": "Twee-factor authenticatie", + "passwords-mismatch-error": "Ingevoerde wachtwoorden moeten gelijk zijn!", + "password-again": "Wachtwoord opnieuw", + "sign-in": "Meld u aan", + "username": "Gebruikersnaam (e-mail)", + "remember-me": "Onthoud mij", + "forgot-password": "Wachtwoord vergeten?", + "password-reset": "Wachtwoordherstel", + "expired-password-reset-message": "Uw inloggegevens zijn verlopen! Maak een nieuw wachtwoord aan.", + "new-password": "Nieuw wachtwoord", + "new-password-again": "Bevestig nieuw wachtwoord", + "password-link-sent-message": "Resetlink is verzonden", + "email": "E-mail", + "invalid-email-format": "Ongeldig e-mailformaat.", + "login-with": "Inloggen met {{name}}", + "or": "of", + "error": "Inlogfout", + "verify-your-identity": "Verifieer uw identiteit", + "select-way-to-verify": "Selecteer een verificatiemethode", + "resend-code": "Code opnieuw verzenden", + "resend-code-wait": "Code opnieuw verzenden over { time, plural, =1 {1 seconde} other {# seconden} }", + "try-another-way": "Probeer een andere manier", + "totp-auth-description": "Voer de beveiligingscode in uit uw authenticator-app.", + "totp-auth-placeholder": "Code", + "sms-auth-description": "Een beveiligingscode is verzonden naar uw telefoon op {{contact}}.", + "sms-auth-placeholder": "SMS-code", + "email-auth-description": "Een beveiligingscode is verzonden naar uw e-mailadres op {{contact}}.", + "email-auth-placeholder": "E-mailcode", + "backup-code-auth-description": "Voer een van uw back-upcodes in.", + "backup-code-auth-placeholder": "Back-upcode", + "activation-link-expired": "Activatielink is verlopen", + "activation-link-expired-message": "De link om uw profiel te activeren is verlopen. U kunt terugkeren naar de inlogpagina om een nieuwe e-mail te ontvangen.", + "reset-password-link-expired": "Wachtwoord resetlink is verlopen", + "reset-password-link-expired-message": "De link om uw wachtwoord opnieuw in te stellen is verlopen. U kunt terugkeren naar de inlogpagina om een nieuwe e-mail te ontvangen." + }, + "mobile": { + "add-application": "Applicatie toevoegen", + "app-id": "App-ID", + "app-id-required": "App-ID is verplicht", + "app-id-pattern": "Ongeldig formaat App-ID", + "app-store-link": "App Store-link", + "app-store-link-required": "App Store-link is verplicht", + "application-details": "Applicatiegegevens", + "application-package": "Applicatiepakket", + "application-secret": "Applicatiegeheim", + "application-secret-required": "Applicatiegeheim is verplicht", + "application": "Applicatie", + "applications": "Applicaties", + "copy-app-id": "App-ID kopiëren", + "copy-app-store-link": "App Store-link kopiëren", + "copy-application-package": "Applicatiepakket kopiëren", + "copy-application-secret": "Applicatiegeheim kopiëren", + "copy-google-play-link": "Google Play-link kopiëren", + "copy-sha256-certificate-fingerprints": "SHA256-certificaatvingerafdrukken kopiëren", + "delete-application": "Applicatie verwijderen", + "delete-application-button-text": "Ik begrijp de gevolgen, verwijder de applicatie", + "delete-application-text": "Deze actie kan niet ongedaan worden gemaakt. Dit verwijdert uw applicatie permanent.
    Als u het niet permanent wilt verwijderen, kunt u de applicatie tijdelijk opschorten.
    Om de applicatie te verwijderen, typ \"{{phrase}}\" ter bevestiging.", + "delete-application-title-short": "Weet u zeker dat u de applicatie '{{name}}' wilt verwijderen?", + "delete-application-text-short": "Wees voorzichtig, na bevestiging worden de applicatie en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-application-phrase": "applicatie verwijderen", + "delete-applications-bundle-text": "Wees voorzichtig, na bevestiging worden het mobiele pakket en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-applications-bundle-title": "Weet u zeker dat u het mobiele pakket '{{bundleName}}' wilt verwijderen?", + "generate-application-secret": "Applicatiegeheim genereren", + "google-play-link": "Google Play-link", + "google-play-link-required": "Google Play-link is verplicht", + "latest-version": "Laatste versie", + "min-version": "Minimale versie", + "invalid-version-pattern": "Ongeldig versieformaat. Gebruik het formaat: major.minor.patch (bijv. 1.0.0).", + "mobile-center": "Mobiel centrum", + "mobile-package": "Applicatiepakket", + "mobile-package-max-length": "Applicatiepakket mag niet langer zijn dan 256 tekens", + "mobile-package-required": "Applicatiepakket is verplicht.", + "mobile-package-pattern": "Ongeldig formaat applicatiepakket", + "no-application": "Geen applicaties gevonden", + "no-bundles": "Geen pakketten gevonden", + "platform-type": "Platformtype", + "search-application": "Zoek applicaties", + "search-bundles": "Zoek pakketten", + "set": "Instellen", + "sha256-certificate-fingerprints": "SHA256-certificaatvingerafdrukken", + "sha256-certificate-fingerprints-required": "SHA256-certificaatvingerafdrukken zijn verplicht", + "sha256-certificate-fingerprints-pattern": "Ongeldig SHA256-certificaatvingerafdrukformaat", + "show-hidden-pages": "Verborgen pagina’s tonen", + "status": "Status", + "status-type": { + "deprecated": "Verouderd", + "draft": "Concept", + "published": "Gepubliceerd", + "suspended": "Geschorst" + }, + "store-information": "Winkelinformatie", + "version-information": "Versie-informatie", + "min-version-release-notes": "Release-opmerkingen voor minimale versie", + "latest-version-release-notes": "Release-opmerkingen voor laatste versie", + "bundle": "Pakket", + "bundles": "Pakketten", + "add-bundle": "Pakket toevoegen", + "title": "Titel", + "title-required": "Titel is verplicht", + "title-cannot-contain-only-spaces": "Titel mag niet alleen spaties bevatten", + "title-max-length": "Titel mag niet langer zijn dan 256 tekens", + "oauth-clients": "OAuth 2.0-clients", + "android-app": "Android-app", + "android-application": "Android-applicatie", + "ios-app": "iOS-app", + "ios-application": "iOS-applicatie", + "invalid-store-link": "Ongeldige winkel-link", + "enable-oauth": "OAuth 2.0 inschakelen", + "enable-self-registration": "Zelfregistratie inschakelen", + "edit-bundle": "Pakket bewerken", + "description": "Beschrijving", + "basic-settings": "Basisinstellingen", + "no-application-matching": "Geen overeenkomende applicatie '{{entity}}' gevonden.", + "no-bundle-matching": "Geen overeenkomend pakket '{{entity}}' gevonden.", + "application-required": "Applicatie is verplicht.", + "bundle-required": "Pakket is verplicht.", + "no-application-text": "Geen applicaties gevonden", + "no-bundle-text": "Geen pakketten gevonden", + "layout": "Indeling", + "pages": "Pagina’s", + "hide-all-pages": "Alle pagina’s verbergen", + "reset-to-default-pages": "Reset naar standaardpagina’s", + "add-specific-page": "Specifieke pagina toevoegen", + "visible": "Zichtbaar", + "hidden": "Verborgen", + "reset-to-page-default": "Pagina terugzetten naar standaard", + "mobile-599": "Mobiel (max 599px)", + "tablet-959": "Tablet (max 959px)", + "max-element-number": "Max aantal elementen", + "page-name": "Paginanaam", + "page-name-required": "Paginanaam is verplicht.", + "page-name-cannot-contain-only-spaces": "Paginanaam mag niet alleen spaties bevatten.", + "page-name-max-length": "Paginanaam mag niet langer zijn dan 256 tekens", + "page-type": "Paginatype", + "pages-types": { + "dashboard": "Dashboard", + "web-view": "Webweergave", + "custom": "Aangepast" + }, + "url": "URL", + "invalid-url-format": "Ongeldig URL-formaat", + "path": "Pad", + "invalid-path-format": "Ongeldig padformaat", + "custom-page": "Aangepaste pagina", + "edit-page": "Pagina bewerken", + "edit-custom-page": "Aangepaste pagina bewerken", + "delete-page": "Pagina verwijderen", + "qr-code-widget": "QR-code widget", + "type-here": "Typ hier", + "configuration-dialog": "Configuratiedialoog", + "configuration-app": "Configuratie-app", + "configuration-step": { + "prepare-environment-title": "Ontwikkelomgeving voorbereiden", + "prepare-environment-text": "Flutter ThingsBoard Mobile App vereist Flutter SDK. Volg de instructies om de Flutter SDK in te stellen.", + "get-source-code-title": "App-broncode verkrijgen", + "get-source-code-text": "Je kunt de broncode van de Flutter ThingsBoard Mobile App verkrijgen door deze uit de GitHub-repository te klonen:", + "configure-api-title": "ThingsBoard API-eindpunt configureren", + "configure-api-text": "Open het flutter_thingsboard_pe_app-project in je editor/IDE. Bewerk:", + "configure-api-hint": "Stel de waarde van de constant thingsBoardApiEndpoint in op het API-eindpunt van je ThingsBoard-serverinstantie. Gebruik geen 'localhost' of '127.0.0.1'.", + "run-app-title": "De app uitvoeren", + "run-app-text": "Voer de app uit zoals beschreven in je IDE.\nGebruik je de terminal, voer de app dan uit met het volgende commando:", + "more-information": "Gedetailleerde informatie vind je in onze Getting Started-documentatie.", + "getting-started": "Aan de slag", + "configure-package-title": "Applicatiepakket configureren", + "configure-package-text": "Je kunt het applicatiepakket handmatig wijzigen of een CLI-hulpprogramma van derden gebruiken.", + "configure-package-text-install": "Voer het volgende commando uit om de Rename CLI Tool te installeren:", + "configure-package-run-commands": "Voer deze commando’s uit in de hoofdmap van je project:" + } + }, + "notification": { + "action-button": "Actieknop", + "action-type": "Actietype", + "active": "Actief", + "add-notification-recipients-group": "Meldingsontvangersgroep toevoegen", + "add-notification-template": "Meldingssjabloon toevoegen", + "add-recipient": "Ontvanger toevoegen", + "add-recipients": "Ontvangers toevoegen", + "add-rule": "Regel toevoegen", + "add-stage": "Fase toevoegen", + "add-template": "Sjabloon toevoegen", + "after": "Na", + "alarm-assignment-trigger-settings": "Instellingen voor alarmtoewijzingsactivering", + "alarm-comment-trigger-settings": "Instellingen voor alarmcommentaaractivering", + "alarm-trigger-settings": "Instellingen voor alarmaanleiding", + "all": "Alle", + "api-feature-hint": "Als dit veld leeg is, wordt de trigger toegepast op alle API-functies", + "api-usage-trigger-settings": "Instellingen voor API-gebruikstrigger", + "new-platform-version-trigger-settings": "Triggerinstellingen voor nieuwe platformversie", + "rate-limits-trigger-settings": "Triggerinstellingen voor overschreden snelheidslimieten", + "task-processing-failure-trigger-settings": "Triggerinstellingen voor taakverwerkingsfouten", + "at-least-one-should-be-selected": "Ten minste één moet worden geselecteerd", + "basic-settings": "Basisinstellingen", + "button-text": "Knoptekst", + "button-text-required": "Knoptekst is verplicht", + "button-text-max-length": "Knoptekst mag niet langer zijn dan {{ length }} tekens", + "compose": "Samenstellen", + "conversation": "Gesprek", + "conversation-required": "Gesprek is verplicht", + "copy-notification-template": "Meldingssjabloon kopiëren", + "copy-rule": "Regel kopiëren", + "copy-template": "Sjabloon kopiëren", + "create-new": "Nieuw aanmaken", + "created": "Gemaakt", + "customize-messages": "Berichten aanpassen", + "delete-notification-text": "Wees voorzichtig, na bevestiging is de melding niet meer herstelbaar.", + "delete-notification-title": "Weet u zeker dat u de melding wilt verwijderen?", + "delete-notifications-text": "Wees voorzichtig, na bevestiging zijn meldingen niet meer herstelbaar.", + "delete-notifications-title": "Weet u zeker dat u { count, plural, =1 {1 melding} other {# meldingen} } wilt verwijderen?", + "delete-recipient-text": "Wees voorzichtig, na bevestiging is de ontvanger niet meer herstelbaar.", + "delete-recipient-title": "Weet u zeker dat u de ontvanger '{{recipientName}}' wilt verwijderen?", + "delete-recipients-text": "Wees voorzichtig, na bevestiging zijn ontvangers niet meer herstelbaar.", + "delete-recipients-title": "Weet u zeker dat u { count, plural, =1 {1 ontvanger} other {# ontvangers} } wilt verwijderen?", + "delete-request-text": "Wees voorzichtig, na bevestiging is het verzoek niet meer herstelbaar.", + "delete-request-title": "Weet u zeker dat u het verzoek wilt verwijderen?", + "delete-requests-text": "Wees voorzichtig, na bevestiging zijn verzoeken niet meer herstelbaar.", + "delete-requests-title": "Weet u zeker dat u { count, plural, =1 {1 verzoek} other {# verzoeken} } wilt verwijderen?", + "delete-rule-text": "Wees voorzichtig, na bevestiging is de regel niet meer herstelbaar.", + "delete-rule-title": "Weet u zeker dat u de regel '{{ruleName}}' wilt verwijderen?", + "delete-rules-text": "Wees voorzichtig, na bevestiging zijn regels niet meer herstelbaar.", + "delete-rules-title": "Weet u zeker dat u { count, plural, =1 {1 regel} other {# regels} } wilt verwijderen?", + "delete-template-text": "Wees voorzichtig, na bevestiging is het sjabloon niet meer herstelbaar.", + "delete-template-title": "Weet u zeker dat u het sjabloon '{{templateName}}' wilt verwijderen?", + "delete-templates-text": "Wees voorzichtig, na bevestiging zijn sjablonen niet meer herstelbaar.", + "delete-templates-title": "Weet u zeker dat u { count, plural, =1 {1 sjabloon} other {# sjablonen} } wilt verwijderen?", + "deleted": "Verwijderd", + "delivery-method": { + "delivery-method": "Bezorgmethode", + "email": "E-mail", + "email-preview": "Voorbeeld e-mailmelding", + "slack": "Slack", + "slack-preview": "Voorbeeld Slack-melding", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Voorbeeld Microsoft Teams-melding", + "sms": "SMS", + "sms-preview": "Voorbeeld SMS-melding", + "web": "Web", + "web-preview": "Voorbeeld webmelding", + "mobile-app": "Mobiele app", + "mobile-app-preview": "Voorbeeld mobiele app-melding" + }, + "delivery-method-not-configure-click": "Bezorgmethode is niet geconfigureerd. Klik om in te stellen.", + "delivery-method-not-configure-contact": "Bezorgmethode is niet geconfigureerd. Neem contact op met uw systeembeheerder.", + "delivery-methods": "Bezorgmethodes", + "description": "Beschrijving", + "device-activity-trigger-settings": "Triggerinstellingen voor apparaatactiviteit", + "device-list-rule-hint": "Als dit veld leeg is, wordt de trigger toegepast op alle apparaten", + "device-profiles-list-rule-hint": "Als dit veld leeg is, wordt de trigger toegepast op alle apparaatprofielen", + "disabled": "Uitgeschakeld", + "edge-trigger-settings": "Triggerinstellingen voor Edge", + "edge-list-rule-hint": "Als dit veld leeg is, wordt de trigger toegepast op alle Edge-instanties", + "edit-notification-recipients-group": "Ontvangersgroep voor melding bewerken", + "edit-notification-template": "Meldingssjabloon bewerken", + "edit-rule": "Regel bewerken", + "edit-template": "Sjabloon bewerken", + "enabled": "Ingeschakeld", + "entities-limit-trigger-settings": "Triggerinstellingen voor entiteitenlimiet", + "entity-action-trigger-settings": "Triggerinstellingen voor entiteitacties", + "entity-type": "Entiteittype", + "escalation-chain": "Escalatieketen", + "failed-send": "Verzending mislukt", + "fails": "{ count, plural, =1 {1 mislukking} other {# mislukkingen} }", + "filter": "Filter", + "first-recipient": "Eerste ontvanger", + "inactive": "Inactief", + "inbox": "Inbox", + "notification-inbox": "Meldingen / Inbox", + "input-field-support-templatization": "Invoerveld ondersteunt templatization.", + "input-fields-support-templatization": "Invoervelden ondersteunen templatization.", + "link": "Link", + "link-required": "Link is verplicht", + "link-type": { + "dashboard": "Dashboard openen", + "link": "URL-link openen" + }, + "loading-notifications": "Meldingen laden...", + "management": "Meldingenbeheer", + "mark-all-as-read": "Markeer alles als gelezen", + "mark-as-read": "Markeer als gelezen", + "message": "Bericht", + "message-required": "Bericht is verplicht", + "message-max-length": "Bericht mag niet langer zijn dan {{ length }} tekens", + "name": "Naam", + "name-required": "Naam is verplicht", + "new-notification": "Nieuwe melding", + "no-inbox-notification": "Geen meldingen gevonden", + "no-notification-request": "Geen meldingsverzoek", + "no-notification-templates": "Geen meldingssjablonen gevonden", + "no-notifications-yet": "Nog geen meldingen", + "no-recipients-notification": "Geen ontvangersmelding", + "no-recipients-matching": "Geen ontvangers gevonden die overeenkomen met '{{entity}}'.", + "no-recipients-text": "Geen ontvangers gevonden", + "no-rule": "Geen regel geconfigureerd", + "no-rules-notification": "Geen meldingsregels", + "no-severity-found": "Geen ernstniveau gevonden", + "no-severity-matching": "'{{severity}}' niet gevonden.", + "no-template-matching": "Geen bron gevonden die overeenkomt met '{{template}}'.", + "not-found-slack-recipient": "Slack-ontvanger niet gevonden", + "notification": "Melding", + "notification-center": "Meldingscentrum", + "notification-tap-action": "Actie bij tikken op melding", + "notification-tap-action-hint": "Indien niet ingeschakeld, wordt het standaard alarmdashboard gebruikt", + "notify": "Melden", + "notify-again": "Opnieuw melden", + "notify-alarm-action": { + "acknowledged": "Alarm bevestigd", + "assigned": "Alarm toegewezen", + "cleared": "Alarm gewist", + "created": "Alarm aangemaakt", + "severity-changed": "Alarmernst gewijzigd", + "unassigned": "Alarm niet-toegewezen" + }, + "notify-on": "Melden bij", + "notify-on-comment-update": "Melden bij commentaarupdate", + "notify-on-required": "Meldingsactie is verplicht", + "notify-on-unassign": "Melden bij ontkoppelen", + "notify-only-user-comments": "Alleen gebruikersopmerkingen melden", + "only-rule-chain-lifecycle-failures": "Alleen fouten in levenscyclus van regelketen", + "only-rule-node-lifecycle-failures": "Alleen fouten in levenscyclus van regelknooppunt", + "platform-users": "Platformgebruikers", + "rate-limits": "Snelheidslimieten", + "rate-limits-hint": "Als dit veld leeg is, wordt de trigger toegepast op alle snelheidslimieten", + "recipient": "Ontvanger", + "recipient-group": "Ontvangersgroep", + "recipient-type": { + "affected-tenant-administrators": "Betrokken tenantbeheerders", + "affected-user": "Betrokken gebruiker", + "all-users": "Alle gebruikers", + "customer-users": "Klantgebruikers", + "system-administrators": "Systeembeheerders", + "tenant-administrators": "Tenantbeheerders", + "user-filters": "Gebruikersfilter", + "user-list": "Gebruikerslijst", + "users-entity-owner": "Gebruikers van de entiteiteigenaar" + }, + "recipients": "Ontvangers", + "notification-recipient": "Ontvanger van melding", + "notification-recipient-required": "Ontvanger van melding is verplicht.", + "notification-recipients": "Meldingen / Ontvangers", + "recipients-count": "{ count, plural, =1 {1 ontvanger} other {# ontvangers} }", + "recipients-required": "Ontvangers zijn verplicht", + "refresh-allow-delivery-method": "Bezorgmethode verversen toestaan", + "request-search": "Verzoek zoeken", + "request-status": { + "processing": "Verwerken", + "scheduled": "Gepland", + "sent": "Verzonden" + }, + "review": "Beoordelen", + "rule": "Regel", + "rule-chain-list-rule-hint": "Als het veld leeg is, wordt de trigger toegepast op alle regelketens", + "rule-engine-events-trigger-settings": "Triggerinstellingen voor regelenginegebeurtenissen", + "rule-engine-filter": "Filter voor regelengine", + "rule-name": "Regelnaam", + "rule-name-required": "Naam is verplicht", + "rule-disable": "Meldingsregel uitschakelen", + "rule-enable": "Meldingsregel inschakelen", + "rule-node-filter": "Regelknooppuntfilter", + "rules": "Regels", + "notification-rules": "Meldingen / Regels", + "scheduler-later": "Plan voor later", + "search-notification": "Meldingen zoeken", + "search-recipients": "Ontvangers zoeken", + "search-rules": "Regels zoeken", + "search-templates": "Sjablonen zoeken", + "see-documentation": "Bekijk documentatie", + "selected-notifications": "{ count, plural, =1 {1 melding} other {# meldingen} } geselecteerd", + "selected-recipients": "{ count, plural, =1 {1 ontvanger} other {# ontvangers} } geselecteerd", + "selected-requests": "{ count, plural, =1 {1 verzoek} other {# verzoeken} } geselecteerd", + "selected-rules": "{ count, plural, =1 {1 regel} other {# regels} } geselecteerd", + "selected-template": "{ count, plural, =1 {1 sjabloon} other {# sjablonen} } geselecteerd", + "send-notification": "Verzend melding", + "sent": "Verzonden", + "setup": "Instellen", + "notification-sent": "Meldingen / Verzonden", + "set-entity-from-notification": "Stel entiteit in van melding naar dashboardstatus", + "slack-chanel-type": "Slack kanaaltype", + "slack-chanel-types": { + "direct": "Direct bericht", + "private-channel": "Privékanaal", + "public-channel": "Publiek kanaal" + }, + "start-from-scratch": "Begin opnieuw", + "status": "Status", + "stop-escalation-alarm-status-become": "Stop escalatie wanneer de alarmstatus wordt:", + "subject": "Onderwerp", + "subject-required": "Onderwerp is verplicht", + "subject-max-length": "Onderwerp mag maximaal {{ length }} tekens bevatten", + "template": "Sjabloon", + "template-name": "Sjabloonnaam", + "template-required": "Sjabloon is verplicht", + "template-type": { + "alarm": "Alarm", + "alarm-assignment": "Alarmaanwijzing", + "alarm-comment": "Alarmopmerking", + "api-usage-limit": "API-gebruiksgrens", + "device-activity": "Apparaatactiviteit", + "entities-limit": "Entiteitenlimiet", + "entity-action": "Entiteitactie", + "general": "Algemeen", + "rule-engine-lifecycle-event": "Levenscyclusgebeurtenis van regelengine", + "rule-node": "Regelknooppunt", + "new-platform-version": "Nieuwe platformversie", + "rate-limits": "Overschreden snelheidslimieten", + "edge-communication-failure": "Edge communicatie storing", + "edge-connection": "Edge verbinding", + "task-processing-failure": "Taakverwerkingsfout" + }, + "templates": "Sjablonen", + "notification-templates": "Meldingen / Sjablonen", + "tenant-profiles-list-rule-hint": "Als het veld leeg is, wordt de trigger toegepast op alle tenantprofielen", + "tenants-list-rule-hint": "Als het veld leeg is, wordt de trigger toegepast op alle tenants", + "threshold": "Drempelwaarde", + "theme-color": "Themakleur", + "time": "Tijd", + "track-rule-node-events": "Volg gebeurtenissen van regelknooppunten", + "trigger": { + "alarm": "Alarm", + "alarm-assignment": "Alarmaanwijzing", + "alarm-comment": "Alarmopmerking", + "api-usage-limit": "API-gebruiksgrens", + "device-activity": "Apparaatactiviteit", + "entities-limit": "Entiteitenlimiet", + "entity-action": "Entiteitactie", + "rule-engine-lifecycle-event": "Levenscyclusgebeurtenis van regelengine", + "new-platform-version": "Nieuwe platformversie", + "rate-limits": "Overschreden snelheidslimieten", + "edge-connection": "Edge verbinding", + "edge-communication-failure": "Edge communicatie storing", + "task-processing-failure": "Taakverwerkingsfout", + "trigger": "Trigger", + "trigger-required": "Trigger is verplicht" + }, + "type": "Type", + "unread": "Ongelezen", + "updated": "Bijgewerkt", + "use-deprecated-webhook-connectors": "Verouderde Webhook-connectoren gebruiken", + "use-old-api": "Oude API gebruiken", + "use-template": "Sjabloon gebruiken", + "view-all": "Alles bekijken", + "warning": "Waarschuwing", + "webhook-url": "Webhook URL", + "webhook-url-required": "Webhook URL is verplicht", + "workflow-url": "Workflow URL", + "workflow-url-required": "Workflow URL is verplicht", + "channel-name": "Kanaalnaam", + "channel-name-required": "Kanaalnaam is verplicht", + "settings": { + "notification-settings": "Meldingsinstellingen", + "reset-all": "Alle instellingen resetten", + "reset-all-title": "Weet je zeker dat je het formulier wilt resetten?", + "reset-all-text": "Na bevestiging wordt het instellingenformulier teruggezet naar de standaardwaarde en opgeslagen.", + "type": "Type", + "enable-all": "Alles inschakelen", + "disable-all": "Alles uitschakelen", + "delivery-not-configured": "Bezorgmethode is niet geconfigureerd" + } + }, + "ota-update": { + "add": "Pakket toevoegen", + "assign-firmware": "Firmware toewijzen", + "assign-firmware-required": "Toegewezen firmware is vereist", + "assign-software": "Software toewijzen", + "assign-software-required": "Toegewezen software is vereist", + "auto-generate-checksum": "Checksum automatisch genereren", + "checksum": "Checksum", + "checksum-hint": "Als checksum leeg is, wordt deze automatisch gegenereerd", + "checksum-algorithm": "Checksum-algoritme", + "checksum-copied-message": "Pakket-checksum is gekopieerd naar het klembord", + "change-firmware": "Het wijzigen van de firmware kan een update van { count, plural, =1 {1 apparaat} other {# apparaten} } veroorzaken.", + "change-software": "Het wijzigen van de software kan een update van { count, plural, =1 {1 apparaat} other {# apparaten} } veroorzaken.", + "chose-compatible-device-profile": "Het geüploade pakket is alleen beschikbaar voor apparaten met het gekozen profiel.", + "chose-firmware-distributed-device": "Kies firmware die wordt verspreid naar apparaten", + "chose-software-distributed-device": "Kies software die wordt verspreid naar apparaten", + "content-type": "Inhoudstype", + "copy-checksum": "Checksum kopiëren", + "copy-direct-url": "Directe URL kopiëren", + "copyId": "Pakket-ID kopiëren", + "copied": "Gekopieerd!", + "delete": "Pakket verwijderen", + "delete-ota-update-text": "Wees voorzichtig, na bevestiging kan de OTA-update niet worden hersteld.", + "delete-ota-update-title": "Weet je zeker dat je de OTA-update '{{title}}' wilt verwijderen?", + "delete-ota-updates-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde OTA-updates verwijderd.", + "delete-ota-updates-title": "Weet je zeker dat je { count, plural, =1 {1 OTA-update} other {# OTA-updates} } wilt verwijderen?", + "description": "Beschrijving", + "direct-url": "Directe URL", + "direct-url-copied-message": "Directe URL van het pakket is gekopieerd naar het klembord", + "direct-url-required": "Directe URL is vereist", + "download": "Pakket downloaden", + "drop-file": "Sleep een pakketbestand hierheen of klik om een bestand te selecteren om te uploaden.", + "drop-package-file-or": "Sleep een pakketbestand hierheen of", + "file-name": "Bestandsnaam", + "file-size": "Bestandsgrootte", + "file-size-bytes": "Bestandsgrootte in bytes", + "idCopiedMessage": "Pakket-ID is gekopieerd naar het klembord", + "no-firmware-matching": "Geen compatibele Firmware OTA Update pakketten gevonden die overeenkomen met '{{entity}}'.", + "no-firmware-text": "Geen compatibele Firmware OTA Update pakketten beschikbaar.", + "no-packages-text": "Geen pakketten gevonden", + "no-software-matching": "Geen compatibele Software OTA Update pakketten gevonden die overeenkomen met '{{entity}}'.", + "no-software-text": "Geen compatibele Software OTA Update pakketten beschikbaar.", + "ota-update": "OTA-update", + "ota-update-details": "OTA-update details", + "ota-updates": "OTA-updates", + "package-file": "Pakketbestand", + "package-type": "Pakkettype", + "packages-repository": "Pakketopslag", + "search": "Zoek pakketten", + "selected-package": "{ count, plural, =1 {1 pakket} other {# pakketten} } geselecteerd", + "title": "Titel", + "title-required": "Titel is vereist.", + "title-max-length": "Titel moet minder dan 256 tekens zijn", + "types": { + "firmware": "Firmware", + "software": "Software" + }, + "upload-binary-file": "Binair bestand uploaden", + "use-external-url": "Externe URL gebruiken", + "version": "Versie", + "version-required": "Versie is vereist.", + "version-tag": "Versietag", + "version-tag-hint": "Aangepaste tag moet overeenkomen met de pakketversie die door je apparaat is gerapporteerd.", + "version-max-length": "Versie moet minder dan 256 tekens zijn", + "warning-after-save-no-edit": "Nadat het pakket is geüpload, kunnen titel, versie, apparaatprofiel en pakkettype niet meer worden gewijzigd." + }, + "position": { + "top": "Boven", + "bottom": "Onder", + "left": "Links", + "right": "Rechts" + }, + "profile": { + "profile": "Profiel", + "last-login-time": "Laatste login", + "change-password": "Wachtwoord wijzigen", + "current-password": "Huidig wachtwoord", + "copy-jwt-token": "JWT-token kopiëren", + "jwt-token": "JWT-token", + "token-valid-till": "Token is geldig tot", + "tokenCopiedSuccessMessage": "JWT-token is gekopieerd naar het klembord", + "tokenCopiedWarnMessage": "JWT-token is verlopen! Vernieuw de pagina." + }, + "profiles": { + "profiles": "Profielen" + }, + "security": { + "security": "Beveiliging", + "general-settings": "Algemene beveiligingsinstellingen", + "access-token": "Toegangstoken", + "access-token-required": "Toegangstoken is vereist", + "clientId": "Client ID", + "clientId-required": "Client ID is vereist", + "username": "Gebruikersnaam", + "username-required": "Gebruikersnaam is vereist", + "ca-cert": "CA-certificaat", + "2fa": { + "2fa": "Twee-factor-authenticatie", + "2fa-description": "Twee-factor-authenticatie beschermt je account tegen ongeautoriseerde toegang. Je hoeft alleen een beveiligingscode in te voeren bij het inloggen.", + "authenticate-with": "Je kunt authenticeren met:", + "disable-2fa-provider-text": "Het uitschakelen van {{name}} maakt je account minder veilig", + "disable-2fa-provider-title": "Weet je zeker dat je {{name}} wilt uitschakelen?", + "get-new-code": "Nieuwe code opvragen", + "main-2fa-method": "Gebruiken als hoofd authenticatiemethode", + "dialog": { + "activation-step-description-email": "De volgende keer dat je inlogt, moet je een beveiligingscode invoeren die naar je e-mailadres wordt gestuurd.", + "activation-step-description-sms": "De volgende keer dat je inlogt, moet je een beveiligingscode invoeren die naar je telefoonnummer wordt gestuurd.", + "activation-step-description-totp": "De volgende keer dat je inlogt, moet je een twee-factor authenticatiecode invoeren.", + "activation-step-label": "Activering", + "backup-code-description": "Print de codes uit zodat je ze bij de hand hebt wanneer je ze nodig hebt om in te loggen op je account. Je kunt elke back-upcode één keer gebruiken.", + "backup-code-warn": "Zodra je deze pagina verlaat, kunnen deze codes niet opnieuw worden weergegeven. Bewaar ze veilig met de onderstaande opties.", + "download-txt": "Download (txt)", + "email-step-description": "Voer een e-mailadres in dat wordt gebruikt als je authenticator.", + "email-step-label": "E-mail", + "enable-email-title": "E-mailauthenticator inschakelen", + "enable-sms-title": "SMS-authenticator inschakelen", + "enable-totp-title": "Authenticator-app inschakelen", + "enter-verification-code": "Voer hier de 6-cijferige code in", + "get-backup-code-title": "Back-upcode verkrijgen", + "next": "Volgende", + "scan-qr-code": "Scan deze QR-code met je verificatie-app", + "send-code": "Code verzenden", + "sms-step-description": "Voer een telefoonnummer in dat wordt gebruikt als je authenticator.", + "sms-step-label": "Telefoonnummer", + "success": "Succes!", + "totp-step-description-install": "Je kunt apps zoals Google Authenticator, Authy of Duo installeren.", + "totp-step-description-open": "Open de authenticator-app op je mobiele telefoon.", + "totp-step-label": "App verkrijgen", + "verification-code": "6-cijferige code", + "verification-code-invalid": "Ongeldig verificatiecodeformaat", + "verification-code-incorrect": "Verificatiecode is onjuist", + "verification-code-many-request": "Te veel verzoeken voor verificatiecode", + "verification-step-description": "Voer een 6-cijferige code in die we net naar {{address}} hebben gestuurd", + "verification-step-label": "Verificatie" + }, + "provider": { + "email": "E-mail", + "email-description": "Gebruik een beveiligingscode die naar je e-mailadres wordt gestuurd om te authenticeren.", + "email-hint": "Authenticatiecodes worden via e-mail verzonden naar {{ info }}", + "sms": "SMS", + "sms-description": "Gebruik je telefoon om te authenticeren. We sturen je een beveiligingscode via SMS wanneer je inlogt.", + "sms-hint": "Authenticatiecodes worden via tekstbericht verzonden naar {{ info }}", + "totp": "Authenticator-app", + "totp-description": "Gebruik apps zoals Google Authenticator, Authy of Duo op je telefoon om te authenticeren. Hiermee wordt een beveiligingscode gegenereerd om in te loggen.", + "totp-hint": "Authenticator-app is ingesteld voor je account", + "backup_code": "Back-upcode", + "backup-code-description": "Deze afdrukbare eenmalige codes stellen je in staat om in te loggen wanneer je je telefoon niet bij je hebt, bijvoorbeeld tijdens het reizen.", + "backup-code-hint": "{{ info }} eenmalige codes zijn momenteel actief" + } + }, + "password-requirement": { + "at-least": "Ten minste:", + "character": "{ count, plural, =1 {1 teken} other {# tekens} }", + "digit": "{ count, plural, =1 {1 cijfer} other {# cijfers} }", + "incorrect-password-try-again": "Onjuist wachtwoord. Probeer opnieuw", + "lowercase-letter": "{ count, plural, =1 {1 kleine letter} other {# kleine letters} }", + "new-passwords-not-match": "Nieuw wachtwoord komt niet overeen", + "password-should-not-contain-spaces": "Je wachtwoord mag geen spaties bevatten", + "password-not-meet-requirements": "Wachtwoord voldoet niet aan de vereisten", + "password-requirements": "Wachtwoordeisen", + "password-should-difference": "Nieuw wachtwoord moet verschillen van het huidige", + "special-character": "{ count, plural, =1 {1 speciaal teken} other {# speciale tekens} }", + "uppercase-letter": "{ count, plural, =1 {1 hoofdletter} other {# hoofdletters} }", + "at-most": "Ten hoogste:" + } + }, + "relation": { + "relations": "Relaties", + "direction": "Richting", + "clear-relation-type": "Relatietype wissen", + "search-direction": { + "FROM": "Van", + "TO": "Naar" + }, + "direction-type": { + "FROM": "van", + "TO": "naar" + }, + "from-relations": "Uitgaande relaties", + "to-relations": "Inkomende relaties", + "selected-relations": "{ count, plural, =1 {1 relatie} other {# relaties} } geselecteerd", + "type": "Type", + "to-entity-type": "Naar entiteitstype", + "to-entity-name": "Naar entiteitsnaam", + "from-entity-type": "Van entiteitstype", + "from-entity-name": "Van entiteitsnaam", + "to-entity": "Naar entiteit", + "from-entity": "Van entiteit", + "delete": "Relatie verwijderen", + "relation-type": "Relatietype", + "relation-type-required": "Relatietype is vereist.", + "relation-type-max-length": "Relatietype mag niet langer zijn dan 256 tekens", + "any-relation-type": "Elk type", + "add": "Relatie toevoegen", + "edit": "Relatie bewerken", + "delete-to-relation-title": "Weet je zeker dat je de relatie naar entiteit '{{entityName}}' wilt verwijderen?", + "delete-to-relation-text": "Wees voorzichtig, na bevestiging wordt de entiteit '{{entityName}}' ontkoppeld van de huidige entiteit.", + "delete-to-relations-title": "Weet je zeker dat je { count, plural, =1 {1 relatie} other {# relaties} } wilt verwijderen?", + "delete-to-relations-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde relaties verwijderd en de bijbehorende entiteiten ontkoppeld van de huidige entiteit.", + "delete-from-relation-title": "Weet je zeker dat je de relatie van entiteit '{{entityName}}' wilt verwijderen?", + "delete-from-relation-text": "Wees voorzichtig, na bevestiging wordt de huidige entiteit ontkoppeld van entiteit '{{entityName}}'.", + "delete-from-relations-title": "Weet je zeker dat je { count, plural, =1 {1 relatie} other {# relaties} } wilt verwijderen?", + "delete-from-relations-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde relaties verwijderd en wordt de huidige entiteit ontkoppeld van de bijbehorende entiteiten.", + "remove-relation-filter": "Relatiefilter verwijderen", + "remove-filter": "Filter verwijderen", + "add-relation-filter": "Relatiefilter toevoegen", + "any-relation": "Elke relatie", + "relation-filters": "Relatiefilters", + "additional-info": "Aanvullende info (JSON)", + "invalid-additional-info": "Kan aanvullende info JSON niet verwerken.", + "no-relations-text": "Geen relaties gevonden", + "not": "Niet" + }, + "resource": { + "add": "Bron toevoegen", + "all-types": "Alle", + "copyId": "Kopieer bron-ID", + "delete": "Bron verwijderen", + "delete-resource-text": "Wees voorzichtig, na bevestiging wordt de bron onherstelbaar verwijderd.", + "delete-resource-title": "Weet je zeker dat je de bron '{{resourceTitle}}' wilt verwijderen?", + "delete-resources-action-title": "Verwijder { count, plural, =1 {1 bron} other {# bronnen} }", + "delete-resources-text": "Let op: de geselecteerde bronnen worden verwijderd, zelfs als ze worden gebruikt in apparaatsprofielen.", + "delete-resources-title": "Weet je zeker dat je { count, plural, =1 {1 bron} other {# bronnen} } wilt verwijderen?", + "download": "Bron downloaden", + "drop-file": "Sleep een bronbestand of klik om een bestand te selecteren om te uploaden.", + "drop-resource-file-or": "Sleep een bronbestand of", + "empty": "Bron is leeg", + "file-name": "Bestandsnaam", + "idCopiedMessage": "Bron-ID is gekopieerd naar klembord", + "no-resource-matching": "Geen bron die overeenkomt met '{{widgetsBundle}}' gevonden.", + "no-resource-text": "Geen bronnen gevonden", + "open-widgets-bundle": "Open widgets bundel", + "resource": "Bron", + "resource-file": "Bronbestand", + "resource-files": "Bronbestanden", + "resource-library-details": "Brondetails", + "resource-type": "Brontype", + "resources-library": "Bronbibliotheek", + "search": "Zoek bronnen", + "selected-resources": "{ count, plural, =1 {1 bron} other {# bronnen} } geselecteerd", + "system": "Systeem", + "title": "Titel", + "title-required": "Titel is vereist.", + "title-max-length": "Titel mag niet langer zijn dan 256 tekens", + "type": { + "jks": "JKS", + "js-module": "JS-module", + "lwm2m-model": "LWM2M-model", + "pkcs-12": "PKCS #12" + }, + "resource-sub-type": "Subtype", + "sub-type": { + "image": "afbeelding", + "scada-symbol": "Scada-symbool", + "extension": "Extensie", + "module": "Module" + } + }, + "javascript": { + "add": "JavaScript-bron toevoegen", + "delete": "JavaScript-bron verwijderen", + "delete-javascript-resource-text": "Wees voorzichtig, na bevestiging wordt de JavaScript-bron onherstelbaar verwijderd.", + "delete-javascript-resource-title": "Weet je zeker dat je de JavaScript-bron '{{resourceTitle}}' wilt verwijderen?", + "delete-javascript-resources-action-title": "Verwijder JavaScript { count, plural, =1 {1 bron} other {# bronnen} }", + "delete-javascript-resources-text": "Let op: de geselecteerde JavaScript-bronnen worden verwijderd, zelfs als ze worden gebruikt in JavaScript-functies.", + "delete-javascript-resources-title": "Weet je zeker dat je JavaScript { count, plural, =1 {1 bron} other {# bronnen} } wilt verwijderen?", + "delete-javascript-resource-in-use-text": "Als je de JavaScript-bron toch wilt verwijderen, klik op de knop Toch verwijderen.", + "download": "JavaScript-bron downloaden", + "upload-from-file": "Upload JavaScript vanaf bestand", + "resource-file": "JavaScript-bronbestand", + "drop-file": "Sleep een JavaScript-bestand of klik om een bestand te selecteren om te uploaden.", + "drop-resource-file-or": "Sleep een JavaScript-bestand of", + "javascript-library": "JavaScript-bibliotheek", + "javascript-type": "JavaScript-type", + "javascript-resource-details": "Details van JavaScript-bron", + "javascript-resource-is-in-use": "JavaScript-bron wordt gebruikt door andere entiteiten", + "javascript-resources-are-in-use": "JavaScript-bronnen worden gebruikt door andere entiteiten", + "javascript-resource-is-in-use-text": "De JavaScript-bron '{{title}}' is niet verwijderd omdat deze wordt gebruikt door de volgende entiteiten:", + "javascript-resources-are-in-use-text": "Niet alle JavaScript-bronnen zijn verwijderd omdat ze worden gebruikt door andere entiteiten.
    Je kunt de verwijzende entiteiten bekijken door op de knop Referenties te klikken in de bijbehorende rij.
    Als je deze bronnen toch wilt verwijderen, selecteer ze dan in de onderstaande tabel en klik op de knop Geselecteerde verwijderen.", + "search": "Zoek JavaScript-bronnen", + "selected-javascript-resources": "{ count, plural, =1 {1 JavaScript-bron} other {# JavaScript-bronnen} } geselecteerd", + "no-javascript-resource-text": "Geen JavaScript-bronnen gevonden", + "all-types": "Alle", + "module-script": "Modulescript" + }, + "rpc": { + "error": { + "target-device-is-not-set": "Doelapparaat is niet ingesteld!", + "invalid-target-entity": "RPC-opdrachten worden niet ondersteund door {{entityType}} entiteit.", + "failed-to-resolve-target-device": "Kan doelapparaat niet bepalen!", + "request-timeout": "Verzoek time-out", + "rpc-http-error": "Fout: {{status}} - {{statusText}}" + } + }, + "rulechain": { + "rulechain": "Regelketen", + "rulechain-events": "Regelketengebeurtenissen", + "rulechains": "Regelketens", + "root": "Hoofd", + "delete": "Verwijder regelketen", + "name": "Naam", + "name-required": "Naam is verplicht.", + "name-max-length": "Naam moet minder dan 256 tekens zijn", + "description": "Beschrijving", + "add": "Voeg regelketen toe", + "set-root": "Maak regelketen hoofd", + "set-root-rulechain-title": "Weet je zeker dat je de regelketen '{{ruleChainName}}' als hoofd wilt instellen?", + "set-root-rulechain-text": "Na bevestiging wordt de regelketen hoofd en zal alle inkomende transportberichten verwerken.", + "delete-rulechain-title": "Weet je zeker dat je de regelketen '{{ruleChainName}}' wilt verwijderen?", + "delete-rulechain-text": "Wees voorzichtig, na bevestiging wordt de regelketen en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-rulechains-title": "Weet je zeker dat je { count, plural, =1 {1 regelketen} other {# regelketens} } wilt verwijderen?", + "delete-rulechains-action-title": "Verwijder { count, plural, =1 {1 regelketen} other {# regelketens} }", + "delete-rulechains-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde regelketens verwijderd en alle gerelateerde gegevens onherstelbaar.", + "add-rulechain-text": "Nieuwe regelketen toevoegen", + "no-rulechains-text": "Geen regelketens gevonden", + "rulechain-details": "Details van regelketen", + "details": "Details", + "events": "Gebeurtenissen", + "system": "Systeem", + "import": "Importeer regelketen", + "export": "Exporteer regelketen", + "export-failed-error": "Kan regelketen niet exporteren: {{error}}", + "create-new-rulechain": "Maak nieuwe regelketen", + "rulechain-file": "Regelketenbestand", + "invalid-rulechain-file-error": "Kan regelketen niet importeren: Ongeldige gegevensstructuur.", + "copyId": "Kopieer regelketen-ID", + "idCopiedMessage": "Regelketen-ID is gekopieerd naar klembord", + "select-rulechain": "Selecteer regelketen", + "no-rulechains-matching": "Geen regelketens gevonden die overeenkomen met '{{entity}}'.", + "rulechain-required": "Regelketen is verplicht", + "management": "Regelbeheer", + "debug-mode": "Debug-modus", + "search": "Zoek regelketens", + "selected-rulechains": "{ count, plural, =1 {1 regelketen} other {# regelketens} } geselecteerd", + "open-rulechain": "Open regelketen", + "edge-template-root": "Sjabloon-hoofd", + "assign-to-edge": "Toewijzen aan edge", + "edge-rulechain": "Edge regelketen", + "unassign-rulechain-from-edge-text": "Na bevestiging wordt de regelketen losgekoppeld en is niet meer toegankelijk voor de edge.", + "unassign-rulechains-from-edge-title": "Weet je zeker dat je { count, plural, =1 {1 regelketen} other {# regelketens} } wilt loskoppelen?", + "unassign-rulechains-from-edge-text": "Na bevestiging worden alle geselecteerde regelketens losgekoppeld en niet meer toegankelijk voor de edge.", + "assign-rulechain-to-edge-title": "Regelketen(s) toewijzen aan edge", + "assign-rulechain-to-edge-text": "Selecteer de regelketens om aan de edge toe te wijzen", + "set-edge-template-root-rulechain": "Maak regelketen sjabloon-hoofd", + "set-edge-template-root-rulechain-title": "Weet je zeker dat je de regelketen '{{ruleChainName}}' als sjabloon-hoofd wilt instellen?", + "set-edge-template-root-rulechain-text": "Na bevestiging wordt de regelketen ingesteld als sjabloon-hoofd en gebruikt voor nieuwe edges.", + "invalid-rulechain-type-error": "Kan regelketen niet importeren: Ongeldig type. Verwacht type is {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Regelketen automatisch toewijzen bij edge-creatie", + "set-auto-assign-to-edge-title": "Weet je zeker dat je de regelketen '{{ruleChainName}}' automatisch wilt toewijzen bij edge-creatie?", + "set-auto-assign-to-edge-text": "Na bevestiging wordt de edge regelketen automatisch toegewezen bij edge-creatie.", + "unset-auto-assign-to-edge": "Niet automatisch toewijzen bij edge-creatie", + "unset-auto-assign-to-edge-title": "Weet je zeker dat je de regelketen '{{ruleChainName}}' niet automatisch wilt toewijzen bij edge-creatie?", + "unset-auto-assign-to-edge-text": "Na bevestiging wordt de edge regelketen niet meer automatisch toegewezen bij edge-creatie.", + "unassign-rulechain-title": "Weet je zeker dat je de regelketen '{{ruleChainName}}' wilt loskoppelen?", + "unassign-rulechains": "Regelketens loskoppelen" + }, + "rulenode": { + "rule-node-events": "Regelknoopgebeurtenissen", + "details": "Details", + "events": "Gebeurtenissen", + "search": "Zoek knopen", + "open-node-library": "Open knopenbibliotheek", + "close-node-library": "Sluit knopenbibliotheek", + "add": "Voeg regelknoop toe", + "name": "Naam", + "name-required": "Naam is verplicht.", + "name-max-length": "Naam moet minder dan 256 tekens zijn", + "type": "Type", + "rule-node-description": "Beschrijving van regelknoop", + "delete": "Verwijder regelknoop", + "select-all-objects": "Selecteer alle knopen en verbindingen", + "deselect-all-objects": "Deselecteer alle knopen en verbindingen", + "delete-selected-objects": "Verwijder geselecteerde knopen en verbindingen", + "delete-selected": "Verwijder geselecteerde", + "create-nested-rulechain": "Maak geneste regelketen", + "select-all": "Selecteer alles", + "copy-selected": "Kopieer geselecteerde", + "deselect-all": "Deselecteer alles", + "rulenode-details": "Details van regelknoop", + "debug-mode": "Debug-modus", + "singleton": "Singleton", + "configuration": "Configuratie", + "link": "Verbinding", + "link-details": "Details van regelknoopverbinding", + "add-link": "Voeg verbinding toe", + "link-label": "Verbindingslabel", + "link-label-required": "Verbindingslabel is verplicht.", + "custom-link-label": "Aangepast verbindingslabel", + "custom-link-label-required": "Aangepast verbindingslabel is verplicht.", + "link-labels": "Verbindingslabels", + "link-labels-required": "Verbindingslabels zijn verplicht.", + "no-link-labels-found": "Geen verbindingslabels gevonden", + "no-link-label-matching": "'{{label}}' niet gevonden.", + "create-new-link-label": "Maak een nieuwe!", + "type-filter": "Filter", + "type-filter-details": "Filter inkomende berichten met geconfigureerde voorwaarden", + "type-enrichment": "Verrijking", + "type-enrichment-details": "Voeg extra informatie toe aan berichtmetadata", + "type-transformation": "Transformatie", + "type-transformation-details": "Wijzig berichtinhoud en metadata", + "type-action": "Actie", + "type-action-details": "Voer speciale actie uit", + "type-external": "Extern", + "type-external-details": "Interageert met extern systeem", + "type-rule-chain": "Regelketen", + "type-rule-chain-details": "Stuurt inkomende berichten door naar opgegeven regelketen", + "type-flow": "Stroom", + "type-flow-details": "Organiseert berichtenstroom", + "type-input": "Invoer", + "type-input-details": "Logische invoer van regelketen, stuurt berichten door naar volgende regelknoop", + "type-unknown": "Onbekend", + "type-unknown-details": "Niet-opgeloste regelknoop", + "directive-is-not-loaded": "Gedefinieerde configuratierichtlijn '{{directiveName}}' is niet beschikbaar.", + "ui-resources-load-error": "Kan configuratie UI-bronnen niet laden.", + "invalid-target-rulechain": "Kan doelregelketen niet oplossen!", + "test-script-function": "Test scriptfunctie", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", + "message": "Bericht", + "message-type": "Berichttype", + "select-message-type": "Selecteer berichttype", + "message-type-required": "Berichttype is verplicht", + "metadata": "Metadata", + "metadata-required": "Metadata-items mogen niet leeg zijn.", + "output": "Uitvoer", + "test": "Test", + "help": "Hulp", + "reset-debug-settings": "Reset debug-instellingen in alle knopen", + "test-with-this-message": "{{test}} met dit bericht", + "queue-hint": "Selecteer een wachtrij voor doorsturen naar een andere wachtrij. Standaard wordt 'Main' gebruikt.", + "queue-singleton-hint": "Selecteer een wachtrij voor doorsturen in multi-instantie omgevingen. Standaard wordt 'Main' gebruikt." + }, + "rule-node-config": { + "id": "Id", + "additional-info": "Aanvullende informatie", + "advanced-settings": "Geavanceerde instellingen", + "create-entity-if-not-exists": "Maak nieuwe entiteit aan indien deze niet bestaat", + "create-entity-if-not-exists-hint": "Indien ingeschakeld, wordt een nieuwe entiteit met opgegeven parameters aangemaakt tenzij deze al bestaat. Bestaande entiteiten worden gebruikt zoals ze zijn voor de relatie.", + "select-device-connectivity-event": "Selecteer apparaatconnectiviteitsgebeurtenis", + "entity-name-pattern": "Naam patroon", + "device-name-pattern": "Apparaatnaam", + "asset-name-pattern": "Assetnaam", + "entity-view-name-pattern": "Entity view naam", + "customer-title-pattern": "Klanttitel", + "dashboard-name-pattern": "Dashboardtitel", + "user-name-pattern": "Gebruiker e-mail", + "edge-name-pattern": "Edge naam", + "entity-name-pattern-required": "Naam patroon is verplicht", + "entity-name-pattern-hint": "Naam patroon veld ondersteunt templatization. Gebruik $[messageKey] om waarde uit bericht te halen en ${metadataKey} om waarde uit metadata te halen.", + "copy-message-type": "Kopieer berichttype", + "entity-type-pattern": "Type patroon", + "entity-type-pattern-required": "Type patroon is verplicht", + "message-type-value": "Berichttype waarde", + "message-type-value-required": "Berichttype waarde is verplicht", + "message-type-value-max-length": "Berichttype waarde moet minder dan 256 tekens zijn", + "output-message-type": "Uitgaand berichttype", + "entity-cache-expiration": "Vervaltermijn entiteitencache (sec)", + "entity-cache-expiration-hint": "Specificeert maximale tijdsinterval om gevonden entiteiten op te slaan. Waarde 0 betekent dat records nooit vervallen.", + "entity-cache-expiration-required": "Vervaltermijn entiteitencache is verplicht.", + "entity-cache-expiration-range": "Vervaltermijn entiteitencache moet groter dan of gelijk zijn aan 0.", + "customer-name-pattern": "Klanttitel", + "customer-name-pattern-required": "Klanttitel is verplicht", + "customer-name-pattern-hint": "Gebruik $[messageKey] om waarde uit bericht te halen en ${metadataKey} om waarde uit metadata te halen.", + "create-customer-if-not-exists": "Maak nieuwe klant aan indien deze niet bestaat", + "unassign-from-customer": "Ontkoppel van specifieke klant als afzender dashboard is", + "unassign-from-customer-tooltip": "Alleen dashboards kunnen aan meerdere klanten tegelijk worden toegewezen. \nIndien afzender een dashboard is, moet de klanttitel expliciet worden opgegeven om te ontkoppelen.", + "customer-cache-expiration": "Vervaltermijn klantencache (sec)", + "customer-cache-expiration-hint": "Specificeert maximale tijdsinterval om gevonden klantrecords op te slaan. Waarde 0 betekent dat records nooit vervallen.", + "customer-cache-expiration-required": "Vervaltermijn klantencache is verplicht.", + "customer-cache-expiration-range": "Vervaltermijn klantencache moet groter dan of gelijk zijn aan 0.", + "interval-start": "Interval start", + "interval-end": "Interval einde", + "time-unit": "Tijdseenheid", + "fetch-mode": "Ophaalmodus", + "order-by-timestamp": "Orden op tijdstempel", + "limit": "Limiet", + "limit-hint": "Minimale limietwaarde is 2, maximaal - 1000. Om één item op te halen, selecteer ophaalmodus 'Eerste' of 'Laatste'.", + "limit-required": "Limiet is verplicht.", + "limit-range": "Limiet moet tussen 2 en 1000 liggen.", + "time-unit-milliseconds": "Milliseconden", + "time-unit-seconds": "Seconden", + "time-unit-minutes": "Minuten", + "time-unit-hours": "Uren", + "time-unit-days": "Dagen", + "time-value-range": "Toegestane bereik van 1 tot 2147483647.", + "start-interval-value-required": "Interval start is verplicht.", + "end-interval-value-required": "Interval einde is verplicht.", + "filter": "Filter", + "switch": "Schakelaar", + "math-templatization-tooltip": "Dit veld ondersteunt templatization. Gebruik $[messageKey] om waarde uit bericht te halen en ${metadataKey} om waarde uit metadata te halen.", + "add-message-type": "Voeg berichttype toe", + "select-message-types-required": "Ten minste één berichttype moet worden geselecteerd.", + "select-message-types": "Selecteer berichttypes", + "no-message-types-found": "Geen berichttypes gevonden", + "no-message-type-matching": "'{{messageType}}' niet gevonden.", + "create-new-message-type": "Maak een nieuwe aan.", + "message-types-required": "Berichttypes zijn verplicht.", + "client-attributes": "Client attributen", + "shared-attributes": "Gedeelde attributen", + "server-attributes": "Server attributen", + "attributes-keys": "Attribuutsleutels", + "attributes-keys-required": "Attribuutsleutels zijn verplicht", + "attributes-scope": "Attribuutscope", + "attributes-scope-value": "Attribuutscope waarde", + "attributes-scope-value-copy": "Kopieer attribuutscope waarde", + "attributes-scope-hint": "Gebruik de 'scope' metadata sleutel om dynamisch de attribuutscope per bericht te bepalen. Indien opgegeven, overschrijft dit de scope in de configuratie.", + "notify-device": "Forceer notificatie naar apparaat", + "send-attributes-updated-notification": "Verzend melding bij bijgewerkte attributen", + "send-attributes-updated-notification-hint": "Stuur melding over bijgewerkte attributen als apart bericht naar regelengine wachtrij.", + "send-attributes-deleted-notification": "Verzend melding bij verwijderde attributen", + "send-attributes-deleted-notification-hint": "Stuur melding over verwijderde attributen als apart bericht naar regelengine wachtrij.", + "update-attributes-only-on-value-change": "Sla attributen alleen op bij waarde wijziging", + "update-attributes-only-on-value-change-hint": "Werkt attributen bij bij elk inkomend bericht, ongeacht of de waarde veranderd is. Verhoogt API-gebruik en vermindert prestaties.", + "update-attributes-only-on-value-change-hint-enabled": "Werkt attributen alleen bij als hun waarde veranderd is. Geen update indien waarde hetzelfde blijft.", + "fetch-credentials-to-metadata": "Haal referenties op naar metadata", + "notify-device-on-update-hint": "Indien ingeschakeld, forceer melding naar apparaat bij update gedeelde attributen. Anders wordt dit geregeld door 'notifyDevice' parameter in metadata.", + "notify-device-on-delete-hint": "Indien ingeschakeld, forceer melding naar apparaat bij verwijdering gedeelde attributen. Anders wordt dit geregeld door 'notifyDevice' parameter in metadata.", + "latest-timeseries": "Laatste tijdreeks gegevenssleutels", + "timeseries-keys": "Tijdreeks sleutels", + "timeseries-keys-required": "Ten minste één tijdreeks sleutel moet worden geselecteerd.", + "add-timeseries-key": "Voeg tijdreeks sleutel toe", + "add-message-field": "Voeg berichtveld toe", + "relation-search-parameters": "Relatie zoekparameters", + "relation-parameters": "Relatieparameters", + "add-metadata-field": "Voeg metadata veld toe", + "data-keys": "Berichtveld namen", + "copy-from": "Kopieer van", + "data-to-metadata": "Data naar metadata", + "metadata-to-data": "Metadata naar data", + "use-regular-expression-hint": "Gebruik reguliere expressie om sleutels op patroon te kopiëren.\n\nTips & trucs:\nDruk op 'Enter' om het veldnaam in te voeren.\nDruk op 'Backspace' om veldnaam te verwijderen. Meerdere veldnamen worden ondersteund.", + "interval": "Interval", + "interval-required": "Interval is verplicht", + "interval-hint": "Deduplicatie-interval in seconden.", + "interval-min-error": "Minimale toegestane waarde is 1", + "max-pending-msgs": "Max in behandeling zijnde berichten", + "max-pending-msgs-hint": "Maximaal aantal berichten dat in het geheugen wordt opgeslagen per unieke deduplicatie-ID.", + "max-pending-msgs-required": "Max in behandeling zijnde berichten is verplicht", + "max-pending-msgs-max-error": "Maximale toegestane waarde is 1000", + "max-pending-msgs-min-error": "Minimale toegestane waarde is 1", + "max-retries": "Max pogingen", + "max-retries-required": "Max pogingen is verplicht", + "max-retries-hint": "Maximaal aantal pogingen om deduplicatieberichten naar de wachtrij te pushen. Tussen pogingen zit 10 seconden vertraging", + "max-retries-max-error": "Maximale toegestane waarde is 100", + "max-retries-min-error": "Minimale toegestane waarde is 0", + "strategy": "Strategie", + "strategy-required": "Strategie is verplicht", + "strategy-all-hint": "Retourneer alle berichten die tijdens de deduplicatieperiode zijn aangekomen als één JSON-arraybericht. Elk element bevat 'msg' en 'metadata' eigenschappen.", + "strategy-first-hint": "Retourneer het eerste bericht dat aankwam tijdens de deduplicatieperiode.", + "strategy-last-hint": "Retourneer het laatste bericht dat aankwam tijdens de deduplicatieperiode.", + "first": "Eerste", + "last": "Laatste", + "all": "Alle", + "output-msg-type-hint": "Het berichttype van het deduplicatieresultaat.", + "queue-name-hint": "De naam van de wachtrij waarin het deduplicatieresultaat wordt gepubliceerd.", + "keys": "Sleutels", + "keys-required": "Sleutels zijn verplicht", + "rename-keys-in": "Hernoem sleutels in", + "data": "Data", + "message": "Bericht", + "metadata": "Metadata", + "current-key-name": "Huidige sleutelnaam", + "key-name-required": "Sleutelnaam is verplicht", + "new-key-name": "Nieuwe sleutelnaam", + "new-key-name-required": "Nieuwe sleutelnaam is verplicht", + "metadata-keys": "Metadata veldnamen", + "json-path-expression": "JSON pad expressie", + "json-path-expression-required": "JSON pad expressie is verplicht", + "json-path-expression-hint": "JSONPath specificeert een pad naar een element in een JSON-structuur. '$' vertegenwoordigt het rootobject of array.", + "relations-query": "Relaties query", + "device-relations-query": "Apparaatrelaties query", + "max-relation-level": "Maximale relatieniveau", + "max-relation-level-error": "Waarde moet groter dan 0 of leeg zijn.", + "max-relation-level-invalid": "Waarde moet een geheel getal zijn.", + "relation-type": "Relatietype", + "relation-type-pattern": "Relatietype patroon", + "relation-type-pattern-required": "Relatietype patroon is verplicht", + "relation-types-list": "Relatietypes om te propageren", + "relation-types-list-hint": "Als er geen propagatierelatietypes zijn geselecteerd, worden alarmen zonder filtering gepropageerd.", + "unlimited-level": "Onbeperkt niveau", + "latest-telemetry": "Laatste telemetrie", + "add-telemetry-key": "Voeg telemetriesleutel toe", + "delete-from": "Verwijder uit", + "use-regular-expression-delete-hint": "Gebruik reguliere expressie om sleutels op patroon te verwijderen.\n\nTips & trucs:\nDruk op 'Enter' om veldnaam in te voeren.\nDruk op 'Backspace' om veldnaam te verwijderen.\nMeerdere veldnamen worden ondersteund.", + "fetch-into": "Haal op in", + "attr-mapping": "Attribuuttoewijzing:", + "source-attribute": "Bronattribuut sleutel", + "source-attribute-required": "Bronattribuut sleutel is verplicht.", + "source-telemetry": "Brontelemetriesleutel", + "source-telemetry-required": "Brontelemetriesleutel is verplicht.", + "target-key": "Doelsleutel", + "target-key-required": "Doelsleutel is verplicht.", + "attr-mapping-required": "Ten minste één toewijzing moet worden opgegeven.", + "fields-mapping": "Velden toewijzing", + "fields-mapping-hint": "Indien veld op $entityId staat, wordt ID van afzender opgeslagen in opgegeven tabelkolom.", + "relations-query-config-direction-suffix": "afzender", + "profile-name": "Profielnaam", + "fetch-circle-parameter-info-from-metadata-hint": "Metadata veld '{{perimeterKeyName}}' moet het volgende formaat hebben: {\"latitude\":48.196, \"longitude\":24.6532, \"radius\":100.0, \"radiusUnit\":\"METER\"}", + "fetch-poligon-parameter-info-from-metadata-hint": "Metadata veld '{{perimeterKeyName}}' moet het volgende formaat hebben: [[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "Gebruik $[messageKey] om waarde uit bericht te halen en ${metadataKey} om waarde uit metadata te halen.", + "fields-mapping-required": "Ten minste één veldtoewijzing moet worden opgegeven.", + "at-least-one-field-required": "Minstens één invoerveld moet een waarde bevatten.", + "originator-fields-sv-map-hint": "Doelsleutels ondersteunen templatization. Gebruik $[messageKey] of ${metadataKey}.", + "sv-map-hint": "Alleen doelsleutels ondersteunen templatization. Gebruik $[messageKey] of ${metadataKey}.", + "source-field": "Bronveld", + "source-field-required": "Bronveld is verplicht.", + "originator-source": "Afzender bron", + "new-originator": "Nieuwe afzender", + "originator-customer": "Klant", + "originator-tenant": "Huurder", + "originator-related": "Gerelateerde entiteit", + "originator-alarm-originator": "Afzender van alarm", + "originator-entity": "Entiteit via naam patroon", + "clone-message": "Bericht klonen", + "transform": "Transformeren", + "default-ttl": "Standaard TTL", + "default-ttl-required": "Standaard TTL is verplicht.", + "default-ttl-hint": "Rule node haalt de Time-to-Live (TTL) waarde uit de metadata van het bericht. Indien niet aanwezig, wordt de TTL uit de configuratie gebruikt. Bij waarde 0 wordt de TTL uit het tenantprofiel toegepast.", + "default-ttl-zero-hint": "TTL wordt niet toegepast als de waarde 0 is.", + "min-default-ttl-message": "Alleen minimale TTL van 0 is toegestaan.", + "generation-parameters": "Genereerparameters", + "message-count": "Limiet gegenereerde berichten (0 - onbeperkt)", + "message-count-required": "Limiet voor gegenereerde berichten is verplicht.", + "min-message-count-message": "Alleen 0 is toegestaan als minimum berichtenaantal.", + "period-seconds": "Periode in seconden", + "period-seconds-required": "Periode is verplicht.", + "generation-frequency-seconds": "Genereerfrequentie in seconden", + "generation-frequency-required": "Genereerfrequentie is verplicht.", + "min-generation-frequency-message": "Minimaal 60 seconden is toegestaan.", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "Gebruik periode in seconden patroon", + "use-metadata-period-in-seconds-patterns-hint": "Indien geselecteerd, gebruikt de rule node de interval van de metadata of data, aangenomen dat deze in seconden is.", + "period-in-seconds-pattern": "Periode in seconden patroon", + "period-in-seconds-pattern-required": "Periode in seconden patroon is verplicht", + "min-period-seconds-message": "Minimale toegestane periode is 60 seconden.", + "originator": "Afzender", + "message-body": "Berichtinhoud", + "message-metadata": "Berichtmetadata", + "generate": "Genereren", + "current-rule-node": "Huidige Rule Node", + "current-tenant": "Huidige Huurder", + "generator-function": "Genereerfunctie", + "test-generator-function": "Test genereerfunctie", + "generator": "Generator", + "test-filter-function": "Test filterfunctie", + "test-switch-function": "Test switchfunctie", + "test-transformer-function": "Test transformatorfunctie", + "transformer": "Transformator", + "alarm-create-condition": "Voorwaarde voor alarm aanmaken", + "test-condition-function": "Test voorwaarde-functie", + "alarm-clear-condition": "Voorwaarde voor alarm wissen", + "alarm-details-builder": "Alarm details samensteller", + "test-details-function": "Test details functie", + "alarm-type": "Alarmtype", + "select-entity-types": "Selecteer entiteitstypen", + "alarm-type-required": "Alarmtype is verplicht.", + "alarm-severity": "Alarm ernst", + "alarm-severity-required": "Alarm ernst is verplicht", + "alarm-severity-pattern": "Alarm ernst patroon", + "alarm-status-filter": "Alarmstatusfilter", + "alarm-status-list-empty": "Alarmstatuslijst is leeg", + "no-alarm-status-matching": "Geen overeenkomende alarmstatus gevonden.", + "propagate": "Alarm propageren naar gerelateerde entiteiten", + "propagate-to-owner": "Propageren naar entiteitseigenaar (Klant of Huurder)", + "propagate-to-tenant": "Propageren naar Huurder", + "condition": "Voorwaarde", + "details": "Details", + "to-string": "Naar tekenreeks", + "test-to-string-function": "Test naar-tekenreeks functie", + "from-template": "Van", + "from-template-required": "Van is verplicht", + "message-to-metadata": "Bericht naar metadata", + "metadata-to-message": "Metadata naar bericht", + "from-message": "Van bericht", + "from-metadata": "Van metadata", + "to-template": "Naar", + "to-template-required": "Naar is verplicht", + "mail-address-list-template-hint": "Komma-gescheiden lijst van adressen, gebruik ${metadataKey} voor metadatawaarde, $[messageKey] voor berichtwaarde", + "cc-template": "Cc", + "bcc-template": "Bcc", + "subject-template": "Onderwerp", + "subject-template-required": "Onderwerp is verplicht", + "body-template": "Inhoud", + "body-template-required": "Inhoud is verplicht", + "dynamic-mail-body-type": "Dynamisch e-mailinhoudtype", + "mail-body-type": "E-mailinhoudtype", + "body-type-template": "Inhoudstype sjabloon", + "reply-routing-configuration": "Configuratie voor antwoordroutering", + "rpc-reply-routing-configuration-hint": "Deze configuratieparameters geven metadata-sleutelnamen aan voor service, sessie en verzoek-ID om een antwoord te versturen.", + "reply-routing-configuration-hint": "Deze configuratieparameters geven metadata-sleutelnamen aan voor service en verzoek-ID om een antwoord te versturen.", + "request-id-metadata-attribute": "Verzoek ID", + "service-id-metadata-attribute": "Service ID", + "session-id-metadata-attribute": "Sessie ID", + "timeout-sec": "Timeout in seconden", + "timeout-required": "Timeout is verplicht", + "min-timeout-message": "Minimale toegestane timeout is 0.", + "endpoint-url-pattern": "Endpoint URL patroon", + "endpoint-url-pattern-required": "Endpoint URL patroon is verplicht", + "request-method": "Aanvraagmethode", + "use-simple-client-http-factory": "Gebruik eenvoudige HTTP-clientfactory", + "ignore-request-body": "Zonder request body", + "parse-to-plain-text": "Omzetten naar platte tekst", + "parse-to-plain-text-hint": "Indien geselecteerd, wordt de payload van het bericht als JSON-string omgezet naar platte tekst, bijv. msg = \"Hello,\\t\"world\"\" wordt Hello, \"world\"", + "read-timeout": "Leestime-out in milliseconden", + "read-timeout-hint": "Waarde 0 betekent oneindige time-out", + "max-parallel-requests-count": "Maximaal aantal parallelle verzoeken", + "max-parallel-requests-count-hint": "Waarde 0 betekent geen limiet op parallelle verwerking", + "max-response-size": "Maximale responsgrootte (in KB)", + "max-response-size-hint": "Maximale geheugengrootte voor het bufferen van gegevens bij decoderen of coderen van HTTP-berichten zoals JSON of XML", + "headers": "Headers", + "headers-hint": "Gebruik ${metadataKey} voor waarde uit metadata, $[messageKey] voor waarde uit berichtinhoud", + "header": "Header", + "header-required": "Header is verplicht", + "value": "Waarde", + "value-required": "Waarde is verplicht", + "topic-pattern": "Onderwerppatroon", + "key-pattern": "Sleutelpatteron", + "key-pattern-hint": "Optioneel. Indien partitie opgegeven, wordt deze gebruikt. Anders wordt de sleutel gebruikt of wordt een partitie willekeurig toegewezen.", + "topic-pattern-required": "Onderwerppatroon is verplicht", + "topic": "Onderwerp", + "topic-required": "Onderwerp is verplicht", + "bootstrap-servers": "Bootstrap-servers", + "bootstrap-servers-required": "Bootstrap-servers zijn verplicht", + "other-properties": "Overige eigenschappen", + "key": "Sleutel", + "key-required": "Sleutel is verplicht", + "retries": "Aantal automatische pogingen bij mislukking", + "min-retries-message": "Minimale waarde is 0", + "batch-size-bytes": "Batchgrootte in bytes", + "min-batch-size-bytes-message": "Minimale batchgrootte is 0", + "linger-ms": "Bufferperiode lokaal (ms)", + "min-linger-ms-message": "Minimale waarde is 0 ms", + "buffer-memory-bytes": "Maximale clientbuffer in bytes", + "min-buffer-memory-message": "Minimale buffer is 0", + "memory-buffer-size-range": "Buffergrootte moet tussen 0 en {{max}} KB liggen", + "acks": "Aantal bevestigingen", + "topic-arn-pattern": "ARN-patroon van onderwerp", + "topic-arn-pattern-required": "ARN-patroon van onderwerp is verplicht", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID is verplicht", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key is verplicht", + "aws-region": "AWS-regio", + "aws-region-required": "AWS-regio is verplicht", + "exchange-name-pattern": "Exchange-naam patroon", + "routing-key-pattern": "Routeringssleutel patroon", + "message-properties": "Berichteigenschappen", + "host": "Host", + "host-required": "Host is verplicht", + "port": "Poort", + "port-required": "Poort is verplicht", + "port-range": "Poort moet tussen 1 en 65535 liggen", + "virtual-host": "Virtuele host", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "automatic-recovery": "Automatisch herstel", + "connection-timeout-ms": "Verbindingstime-out (ms)", + "min-connection-timeout-ms-message": "Minimale waarde is 0 ms", + "handshake-timeout-ms": "Handshake-time-out (ms)", + "min-handshake-timeout-ms-message": "Minimale waarde is 0 ms", + "client-properties": "Clienteigenschappen", + "queue-url-pattern": "Wachtrij URL patroon", + "queue-url-pattern-required": "Wachtrij URL patroon is verplicht", + "delay-seconds": "Vertraging (seconden)", + "min-delay-seconds-message": "Minimale waarde is 0 seconden", + "max-delay-seconds-message": "Maximale waarde is 900 seconden", + "name": "Naam", + "name-required": "Naam is verplicht", + "queue-type": "Wachtrijtype", + "sqs-queue-standard": "Standaard", + "sqs-queue-fifo": "FIFO", + "gcp-project-id": "GCP-project-ID", + "gcp-project-id-required": "GCP-project-ID is verplicht", + "gcp-service-account-key": "GCP serviceaccount-sleutelbestand", + "gcp-service-account-key-required": "GCP serviceaccount-sleutelbestand is verplicht", + "pubsub-topic-name": "Onderwerpnaam", + "pubsub-topic-name-required": "Onderwerpnaam is verplicht", + "message-attributes": "Berichtattributen", + "message-attributes-hint": "Gebruik ${metadataKey} voor waarden uit metadata, $[messageKey] voor waarden uit het berichtlichaam in naam/waarde-velden", + "connect-timeout": "Verbindingstime-out (sec)", + "connect-timeout-required": "Verbindingstime-out is verplicht.", + "connect-timeout-range": "Verbindingstime-out moet tussen 1 en 200 liggen.", + "client-id": "Client ID", + "client-id-hint": "Optioneel. Leeg laten voor automatisch gegenereerde ID. Zorg voor unieke ID in microservices-modus. Gebruik de optie hieronder om service-ID als suffix toe te voegen.", + "append-client-id-suffix": "Voeg service-ID toe als suffix aan Client ID", + "client-id-suffix-hint": "Optioneel. Alleen van toepassing als 'Client ID' is opgegeven. Toevoegen van service-ID als suffix helpt bij microservices-omgevingen.", + "device-id": "Apparaat ID", + "device-id-required": "Apparaat ID is verplicht.", + "clean-session": "Schone sessie", + "enable-ssl": "SSL inschakelen", + "credentials": "Referenties", + "credentials-type": "Type referenties", + "credentials-type-required": "Type referenties is verplicht.", + "credentials-anonymous": "Anoniem", + "credentials-basic": "Basis", + "credentials-pem": "PEM", + "credentials-pem-hint": "Minstens een Server CA-certificaatbestand of een paar Client-certificaat en Client privésleutelbestanden zijn vereist", + "credentials-sas": "Gedeelde Toegangshandtekening", + "sas-key": "SAS-sleutel", + "sas-key-required": "SAS-sleutel is verplicht.", + "hostname": "Hostnaam", + "hostname-required": "Hostnaam is verplicht.", + "azure-ca-cert": "CA-certificaatbestand", + "username-required": "Gebruikersnaam is verplicht.", + "password-required": "Wachtwoord is verplicht.", + "ca-cert": "Server CA-certificaatbestand", + "private-key": "Client privésleutelbestand", + "cert": "Client certificaatbestand", + "no-file": "Geen bestand geselecteerd.", + "drop-file": "Sleep een bestand of klik om te selecteren.", + "private-key-password": "Privésleutel wachtwoord", + "use-system-smtp-settings": "Gebruik systeem SMTP-instellingen", + "use-metadata-dynamic-interval": "Gebruik dynamisch interval", + "metadata-dynamic-interval-hint": "De velden voor intervalstart en -einde ondersteunen templatization. De vervangende waarde moet in milliseconden zijn.", + "use-metadata-interval-patterns-hint": "Indien geselecteerd, gebruikt regelknoop start- en eindintervalpatronen uit berichtmetadata of gegevens in milliseconden.", + "use-message-alarm-data": "Gebruik alarmgegevens uit bericht", + "overwrite-alarm-details": "Overschrijf alarmdetails", + "use-alarm-severity-pattern": "Gebruik alarmernstpatroon", + "check-all-keys": "Controleer dat alle gespecificeerde velden aanwezig zijn", + "check-all-keys-hint": "Controleert of alle gespecificeerde sleutels aanwezig zijn in de berichtgegevens en metadata.", + "check-relation-to-specific-entity": "Controleer relatie met specifieke entiteit", + "check-relation-to-specific-entity-tooltip": "Indien ingeschakeld, controleert op relatie met specifieke entiteit; anders met een willekeurige entiteit.", + "check-relation-hint": "Controleert het bestaan van een relatie op basis van richting en type.", + "delete-relation-with-specific-entity": "Verwijder relatie met specifieke entiteit", + "delete-relation-with-specific-entity-hint": "Verwijdert alleen relatie met één specifieke entiteit als ingeschakeld.", + "delete-relation-hint": "Verwijdert relatie van afzender naar entiteit(en) gebaseerd op richting en type.", + "remove-current-relations": "Verwijder huidige relaties", + "remove-current-relations-hint": "Verwijdert huidige relaties gebaseerd op richting en type.", + "change-originator-to-related-entity": "Wijzig afzender naar gerelateerde entiteit", + "change-originator-to-related-entity-hint": "Verwerkt bericht als afkomstig van een andere entiteit.", + "start-interval": "Intervalstart", + "end-interval": "Intervaleinde", + "start-interval-required": "Intervalstart is verplicht.", + "end-interval-required": "Intervaleinde is verplicht.", + "smtp-protocol": "Protocol", + "smtp-host": "SMTP-host", + "smtp-host-required": "SMTP-host is verplicht.", + "smtp-port": "SMTP-poort", + "smtp-port-required": "SMTP-poort is verplicht.", + "smtp-port-range": "SMTP-poort moet tussen 1 en 65535 liggen.", + "timeout-msec": "Timeout ms", + "min-timeout-msec-message": "Minimale waarde is 0 ms.", + "enter-username": "Voer gebruikersnaam in", + "enter-password": "Voer wachtwoord in", + "enable-tls": "TLS inschakelen", + "tls-version": "TLS-versie", + "enable-proxy": "Proxy inschakelen", + "use-system-proxy-properties": "Gebruik systeemeigenschappen voor proxy", + "proxy-host": "Proxyhost", + "proxy-host-required": "Proxyhost is verplicht.", + "proxy-port": "Proxypoort", + "proxy-port-required": "Proxypoort is verplicht.", + "proxy-port-range": "Proxypoort moet tussen 1 en 65535 liggen.", + "proxy-user": "Proxygebruiker", + "proxy-password": "Proxywachtwoord", + "proxy-scheme": "Proxyschema", + "numbers-to-template": "Telefoonnummers voor template", + "numbers-to-template-required": "Telefoonnummers voor template is verplicht", + "numbers-to-template-hint": "Kommagescheiden telefoonnummers, gebruik ${metadataKey} voor de waarde uit de metadata, $[messageKey] voor de waarde uit de berichtinhoud", + "sms-message-template": "SMS-berichttemplate", + "sms-message-template-required": "SMS-berichttemplate is verplicht", + "use-system-sms-settings": "Gebruik systeem SMS-instellingen", + "min-period-0-seconds-message": "Minimale periode is 0 seconden.", + "max-pending-messages": "Maximaal aantal wachtrijberichten", + "max-pending-messages-required": "Maximaal aantal wachtrijberichten is verplicht.", + "max-pending-messages-range": "Aantal berichten moet tussen 1 en 100000 liggen.", + "originator-types-filter": "Filter op oorsprongtype", + "interval-seconds": "Interval in seconden", + "interval-seconds-required": "Interval is verplicht.", + "int-range": "Waarde mag maximumwaarde van integer niet overschrijden (2147483648)", + "min-interval-seconds-message": "Minimaal 1 seconde interval is vereist.", + "output-timeseries-key-prefix": "Prefix voor output tijdreeksleutel", + "output-timeseries-key-prefix-required": "Prefix voor tijdreeksleutel is verplicht.", + "separator-hint": "Druk op \"Enter\" om het veldinvoer te voltooien.", + "select-details": "Selecteer details", + "entity-details-id": "Id", + "entity-details-title": "Titel", + "entity-details-country": "Land", + "entity-details-state": "Staat", + "entity-details-city": "Stad", + "entity-details-zip": "Postcode", + "entity-details-address": "Adres", + "entity-details-address2": "Adres 2", + "entity-details-additional_info": "Aanvullende info", + "entity-details-phone": "Telefoon", + "entity-details-email": "E-mail", + "email-sender": "E-mailsender", + "fields-to-check": "Velden om te controleren", + "add-detail": "Detail toevoegen", + "check-all-keys-tooltip": "Controleer of alle opgegeven velden aanwezig zijn in gegevens en metadata van het bericht.", + "fields-to-check-hint": "Druk op 'Enter' om veldnaam te voltooien. Meerdere namen ondersteund.", + "entity-details-list-empty": "Selecteer ten minste één detail.", + "alarm-status": "Alarmstatus", + "alarm-required": "Minstens één alarmstatus is vereist.", + "no-entity-details-matching": "Geen overeenkomende entiteitsdetails gevonden.", + "custom-table-name": "Aangepaste tabelnaam", + "custom-table-name-required": "Tabelnaam is verplicht", + "custom-table-hint": "Tabel moet bestaan in Cassandra-cluster en beginnen met 'cs_tb_'. Geef naam zonder prefix op.", + "message-field": "Berichtveld", + "message-field-required": "Berichtveld is verplicht.", + "table-col": "Tabelkolom", + "table-col-required": "Tabelkolom is verplicht.", + "latitude-field-name": "Breedtegraadveldnaam", + "longitude-field-name": "Lengtegraadveldnaam", + "latitude-field-name-required": "Breedtegraadveldnaam is verplicht.", + "longitude-field-name-required": "Lengtegraadveldnaam is verplicht.", + "fetch-perimeter-info-from-metadata": "Perimeterinformatie uit metadata ophalen", + "fetch-perimeter-info-from-metadata-tooltip": "Bij 'Polygon' wordt waarde direct gebruikt. Bij 'Circle' worden velden 'latitude', 'longitude', 'radius' en 'radiusUnit' verwacht.", + "perimeter-key-name": "Perimeter sleutelnaam", + "perimeter-key-name-hint": "Metadata veldnaam met perimeterinformatie.", + "perimeter-key-name-required": "Perimeter sleutelnaam is verplicht.", + "perimeter-circle": "Cirkel", + "perimeter-polygon": "Polygoon", + "perimeter-type": "Perimetertype", + "circle-center-latitude": "Middelpunt breedtegraad", + "circle-center-latitude-required": "Middelpunt breedtegraad is verplicht.", + "circle-center-longitude": "Middelpunt lengtegraad", + "circle-center-longitude-required": "Middelpunt lengtegraad is verplicht.", + "range-unit-meter": "Meter", + "range-unit-kilometer": "Kilometer", + "range-unit-foot": "Voet", + "range-unit-mile": "Mijl", + "range-unit-nautical-mile": "Zeemijl", + "range-units": "Afstandseenheden", + "range-units-required": "Afstandseenheden zijn verplicht.", + "range": "Bereik", + "range-required": "Bereik is verplicht.", + "polygon-definition": "Polygoondefinitie", + "polygon-definition-required": "Polygoondefinitie is verplicht.", + "polygon-definition-hint": "Gebruik formaat: [[lat1,lon1],[lat2,lon2],...,[latN,lonN]].", + "min-inside-duration": "Minimale binnentijdsduur", + "min-inside-duration-value-required": "Minimale binnentijdsduur is verplicht", + "min-inside-duration-time-unit": "Tijdseenheid minimale binnentijdsduur", + "min-outside-duration": "Minimale buitentijdsduur", + "min-outside-duration-value-required": "Minimale buitentijdsduur is verplicht", + "min-outside-duration-time-unit": "Tijdseenheid minimale buitentijdsduur", + "tell-failure-if-absent": "Meld mislukking", + "tell-failure-if-absent-hint": "Indien geselecteerd, genereert fout bij ontbrekende sleutelwaarden.", + "get-latest-value-with-ts": "Ophalen met tijdstempel", + "get-latest-value-with-ts-hint": "Geeft ook tijdstempel bij laatste telemetrie, bijv.: \"temp\": {\"ts\":1574329385897, \"value\":42}", + "ignore-null-strings": "Negeer lege strings", + "ignore-null-strings-hint": "Indien geselecteerd, worden lege waarden genegeerd.", + "add-metadata-key-values-as-kafka-headers": "Voeg metadata toe aan Kafka headers", + "add-metadata-key-values-as-kafka-headers-hint": "Metadata key-value paren worden als byte arrays met charset toegevoegd.", + "charset-encoding": "Tekenset codering", + "charset-encoding-required": "Tekenset codering is verplicht.", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "De naam van de wachtrij kan uit de lijst gekozen worden of als aangepaste naam worden ingevoerd.", + "device-profile-node-hint": "Nuttig bij duur- of herhalingscondities om consistentie van alarmstatus te waarborgen.", + "persist-alarm-rules": "Alarmregelstatus opslaan", + "persist-alarm-rules-hint": "Indien ingeschakeld, slaat deze knooppunt de status van verwerking op in de database.", + "fetch-alarm-rules": "Haal alarmregelstatus op", + "fetch-alarm-rules-hint": "Herstelt status bij initialisatie, zodat alarmen na herstart blijven functioneren.", + "input-value-key": "Sleutel invoerwaarde", + "input-value-key-required": "Sleutel invoerwaarde is verplicht.", + "output-value-key": "Sleutel uitvoerwaarde", + "output-value-key-required": "Sleutel uitvoerwaarde is verplicht.", + "number-of-digits-after-floating-point": "Aantal decimalen na komma", + "number-of-digits-after-floating-point-range": "Moet tussen 0 en 15 liggen.", + "failure-if-delta-negative": "Foutmelding bij negatieve delta", + "failure-if-delta-negative-tooltip": "Veroorzaakt fout als delta negatief is.", + "use-caching": "Gebruik caching", + "use-caching-tooltip": "Cache waarde van \"{{inputValueKey}}\" voor betere prestaties.", + "add-time-difference-between-readings": "Voeg tijdsverschil toe tussen \"{{inputValueKey}}\" metingen", + "add-time-difference-between-readings-tooltip": "Voegt \"{{periodValueKey}}\" toe aan uitgaand bericht.", + "period-value-key": "Periode sleutel", + "period-value-key-required": "Periode sleutel is verplicht.", + "general-pattern-hint": "Gebruik ${metadataKey} voor metadata en $[messageKey] voor berichtgegevens.", + "alarm-severity-pattern-hint": "Gebruik ${metadataKey} voor de waarde uit de metadata, $[messageKey] voor de waarde uit de berichtinhoud. De alarmernst moet een systeemwaarde zijn (CRITICAL, MAJOR, enz.)", + "output-node-name-hint": "Naam van rule node is het relatietype dat berichten doorstuurt naar andere nodes.", + "use-server-ts": "Gebruik server tijdstempel", + "use-server-ts-hint": "Gebruik server tijdstempel bij ontbreken van expliciete tijd in gegevens.", + "kv-map-pattern-hint": "Alle invoervelden ondersteunen templates. Gebruik $[messageKey] of ${metadataKey}.", + "kv-map-single-pattern-hint": "Ondersteunt templates. Gebruik $[messageKey] of ${metadataKey}.", + "shared-scope": "Gedeeld bereik", + "server-scope": "Serverbereik", + "client-scope": "Clientbereik", + "attribute-type": "Attribuut", + "attribute-type-description": "Waarde uit database ophalen", + "attribute-type-result-description": "Resultaat opslaan als attribuut", + "constant-type": "Constante", + "constant-type-description": "Stel vaste waarde in", + "time-series-type": "Tijdreeks", + "time-series-type-description": "Laatste tijdreekswaarde ophalen", + "time-series-type-result-description": "Resultaat opslaan als tijdreeks", + "message-body-type": "Bericht", + "message-body-type-description": "Waarde uit bericht ophalen", + "message-body-type-result-description": "Resultaat toevoegen aan bericht", + "message-metadata-type": "Metadata", + "message-metadata-type-description": "Waarde uit metadata ophalen", + "message-metadata-result-description": "Resultaat toevoegen aan metadata", + "argument-tile": "Argumenten", + "no-arguments-prompt": "Geen argumenten geconfigureerd", + "result-title": "Resultaat", + "functions-field-input": "Functies", + "no-option-found": "Geen optie gevonden", + "argument-source-field-input": "Bron", + "argument-source-field-input-required": "Bron is verplicht.", + "argument-key-field-input": "Sleutel", + "argument-key-field-input-required": "Sleutel is verplicht.", + "constant-value-field-input": "Constante waarde", + "constant-value-field-input-required": "Constante waarde is verplicht.", + "attribute-scope-field-input": "Attribuutbereik", + "attribute-scope-field-input-required": "Attribuutbereik is verplicht.", + "default-value-field-input": "Standaardwaarde", + "type-field-input": "Type", + "type-field-input-required": "Type is verplicht.", + "key-field-input": "Sleutel", + "add-entity-type": "Voeg entiteitstype toe", + "add-device-profile": "Voeg apparaatprofiel toe", + "key-field-input-required": "Sleutel is verplicht.", + "number-floating-point-field-input": "Aantal decimalen", + "number-floating-point-field-input-hint": "Gebruik 0 voor gehele getallen", + "add-to-message-field-input": "Voeg toe aan bericht", + "add-to-metadata-field-input": "Voeg toe aan metadata", + "custom-expression-field-input": "Wiskundige uitdrukking", + "custom-expression-field-input-required": "Wiskundige uitdrukking is verplicht", + "custom-expression-field-input-hint": "Bijv. om Fahrenheit naar Celsius om te rekenen", + "retained-message": "Behouden bericht", + "attributes-mapping": "Attribuuttoewijzing", + "latest-telemetry-mapping": "Laatste telemetrie-toewijzing", + "add-mapped-attribute-to": "Voeg toegewezen attributen toe aan", + "add-mapped-latest-telemetry-to": "Voeg toegewezen telemetrie toe aan", + "add-mapped-fields-to": "Voeg toegewezen velden toe aan", + "add-selected-details-to": "Voeg geselecteerde details toe aan", + "clear-selected-types": "Geselecteerde types wissen", + "clear-selected-details": "Geselecteerde details wissen", + "clear-selected-fields": "Geselecteerde velden wissen", + "clear-selected-keys": "Geselecteerde sleutels wissen", + "geofence-configuration": "Geofence-configuratie", + "coordinate-field-names": "Coördinaat veldnamen", + "coordinate-field-hint": "Node probeert deze velden uit het bericht te halen. Indien niet aanwezig, wordt metadata gebruikt.", + "presence-monitoring-strategy": "Aanwezigheidsbewakingsstrategie", + "presence-monitoring-strategy-on-first-message": "Bij eerste bericht", + "presence-monitoring-strategy-on-each-message": "Bij elk bericht", + "presence-monitoring-strategy-on-first-message-hint": "Meldt status 'Binnen' of 'Buiten' bij eerste bericht na ingestelde minimale duur sinds laatste statuswijziging.", + "presence-monitoring-strategy-on-each-message-hint": "Meldt status 'Binnen' of 'Buiten' bij elk bericht na status 'Binnengekomen' of 'Verlaten'.", + "fetch-credentials-to": "Haal inloggegevens op naar", + "add-originator-attributes-to": "Voeg attributen van bron toe aan", + "originator-attributes": "Bronattributen", + "fetch-latest-telemetry-with-timestamp": "Haal laatste telemetrie op met tijdstempel", + "fetch-latest-telemetry-with-timestamp-tooltip": "Voegt tijdstempel toe aan metadata bij laatste telemetrie, bijv: \"{{latestTsKeyName}}\": \"{\"ts\":1574329385897, \"value\":42}\"", + "tell-failure": "Melding van mislukking indien attributen ontbreken", + "tell-failure-tooltip": "Indien ten minste één geselecteerde sleutel ontbreekt, wordt bericht als 'Mislukt' gemarkeerd.", + "created-time": "Aangemaakt op", + "chip-help": "Druk op 'Enter' om {{inputName}} in te voeren. Backspace wist invoer. Meerdere waarden ondersteund.", + "detail": "detail", + "field-name": "veldnaam", + "device-profile": "apparaatprofiel", + "entity-type": "entiteitstype", + "message-type": "berichttype", + "timeseries-key": "tijdreeks-sleutel", + "type": "Type", + "first-name": "Voornaam", + "last-name": "Achternaam", + "label": "Label", + "originator-fields-mapping": "Bronveld-mapping", + "add-mapped-originator-fields-to": "Voeg gemapte bronvelden toe aan", + "fields": "Velden", + "skip-empty-fields": "Lege velden overslaan", + "skip-empty-fields-tooltip": "Velden zonder waarde worden niet toegevoegd aan bericht of metadata.", + "fetch-interval": "Ophaalinterval", + "fetch-strategy": "Ophaalstrategie", + "fetch-timeseries-from-to": "Ophaal tijdreeks van {{startInterval}} {{startIntervalTimeUnit}} geleden tot {{endInterval}} {{endIntervalTimeUnit}} geleden.", + "fetch-timeseries-from-to-invalid": "\"Interval start\" moet kleiner zijn dan \"Interval einde\".", + "use-metadata-dynamic-interval-tooltip": "Gebruikt dynamisch interval op basis van bericht/metadata.", + "all-mode-hint": "Met 'All' worden alle tijdreeksen binnen het interval opgehaald.", + "first-mode-hint": "'First' haalt dichtstbijzijnde tijdreeks op bij start van interval.", + "last-mode-hint": "'Last' haalt dichtstbijzijnde tijdreeks op bij einde van interval.", + "ascending": "Oplopend", + "descending": "Aflopend", + "min": "Min", + "max": "Max", + "average": "Gemiddelde", + "sum": "Som", + "count": "Aantal", + "none": "Geen", + "last-level-relation-tooltip": "Zoekt alleen gerelateerde entiteiten op laatste niveau.", + "last-level-device-relation-tooltip": "Zoekt alleen gerelateerde apparaten op laatste niveau.", + "data-to-fetch": "Gegevens op te halen", + "mapping-of-customers": "Mapping van klant", + "map-fields-required": "Alle mapping-velden zijn verplicht.", + "attributes": "Attributen", + "related-device-attributes": "Gerelateerde apparaat attributen", + "add-selected-attributes-to": "Voeg geselecteerde attributen toe aan", + "device-profiles": "Apparaatprofielen", + "mapping-of-tenant": "Mapping van huurder", + "add-attribute-key": "Attribuutsleutel toevoegen", + "message-template": "Berichtsjabloon", + "message-template-required": "Berichtsjabloon is verplicht", + "use-system-slack-settings": "Gebruik systeem Slack-instellingen", + "slack-api-token": "Slack API-token", + "slack-api-token-required": "Slack API-token is verplicht", + "keys-mapping": "Sleutelmapping", + "add-key": "Sleutel toevoegen", + "recipients": "Ontvangers", + "message-subject-and-content": "Onderwerp en inhoud van bericht", + "template-rules-hint": "Gebruik $[messageKey] of ${metadataKey} om waarden te extraheren.", + "originator-customer-desc": "Gebruik klant van oorspronkelijke bron als nieuwe bron.", + "originator-tenant-desc": "Gebruik huidige huurder als nieuwe bron.", + "originator-related-entity-desc": "Gebruik gerelateerde entiteit als nieuwe bron.", + "originator-alarm-originator-desc": "Gebruik alarmbron als nieuwe bron.", + "originator-entity-by-name-pattern-desc": "Zoek entiteit op naam en gebruik als nieuwe bron.", + "email-from-template-hint": "Gebruik $[messageKey] of ${metadataKey} om waarden op te halen.", + "recipients-block-main-hint": "Komma-gescheiden lijst, ondersteunt templates.", + "forward-msg-default-rule-chain": "Stuur bericht naar standaard regelketen van bron", + "forward-msg-default-rule-chain-tooltip": "Gebruikt standaard of geconfigureerde regelketen van bron.", + "exclude-zero-deltas": "Negeer nulverschillen", + "exclude-zero-deltas-hint": "Voegt sleutel toe aan bericht indien waarde niet nul is.", + "exclude-zero-deltas-time-difference-hint": "Voegt alleen toe als waarde niet nul is.", + "search-direction-from": "Van bron naar doelentiteit", + "search-direction-to": "Van doelentiteit naar bron", + "del-relation-direction-from": "Van bron", + "del-relation-direction-to": "Naar bron", + "target-entity": "Doelentiteit", + "function-configuration": "Functieconfiguratie", + "function-name": "Functienaam", + "function-name-required": "Functienaam is verplicht.", + "qualifier": "Kwalificatie", + "qualifier-hint": "Als niet opgegeven, wordt de standaardkwalificatie \"$LATEST\" gebruikt.", + "aws-credentials": "AWS-referenties", + "connection-timeout": "Verbindingstime-out", + "connection-timeout-required": "Verbindingstime-out is verplicht.", + "connection-timeout-min": "Minimale verbindingstime-out is 0.", + "connection-timeout-hint": "Wachttijd in seconden voor het tot stand brengen van een verbinding. 0 betekent oneindig (niet aanbevolen).", + "request-timeout": "Verzoek-time-out", + "request-timeout-required": "Verzoek-time-out is verplicht.", + "request-timeout-min": "Minimale verzoek-time-out is 0", + "request-timeout-hint": "Wachttijd in seconden voor voltooiing van het verzoek. 0 betekent oneindig (niet aanbevolen).", + "units": "Eenheden", + "tell-failure-aws-lambda": "Mislukking melden bij uitzondering in AWS Lambda-uitvoering", + "tell-failure-aws-lambda-hint": "Forceert verwerkingsmislukking als de AWS Lambda-functie een uitzondering geeft.", + "basic-mode": "Basis", + "advanced-mode": "Geavanceerd", + "save-time-series": { + "processing-settings": "Verwerkingsinstellingen", + "processing-settings-hint": "Definieer hoe inkomende berichten worden verwerkt.", + "advanced-settings-hint": "Let op met het configureren van strategieën. Onverwacht gedrag mogelijk.", + "strategy": "Strategie", + "deduplication-interval": "Deduplicatie-interval", + "deduplication-interval-required": "Deduplicatie-interval is verplicht", + "deduplication-interval-min-max-range": "Minimaal 1 seconde en maximaal 1 dag", + "strategy-type": { + "every-message": "Bij elk bericht", + "skip": "Overslaan", + "deduplicate": "Dedupliceren", + "web-sockets-only": "Alleen WebSockets" + }, + "time-series": "Tijdreeksen", + "latest": "Laatste waarden", + "web-sockets": "WebSockets", + "calculated-fields": "Berekende velden" + }, + "save-attribute": { + "processing-settings": "Verwerkingsinstellingen", + "processing-settings-hint": "Definieer hoe inkomende berichten worden verwerkt.", + "advanced-settings-hint": "Let op met het configureren van strategieën. Onverwacht gedrag mogelijk.", + "strategy": "Strategie", + "deduplication-interval": "Deduplicatie-interval", + "deduplication-interval-required": "Deduplicatie-interval is verplicht", + "deduplication-interval-min-max-range": "Minimaal 1 seconde en maximaal 1 dag", + "scope": "Bereik", + "strategy-type": { + "every-message": "Bij elk bericht", + "skip": "Overslaan", + "deduplicate": "Dedupliceren", + "web-sockets-only": "Alleen WebSockets" + }, + "attributes": "Attributen" + }, + "key-val": { + "key": "Sleutel", + "value": "Waarde", + "see-examples": "Zie voorbeelden.", + "remove-entry": "Verwijder invoer", + "remove-mapping-entry": "Verwijder mapping", + "add-mapping-entry": "Voeg mapping toe", + "add-entry": "Voeg invoer toe", + "copy-key-values-from": "Kopieer sleutel-waarden van", + "delete-key-values": "Verwijder sleutel-waarden", + "delete-key-values-from": "Verwijder sleutel-waarden van", + "at-least-one-key-error": "Minstens één sleutel moet geselecteerd zijn.", + "unique-key-value-pair-error": "'{{keyText}}' moet verschillend zijn van '{{valText}}'!" + }, + "mail-body-types": { + "plain-text": "Platte tekst", + "html": "HTML", + "dynamic": "Dynamisch", + "use-body-type-template": "Gebruik body-type sjabloon", + "plain-text-description": "Eenvoudige tekst zonder opmaak.", + "html-text-description": "Gebruik HTML-tags voor opmaak, links en afbeeldingen.", + "dynamic-text-description": "Gebruik dynamisch platte tekst of HTML via sjabloon.", + "after-template-evaluation-hint": "Na sjabloonevaluatie: true voor HTML, false voor platte tekst." + } + }, + "timezone": { + "timezone": "Tijdzone", + "select-timezone": "Selecteer tijdzone", + "no-timezones-matching": "Geen tijdzones gevonden die overeenkomen met '{{timezone}}'.", + "timezone-required": "Tijdzone is verplicht.", + "browser-time": "Browsertijd" + }, + "queue": { + "queue-name": "Wachtrij", + "no-queues-found": "Geen wachtrijen gevonden.", + "no-queues-matching": "Geen wachtrijen gevonden die overeenkomen met '{{queue}}'.", + "select-name": "Selecteer wachtrijnaam", + "name": "Naam", + "name-required": "Wachtrijnaam is verplicht!", + "name-unique": "Wachtrijnaam is niet uniek!", + "name-pattern": "Wachtrijnaam bevat een ongeldig teken. Alleen ASCII-alfanumeriek, '.', '_' en '-' zijn toegestaan!", + "queue-required": "Wachtrij is verplicht!", + "topic-required": "Onderwerp van wachtrij is verplicht!", + "poll-interval-required": "Poll-interval is verplicht!", + "poll-interval-min-value": "Poll-interval mag niet kleiner zijn dan 1", + "partitions-required": "Partities is verplicht!", + "partitions-min-value": "Aantal partities mag niet kleiner zijn dan 1", + "pack-processing-timeout-required": "Verwerkingstijdslimiet is verplicht", + "pack-processing-timeout-min-value": "Verwerkingstijdslimiet mag niet kleiner zijn dan 1", + "batch-size-required": "Batchgrootte is verplicht!", + "batch-size-min-value": "Batchgrootte mag niet kleiner zijn dan 1", + "retries-required": "Aantal pogingen is verplicht!", + "retries-min-value": "Aantal pogingen mag niet negatief zijn", + "failure-percentage-required": "Faalpercentage is verplicht!", + "failure-percentage-min-value": "Faalpercentage mag niet kleiner zijn dan 0", + "failure-percentage-max-value": "Faalpercentage mag niet groter zijn dan 100", + "pause-between-retries-required": "Pauze tussen pogingen is verplicht!", + "pause-between-retries-min-value": "Pauze tussen pogingen mag niet kleiner zijn dan 1", + "max-pause-between-retries-required": "Maximale pauze tussen pogingen is verplicht!", + "max-pause-between-retries-min-value": "Maximale pauze mag niet kleiner zijn dan 1", + "submit-strategy-type-required": "Indientypstrategie is verplicht!", + "processing-strategy-type-required": "Verwerkingstype is verplicht!", + "queues": "Wachtrijen", + "selected-queues": "{ count, plural, =1 {1 wachtrij} other {# wachtrijen} } geselecteerd", + "delete-queue-title": "Weet je zeker dat je de wachtrij '{{queueName}}' wilt verwijderen?", + "delete-queues-title": "Weet je zeker dat je { count, plural, =1 {1 wachtrij} other {# wachtrijen} } wilt verwijderen?", + "delete-queue-text": "Let op: na bevestiging wordt de wachtrij en alle gerelateerde gegevens permanent verwijderd.", + "delete-queues-text": "Na bevestiging worden alle geselecteerde wachtrijen verwijderd en zijn ze niet meer toegankelijk.", + "search": "Zoek wachtrij", + "add": "Wachtrij toevoegen", + "details": "Wachtrijdetails", + "topic": "Onderwerp", + "submit-settings": "Indieninstellingen", + "submit-strategy": "Strategietype *", + "grouping-parameter": "Groepeerparameter", + "processing-settings": "Verwerkingsinstellingen voor pogingen", + "processing-strategy": "Verwerkingstype *", + "retries-settings": "Instellingen voor pogingen", + "polling-settings": "Pollinginstellingen", + "batch-processing": "Batchverwerking", + "poll-interval": "Poll-interval", + "partitions": "Partities", + "immediate-processing": "Directe verwerking", + "consumer-per-partition": "Poll-bericht per consument", + "consumer-per-partition-hint": "Schakel aparte consument(en) in voor elke partitie", + "duplicate-msg-to-all-partitions": "Dupliceer bericht naar alle partities", + "processing-timeout": "Verwerking binnen, ms", + "batch-size": "Batchgrootte", + "retries": "Aantal pogingen (0 – onbeperkt)", + "failure-percentage": "Percentage fouten om pogingen over te slaan, %", + "pause-between-retries": "Poging opnieuw binnen, sec", + "max-pause-between-retries": "Extra poging binnen, sec", + "delete": "Wachtrij verwijderen", + "copyId": "Kopieer wachtrij-ID", + "idCopiedMessage": "Wachtrij-ID is gekopieerd naar het klembord", + "description": "Beschrijving", + "description-hint": "Deze tekst wordt weergegeven in de wachtrijbeschrijving in plaats van de geselecteerde strategie", + "alt-description": "Indienstrategie: {{submitStrategy}}, Verwerkingsstrategie: {{processingStrategy}}", + "custom-properties": "Aangepaste eigenschappen", + "custom-properties-hint": "Aangepaste eigenschappen voor het maken van wachtrijen (onderwerp), bijv. 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "Sequentieel per verzender", + "sequential-by-originator-hint": "Nieuw bericht voor bijv. apparaat A wordt niet ingediend totdat het vorige bericht voor apparaat A is bevestigd", + "sequential-by-tenant-label": "Sequentieel per tenant", + "sequential-by-tenant-hint": "Nieuw bericht voor bijv. tenant A wordt niet ingediend totdat het vorige bericht voor tenant A is bevestigd", + "sequential-label": "Sequentieel", + "sequential-hint": "Nieuw bericht wordt niet ingediend totdat het vorige bericht is bevestigd", + "burst-label": "Burst", + "burst-hint": "Alle berichten worden in de volgorde van binnenkomst naar de regels doorgestuurd", + "batch-label": "Batch", + "batch-hint": "Nieuwe batch wordt niet ingediend totdat vorige batch is bevestigd", + "skip-all-failures-label": "Sla alle fouten over", + "skip-all-failures-hint": "Negeer alle fouten", + "skip-all-failures-and-timeouts-label": "Sla alle fouten en time-outs over", + "skip-all-failures-and-timeouts-hint": "Negeer alle fouten en time-outs", + "retry-all-label": "Alles opnieuw proberen", + "retry-all-hint": "Probeer alle berichten uit het verwerkingspakket opnieuw", + "retry-failed-label": "Mislukte opnieuw proberen", + "retry-failed-hint": "Probeer alle mislukte berichten uit het verwerkingspakket opnieuw", + "retry-timeout-label": "Time-outs opnieuw proberen", + "retry-timeout-hint": "Probeer alle time-out berichten uit het verwerkingspakket opnieuw", + "retry-failed-and-timeout-label": "Mislukte en time-outs opnieuw proberen", + "retry-failed-and-timeout-hint": "Probeer alle mislukte en time-out berichten uit het verwerkingspakket opnieuw" + } + }, + "queue-statistics": { + "queue-statistics": "Wachtrijstatistieken", + "no-queue-statistics-matching": "Geen wachtrijstatistieken gevonden die overeenkomen met '{{entity}}'.", + "queue-statistics-required": "Wachtrijstatistieken zijn vereist.", + "list-of-queue-statistics": "{ count, plural, =1 {Eén wachtrijstatistiek} other {Lijst van # wachtrijstatistieken} }", + "selected-queue-statistics": "{ count, plural, =1 {1 wachtrijstatistiek} other {# wachtrijstatistieken} } geselecteerd", + "no-queue-statistics-text": "Geen wachtrijstatistieken gevonden", + "queue-statistics-starts-with": "Wachtrijstatistieken die beginnen met '{{prefix}}'" + }, + "server-error": { + "general": "Algemene serverfout", + "authentication": "Authenticatiefout", + "jwt-token-expired": "JWT-token is verlopen", + "tenant-trial-expired": "Proefperiode van tenant is verlopen", + "credentials-expired": "Inloggegevens verlopen", + "permission-denied": "Toegang geweigerd", + "invalid-arguments": "Ongeldige argumenten", + "bad-request-params": "Ongeldige aanvraagparameters", + "item-not-found": "Item niet gevonden", + "too-many-requests": "Te veel aanvragen", + "too-many-updates": "Te veel updates" + }, + "tenant": { + "tenant": "Tenant", + "tenants": "Tenants", + "management": "Tenantbeheer", + "add": "Tenant toevoegen", + "admins": "Beheerders", + "manage-tenant-admins": "Beheer tenantbeheerders", + "delete": "Tenant verwijderen", + "add-tenant-text": "Nieuwe tenant toevoegen", + "no-tenants-text": "Geen tenants gevonden", + "tenant-details": "Tenantgegevens", + "title-max-length": "Titel moet minder dan 256 tekens zijn", + "delete-tenant-title": "Weet je zeker dat je de tenant '{{tenantTitle}}' wilt verwijderen?", + "delete-tenant-text": "Wees voorzichtig, na bevestiging wordt de tenant en alle gerelateerde gegevens permanent verwijderd.", + "delete-tenants-title": "Weet je zeker dat je { count, plural, =1 {1 tenant} other {# tenants} } wilt verwijderen?", + "delete-tenants-action-title": "Verwijder { count, plural, =1 {1 tenant} other {# tenants} }", + "delete-tenants-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde tenants en hun gegevens permanent verwijderd.", + "title": "Titel", + "title-required": "Titel is verplicht.", + "description": "Beschrijving", + "details": "Details", + "events": "Gebeurtenissen", + "copyId": "Kopieer tenant-ID", + "idCopiedMessage": "Tenant-ID is gekopieerd naar het klembord", + "select-tenant": "Selecteer tenant", + "no-tenants-matching": "Geen tenants gevonden die overeenkomen met '{{entity}}'.", + "tenant-required": "Tenant is verplicht", + "search": "Zoek tenants", + "selected-tenants": "{ count, plural, =1 {1 tenant} other {# tenants} } geselecteerd", + "isolated-tb-rule-engine": "Gebruik geïsoleerde ThingsBoard Rule Engine-wachtrijen", + "isolated-tb-rule-engine-details": "Elke tenant krijgt toegewezen Rule Engine-wachtrijen" + }, + "tenant-profile": { + "tenant-profile": "Tenantprofiel", + "tenant-profiles": "Tenantprofielen", + "add": "Tenantprofiel toevoegen", + "add-profile": "Profiel toevoegen", + "debug": "Debuggen", + "edit": "Tenantprofiel bewerken", + "tenant-profile-details": "Details van tenantprofiel", + "no-tenant-profiles-text": "Geen tenantprofielen gevonden", + "name-max-length": "Naam moet minder dan 256 tekens zijn", + "search": "Zoek tenantprofielen", + "selected-tenant-profiles": "{ count, plural, =1 {1 tenantprofiel} other {# tenantprofielen} } geselecteerd", + "no-tenant-profiles-matching": "Geen tenantprofiel gevonden die overeenkomt met '{{entity}}'.", + "tenant-profile-required": "Tenantprofiel is verplicht", + "idCopiedMessage": "Tenantprofiel-ID is gekopieerd naar klembord", + "set-default": "Maak tenantprofiel standaard", + "delete": "Tenantprofiel verwijderen", + "copyId": "Kopieer tenantprofiel-ID", + "name": "Naam", + "name-required": "Naam is verplicht.", + "data": "Profielgegevens", + "profile-configuration": "Profielconfiguratie", + "description": "Beschrijving", + "default": "Standaard", + "delete-tenant-profile-title": "Weet je zeker dat je het tenantprofiel '{{tenantProfileName}}' wilt verwijderen?", + "delete-tenant-profile-text": "Wees voorzichtig, na bevestiging worden het tenantprofiel en alle bijbehorende gegevens permanent verwijderd.", + "delete-tenant-profiles-title": "Weet je zeker dat je { count, plural, =1 {1 tenantprofiel} other {# tenantprofielen} } wilt verwijderen?", + "delete-tenant-profiles-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde tenantprofielen en hun gegevens permanent verwijderd.", + "set-default-tenant-profile-title": "Weet je zeker dat je het tenantprofiel '{{tenantProfileName}}' als standaard wilt instellen?", + "set-default-tenant-profile-text": "Na bevestiging wordt het tenantprofiel als standaard gemarkeerd en gebruikt voor nieuwe tenants zonder opgegeven profiel.", + "no-tenant-profiles-found": "Geen tenantprofielen gevonden.", + "create-new-tenant-profile": "Maak een nieuw profiel aan!", + "create-tenant-profile": "Nieuw tenantprofiel aanmaken", + "import": "Tenantprofiel importeren", + "export": "Tenantprofiel exporteren", + "export-failed-error": "Tenantprofiel kan niet worden geëxporteerd: {{error}}", + "tenant-profile-file": "Tenantprofielbestand", + "invalid-tenant-profile-file-error": "Kan tenantprofiel niet importeren: Ongeldige datastructuur van tenantprofiel.", + "advanced-settings": "Geavanceerde instellingen", + "entities": "Entiteiten", + "rule-engine": "Regelengine", + "time-to-live": "Levensduur (TTL)", + "calculated-fields": "Berekende velden", + "alarms-and-notifications": "Alarmen en meldingen", + "ota-files-in-bytes": "Bestanden", + "ws-title": "WS", + "unlimited": "(0 - onbeperkt)", + "maximum-devices": "Maximaal aantal apparaten", + "maximum-devices-required": "Maximaal aantal apparaten is verplicht.", + "maximum-devices-range": "Maximaal aantal apparaten mag niet negatief zijn", + "maximum-assets": "Maximaal aantal assets", + "maximum-assets-required": "Maximaal aantal assets is verplicht.", + "maximum-assets-range": "Maximaal aantal assets mag niet negatief zijn", + "maximum-customers": "Maximaal aantal klanten", + "maximum-customers-required": "Maximaal aantal klanten is verplicht.", + "maximum-customers-range": "Maximaal aantal klanten mag niet negatief zijn", + "maximum-users": "Maximaal aantal gebruikers", + "maximum-users-required": "Maximaal aantal gebruikers is verplicht.", + "maximum-users-range": "Maximaal aantal gebruikers mag niet negatief zijn", + "maximum-dashboards": "Maximaal aantal dashboards", + "maximum-dashboards-required": "Maximaal aantal dashboards is verplicht.", + "maximum-dashboards-range": "Maximaal aantal dashboards mag niet negatief zijn", + "maximum-edges": "Maximaal aantal edges", + "maximum-edges-required": "Maximaal aantal edges is verplicht.", + "maximum-edges-range": "Maximaal aantal edges mag niet negatief zijn", + "maximum-rule-chains": "Maximaal aantal regelketens", + "maximum-rule-chains-required": "Maximaal aantal regelketens is verplicht.", + "maximum-rule-chains-range": "Maximaal aantal regelketens mag niet negatief zijn", + "maximum-resources-sum-data-size": "Maximale totale grootte van resourcebestanden (bytes)", + "maximum-resources-sum-data-size-required": "Maximale totale grootte van resourcebestanden is verplicht.", + "maximum-resources-sum-data-size-range": "Maximale totale grootte van resourcebestanden mag niet negatief zijn", + "maximum-resource-size": "Maximale grootte van resourcebestand (bytes)", + "maximum-resource-size-required": "Maximale grootte van resourcebestand is verplicht", + "maximum-resource-size-range": "Maximale grootte van resourcebestand mag niet negatief zijn", + "maximum-ota-packages-sum-data-size": "Maximale totale grootte van OTA-pakketbestanden (bytes)", + "maximum-ota-package-sum-data-size-required": "Maximale totale grootte van OTA-pakketbestanden is verplicht.", + "maximum-ota-package-sum-data-size-range": "Maximale totale grootte van OTA-pakketbestanden mag niet negatief zijn", + "maximum-debug-duration-min": "Maximale debugduur (minuten)", + "maximum-debug-duration-min-range": "Maximale debugduur mag niet negatief zijn", + "rest-requests-for-tenant": "REST-verzoeken voor tenant", + "transport-tenant-telemetry-msg-rate-limit": "Transport tenant telemetrieberichten", + "transport-tenant-telemetry-data-points-rate-limit": "Transport tenant telemetriegegevenspunten", + "transport-device-msg-rate-limit": "Transport apparaatberichten", + "transport-device-telemetry-msg-rate-limit": "Transport apparaat telemetrieberichten", + "transport-device-telemetry-data-points-rate-limit": "Transport apparaat telemetriegegevenspunten", + "transport-gateway-msg-rate-limit": "Transport gatewayberichten", + "transport-gateway-telemetry-msg-rate-limit": "Transport gateway telemetrieberichten", + "transport-gateway-telemetry-data-points-rate-limit": "Transport gateway telemetriegegevenspunten", + "transport-gateway-device-msg-rate-limit": "Transport gateway-apparaatberichten", + "transport-gateway-device-telemetry-msg-rate-limit": "Transport gateway-apparaat telemetrieberichten", + "transport-gateway-device-telemetry-data-points-rate-limit": "Transport gateway-apparaat telemetriegegevenspunten", + "tenant-entity-export-rate-limit": "Entiteitsversie maken", + "tenant-entity-import-rate-limit": "Entiteitsversie laden", + "tenant-notification-request-rate-limit": "Meldingsverzoeken", + "tenant-notification-requests-per-rule-rate-limit": "Meldingsverzoeken per meldingsregel", + "max-calculated-fields": "Maximaal aantal berekende velden per entiteit", + "max-calculated-fields-range": "Maximaal aantal berekende velden mag niet negatief zijn", + "max-calculated-fields-required": "Maximaal aantal berekende velden is verplicht", + "max-data-points-per-rolling-arg": "Maximaal aantal gegevenspunten in rollende argumenten", + "max-data-points-per-rolling-arg-range": "Maximaal aantal gegevenspunten in rollende argumenten mag niet negatief zijn", + "max-data-points-per-rolling-arg-required": "Maximaal aantal gegevenspunten in rollende argumenten is verplicht", + "max-arguments-per-cf": "Maximaal aantal argumenten per berekend veld", + "max-arguments-per-cf-range": "Maximaal aantal argumenten mag niet negatief zijn", + "max-arguments-per-cf-required": "Maximaal aantal argumenten is verplicht", + "max-state-size": "Maximale staatsgrootte in KB", + "max-state-size-range": "Staatsgrootte mag niet negatief zijn", + "max-state-size-required": "Staatsgrootte is verplicht", + "max-value-argument-size": "Maximale grootte van enkel waardeargument in KB", + "max-value-argument-size-range": "Maximale grootte mag niet negatief zijn", + "max-value-argument-size-required": "Maximale grootte is verplicht", + "max-transport-messages": "Maximaal aantal transportberichten", + "max-transport-messages-required": "Maximaal aantal transportberichten is verplicht.", + "max-transport-messages-range": "Maximaal aantal transportberichten mag niet negatief zijn", + "max-transport-data-points": "Maximaal aantal transportgegevenspunten", + "max-transport-data-points-required": "Maximaal aantal transportgegevenspunten is verplicht.", + "max-transport-data-points-range": "Maximaal aantal transportgegevenspunten mag niet negatief zijn", + "max-r-e-executions": "Maximaal aantal Rule Engine-uitvoeringen", + "max-r-e-executions-required": "Maximaal aantal Rule Engine-uitvoeringen is verplicht.", + "max-r-e-executions-range": "Maximaal aantal Rule Engine-uitvoeringen mag niet negatief zijn", + "max-j-s-executions": "Maximaal aantal JavaScript-uitvoeringen", + "max-j-s-executions-required": "Maximaal aantal JavaScript-uitvoeringen is verplicht.", + "max-j-s-executions-range": "Maximaal aantal JavaScript-uitvoeringen mag niet negatief zijn", + "max-tbel-executions": "Maximaal aantal TBEL-uitvoeringen", + "max-tbel-executions-required": "Maximaal aantal TBEL-uitvoeringen is verplicht.", + "max-tbel-executions-range": "Maximaal aantal TBEL-uitvoeringen mag niet negatief zijn", + "max-d-p-storage-days": "Maximaal aantal dagen voor opslag van gegevenspunten", + "max-d-p-storage-days-required": "Maximaal aantal opslagdagen is verplicht.", + "max-d-p-storage-days-range": "Opslagdagen mogen niet negatief zijn", + "default-storage-ttl-days": "Standaard opslag-TTL in dagen", + "default-storage-ttl-days-required": "Standaard opslag-TTL is verplicht.", + "default-storage-ttl-days-range": "Opslag-TTL mag niet negatief zijn", + "alarms-ttl-days": "Alarmen TTL in dagen", + "alarms-ttl-days-required": "Alarmen TTL is verplicht", + "alarms-ttl-days-days-range": "Alarmen TTL mag niet negatief zijn", + "rpc-ttl-days": "RPC TTL in dagen", + "rpc-ttl-days-required": "RPC TTL is verplicht", + "rpc-ttl-days-days-range": "RPC TTL mag niet negatief zijn", + "queue-stats-ttl-days": "Queue-statistieken TTL in dagen", + "queue-stats-ttl-days-required": "Queue-statistieken TTL is verplicht", + "queue-stats-ttl-days-range": "Queue-statistieken TTL mag niet negatief zijn", + "rule-engine-exceptions-ttl-days": "Rule Engine-uitzonderingen TTL in dagen", + "rule-engine-exceptions-ttl-days-required": "TTL van Rule Engine-uitzonderingen is verplicht", + "rule-engine-exceptions-ttl-days-range": "TTL van Rule Engine-uitzonderingen mag niet negatief zijn", + "max-rule-node-executions-per-message": "Maximaal aantal rule node-uitvoeringen per bericht", + "max-rule-node-executions-per-message-required": "Maximaal aantal uitvoeringen per bericht is verplicht.", + "max-rule-node-executions-per-message-range": "Maximaal aantal uitvoeringen per bericht mag niet negatief zijn", + "max-emails": "Maximaal aantal verzonden e-mails", + "max-emails-required": "Maximaal aantal verzonden e-mails is verplicht.", + "max-emails-range": "Maximaal aantal e-mails mag niet negatief zijn", + "sms-enabled": "SMS ingeschakeld", + "max-sms": "Maximaal aantal verzonden SMS", + "max-sms-required": "Maximaal aantal verzonden SMS is verplicht.", + "max-sms-range": "Maximaal aantal SMS mag niet negatief zijn", + "max-created-alarms": "Maximaal aantal aangemaakte alarmen", + "max-created-alarms-required": "Maximaal aantal aangemaakte alarmen is verplicht.", + "max-created-alarms-range": "Maximaal aantal alarmen mag niet negatief zijn", + "no-queue": "Geen queue geconfigureerd", + "add-queue": "Queue toevoegen", + "queues-with-count": "Queues ({{count}})", + "tenant-rest-limits": "REST-verzoeken voor tenant", + "customer-rest-limits": "REST-verzoeken voor klant", + "incorrect-pattern-for-rate-limits": "Het formaat is komma-gescheiden paren van capaciteit en periode (in seconden), bijv. 100:1,2000:60", + "too-small-value-zero": "De waarde moet groter zijn dan 0", + "too-small-value-one": "De waarde moet groter zijn dan 1", + "queue-size-is-limited-by-system-configuration": "De grootte van de wachtrij is ook beperkt door de systeemconfiguratie.", + "cassandra-tenant-limits-configuration": "Cassandra-query voor tenant", + "ws-limit-max-sessions-per-tenant": "Maximaal aantal sessies per tenant", + "ws-limit-max-sessions-per-customer": "Maximaal aantal sessies per klant", + "ws-limit-max-sessions-per-regular-user": "Maximaal aantal sessies per reguliere gebruiker", + "ws-limit-max-sessions-per-public-user": "Maximaal aantal sessies per publieke gebruiker", + "ws-limit-queue-per-session": "Maximale wachtrijgrootte per sessie", + "ws-limit-max-subscriptions-per-tenant": "Maximaal aantal abonnementen per tenant", + "ws-limit-max-subscriptions-per-customer": "Maximaal aantal abonnementen per klant", + "ws-limit-max-subscriptions-per-regular-user": "Maximaal aantal abonnementen per reguliere gebruiker", + "ws-limit-max-subscriptions-per-public-user": "Maximaal aantal abonnementen per publieke gebruiker", + "ws-limit-updates-per-session": "WS-updates per sessie", + "rate-limits": { + "add-limit": "Limiet toevoegen", + "advanced-settings": "Geavanceerde instellingen", + "edit-limit": "Limiet bewerken", + "but-less-than": "maar minder dan", + "calculated-field-debug-event-rate-limit": "Debuggebeurtenissen van berekende velden", + "edit-calculated-field-debug-event-rate-limit": "Bewerk rate-limieten voor debuggebeurtenissen van berekende velden", + "edit-transport-tenant-msg-title": "Bewerk rate-limieten voor tenanttransportberichten", + "edit-transport-tenant-telemetry-msg-title": "Bewerk rate-limieten voor tenanttelemetrieberichten", + "edit-transport-tenant-telemetry-data-points-title": "Bewerk rate-limieten voor tenant telemetriegegevenspunten", + "edit-transport-device-msg-title": "Bewerk rate-limieten voor apparaatberichten", + "edit-transport-device-telemetry-msg-title": "Bewerk rate-limieten voor apparaattelmetrieberichten", + "edit-transport-device-telemetry-data-points-title": "Bewerk rate-limieten voor apparaattelmetriegegevenspunten", + "edit-transport-gateway-msg-title": "Bewerk rate-limieten voor gatewayberichten", + "edit-transport-gateway-telemetry-msg-title": "Bewerk rate-limieten voor gatewaytelemetrieberichten", + "edit-transport-gateway-telemetry-data-points-title": "Bewerk rate-limieten voor gatewaytelemetriegegevenspunten", + "edit-transport-gateway-device-msg-title": "Bewerk rate-limieten voor gatewayapparaatberichten", + "edit-transport-gateway-device-telemetry-msg-title": "Bewerk rate-limieten voor gatewayapparaattelemetrieberichten", + "edit-transport-gateway-device-telemetry-data-points-title": "Bewerk rate-limieten voor gatewayapparaattelemetriegegevenspunten", + "edit-tenant-rest-limits-title": "Bewerk REST-verzoeken voor tenant", + "edit-customer-rest-limits-title": "Bewerk REST-verzoeken voor klant", + "edit-ws-limit-updates-per-session-title": "Bewerk WS-updates per sessie", + "edit-cassandra-tenant-limits-configuration-title": "Bewerk Cassandra-query voor tenant", + "edit-tenant-entity-export-rate-limit-title": "Bewerk rate-limieten voor entiteitsversie-aanmaak", + "edit-tenant-entity-import-rate-limit-title": "Bewerk rate-limieten voor entiteitsversielading", + "edit-tenant-notification-request-rate-limit-title": "Bewerk rate-limieten voor meldingsverzoeken", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Bewerk rate-limieten per meldingsregel", + "edit-edge-events-rate-limit": "Bewerk rate-limieten voor edge-gebeurtenissen", + "edit-edge-events-per-edge-rate-limit": "Bewerk rate-limieten voor edge-gebeurtenissen per edge", + "edge-events-rate-limit": "Edge-gebeurtenissen", + "edge-events-per-edge-rate-limit": "Edge-gebeurtenissen per edge", + "edit-edge-uplink-messages-rate-limit": "Bewerk rate-limieten voor uplinkberichten van edge", + "edit-edge-uplink-messages-per-edge-rate-limit": "Bewerk rate-limieten voor uplinkberichten per edge", + "edge-uplink-messages-rate-limit": "Edge-uplinkberichten", + "edge-uplink-messages-per-edge-rate-limit": "Edge-uplinkberichten per edge", + "messages-per": "berichten per", + "not-set": "Niet ingesteld", + "number-of-messages": "Aantal berichten", + "number-of-messages-required": "Aantal berichten is verplicht.", + "number-of-messages-min": "Minimale waarde is 1.", + "preview": "Voorbeeld", + "per-seconds": "Per seconden", + "per-seconds-required": "Tijdslimiet is verplicht.", + "per-seconds-min": "Minimale waarde is 1.", + "rate-limits": "Rate-limieten", + "remove-limit": "Limiet verwijderen", + "transport-tenant-msg": "Transport tenantberichten", + "transport-tenant-telemetry-msg": "Transport tenanttelemetrieberichten", + "transport-tenant-telemetry-data-points": "Transport tenanttelemetriegegevenspunten", + "transport-device-msg": "Transport apparaatberichten", + "transport-device-telemetry-msg": "Transport apparaattelmetrieberichten", + "transport-device-telemetry-data-points": "Transport apparaattelmetriegegevenspunten", + "transport-gateway-msg": "Transport gatewayberichten", + "transport-gateway-telemetry-msg": "Transport gatewaytelemetrieberichten", + "transport-gateway-telemetry-data-points": "Transport gatewaytelemetriegegevenspunten", + "transport-gateway-device-msg": "Transport gatewayapparaatberichten", + "transport-gateway-device-telemetry-msg": "Transport gatewayapparaattelemetrieberichten", + "transport-gateway-device-telemetry-data-points": "Transport gatewayapparaattelemetriegegevenspunten", + "sec": "sec" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, =1 {1 seconde} other {# seconden} }", + "minutes-interval": "{ minutes, plural, =1 {1 minuut} other {# minuten} }", + "hours-interval": "{ hours, plural, =1 {1 uur} other {# uur} }", + "days-interval": "{ days, plural, =1 {1 dag} other {# dagen} }", + "days": "Dagen", + "hours": "Uren", + "minutes": "Minuten", + "seconds": "Seconden", + "advanced": "Geavanceerd", + "custom": "Aangepast", + "predefined": { + "yesterday": "Gisteren", + "day-before-yesterday": "Eergisteren", + "this-day-last-week": "Deze dag vorige week", + "previous-week": "Vorige week (zon - zat)", + "previous-week-iso": "Vorige week (maa - zon)", + "previous-month": "Vorige maand", + "previous-quarter": "Vorig kwartaal", + "previous-half-year": "Vorige halfjaar", + "previous-year": "Vorig jaar", + "current-hour": "Huidig uur", + "current-day": "Huidige dag", + "current-day-so-far": "Tot nu toe vandaag", + "current-week": "Huidige week (zon - zat)", + "current-week-iso": "Huidige week (maa - zon)", + "current-week-so-far": "Tot nu toe deze week (zon - zat)", + "current-week-iso-so-far": "Tot nu toe deze week (maa - zon)", + "current-month": "Huidige maand", + "current-month-so-far": "Tot nu toe deze maand", + "current-quarter": "Huidige kwartaal", + "current-quarter-so-far": "Tot nu toe dit kwartaal", + "current-half-year": "Huidige halfjaar", + "current-half-year-so-far": "Tot nu toe dit halfjaar", + "current-year": "Huidig jaar", + "current-year-so-far": "Tot nu toe dit jaar" + }, + "type": { + "week": "Week (zon - zat)", + "week-iso": "Week (maa - zon)", + "month": "Maand", + "quarter": "Kwartaal" + } + }, + "timeunit": { + "milliseconds": "Milliseconden", + "seconds": "Seconden", + "minutes": "Minuten", + "hours": "Uren", + "days": "Dagen" + }, + "timewindow": { + "timewindow": "Tijdvenster", + "timewindow-settings": "Tijdvensterinstellingen", + "years": "{ years, plural, =1 { jaar } other {# jaren } }", + "years-short": "{{ years }}j", + "months": "{ months, plural, =1 { maand } other {# maanden } }", + "months-short": "{{ months }}M", + "weeks": "{ weeks, plural, =1 { week } other {# weken } }", + "weeks-short": "{{ weeks }}w", + "days": "{ days, plural, =1 { dag } other {# dagen } }", + "days-short": "{{ days }}d", + "hours": "{ hours, plural, =0 { uur } =1 {1 uur } other {# uren } }", + "hr": "{{ hr }} uur", + "hr-short": "{{ hr }}u", + "minutes": "{ minutes, plural, =0 { minuut } =1 {1 minuut } other {# minuten } }", + "min": "{{ min }} min", + "min-short": "{{ min }}m", + "seconds": "{ seconds, plural, =0 { seconde } =1 {1 seconde } other {# seconden } }", + "sec": "{{ sec }} sec", + "sec-short": "{{ sec }}s", + "short": { + "years": "{ years, plural, =1 {1 jaar } other {# jaren } }", + "days": "{ days, plural, =1 {1 dag } other {# dagen } }", + "hours": "{ hours, plural, =1 {1 uur } other {# uren } }", + "minutes": "{{minutes}} min", + "seconds": "{{seconds}} sec" + }, + "realtime": "Realtime", + "history": "Geschiedenis", + "last-prefix": "laatste", + "period": "van {{ startTime }} tot {{ endTime }}", + "edit": "Tijdvenster bewerken", + "date-range": "Datumbereik", + "for-all-time": "Voor alle tijd", + "last": "Laatste", + "time-period": "Tijdperiode", + "hide": "Verbergen", + "interval": "Interval", + "just-now": "Zojuist", + "just-now-lower": "zojuist", + "ago": "geleden", + "style": "Tijdvensterstijl", + "icon": "Icoon", + "icon-position": "Icoonpositie", + "icon-position-left": "Links", + "icon-position-right": "Rechts", + "font": "Lettertype", + "color": "Kleur", + "displayTypePrefix": "Toon realtime/geschiedenisvoorvoegsel", + "preview": "Voorbeeld", + "relative": "Relatief", + "range": "Bereik", + "hide-timewindow-section": "Verberg tijdvenstergedeelte voor eindgebruikers", + "hide-last-interval": "Verberg laatste interval voor eindgebruikers", + "hide-relative-interval": "Verberg relatieve interval voor eindgebruikers", + "hide-fixed-interval": "Verberg vast interval voor eindgebruikers", + "hide-aggregation": "Verberg aggregatie voor eindgebruikers", + "hide-group-interval": "Verberg groeperingsinterval voor eindgebruikers", + "hide-max-values": "Verberg maximumwaarden voor eindgebruikers", + "hide-timezone": "Verberg tijdzone voor eindgebruikers", + "disable-custom-interval": "Schakel aangepaste intervalselectie uit", + "edit-aggregation-functions-list": "Bewerk lijst met aggregatiefuncties", + "edit-aggregation-functions-list-hint": "Lijst met beschikbare opties kan worden gespecificeerd.", + "allowed-aggregation-functions": "Toegestane aggregatiefuncties", + "edit-intervals-list": "Bewerk intervallijst", + "allowed-agg-intervals": "Groeperingsintervallen", + "default-agg-interval": "Standaard groeperingsinterval", + "edit-intervals-list-hint": "Lijst met beschikbare intervalopties kan worden gespecificeerd.", + "edit-grouping-intervals-list-hint": "Het is mogelijk om de lijst met groeperingsintervallen en het standaardinterval te configureren.", + "all": "Alles" + }, + "tooltip": { + "trigger": "Trigger", + "trigger-point": "Punt", + "trigger-axis": "As", + "label": "Label", + "value": "Waarde", + "date": "Datum", + "show-date-time-interval": "Toon datum-tijdinterval", + "show-date-time-interval-hint": "Toon datum-tijdinterval volgens de gegevensaggregatie.", + "background-color": "Achtergrondkleur", + "background-blur": "Achtergrondvervaging" + }, + "unit": { + "millimeter": "Millimeter", + "centimeter": "Centimeter", + "angstrom": "Angström", + "nanometer": "Nanometer", + "micrometer": "Micrometer", + "meter": "Meter", + "kilometer": "Kilometer", + "inch": "Inch", + "foot": "Voet", + "yard": "Yard", + "mile": "Mijl", + "nautical-mile": "Zeemijl", + "astronomical-unit": "Astronomische eenheid", + "reciprocal-metre": "Reciproque meter", + "meter-per-meter": "Meter per meter", + "steradian": "Steradiaal", + "thou": "Duizendste inch", + "barleycorn": "Barleycorn", + "hand": "Hand", + "chain": "Ketting", + "furlong": "Furlong", + "league": "League", + "fathom": "Vadem", + "cable": "Kabel", + "link": "Schakel", + "rod": "Roede", + "nanogram": "Nanogram", + "microgram": "Microgram", + "milligram": "Milligram", + "gram": "Gram", + "kilogram": "Kilogram", + "tonne": "Ton", + "ounce": "Ons", + "pound": "Pond", + "stone": "Stone", + "hundredweight-count": "Honderdpond", + "short-tons": "Amerikaanse ton", + "dalton": "Dalton", + "grain": "Graan", + "drachm": "Drachme", + "quarter": "Quarter", + "slug": "Slug", + "carat": "Karaat", + "cubic-millimeter": "Kubieke millimeter", + "cubic-centimeter": "Kubieke centimeter", + "cubic-meter": "Kubieke meter", + "cubic-kilometer": "Kubieke kilometer", + "microliter": "Microliter", + "milliliter": "Milliliter", + "liter": "Liter", + "hectoliter": "Hectoliter", + "cubic-inch": "Kubieke inch", + "cubic-foot": "Kubieke voet", + "cubic-yard": "Kubieke yard", + "fluid-ounce": "Fluid ounce", + "pint": "Pint", + "quart": "Quart", + "gallon": "Gallon", + "oil-barrels": "Olievat", + "cubic-meter-per-kilogram": "Kubieke meter per kilogram", + "gill": "Gill", + "hogshead": "Hogshead", + "teaspoon": "Theelepel", + "tablespoon": "Eetlepel", + "cup": "Kopje", + "celsius": "Celsius", + "kelvin": "Kelvin", + "rankine": "Rankine", + "fahrenheit": "Fahrenheit", + "percent": "Procent", + "meter-per-second": "Meter per seconde", + "kilometer-per-hour": "Kilometer per uur", + "foot-per-second": "Voet per seconde", + "mile-per-hour": "Mijl per uur", + "knot": "Knoop", + "millimeters-per-minute": "Millimeter per minuut", + "kilometer-per-hour-squared": "Kilometer per uur kwadraat", + "foot-per-second-squared": "Voet per seconde kwadraat", + "pascal": "Pascal", + "kilopascal": "Kilopascal", + "megapascal": "Megapascal", + "gigapascal": "Gigapascal", + "millibar": "Millibar", + "bar": "Bar", + "kilobar": "Kilobar", + "newton": "Newton", + "newton-meter": "Newtonmeter", + "foot-pounds": "Voet-pond", + "inch-pounds": "Inch-pond", + "newton-per-meter": "Newton per meter", + "atmospheres": "Atmospferen", + "pounds-per-square-inch": "Ponden per vierkante inch", + "torr": "Torr", + "inches-of-mercury": "Inches kwik", + "pascal-per-square-meter": "Pascal per vierkante meter", + "pound-per-square-inch": "Pond per vierkante inch", + "newton-per-square-meter": "Newton per vierkante meter", + "kilogram-force-per-square-meter": "Kilogramkracht per vierkante meter", + "pascal-per-square-centimeter": "Pascal per vierkante centimeter", + "ton-force-per-square-inch": "Ton-kracht per vierkante inch", + "kilonewton-per-square-meter": "Kilonewton per vierkante meter", + "newton-per-square-millimeter": "Newton per vierkante millimeter", + "microjoule": "Microjoule", + "millijoule": "Millijoule", + "joule": "Joule", + "kilojoule": "Kilojoule", + "megajoule": "Megajoule", + "gigajoule": "Gigajoule", + "watt-hour": "Wattuur", + "kilowatt-hour": "Kilowattuur", + "electron-volts": "Elektronvolt", + "joules-per-coulomb": "Joules per coulomb", + "british-thermal-unit": "Britse thermische eenheid", + "foot-pound": "Voet-pond", + "calorie": "Calorie", + "small-calorie": "Kleine calorie", + "kilocalorie": "Kilocalorie", + "joule-per-kelvin": "Joule per Kelvin", + "joule-per-kilogram-kelvin": "Joule per kilogram-Kelvin", + "joule-per-kilogram": "Joule per kilogram", + "watt-per-meter-kelvin": "Watt per meter-Kelvin", + "joule-per-cubic-meter": "Joule per kubieke meter", + "therm": "Therm", + "electric-dipole-moment": "Elektrisch dipoolmoment", + "magnetic-dipole-moment": "Magnetisch dipoolmoment", + "debye": "Debye", + "coulomb-per-square-meter-per-volt": "Coulomb per vierkante meter per volt", + "milliwatt": "Milliwatt", + "microwatt": "Microwatt", + "watt": "Watt", + "kilowatt": "Kilowatt", + "megawatt": "Megawatt", + "gigawatt": "Gigawatt", + "metric-horsepower": "Metrisch paardekracht", + "milliwatt-per-square-centimeter": "Milliwatt per vierkante centimeter", + "watt-per-square-centimeter": "Watt per vierkante centimeter", + "kilowatt-per-square-centimeter": "Kilowatt per vierkante centimeter", + "milliwatt-per-square-meter": "Milliwatt per vierkante meter", + "watt-per-square-meter": "Watt per vierkante meter", + "kilowatt-per-square-meter": "Kilowatt per vierkante meter", + "watt-per-square-inch": "Watt per vierkante inch", + "kilowatt-per-square-inch": "Kilowatt per vierkante inch", + "horsepower": "Paardenkracht", + "btu-per-hour": "BTU per uur", + "coulomb": "Coulomb", + "millicoulomb": "Millicoulomb", + "microcoulomb": "Microcoulomb", + "picocoulomb": "Picocoulomb", + "coulomb-per-meter": "Coulomb per meter", + "coulomb-per-cubic-meter": "Coulomb per kubieke meter", + "coulomb-per-square-meter": "Coulomb per vierkante meter", + "square-millimeter": "Vierkante millimeter", + "square-centimeter": "Vierkante centimeter", + "square-meter": "Vierkante meter", + "hectare": "Hectare", + "square-kilometer": "Vierkante kilometer", + "square-inch": "Vierkante inch", + "square-foot": "Vierkante voet", + "square-yard": "Vierkante yard", + "acre": "Acre", + "square-mile": "Vierkante mijl", + "are": "Are", + "barn": "Barn", + "circular-inch": "Circulaire inch", + "milliampere-hour": "Milliampère-uur", + "ampere-hours": "Ampère-uren", + "kiloampere-hours": "Kiloampère-uren", + "nanoampere": "Nanoampère", + "picoampere": "Picoampère", + "microampere": "Microampère", + "milliampere": "Milliampère", + "ampere": "Ampère", + "microampere-per-square-centimeter": "Microampère per vierkante centimeter", + "ampere-per-square-meter": "Ampère per vierkante meter", + "ampere-per-meter": "Ampère per meter", + "oersted": "Oersted", + "bohr-magneton": "Bohr Magneton", + "ampere-meter-squared": "Ampère-meter in het kwadraat", + "nanovolt": "Nanovolt", + "picovolt": "Picovolt", + "volt": "Volt", + "dbmV": "dBmV", + "dbm": "dBm", + "volt-meter": "Volt-meter", + "kilovolt-meter": "Kilovolt-meter", + "megavolt-meter": "Megavolt-meter", + "microvolt-meter": "Microvolt-meter", + "millivolt-meter": "Millivolt-meter", + "nanovolt-meter": "Nanovolt-meter", + "ohm": "Ohm", + "microohm": "Micro-ohm", + "milliohm": "Milli-ohm", + "kilohm": "Kilo-ohm", + "megohm": "Mega-ohm", + "gigohm": "Giga-ohm", + "hertz": "Hertz", + "kilohertz": "Kilohertz", + "megahertz": "Megahertz", + "gigahertz": "Gigahertz", + "rpm": "Omwentelingen per minuut", + "candela-per-square-meter": "Candela per vierkante meter", + "candela": "Candela", + "lumen": "Lumen", + "lux": "Lux", + "foot-candle": "Voet-candela", + "lumen-per-square-meter": "Lumen per vierkante meter", + "lux-second": "Lux-seconde", + "lumen-second": "Lumen-seconde", + "lumens-per-watt": "Lumen per watt", + "mole": "Mol", + "nanomole": "Nanomol", + "micromole": "Micromol", + "millimole": "Millimol", + "kilomole": "Kilomol", + "mole-per-cubic-meter": "Mol per kubieke meter", + "rssi": "RSSI", + "ppm": "Deeltjes per miljoen (ppm)", + "ppb": "Deeltjes per miljard (ppb)", + "micrograms-per-cubic-meter": "Microgram per kubieke meter", + "aqi": "Luchtkwaliteitsindex (AQI)", + "gram-per-cubic-meter": "Gram per kubieke meter", + "gram-per-kilogram": "Specifieke vochtigheid", + "millimeters-per-second": "Millimeter per seconde", + "neper": "Neper", + "bel": "Bel", + "decibel": "Decibel", + "meters-per-second-squared": "Meter per seconde kwadraat", + "becquerel": "Becquerel", + "curie": "Curie", + "gray": "Gray", + "sievert": "Sievert", + "roentgen": "Röntgen", + "cps": "Tellingen per seconde", + "rad": "Rad", + "rem": "Rem", + "dps": "Desintegraties per seconde", + "rutherford": "Rutherford", + "coulombs-per-kilogram": "Coulomb per kilogram", + "becquerels-per-cubic-meter": "Becquerel per kubieke meter", + "curies-per-liter": "Curie per liter", + "becquerels-per-second": "Becquerel per seconde", + "curies-per-second": "Curie per seconde", + "gy-per-second": "Gray per seconde", + "watt-per-steradian": "Watt per steradiaal", + "watt-per-square-metre-steradian": "Watt per vierkante meter-steradiaal", + "ph-level": "pH-waarde", + "turbidity": "Troebelheid", + "mg-per-liter": "Milligram per liter", + "microsiemens-per-centimeter": "Microsiemens per centimeter", + "millisiemens-per-meter": "Millisiemens per meter", + "siemens-per-meter": "Siemens per meter", + "kilogram-per-cubic-meter": "Kilogram per kubieke meter", + "gram-per-cubic-centimeter": "Gram per kubieke centimeter", + "kilogram-per-square-meter": "Kilogram per vierkante meter", + "milligram-per-milliliter": "Milligram per milliliter", + "milligram-per-cubic-meter": "Milligram per kubieke meter", + "pound-per-cubic-foot": "Pond per kubieke voet", + "ounces-per-cubic-inch": "Ons per kubieke inch", + "tons-per-cubic-yard": "Ton per kubieke yard", + "particle-density": "Deeltjesdichtheid", + "kilometers-per-liter": "Kilometer per liter", + "miles-per-gallon": "Mijl per gallon", + "liters-per-100-km": "Liter per 100 km", + "gallons-per-mile": "Gallon per mijl", + "liters-per-hour": "Liter per uur", + "gallons-per-hour": "Gallon per uur", + "beats-per-minute": "Slagen per minuut", + "millimeters-of-mercury": "Millimeter kwik", + "milligrams-per-deciliter": "Milligram per deciliter", + "g-force": "G-kracht", + "kilonewton": "Kilonewton", + "kilogram-force": "Kilogramkracht", + "pound-force": "Pondkracht", + "kilopound-force": "Kilopondkracht", + "dyne": "Dyne", + "poundal": "Poundal", + "kip": "Kip", + "gal": "Gal", + "gravity": "Zwaartekracht", + "hectopascal": "Hectopascal", + "atmosphere": "Atmosfeer", + "millibars": "Millibar", + "inch-of-mercury": "Inch kwik", + "richter-scale": "Richterschaal", + "second": "Seconde", + "minute": "Minuut", + "hour": "Uur", + "day": "Dag", + "week": "Week", + "month": "Maand", + "year": "Jaar", + "cubic-foot-per-minute": "Kubieke voet per minuut", + "cubic-meters-per-hour": "Kubieke meter per uur", + "cubic-meters-per-second": "Kubieke meter per seconde", + "liter-per-second": "Liter per seconde", + "liter-per-minute": "Liter per minuut", + "gallons-per-minute": "Gallon per minuut", + "cubic-foot-per-second": "Kubieke voet per seconde", + "milliliters-per-minute": "Milliliter per minuut", + "bit": "Bit", + "byte": "Byte", + "kilobyte": "Kilobyte", + "megabyte": "Megabyte", + "gigabyte": "Gigabyte", + "terabyte": "Terabyte", + "petabyte": "Petabyte", + "exabyte": "Exabyte", + "zettabyte": "Zettabyte", + "yottabyte": "Yottabyte", + "bit-per-second": "Bit per seconde", + "kilobit-per-second": "Kilobit per seconde", + "megabit-per-second": "Megabit per seconde", + "gigabit-per-second": "Gigabit per seconde", + "terabit-per-second": "Terabit per seconde", + "byte-per-second": "Byte per seconde", + "kilobyte-per-second": "Kilobyte per seconde", + "megabyte-per-second": "Megabyte per seconde", + "gigabyte-per-second": "Gigabyte per seconde", + "degree": "Graad", + "radian": "Radiaal", + "gradian": "Gradiënt", + "revolution": "Omwenteling", + "siemens": "Siemens", + "millisiemens": "Millisiemens", + "microsiemens": "Microsiemens", + "kilosiemens": "Kilosiemens", + "megasiemens": "Megasiemens", + "gigasiemens": "Gigasiemens", + "farad": "Farad", + "millifarad": "Millifarad", + "microfarad": "Microfarad", + "nanofarad": "Nanofarad", + "picofarad": "Picofarad", + "kilofarad": "Kilofarad", + "megafarad": "Megafarad", + "gigafarad": "Gigafarad", + "terfarad": "Terafarad", + "farad-per-meter": "Farad per meter", + "tesla": "Tesla", + "gauss": "Gauss", + "kilogauss": "Kilogauss", + "millitesla": "Millitesla", + "microtesla": "Microtesla", + "nanotesla": "Nanotesla", + "kilotesla": "Kilotesla", + "megatesla": "Megatesla", + "millitesla-square-meters": "Millitesla vierkante meter", + "gamma": "Gamma", + "lambda": "Lambda", + "square-meter-per-second": "Vierkante meter per seconde", + "square-centimeter-per-second": "Vierkante centimeter per seconde", + "stoke": "Stoke", + "centistokes": "Centistokes", + "square-foot-per-second": "Vierkante voet per seconde", + "square-inch-per-second": "Vierkante inch per seconde", + "pascal-second": "Pascal-seconde", + "centipoise": "Centipoise", + "poise": "Poise", + "reynolds": "Reynolds", + "pound-per-foot-hour": "Pond per voet-uur", + "newton-second-per-square-meter": "Newton-seconde per vierkante meter", + "dyne-second-per-square-centimeter": "Dyne-seconde per vierkante centimeter", + "kilogram-per-meter-second": "Kilogram per meter-seconde", + "tesla-square-meters": "Tesla vierkante meters", + "maxwell": "Maxwell", + "tesla-per-meter": "Tesla per meter", + "gauss-per-centimeter": "Gauss per centimeter", + "weber": "Weber", + "microweber": "Microweber", + "milliweber": "Milliweber", + "gauss-square-centimeter": "Gauss-vierkante centimeter", + "kilogauss-square-centimeter": "Kilogauss-vierkante centimeter", + "henry": "Henry", + "millihenry": "Millihenry", + "microhenry": "Microhenry", + "nanohenry": "Nanohenry", + "henry-per-meter": "Henry per meter", + "tesla-meter-per-ampere": "Tesla-meter per ampère", + "gauss-per-oersted": "Gauss per oersted", + "kilogram-per-mole": "Kilogram per mol", + "gram-per-mole": "Gram per mol", + "milligram-per-mole": "Milligram per mol", + "joule-per-mole": "Joule per mol", + "joule-per-mole-kelvin": "Joule per mol-Kelvin", + "millivolts-per-meter": "Millivolt per meter", + "volts-per-meter": "Volt per meter", + "kilovolts-per-meter": "Kilovolt per meter", + "radian-per-second": "Radiaal per seconde", + "radian-per-second-squared": "Radiaal per seconde kwadraat", + "revolutions-per-minute-per-second": "Hoeksversnelling", + "deg-per-second": "Graden per seconde", + "degrees-brix": "Graden Brix", + "katal": "Katal", + "katal-per-cubic-metre": "Katal per kubieke meter" + }, + "user": { + "user": "Gebruiker", + "users": "Gebruikers", + "customer-users": "Klantgebruikers", + "tenant-admins": "Tenantbeheerders", + "sys-admin": "Systeembeheerder", + "tenant-admin": "Tenantbeheerder", + "customer": "Klant", + "anonymous": "Anoniem", + "add": "Gebruiker toevoegen", + "delete": "Gebruiker verwijderen", + "add-user-text": "Nieuwe gebruiker toevoegen", + "no-users-text": "Geen gebruikers gevonden", + "user-details": "Gebruikersgegevens", + "delete-user-title": "Weet je zeker dat je gebruiker '{{userEmail}}' wilt verwijderen?", + "delete-user-text": "Wees voorzichtig, na bevestiging worden de gebruiker en alle gerelateerde gegevens permanent verwijderd.", + "delete-users-title": "Weet je zeker dat je { count, plural, =1 {1 gebruiker} other {# gebruikers} } wilt verwijderen?", + "delete-users-action-title": "Verwijder { count, plural, =1 {1 gebruiker} other {# gebruikers} }", + "delete-users-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde gebruikers en gerelateerde gegevens permanent verwijderd.", + "activation-email-sent-message": "Activatie-e-mail is succesvol verzonden!", + "resend-activation": "Activatie opnieuw verzenden", + "email": "E-mail", + "email-required": "E-mail is verplicht.", + "invalid-email-format": "Ongeldig e-mailformaat.", + "first-name": "Voornaam", + "last-name": "Achternaam", + "description": "Beschrijving", + "default-dashboard": "Standaarddashboard", + "always-fullscreen": "Altijd volledig scherm", + "select-user": "Selecteer gebruiker", + "no-users-matching": "Geen gebruikers gevonden die overeenkomen met '{{entity}}'.", + "user-required": "Gebruiker is verplicht", + "activation-method": "Activatiemethode", + "display-activation-link": "Toon activatielink", + "send-activation-mail": "Stuur activatie-e-mail", + "activation-link": "Gebruikersactivatielink", + "activation-link-text": "Om de gebruiker te activeren, gebruik de volgende activatielink (vervalt over {{activationLinkTtl}}) :", + "copy-activation-link": "Kopieer activatielink", + "activation-link-copied-message": "Gebruikersactivatielink is gekopieerd naar klembord", + "details": "Details", + "login-as-tenant-admin": "Inloggen als tenantbeheerder", + "login-as-customer-user": "Inloggen als klantgebruiker", + "search": "Zoek gebruikers", + "selected-users": "{ count, plural, =1 {1 gebruiker} other {# gebruikers} } geselecteerd", + "disable-account": "Gebruikersaccount uitschakelen", + "enable-account": "Gebruikersaccount inschakelen", + "enable-account-message": "Gebruikersaccount is succesvol ingeschakeld!", + "disable-account-message": "Gebruikersaccount is succesvol uitgeschakeld!", + "copyId": "Kopieer gebruikers-ID", + "idCopiedMessage": "Gebruikers-ID is gekopieerd naar klembord", + "user-list": "Gebruikerslijst", + "user-list-required": "Gebruikerslijst is verplicht" + }, + "value": { + "type": "Waarde type", + "string": "Tekenreeks", + "string-value": "Tekenreekswaarde", + "string-value-required": "Tekenreekswaarde is verplicht", + "integer": "Geheel getal", + "integer-value": "Geheel getalwaarde", + "integer-value-required": "Geheel getalwaarde is verplicht", + "invalid-integer-value": "Ongeldige geheel getalwaarde", + "double": "Dubbel", + "double-value": "Dubbele waarde", + "double-value-required": "Dubbele waarde is verplicht", + "boolean": "Boolean", + "boolean-value": "Boolean waarde", + "false": "Onwaar", + "true": "Waar", + "long": "Lang", + "json": "JSON", + "json-value": "JSON waarde", + "json-value-invalid": "JSON waarde heeft een ongeldig formaat", + "json-value-required": "JSON waarde is verplicht." + }, + "version-control": { + "version-control": "Versiebeheer", + "management": "Beheer van versies", + "search": "Zoek versies", + "branch": "Branch", + "default": "Standaard", + "select-branch": "Selecteer branch", + "branch-required": "Branch is verplicht", + "create-entity-version": "Maak entiteitversie aan", + "version-name": "Versienaam", + "version-name-required": "Versienaam is verplicht", + "author": "Auteur", + "export-relations": "Exporteer relaties", + "export-attributes": "Exporteer attributen", + "export-credentials": "Exporteer inloggegevens", + "export-calculated-fields": "Exporteer berekende velden", + "entity-versions": "Entiteitversies", + "versions": "Versies", + "created-time": "Aangemaakt op", + "version-id": "Versie-ID", + "no-entity-versions-text": "Geen entiteitversies gevonden", + "no-versions-text": "Geen versies gevonden", + "copy-full-version-id": "Kopieer volledige versie-ID", + "create-version": "Versie aanmaken", + "creating-version": "Versie wordt aangemaakt... Even geduld", + "nothing-to-commit": "Geen wijzigingen om op te slaan", + "restore-version": "Herstel versie", + "restore-entity-from-version": "Herstel entiteit vanuit versie '{{versionName}}'", + "restoring-entity-version": "Entiteitversie wordt hersteld... Even geduld", + "load-relations": "Relaties laden", + "load-attributes": "Attributen laden", + "load-credentials": "Inloggegevens laden", + "load-calculated-fields": "Berekende velden laden", + "compare-with-current": "Vergelijk met huidige", + "diff-entity-with-version": "Verschil met entiteitversie '{{versionName}}'", + "previous-difference": "Vorige verschil", + "next-difference": "Volgende verschil", + "current": "Huidig", + "differences": "{ count, plural, =1 {1 verschil} other {# verschillen} }", + "create-entities-version": "Maak versie van entiteiten", + "default-sync-strategy": "Standaard synchronisatiestrategie", + "sync-strategy-merge": "Samenvoegen", + "sync-strategy-overwrite": "Overschrijven", + "entities-to-export": "Te exporteren entiteiten", + "entities-to-restore": "Te herstellen entiteiten", + "sync-strategy": "Synchronisatiestrategie", + "all-entities": "Alle entiteiten", + "no-entities-to-export-prompt": "Specificeer entiteiten om te exporteren", + "no-entities-to-restore-prompt": "Specificeer entiteiten om te herstellen", + "add-entity-type": "Voeg entiteittype toe", + "remove-all": "Alles verwijderen", + "version-create-result": "{ added, plural, =0 {Geen entiteiten} =1 {1 entiteit} other {# entiteiten} } toegevoegd.
    { modified, plural, =0 {Geen entiteiten} =1 {1 entiteit} other {# entiteiten} } aangepast.
    { removed, plural, =0 {Geen entiteiten} =1 {1 entiteit} other {# entiteiten} } verwijderd.", + "remove-other-entities": "Andere entiteiten verwijderen", + "find-existing-entity-by-name": "Zoek bestaande entiteit op naam", + "restore-entities-from-version": "Herstel entiteiten vanuit versie '{{versionName}}'", + "restoring-entities-from-version": "Entiteiten worden hersteld... Even geduld", + "no-entities-restored": "Geen entiteiten hersteld", + "created": "{{created}} aangemaakt", + "updated": "{{updated}} bijgewerkt", + "deleted": "{{deleted}} verwijderd", + "remove-other-entities-confirm-text": "Wees voorzichtig! Dit zal permanent alle huidige entiteiten verwijderen
    die niet aanwezig zijn in de versie die u wilt herstellen.

    Typ \"remove other entities\" om te bevestigen.", + "auto-commit-to-branch": "automatisch vastleggen naar {{ branch }} branch", + "default-create-entity-version-name": "{{entityName}} update", + "sync-strategy-merge-hint": "Maakt of werkt geselecteerde entiteiten bij in de repository. Alle andere entiteiten in de repository worden niet gewijzigd.", + "sync-strategy-overwrite-hint": "Maakt of werkt geselecteerde entiteiten bij in de repository. Alle andere entiteiten in de repository worden verwijderd.", + "device-credentials-conflict": "Kan apparaat met externe id {{entityId}} niet laden
    omdat dezelfde inloggegevens al bestaan in de database voor een ander apparaat.
    Overweeg om de instelling inloggegevens laden uit te schakelen in het herstelformulier.", + "missing-referenced-entity": "Kan {{sourceEntityTypeName}} met externe id {{sourceEntityId}} niet laden
    omdat het verwijst naar ontbrekende {{targetEntityTypeName}} met id {{targetEntityId}}.", + "runtime-failed": "Mislukt: {{message}}", + "auto-commit-settings-read-only-hint": "De functie voor automatisch vastleggen werkt niet wanneer de optie alleen-lezen is ingeschakeld in de repository-instellingen.", + "rollback-on-error": "Rollback bij fout", + "rollback-on-error-hint": "Als u een groot aantal entiteiten moet herstellen, overweeg dan om deze optie uit te schakelen om de prestaties te verbeteren.\n Let op: als er tijdens het laden van de versie een fout optreedt, blijven reeds opgeslagen entiteiten (met relaties, attributen, enz.) ongewijzigd." + }, + "widget": { + "widget-library": "Widgetbibliotheek", + "widget-bundle": "Widgetbundel", + "all-bundles": "Alle bundels", + "select-widgets-bundle": "Selecteer widgetbundel", + "widgets": "Widgets", + "all-widgets": "Alle widgets", + "widget": "Widget", + "select-widget": "Selecteer widget", + "no-widgets-matching": "Geen widgets gevonden die overeenkomen met '{{entity}}'.", + "no-widgets": "Nog geen widgets", + "no-widgets-text": "Geen widgets gevonden", + "management": "Widgetbeheer", + "editor": "Widgeteditor", + "confirm-to-exit-editor-html": "U heeft niet-opgeslagen widgetinstellingen.
    Weet u zeker dat u deze pagina wilt verlaten?", + "widget-type-not-found": "Probleem bij het laden van widgetconfiguratie.
    Mogelijk is het bijbehorende widgettype verwijderd.", + "widget-type-load-error": "Widget kon niet worden geladen vanwege de volgende fouten:", + "remove": "Widget verwijderen", + "delete": "Widget verwijderen", + "edit": "Widget bewerken", + "remove-widget-title": "Weet u zeker dat u de widget '{{widgetTitle}}' wilt verwijderen?", + "remove-widget-text": "Na bevestiging worden de widget en alle gerelateerde gegevens onherstelbaar verwijderd.", + "replace-reference-with-widget-copy": "Vervang referentie door widgetkopie", + "timeseries": "Tijdreeksen", + "search-data": "Gegevens zoeken", + "no-data-found": "Geen gegevens gevonden", + "latest": "Laatste waarden", + "rpc": "Bedieningswidget", + "alarm": "Alarmwidget", + "static": "Statische widget", + "timeseries-short": "reeksen", + "latest-short": "laatste", + "rpc-short": "bediening", + "alarm-short": "alarm", + "static-short": "statisch", + "select-widget-type": "Selecteer widgettype", + "missing-widget-title-error": "Widgettitel moet worden opgegeven!", + "widget-saved": "Widget opgeslagen", + "unable-to-save-widget-error": "Kan widget niet opslaan! Widget bevat fouten!", + "save": "Widget opslaan", + "saveAs": "Widget opslaan als", + "move": "Widget verplaatsen", + "save-widget-as": "Widget opslaan als", + "save-widget-as-text": "Voer nieuwe widgettitel in", + "toggle-fullscreen": "Volledig scherm wisselen", + "run": "Widget uitvoeren", + "widget-title": "Widgettitel", + "title": "Titel", + "title-required": "Widgettitel is verplicht.", + "title-max-length": "Titel mag niet langer zijn dan 256 tekens", + "system": "Systeem", + "type": "Widgettype", + "resources": "Resources", + "resource-url": "JavaScript/CSS URL", + "resource-is-extension": "Is extensie", + "remove-resource": "Resource verwijderen", + "add-resource": "Resource toevoegen", + "html": "HTML", + "tidy": "Netjes maken", + "css": "CSS", + "settings-form": "Instellingenformulier", + "data-key-settings-form": "Instellingenformulier voor gegevenssleutel", + "latest-data-key-settings-form": "Instellingenformulier voor laatste gegevenssleutel", + "widget-settings": "Widgetinstellingen", + "description": "Beschrijving", + "tags": "Tags", + "image-preview": "Voorbeeldafbeelding", + "settings-form-selector": "Instellingenformulierkiezer", + "data-key-settings-form-selector": "Kiezer voor instellingenformulier voor gegevenssleutel", + "latest-data-key-settings-form-selector": "Kiezer voor instellingenformulier voor laatste gegevenssleutel", + "all": "Alles", + "actual": "Actueel", + "scada": "SCADA-symbool", + "deprecated": "Verouderd", + "has-basic-mode": "Heeft basisweergave", + "basic-mode-form-selector": "Formulierkiezer basisweergave", + "basic-mode": "Basis", + "advanced-mode": "Geavanceerd", + "javascript": "Javascript", + "js": "JS", + "delete-widget-title": "Weet u zeker dat u de widget '{{widgetName}}' wilt verwijderen?", + "delete-widget-text": "Na bevestiging worden de widget en alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-widgets-title": "Weet u zeker dat u { count, plural, =1 {1 widget} other {# widgets} } wilt verwijderen?", + "delete-widgets-text": "Let op, na bevestiging worden alle geselecteerde widgets verwijderd en worden alle gerelateerde gegevens onherstelbaar verwijderd.", + "delete-widget": "Widget verwijderen", + "widget-template-load-failed-error": "Kan widgettemplate niet laden!", + "details": "Details", + "widget-details": "Widgetdetails", + "add": "Widget toevoegen", + "add-existing-widget": "Bestaande widget toevoegen", + "add-new-widget": "Nieuwe widget toevoegen", + "search-widgets": "Widgets zoeken", + "selected-widgets": "{ count, plural, =1 {1 widget} other {# widgets} } geselecteerd", + "undo": "Widgetwijzigingen ongedaan maken", + "export": "Widget exporteren", + "export-prompt": "Widgetafbeeldingen en bronnen insluiten", + "export-widgets": "Widgets exporteren", + "export-widgets-prompt": "Widgetafbeeldingen en bronnen insluiten", + "import": "Widget importeren", + "no-data": "Geen gegevens om weer te geven op de widget", + "data-overflow": "Widget toont {{count}} van de {{total}} entiteiten", + "alarm-data-overflow": "Widget toont alarmen voor {{allowedEntities}} (maximaal toegestaan) entiteiten van in totaal {{totalEntities}} entiteiten", + "search": "Widget zoeken", + "filter": "Widgetfiltertype", + "loading-widgets": "Widgets laden...", + "widget-template-error": "Ongeldige HTML-template van widget.", + "reference": "Referentie" + }, + "widget-action": { + "header-button": "Widgetkopknop", + "do-nothing": "Niets doen", + "open-dashboard-state": "Navigeer naar nieuwe dashboardstatus", + "update-dashboard-state": "Huidige dashboardstatus bijwerken", + "open-dashboard": "Navigeer naar ander dashboard", + "custom": "Aangepaste actie", + "custom-pretty": "Aangepaste actie (met HTML-sjabloon)", + "custom-pretty-error-title": "Fout in aangepaste dialoog", + "custom-pretty-template-error": "Ongeldige aangepaste dialoogsjabloon.", + "custom-pretty-controller-error": "Er is een fout opgetreden bij het evalueren van de aangepaste dialoogfunctie.", + "mobile-action": "Mobiele actie", + "target-dashboard-state": "Doeldashboardstatus", + "target-dashboard-state-required": "Doeldashboardstatus is verplicht", + "set-entity-from-widget": "Entiteit instellen vanuit widget", + "target-dashboard": "Doeldashboard", + "select-target-dashboard": "Selecteer doeldashboard", + "target-dashboard-required": "Doeldashboard is verplicht.", + "open-right-layout": "Open rechter dashboardlayout (mobiele weergave)", + "state-display-type": "Weergaveoptie dashboardstatus", + "open-normal": "Normaal", + "open-in-separate-dialog": "Open in aparte dialoog", + "open-in-popover": "Open in popover", + "dialog-title": "Dialoogtitel", + "dialog-hide-dashboard-toolbar": "Verberg dashboardwerkbalk in dialoog", + "dialog-width": "Dialoogbreedte in procenten van weergavebreedte", + "dialog-height": "Dialooghoogte in procenten van weergavehoogte", + "dialog-size-range-error": "Dialoogmaat moet tussen 1 en 100 procent zijn.", + "popover-preferred-placement": "Voorkeurspositie van popover", + "popover-placement-top": "Boven", + "popover-placement-topLeft": "Boven links", + "popover-placement-topRight": "Boven rechts", + "popover-placement-right": "Rechts", + "popover-placement-rightTop": "Rechts boven", + "popover-placement-rightBottom": "Rechts onder", + "popover-placement-bottom": "Onder", + "popover-placement-bottomLeft": "Onder links", + "popover-placement-bottomRight": "Onder rechts", + "popover-placement-left": "Links", + "popover-placement-leftTop": "Links boven", + "popover-placement-leftBottom": "Links onder", + "popover-hide-on-click-outside": "Verberg popover bij klikken buiten", + "popover-hide-dashboard-toolbar": "Verberg dashboardwerkbalk in popover", + "popover-width": "Popoverbreedte", + "popover-height": "Popoverhoogte", + "popover-style": "Popoverstijl", + "open-new-browser-tab": "Open in nieuw browsertabblad", + "open-URL": "Open URL", + "URL": "URL", + "url-required": "URL is verplicht.", + "mobile": { + "device-provision": "Apparaatconfiguratie", + "action-type": "Mobiele actietype", + "select-action-type": "Selecteer mobiele actietype", + "action-type-required": "Mobiele actietype is verplicht", + "take-picture-from-gallery": "Afbeelding uit galerij kiezen", + "take-photo": "Foto maken", + "map-direction": "Open kaartnavigatie", + "map-location": "Open kaartlocatie", + "scan-qr-code": "QR-code scannen", + "make-phone-call": "Telefoongesprek voeren", + "get-location": "Telefoonlocatie ophalen", + "take-screenshot": "Schermafbeelding maken" + }, + "custom-action-function": "Aangepaste actiefunctie", + "custom-pretty-function": "Aangepaste actie (met HTML-sjabloon) functie", + "map-item-type": "Kaartobjecttype", + "map-item": { + "marker": "Markering", + "polygon": "Polygoon", + "rectangle": "Rechthoek", + "circle": "Cirkel" + }, + "place-map-item": "Kaartobject plaatsen", + "map-item-tooltip": { + "customize-map-item-tooltips": "Kaartobjecttooltips aanpassen", + "place-marker": "Markering plaatsen", + "start-draw-rectangle": "Teken rechthoek starten", + "finish-draw-rectangle": "Teken rechthoek beëindigen", + "start-draw-polygon": "Teken polygoon starten", + "continue-draw-polygon": "Doorgaan met polygoon tekenen", + "finish-draw-polygon": "Teken polygoon beëindigen", + "start-draw-circle": "Teken cirkel starten", + "finish-draw-circle": "Teken cirkel beëindigen" + } + }, + "widgets-bundle": { + "current": "Huidige bundel", + "widgets-bundles": "Widgetbundels", + "widgets-bundle-widgets": "Widgets van de bundel", + "add": "Widgetbundel toevoegen", + "delete": "Widgetbundel verwijderen", + "title": "Titel", + "title-required": "Titel is verplicht.", + "title-max-length": "Titel moet minder dan 256 tekens bevatten", + "description": "Beschrijving", + "image-preview": "Afbeeldingsvoorbeeld", + "scada": "SCADA-widgetbundel", + "order": "Volgorde", + "add-widgets-bundle-text": "Nieuwe widgetbundel toevoegen", + "no-widgets-bundles-text": "Geen widgetbundels gevonden", + "empty": "Widgetbundel is leeg", + "details": "Details", + "widgets-bundle-details": "Details van de widgetbundel", + "delete-widgets-bundle-title": "Weet je zeker dat je de widgetbundel '{{widgetsBundleTitle}}' wilt verwijderen?", + "delete-widgets-bundle-text": "Wees voorzichtig, na bevestiging worden de widgetbundel en alle gerelateerde gegevens onherroepelijk verwijderd.", + "delete-widgets-bundles-title": "Weet je zeker dat je { count, plural, =1 {1 widgetbundel} other {# widgetbundels} } wilt verwijderen?", + "delete-widgets-bundles-action-title": "Verwijder { count, plural, =1 {1 widgetbundel} other {# widgetbundels} }", + "delete-widgets-bundles-text": "Wees voorzichtig, na bevestiging worden alle geselecteerde widgetbundels en alle gerelateerde gegevens onherroepelijk verwijderd.", + "no-widgets-bundles-matching": "Geen widgetbundels gevonden die overeenkomen met '{{widgetsBundle}}'.", + "widgets-bundle-required": "Widgetbundel is verplicht.", + "system": "Systeem", + "import": "Widgetbundel importeren", + "export": "Widgetbundel exporteren", + "export-widgets-bundle-widgets-prompt": "Inclusief bundelwidgets in geëxporteerde gegevens (anders worden alleen verwijzingen naar widget FQNs geëxporteerd)", + "export-failed-error": "Kan widgetbundel niet exporteren: {{error}}", + "create-new-widgets-bundle": "Nieuwe widgetbundel maken", + "widgets-bundle-file": "Widgetbundelbestand", + "invalid-widgets-bundle-file-error": "Kan widgetbundel niet importeren: Ongeldige datastructuur van widgetbundel.", + "search": "Zoek widgetbundels", + "selected-widgets-bundles": "{ count, plural, =1 {1 widgetbundel} other {# widgetbundels} } geselecteerd", + "open-widgets-bundle": "Widgetbundel openen", + "loading-widgets-bundles": "Widgetbundels worden geladen...", + "create-new": "Nieuwe widgetbundel maken" + }, + "widget-config": { + "data": "Gegevens", + "settings": "Instellingen", + "advanced": "Geavanceerd", + "appearance": "Uiterlijk", + "widget-card": "Widgetkaart", + "mobile": "Mobiel", + "title": "Titel", + "title-tooltip": "Titeltooltip", + "general-settings": "Algemene instellingen", + "display-title": "Toon widgettitel", + "card-title": "Kaarttitel", + "drop-shadow": "Schaduw", + "enable-fullscreen": "Activeer volledig scherm", + "background-color": "Achtergrondkleur", + "text-color": "Tekstkleur", + "border-radius": "Randradius", + "padding": "Opvulling", + "margin": "Marge", + "widget-style": "Widgetstijl", + "widget-css": "Widget CSS", + "title-style": "Titelstijl", + "mobile-mode-settings": "Mobiele modus", + "order": "Volgorde", + "height": "Hoogte", + "mobile-hide": "Verberg widget in mobiele modus", + "desktop-hide": "Verberg widget in desktopmodus", + "units": "Speciaal symbool naast de waarde", + "units-by-default": "Standaardeenheden", + "decimals": "Aantal decimalen", + "decimals-by-default": "Standaard decimalen", + "default-data-key-parameter-hint": "Deze parameter geldt voor alle widgetwaarden tenzij overschreven door de gegevenssleutelconfiguratie", + "units-short": "Eenheden", + "decimals-short": "Decimalen", + "decimals-suffix": "decimalen", + "digits-suffix": "cijfers", + "timewindow": "Tijdsvenster", + "use-dashboard-timewindow": "Gebruik tijdsvenster van dashboard", + "use-widget-timewindow": "Gebruik tijdsvenster van widget", + "display-timewindow": "Toon tijdsvenster", + "legend": "Legenda", + "display-legend": "Toon legenda", + "datasources": "Gegevensbronnen", + "datasource": "Gegevensbron", + "maximum-datasources": "Maximaal { count, plural, =1 {1 gegevensbron toegestaan.} other {# gegevensbronnen toegestaan} }", + "timeseries-key-error": "Minimaal één tijdreeksgegevenssleutel moet worden opgegeven", + "datasource-type": "Type", + "datasource-parameters": "Parameters", + "remove-datasource": "Verwijder gegevensbron", + "add-datasource": "Gegevensbron toevoegen", + "target-device": "Doelapparaat", + "alarm-source": "Alarmbron", + "actions": "Acties", + "action": "Actie", + "add-action": "Actie toevoegen", + "search-actions": "Zoek acties", + "no-actions-text": "Geen acties gevonden", + "action-source": "Actiebron", + "select-action-source": "Selecteer actiebron", + "action-source-required": "Actiebron is verplicht.", + "column-index": "Kolomindex", + "select-column-index": "Selecteer kolomindex", + "column-index-required": "Kolomindex is verplicht.", + "not-set": "Niet ingesteld", + "action-name": "Naam", + "action-name-required": "Actienaam is verplicht.", + "action-name-not-unique": "Er bestaat al een andere actie met dezelfde naam.\nActienaam moet uniek zijn binnen dezelfde actiebron.", + "action-icon": "Pictogram", + "header-button": { + "button-settings": "Knopinstellingen", + "button-type": "Knoptype", + "button-type-basic": "Basis", + "button-type-raised": "Verhoogd", + "button-type-stroked": "Omtrokken", + "button-type-flat": "Vlak", + "button-type-icon": "Pictogram", + "button-type-mini-fab": "FAB", + "colors": "Kleuren", + "color": "Kleur", + "background": "Achtergrond", + "border": "Rand", + "advanced-button-style": "Geavanceerde knopstijl", + "button-style": "Knopstijl" + }, + "show-hide-action-using-function": "Toon/verberg actie met functie", + "show-action-function": "Toon actiefunctie", + "action-type": "Type", + "action-type-required": "Actietype is verplicht.", + "edit-action": "Bewerk actie", + "delete-action": "Verwijder actie", + "delete-action-title": "Verwijder widgetactie", + "delete-action-text": "Weet je zeker dat je de widgetactie met de naam '{{actionName}}' wilt verwijderen?", + "title-icon": "Titelpictogram", + "display-icon": "Toon titelpictogram", + "card-icon": "Kaartpictogram", + "icon": "Pictogram", + "icon-color": "Pictogramkleur", + "icon-size": "Pictogramgrootte", + "advanced-settings": "Geavanceerde instellingen", + "data-settings": "Gegevensinstellingen", + "limits": "Limieten", + "no-data-display-message": "Alternatief bericht voor \"Geen gegevens om weer te geven\"", + "data-page-size": "Maximaal aantal entiteiten per gegevensbron", + "settings-component-not-found": "Instellingencomponent niet gevonden voor selector '{{selector}}'", + "preview": "Voorbeeld", + "set": "Instellen", + "set-message": "Bericht instellen", + "advanced-title-style": "Geavanceerde titelstijl", + "card-style": "Kaartstijl", + "text": "Tekst", + "background": "Achtergrond", + "advanced-widget-style": "Geavanceerde widgetstijl", + "card-buttons": "Kaartknoppen", + "show-card-buttons": "Toon kaartknoppen", + "card-border-radius": "Randradius kaart", + "card-padding": "Kaartopvulling", + "card-appearance": "Kaartuiterlijk", + "color": "Kleur", + "tooltip": "Tooltip", + "units-required": "Eenheid is verplicht.", + "list-layout": "Lijstindeling", + "layout": "Indeling", + "resize-options": "Grootte-aanpassingsopties", + "resizable": "Grootte aanpasbaar", + "preserve-aspect-ratio": "Beeldverhouding behouden" + }, + "widget-type": { + "import": "Widgettype importeren", + "export": "Widgettype exporteren", + "export-failed-error": "Kan widget niet exporteren: {{error}}", + "widget-file": "Widgetbestand", + "invalid-widget-file-error": "Kan widget niet importeren: Ongeldige widgetgegevensstructuur." + }, + "markdown": { + "edit": "Bewerken", + "preview": "Voorbeeld", + "copy-code": "Klik om te kopiëren", + "copied": "Gekopieerd!" + }, + "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "De configuratie is afhankelijk van de Mobile app QR-code widget in de hoofdinstellingen van het platform", + "get-it-on-google-play": "Download in Google Play", + "download-on-the-app-store": "Download in de App Store" + }, + "action-button": { + "behavior": "Gedrag", + "on-click": "Bij klikken", + "on-click-hint": "Actie die wordt geactiveerd wanneer op de knop wordt geklikt", + "first-button-click": "Klik op eerste knop", + "first-button-click-hint": "Actie bij indrukken van de eerste knop.", + "second-button-click": "Klik op tweede knop", + "second-button-click-hint": "Actie bij indrukken van de tweede knop.", + "button-click-hint": "Actie bij klikken op de widget." + }, + "command-button": { + "behavior": "Gedrag", + "on-click": "Bij klikken", + "on-click-hint": "Actie uitgevoerd wanneer op de knop wordt geklikt." + }, + "power-button": { + "behavior": "Gedrag", + "power-on": "Stroom 'Aan'", + "power-on-hint": "Actie uitgevoerd om de component AAN te zetten.", + "power-off": "Stroom 'Uit'", + "power-off-hint": "Actie uitgevoerd om de component UIT te zetten.", + "on-label": "Aan", + "off-label": "Uit", + "layout": "Lay-out", + "layout-default": "Standaard", + "layout-simplified": "Vereenvoudigd", + "layout-outlined": "Omlijnd", + "layout-default-volume": "Standaard.Volume", + "layout-simplified-volume": "Vereenvoudigd.Volume", + "layout-outlined-volume": "Omlijnd.Volume", + "layout-default-icon": "Standaard.Icoon", + "layout-simplified-icon": "Vereenvoudigd.Icoon", + "layout-outlined-icon": "Omlijnd.Icoon", + "main": "Hoofd", + "background": "Achtergrond", + "button-icon-on": "Knop icoon 'Aan'", + "button-icon-off": "Knop icoon 'Uit'", + "power-on-colors": "Kleuren bij 'Aan'", + "power-off-colors": "Kleuren bij 'Uit'", + "disabled-colors": "Uitgeschakelde kleuren", + "button": "Knop" + }, + "toggle-button": { + "behavior": "Gedrag", + "checked": "Aangevinkt", + "unchecked": "Niet aangevinkt", + "check": "Aankruisen", + "check-hint": "Actie uitgevoerd om de component aan te vinken.", + "uncheck": "Uitschakelen", + "uncheck-hint": "Actie uitgevoerd om de component uit te schakelen.", + "auto-scale": "Automatisch schalen", + "horizontal-fill": "Horizontale vulling", + "vertical-fill": "Verticale vulling", + "button-appearance": "Knopweergave" + }, + "segmented-button": { + "layout": "Lay-out", + "layout-squared": "Vierkant", + "layout-rounded": "Afgerond", + "card-border": "Kaartomranding", + "button-appearance": "Knopweergave", + "first": "Eerste", + "second": "Tweede", + "color-styles": "Kleurenstijlen", + "selected": "Geselecteerd", + "unselected": "Niet geselecteerd" + }, + "button": { + "layout": "Lay-out", + "outlined": "Omlijnd", + "filled": "Ingevuld", + "underlined": "Onderlijnd", + "basic": "Basis", + "auto-scale": "Automatisch schalen", + "label": "Label", + "icon": "Icoon", + "border-radius": "Randradius", + "color-palette": "Kleurenpalet", + "main": "Hoofd", + "background": "Achtergrond", + "border": "Rand", + "custom-styles": "Aangepaste stijlen", + "clear-style": "Stijl wissen", + "shadow": "Schaduw", + "enabled": "Ingeschakeld", + "disabled": "Uitgeschakeld", + "preview": "Voorbeeld", + "copy-style-from": "Stijl kopiëren van" + }, + "value-stepper": { + "behavior": "Gedrag", + "simplified": "Vereenvoudigd", + "filled": "Ingevuld", + "outlined": "Omlijnd", + "volume": "Volume", + "initial-state": "Initiële status", + "initial-state-hint": "Actie om de initiële waarde op te halen.", + "disabled-state": "Uitgeschakelde status", + "disabled-state-hint": "Stel de voorwaarde in waaronder het onderdeel is uitgeschakeld.", + "right-button-click": "Klik op rechterknop", + "right-button-click-hint": "Actie bij indrukken van de rechterknop.", + "left-button-click": "Klik op linkerknop", + "left-button-click-hint": "Actie bij indrukken van de linkerknop.", + "auto-scale": "Automatisch schalen", + "value-range": "Bereik", + "min-range": "Min", + "max-range": "Max", + "value-increment-decrement-step": "Stapgrootte waarde verhogen/verlagen", + "value": "Waarde", + "value-box-background": "Achtergrond van waardeveld", + "border": "Rand", + "button-appearance": "Knopweergave", + "left": "Links", + "right": "Rechts", + "left-button": "Linkerknop", + "right-button": "Rechterknop", + "icon": "Icoon", + "color-palette": "Kleurenpalet", + "main": "Hoofd", + "background": "Achtergrond", + "button-icon-on": "Knop icoon 'Aan'", + "button-on-colors": "Kleuren bij 'Aan'", + "disabled-colors": "Uitgeschakelde kleuren" + }, + "button-state": { + "activated-state": "Geactiveerde status", + "activated-state-hint": "Stel de voorwaarde in waaronder de knop actief is.", + "disabled-state": "Uitgeschakelde status", + "disabled-state-hint": "Stel de voorwaarde in waaronder de knop is uitgeschakeld.", + "selected-state": "Knop geselecteerd", + "selected-state-hint": "Stel de voorwaarde in waaronder de knop is geselecteerd.", + "enabled": "Ingeschakeld", + "hovered": "Gehoverd", + "pressed": "Ingedrukt", + "activated": "Geactiveerd", + "disabled": "Uitgeschakeld", + "initial": "Eerste knop", + "first": "Eerste", + "second": "Tweede" + }, + "background": { + "background": "Achtergrond", + "background-settings": "Achtergrondinstellingen", + "background-type-image": "Afbeelding", + "background-type-color": "Kleur", + "image-url": "Afbeeldings-URL", + "overlay": "Overlay", + "enable-overlay": "Overlay inschakelen", + "blur": "Vervagen", + "preview": "Voorbeeld" + }, + "bar-chart": { + "bar-appearance": "Staafweergave", + "label-on-bar": "Label op staaf", + "value-on-bar": "Waarde op staaf", + "bar-chart-style": "Stijl van staafdiagram", + "bar-axis": "Staafas" + }, + "polar-area-chart": { + "polar-axis": "Polaire as", + "start-angle": "Starthoek", + "polar-area-chart-style": "Stijl van polair gebieddiagram" + }, + "battery-level": { + "layout": "Lay-out", + "layout-vertical-solid": "Verticaal. Solide", + "layout-horizontal-solid": "Horizontaal. Solide", + "layout-vertical-divided": "Verticaal. Verdeeld", + "layout-horizontal-divided": "Horizontaal. Verdeeld", + "icon": "Icoon", + "value": "Waarde", + "auto-scale": "Automatisch schalen", + "battery-level-color": "Kleur batterijniveau", + "battery-shape-color": "Kleur batterijvorm", + "battery-level-card-style": "Stijl batterijkaart", + "sections-count": "Aantal secties" + }, + "signal-strength": { + "value": "Waarde", + "last-update": "Laatste update", + "no-signal": "Geen signaal", + "layout": "Lay-out", + "layout-wifi": "Wi-Fi", + "layout-cellular-bar": "Mobiele balk", + "icon": "Icoon", + "date": "Datum", + "active-bars-color": "Kleur actieve signaalbalken", + "inactive-bars-color": "Kleur inactieve signaalbalken", + "signal-strength-card-style": "Stijl signaalsterktekaart", + "no-signal-rssi-value": "\"Geen signaal\" RSSI-waarde" + }, + "status-widget": { + "behavior": "Gedrag", + "layout": "Lay-out", + "layout-default": "Standaard", + "layout-center": "Midden", + "layout-icon": "Icoon", + "on": "Aan", + "off": "Uit", + "label": "Label", + "status": "Status", + "icon": "Icoon", + "color-palette": "Kleurenpalet", + "disabled-color-palette": "Kleurenpalet uitgeschakeld", + "primary": "Primair", + "primary-color-hint": "Kleur van icoon en label", + "secondary": "Secundair", + "secondary-color-hint": "Kleur van status", + "background": "Achtergrond" + }, + "chart": { + "common-settings": "Algemene instellingen", + "enable-stacking-mode": "Stapelmethode inschakelen", + "selection": "Tijdselectie", + "enable-selection-mode": "Selectiemodus inschakelen", + "line-shadow-size": "Lijnschaduw grootte", + "display-smooth-lines": "Glad (gebogen) lijnen weergeven", + "default-bar-width": "Standaard staafbreedte voor niet-geaggregeerde gegevens (milliseconden)", + "bar-alignment": "Uitlijning van staaf", + "bar-alignment-left": "Links", + "bar-alignment-right": "Rechts", + "bar-alignment-center": "Midden", + "default-font": "Standaardlettertype", + "default-font-size": "Standaard lettergrootte", + "default-font-color": "Standaard letterkleur", + "thresholds-line-width": "Standaard lijndikte voor alle drempels", + "tooltip-settings": "Tooltip-instellingen", + "tooltip": "Tooltip", + "show-tooltip": "Tooltip weergeven", + "hover-individual-points": "Individuele punten aanwijzen", + "show-cumulative-values": "Cumulatieve waarden tonen in stapelmodus", + "hide-zero-false-values": "Verberg nul/onwaar waarden in tooltip", + "tooltip-value-format-function": "Waardeformaatfunctie voor tooltip", + "grid-settings": "Rasterinstellingen", + "show-vertical-lines": "Verticale lijnen weergeven", + "show-horizontal-lines": "Horizontale lijnen weergeven", + "grid-outline-border-width": "Rasteromtrek/lijndikte (px)", + "primary-color": "Primaire kleur", + "background-color": "Achtergrondkleur", + "ticks-color": "Kleur van de ticks", + "xaxis-settings": "X-as instellingen", + "axis-title": "As titel", + "xaxis-tick-labels-settings": "Instellingen voor X-as ticks", + "show-tick-labels": "Toon ticks-labels", + "yaxis-settings": "Y-as instellingen", + "min-scale-value": "Minimale schaalwaarde", + "max-scale-value": "Maximale schaalwaarde", + "yaxis-tick-labels-settings": "Instellingen voor Y-as ticks", + "tick-step-size": "Stapsgewijs interval tussen ticks", + "number-of-decimals": "Aantal decimalen om weer te geven", + "ticks-formatter-function": "Ticks formatter functie", + "comparison-settings": "Vergelijkingsinstellingen", + "enable-comparison": "Vergelijking inschakelen", + "time-for-comparison": "Vergelijkingsperiode", + "time-for-comparison-previous-interval": "Vorige interval (standaard)", + "time-for-comparison-days": "Dag geleden", + "time-for-comparison-weeks": "Week geleden", + "time-for-comparison-months": "Maand geleden", + "time-for-comparison-years": "Jaar geleden", + "time-for-comparison-custom-interval": "Aangepaste interval", + "custom-interval-value": "Aangepaste intervalwaarde (ms)", + "comparison-x-axis-settings": "Vergelijking X-as instellingen", + "axis-position": "As positie", + "axis-position-top": "Boven (standaard)", + "axis-position-bottom": "Onder", + "custom-legend-settings": "Aangepaste legenda instellingen", + "enable-custom-legend": "Aangepaste legenda inschakelen (gebruik van attribuut/tijdreekswaarden in sleutel labels)", + "key-name": "Sleutelnaam", + "key-name-required": "Sleutelnaam is vereist", + "key-type": "Sleuteltype", + "key-type-attribute": "Attribuut", + "key-type-timeseries": "Tijdreeks", + "label-keys-list": "Sleutellijst om in labels te gebruiken", + "no-label-keys": "Geen sleutels geconfigureerd", + "add-label-key": "Nieuwe sleutel toevoegen", + "line-width": "Lijndikte", + "color": "Kleur", + "data-is-hidden-by-default": "Gegevens standaard verborgen", + "disable-data-hiding": "Gegevens verbergen uitschakelen", + "remove-from-legend": "Sleutel uit legenda verwijderen", + "exclude-from-stacking": "Uitsluiten van stapeling (beschikbaar in \"Stapelmodus\")", + "line-settings": "Lijninstellingen", + "show-line": "Lijn weergeven", + "fill-line": "Lijn invullen", + "fill-line-opacity": "Invuldoorzichtigheid", + "points-settings": "Punteninstellingen", + "show-points": "Punten weergeven", + "points-line-width": "Lijndikte van punten", + "points-radius": "Straal van punten", + "point-shape": "Vorm van punt", + "point-shape-circle": "Cirkel", + "point-shape-cross": "Kruis", + "point-shape-diamond": "Ruit", + "point-shape-square": "Vierkant", + "point-shape-triangle": "Driehoek", + "point-shape-custom": "Aangepaste functie", + "point-shape-draw-function": "Tekeningfunctie voor puntvorm", + "show-separate-axis": "Aparte as weergeven", + "axis-position-left": "Links", + "axis-position-right": "Rechts", + "thresholds": "Drempels", + "no-thresholds": "Geen drempels geconfigureerd", + "add-threshold": "Drempel toevoegen", + "show-values-for-comparison": "Historische waarden weergeven ter vergelijking", + "comparison-values-label": "Label voor historische waarden", + "comparison-line-color": "Vergelijkingslijnkleur", + "threshold-settings": "Drempelinstellingen", + "use-as-threshold": "Gebruik sleutelwaarde als drempel", + "threshold-line-width": "Lijndikte van drempel", + "threshold-color": "Drempelkleur", + "common-pie-settings": "Algemene cirkeldiagraminstellingen", + "radius": "Straal", + "inner-radius": "Binnenstraal", + "tilt": "Kantel", + "common-pie-settings-range-error": "Waarde moet tussen 0 en 1 liggen", + "stroke-settings": "Lijninstellingen", + "width-pixels": "Breedte (pixels)", + "show-labels": "Labels weergeven", + "animation-settings": "Animatie-instellingen", + "animated-pie": "Animatie inschakelen voor cirkel (experimenteel)", + "border-settings": "Randinstellingen", + "border-width": "Randbreedte", + "border-color": "Randkleur", + "legend-settings": "Legenda-instellingen", + "display-legend": "Legenda weergeven", + "labels-font-color": "Kleur van lettertype voor labels", + "series": "Reeksen", + "add-series": "Reeks toevoegen", + "series-settings": "Reeksinstellingen", + "remove-series": "Reeks verwijderen", + "no-series": "Geen reeksen geconfigureerd", + "no-series-error": "Minstens één reeks moet worden opgegeven", + "chart-appearance": "Grafiekweergave", + "vertical-grid-lines": "Verticale rasterlijnen", + "horizontal-grid-lines": "Horizontale rasterlijnen", + "chart-background": "Achtergrond van de grafiek", + "grid-lines-color": "Rasterlijntjes kleur", + "border": "Rand", + "axis": "As", + "vertical-axis": "Verticale as", + "ticks": "Streepjes", + "horizontal-axis": "Horizontale as", + "shape-empty-circle": "Lege cirkel", + "shape-circle": "Cirkel", + "shape-rect": "Rechthoek", + "shape-round-rect": "Afgeronde rechthoek", + "shape-triangle": "Driehoek", + "shape-diamond": "Ruit", + "shape-pin": "Pin", + "shape-arrow": "Pijl", + "shape-none": "Geen", + "line-type-solid": "Vol", + "line-type-dashed": "Gestreept", + "line-type-dotted": "Gestippeld", + "label-position-top": "Boven", + "label-position-bottom": "Onder", + "label-position-outside": "Buiten", + "label-position-inside": "Binnen", + "fill": "Vulling", + "fill-type-none": "Geen", + "fill-type-solid": "Vol", + "fill-type-opacity": "Doorzichtigheid", + "fill-type-gradient": "Verloop", + "background": "Achtergrond", + "opacity": "Transparantie", + "gradient-stops": "Verloop stops", + "gradient-start": "start", + "gradient-end": "einde", + "animation": { + "animation": "Animatie", + "animation-threshold": "Animatiedrempel", + "animation-duration": "Duur van animatie", + "animation-easing": "Animatieverloop", + "animation-delay": "Animatievertraging", + "update-animation-duration": "Duur update-animatie", + "update-animation-easing": "Update-animatieverloop", + "update-animation-delay": "Vertraging update-animatie" + }, + "chart-axis": { + "scale": "Schaal", + "scale-min": "min", + "scale-max": "max", + "scale-auto": "Automatisch" + }, + "bar": { + "show-border": "Toon rand", + "border-width": "Randbreedte", + "border-radius": "Randradius", + "bar-width": "Staafbreedte", + "label": "Label", + "label-hint": "Label tonen boven de staaf.", + "series-label-hint": "Label met waarde tonen boven de staaf.", + "label-background": "Achtergrond label" + } + }, + "color": { + "color-settings": "Kleurinstellingen", + "color-type-constant": "Constante", + "color-type-gradient": "Verloop", + "color-type-range": "Bereik", + "color-type-function": "Functie", + "color": "Kleur", + "value-range": "Waardenbereik", + "from": "Van", + "to": "Tot", + "color-function": "Kleurfunctie", + "copy-color-settings-from": "Kleurinstellingen kopiëren van", + "copy-from": "Kopiëren van", + "settings-type": "Instellingstype", + "basic-mode": "Basis", + "advanced-mode": "Geavanceerd", + "entity-alias": "Entiteit-alias", + "entity-attribute": "Entiteitattribuut", + "gradient-color": "Verloopkleur", + "gradient-color-min": "Kleur", + "gradient-start": "Startkleur verloop", + "gradient-start-min": "Start", + "gradient-end": "Eindkleur verloop", + "gradient-end-min": "Einde", + "start-value": "Startwaarde", + "end-value": "Eindwaarde", + "gradient-type": "Verlooptype" + }, + "dashboard-state": { + "dashboard-state-settings": "Dashboardstatusinstellingen", + "dashboard-state": "Dashboardstatus-id", + "autofill-state-layout": "Statuslayouthoogte automatisch invullen standaard", + "default-margin": "Standaard marge voor widgets", + "default-background-color": "Standaard achtergrondkleur", + "sync-parent-state-params": "Synchroniseer statusparameters met hoofd-dashboard" + }, + "date-range-navigator": { + "date-range-picker-settings": "Instellingen voor datumbereikselectie", + "hide-date-range-picker": "Verberg datumbereikselector", + "picker-one-panel": "Datumbereikselector met één paneel", + "picker-auto-confirm": "Automatische bevestiging bij datumbereikselector", + "picker-show-template": "Toon sjabloon bij datumbereikselector", + "first-day-of-week": "Eerste dag van de week", + "interval-settings": "Intervalinstellingen", + "hide-interval": "Verberg interval", + "initial-interval": "Initiële interval", + "interval-hour": "Uur", + "interval-day": "Dag", + "interval-week": "Week", + "interval-two-weeks": "2 weken", + "interval-month": "Maand", + "interval-three-months": "3 maanden", + "interval-six-months": "6 maanden", + "step-settings": "Stapinstellingen", + "hide-step-size": "Verberg stapgrootte", + "initial-step-size": "Initiële stapgrootte", + "hide-labels": "Verberg labels", + "use-session-storage": "Gebruik sessieopslag", + "localizationMap": { + "Sun": "Zo", + "Mon": "Ma", + "Tue": "Di", + "Wed": "Wo", + "Thu": "Do", + "Fri": "Vr", + "Sat": "Za", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mrt", + "Apr": "Apr", + "May": "Mei", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Okt", + "Nov": "Nov", + "Dec": "Dec", + "January": "Januari", + "February": "Februari", + "March": "Maart", + "April": "April", + "June": "Juni", + "July": "Juli", + "August": "Augustus", + "September": "September", + "October": "Oktober", + "November": "November", + "December": "December", + "Custom Date Range": "Aangepast datumbereik", + "Date Range Template": "Sjabloon datumbereik", + "Today": "Vandaag", + "Yesterday": "Gisteren", + "This Week": "Deze week", + "Last Week": "Vorige week", + "This Month": "Deze maand", + "Last Month": "Vorige maand", + "Year": "Jaar", + "This Year": "Dit jaar", + "Last Year": "Vorig jaar", + "Date picker": "Datumkiezer", + "Hour": "Uur", + "Day": "Dag", + "Week": "Week", + "2 weeks": "2 weken", + "Month": "Maand", + "3 months": "3 maanden", + "6 months": "6 maanden", + "Custom interval": "Aangepast interval", + "Interval": "Interval", + "Step size": "Stapgrootte", + "Ok": "Oké" + } + }, + "doughnut": { + "doughnut-appearance": "Donut-uiterlijk", + "layout": "Lay-out", + "layout-default": "Standaard", + "layout-with-total": "Met totaal", + "central-total-value": "Centrale totaalwaarde", + "doughnut-card-style": "Donut-kaartstijl" + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Hiërarchische gegevensinstellingen", + "relations-query-function": "Functie voor relaties van knoop", + "has-children-function": "Functie of knoop kinderen heeft", + "node-state-settings": "Instellingen knoopstatus", + "node-opened-function": "Functie voor standaard geopende knoop", + "node-disabled-function": "Functie voor uitgeschakelde knoop", + "display-settings": "Weergave-instellingen", + "node-icon-function": "Functie voor knoopicoon", + "node-text-function": "Functie voor knooptekst", + "sort-settings": "Sorteerinstellingen", + "nodes-sort-function": "Functie voor sorteren van knopen" + }, + "edge": { + "display-default-title": "Standaardtitel weergeven" + }, + "gateway": { + "general-settings": "Algemene instellingen", + "widget-title": "Widgettitel", + "default-archive-file-name": "Standaard naam archiefbestand", + "device-type-for-new-gateway": "Type apparaat voor nieuwe gateway", + "messages-settings": "Berichtinstellingen", + "save-config-success-message": "Bericht bij succesvol opslaan van gatewayconfiguratie", + "device-name-exists-message": "Bericht wanneer apparaatnaam al bestaat", + "gateway-title": "Gatewayformulier", + "read-only": "Alleen-lezen", + "events-title": "Titel gatewaygebeurtenissenformulier", + "events-filter": "Filter gebeurtenissen", + "event-key-contains": "Gebeurtenissleutel bevat...", + "show-connector": "Tonen voor de connector", + "connector-state-param-key": "Sleutel statusparameter connector", + "message": "Bericht", + "level": "Niveau", + "created-time": "Aangemaakt op" + }, + "gauge": { + "default-color": "Standaardkleur", + "radial-gauge-settings": "Instellingen radiale meter", + "ticks-settings": "Tikken instellingen", + "min-value": "Minimumwaarde", + "max-value": "Maximumwaarde", + "min-value-short": "min", + "max-value-short": "max", + "start-ticks-angle": "Starthoek tikken", + "ticks-angle": "Hoek tikken", + "major-ticks": "Grote tikken", + "major-ticks-count": "Aantal grote tikken", + "major-ticks-color": "Kleur grote tikken", + "minor-ticks": "Kleine tikken", + "minor-ticks-count": "Aantal kleine tikken", + "minor-ticks-color": "Kleur kleine tikken", + "tick-numbers-font": "Lettertype tiknummers", + "unit-title-settings": "Instellingen eenheidstitel", + "show-unit-title": "Titel eenheden", + "unit-title": "Titel eenheid", + "title-font": "Lettertype titel", + "units-settings": "Instellingen eenheden", + "units-font": "Lettertype eenheden", + "value-box-settings": "Instellingen waardeveld", + "show-value-box": "Waardeveld weergeven", + "value-box": "Waardeveld", + "value-int": "Aantal cijfers voor geheel getal", + "value-text": "Waarde tekst", + "value-text-shadow": "Schaduw tekst waarde", + "value-font": "Lettertype waarde", + "rect-stroke-color-start": "Kaderkleur - start verloop", + "rect-stroke-color-end": "Kaderkleur - eind verloop", + "background-color": "Achtergrondkleur", + "shadow-color": "Schaduwkleur", + "value-box-rect-stroke-color": "Kaderkleur waardeveld", + "value-box-rect-stroke-color-end": "Kaderkleur waardeveld - eind verloop", + "value-box-background-color": "Achtergrondkleur waardeveld", + "value-box-shadow-color": "Schaduwkleur waardeveld", + "plate-settings": "Instellingen plaat", + "show-plate-border": "Rand plaat", + "plate-color": "Plaatkleur", + "needle-settings": "Instellingen naald", + "needle-circle-size": "Grootte naaldcirkel", + "needle-color": "Kleur naald", + "needle-color-start": "Kleur naald - start verloop", + "needle-color-end": "Kleur naald - eind verloop", + "needle-color-shadow-up": "Bovenste schaduwkleur naald", + "needle-color-shadow-down": "Onderschaduw naald", + "highlights-settings": "Markeringen instellingen", + "highlights-width": "Breedte markeringen", + "highlights": "Markeringen", + "highlight-from": "Van", + "highlight-to": "Tot", + "highlight-color": "Kleur", + "no-highlights": "Geen markeringen geconfigureerd", + "add-highlight": "Markering toevoegen", + "animation-settings": "Animatie-instellingen", + "enable-animation": "Animatie", + "animation-duration-rule": "Duur en regel van animatie", + "animation-duration": "Animatieduur", + "animation-rule": "Animatieregel", + "animation-linear": "Lineair", + "animation-quad": "Kwadratisch", + "animation-quint": "Quint", + "animation-cycle": "Cyclus", + "animation-bounce": "Bounce", + "animation-elastic": "Elastisch", + "animation-dequad": "Dequad", + "animation-dequint": "Dequint", + "animation-decycle": "Decyclus", + "animation-debounce": "Debounce", + "animation-delastic": "Delastisch", + "linear-gauge-settings": "Lineaire meter instellingen", + "bar-stroke": "Staafomtrek", + "bar-stroke-width": "Staafomtrek breedte", + "bar-stroke-color": "Staafomtrek kleur", + "bar-background-color": "Achtergrondkleur staaf - start verloop", + "bar-background-color-end": "Achtergrondkleur staaf - eind verloop", + "progress-bar-color": "Kleur voortgangsbalk", + "progress-bar": "Voortgangsbalk", + "progress-bar-color-start": "Voortgangsbalk kleur - start verloop", + "progress-bar-color-end": "Voortgangsbalk kleur - eind verloop", + "major-ticks-names": "Namen van hoofdverdelingen", + "show-stroke-ticks": "Toon omtrekverdelingen", + "major-ticks-font": "Lettertype hoofdverdelingen", + "border-color": "Randkleur", + "border-width": "Randdikte", + "needle-circle": "Naaldcirkel", + "needle-circle-color": "Kleur naaldcirkel", + "animation-target": "Animatiedoel", + "animation-target-needle": "Naald", + "animation-target-plate": "Plaat", + "common-settings": "Algemene meter instellingen", + "gauge-type": "Type meter", + "gauge-type-arc": "Boog", + "gauge-type-donut": "Donut", + "gauge-type-horizontal-bar": "Horizontale balk", + "gauge-type-vertical-bar": "Verticale balk", + "donut-start-angle": "Starthoek (graden)", + "bar-settings": "Balkinstellingen", + "relative-bar-width": "Relatieve balkbreedte", + "neon-glow-brightness": "Neon gloeieffect helderheid (0-100)", + "neon-glow-brightness-hint": "0 - effect uitschakelen", + "stripes-thickness": "Streepdikte", + "stripes-thickness-hint": "0 - geen strepen", + "rounded-line-cap": "Afgeronde lijnkop", + "bar-color-settings": "Instellingen balkkleur", + "use-precise-level-color-values": "Gebruik precieze kleurwaarden", + "bar-colors": "Balkkleuren, van laag naar hoog", + "color": "Kleur", + "no-bar-colors": "Geen balkkleuren geconfigureerd", + "add-bar-color": "Balkkleur toevoegen", + "from": "Van", + "to": "Tot", + "fixed-level-colors": "Balkkleuren op basis van grenswaarden", + "gauge-title-settings": "Instellingen metertitel", + "show-gauge-title": "Toon metertitel", + "gauge-title": "Metertitel", + "gauge-title-font": "Lettertype metertitel", + "unit-title-and-timestamp-settings": "Instellingen eenheidstitel en tijdstempel", + "show-timestamp": "Tijdstempel", + "timestamp-format": "Tijdstempelformaat", + "label-font": "Lettertype label onder waarde", + "value-settings": "Waarde instellingen", + "show-value": "Toon waardetekst", + "min-max-settings": "Instellingen minimum/maximum labels", + "show-min-max": "Toon min- en max-waarden", + "min-max-font": "Lettertype voor min- en max-labels", + "show-ticks": "Toon verdelingen", + "tick-width": "Breedte verdeling", + "tick-color": "Kleur verdeling", + "tick-values": "Waarden van verdelingen", + "no-tick-values": "Geen waarden voor verdelingen geconfigureerd", + "add-tick-value": "Waarde voor verdeling toevoegen", + "gauge-appearance": "Uiterlijk van de meter", + "units-title": "Titel van eenheden", + "value": "Waarde", + "ticks": "Verdelingen", + "arrow-and-scale-color": "Standaardkleur pijl en schaal", + "scale-settings": "Schaalinstellingen", + "scale": "Schaal", + "scale-color": "Schaalkleuren", + "compass-appearance": "Uiterlijk kompas", + "label": "Label", + "labels": "Labels", + "label-style": "Stijl van label", + "simple-gauge-type": "Type", + "gauge-bar-background": "Achtergrond balk van meter", + "bar-color": "Balkkleur", + "min-and-max-value": "Min- en maxwaarde", + "min-and-max-label": "Label min- en max", + "font": "Lettertype", + "tick-width-and-color": "Breedte en kleur verdeling", + "min-max-validation-text": "Maxwaarde moet groter zijn dan minwaarde" + }, + "gpio": { + "pin": "Pin", + "label": "Label", + "row": "Rij", + "column": "Kolom", + "color": "Kleur", + "panel-settings": "Paneelinstellingen", + "background-color": "Achtergrondkleur", + "gpio-switches": "GPIO-schakelaars", + "no-gpio-switches": "Geen GPIO-schakelaars geconfigureerd", + "add-gpio-switch": "GPIO-schakelaar toevoegen", + "gpio-status-request": "GPIO-statusaanvraag", + "method-name": "Methodenaam", + "method-body": "Methode body", + "gpio-status-change-request": "GPIO-statuswijzigingsaanvraag", + "parse-gpio-status-function": "GPIO-statusfunctie parseren", + "gpio-leds": "GPIO LEDs", + "no-gpio-leds": "Geen GPIO LEDs geconfigureerd", + "add-gpio-led": "GPIO LED toevoegen" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "Attribuutparameter mag niet worden gebruikt in deze widget", + "blocked-location": "Locatie is geblokkeerd in je browser", + "claim-device": "Apparaat claimen", + "claim-failed": "Apparaat claimen is mislukt!", + "claim-not-found": "Apparaat niet gevonden!", + "claim-successful": "Apparaat succesvol geclaimd!", + "date": "Datum", + "device-name": "Apparaatnaam", + "device-name-required": "Apparaatnaam is verplicht", + "discard-changes": "Wijzigingen negeren", + "entity-attribute-required": "Entiteitattribuut is verplicht", + "entity-coordinate-required": "Beide velden, breedte- en lengtegraad, zijn verplicht", + "entity-timeseries-required": "Entiteit tijdreeks is verplicht", + "get-location": "Huidige locatie ophalen", + "invalid-date": "Ongeldige datum", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "min-value-error": "Minimale waarde is {{value}}", + "max-value-error": "Maximale waarde is {{value}}", + "not-allowed-entity": "Geselecteerde entiteit kan geen gedeelde attributen hebben", + "no-attribute-selected": "Geen attribuut geselecteerd", + "no-datakey-selected": "Geen datatoets geselecteerd", + "no-coordinate-specified": "Datatoets voor breedte-/lengtegraad niet opgegeven", + "no-entity-selected": "Geen entiteit geselecteerd", + "no-image": "Geen afbeelding", + "no-support-geolocation": "Je browser ondersteunt geen geolocatie", + "no-support-web-camera": "Je browser ondersteunt geen camera’s", + "enable-https-use-widget": "Activeer HTTPS om deze widget te gebruiken", + "no-found-your-camera": "Kan je camera niet vinden", + "no-permission-camera": "Toestemming geweigerd of geen toegang tot camera", + "no-timeseries-selected": "Geen tijdreeks geselecteerd", + "secret-key": "Geheime sleutel", + "secret-key-required": "Geheime sleutel is verplicht", + "switch-attribute-value": "Schakel attribuutwaarde van entiteit", + "switch-camera": "Schakel camera", + "switch-timeseries-value": "Schakel tijdreekswaarde van entiteit", + "take-photo": "Foto maken", + "time": "Tijd", + "timeseries-not-allowed": "Tijdreeksparameter mag niet worden gebruikt in deze widget", + "update-failed": "Update mislukt", + "update-successful": "Update succesvol", + "update-attribute": "Attribuut bijwerken", + "update-timeseries": "Tijdreeks bijwerken", + "value": "Waarde", + "general-settings": "Algemene instellingen", + "widget-title": "Widgettitel", + "claim-button-label": "Label claimknop", + "show-secret-key-field": "Toon invoerveld 'Geheime sleutel'", + "labels-settings": "Labelinstellingen", + "show-labels": "Toon labels", + "device-name-label": "Label voor apparaatnaamveld", + "secret-key-label": "Label voor geheime sleutelveld", + "messages-settings": "Berichteninstellingen", + "claim-device-success-message": "Bericht bij succesvol apparaat claimen", + "claim-device-not-found-message": "Bericht wanneer apparaat niet gevonden is", + "claim-device-failed-message": "Bericht bij mislukte apparaatclaim", + "claim-device-name-required-message": "Foutbericht 'Apparaatnaam verplicht'", + "claim-device-secret-key-required-message": "Foutbericht 'Geheime sleutel verplicht'", + "show-label": "Toon label", + "label": "Label", + "required": "Verplicht", + "required-error-message": "Foutmelding 'Verplicht'", + "show-result-message": "Toon resultaatbericht", + "integer-field-settings": "Instellingen voor geheel getal", + "min-value": "Minimale waarde", + "max-value": "Maximale waarde", + "double-field-settings": "Instellingen voor decimaal getal", + "text-field-settings": "Instellingen voor tekstveld", + "min-length": "Minimale lengte", + "max-length": "Maximale lengte", + "checkbox-settings": "Instellingen voor selectievakje", + "true-label": "Label indien aangevinkt", + "false-label": "Label indien niet aangevinkt", + "image-input-settings": "Instellingen voor afbeeldingsinvoer", + "display-preview": "Toon voorbeeld", + "display-clear-button": "Toon wissen-knop", + "display-apply-button": "Toon toepassen-knop", + "display-discard-button": "Toon annuleren-knop", + "datetime-field-settings": "Instellingen voor datum/tijd", + "display-time-input": "Toon tijdinvoer", + "latitude-key-name": "Naam van breedtegraadsleutel", + "longitude-key-name": "Naam van lengtegraadsleutel", + "show-get-location-button": "Toon knop 'Huidige locatie ophalen'", + "use-high-accuracy": "Gebruik hoge nauwkeurigheid", + "location-fields-settings": "Instellingen voor locatievelden", + "latitude-label": "Label voor breedtegraad", + "longitude-label": "Label voor lengtegraad", + "input-fields-alignment": "Uitlijning invoervelden", + "input-fields-alignment-column": "Kolom (standaard)", + "input-fields-alignment-row": "Rij", + "layout": "Indeling", + "row-gap": "Afstand tussen rijen (pixels)", + "column-gap": "Afstand tussen kolommen (pixels)", + "latitude-field-required": "Breedtegraad is verplicht", + "longitude-field-required": "Lengtegraad is verplicht", + "attribute-settings": "Attribuutinstellingen", + "widget-mode": "Widgetmodus", + "widget-mode-update-attribute": "Attribuut bijwerken", + "widget-mode-update-timeseries": "Tijdreeks bijwerken", + "attribute-scope": "Attribuutscope", + "attribute-scope-server": "Serverattribuut", + "attribute-scope-shared": "Gedeeld attribuut", + "value-required": "Waarde is verplicht", + "image-settings": "Afbeeldingsinstellingen", + "image-format": "Afbeeldingsformaat", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Afbeeldingskwaliteit voor verliescompressie (zoals jpeg/webp)", + "max-image-width": "Maximale afbeeldingsbreedte", + "max-image-height": "Maximale afbeeldingshoogte", + "action-buttons": "Actieknoppen", + "show-action-buttons": "Toon actieknoppen", + "update-all-values": "Alle waarden bijwerken, niet alleen gewijzigde", + "save-button-label": "Label voor 'OPSLAAN'-knop", + "reset-button-label": "Label voor 'ONGEDAAAN MAKEN'-knop", + "group-settings": "Groepsinstellingen", + "show-group-title": "Toon titel voor groep van velden", + "group-title": "Groepstitel", + "fields-alignment": "Velduitlijning", + "fields-alignment-row": "Rij (standaard)", + "fields-alignment-column": "Kolom", + "fields-in-row": "Aantal velden per rij", + "option-value": "Waarde (schrijf 'null' voor lege optie)", + "option-label": "Label", + "hide-input-field": "Verberg invoerveld", + "datakey-type": "Type datatoets", + "datakey-type-server": "Serverattribuut (standaard)", + "datakey-type-shared": "Gedeeld attribuut", + "datakey-type-timeseries": "Tijdreeks", + "datakey-value-type": "Datatoets waarde type", + "datakey-value-type-string": "Tekenreeks", + "datakey-value-type-double": "Decimaal", + "datakey-value-type-integer": "Geheel getal", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "Boolean (Selectievakje)", + "datakey-value-type-boolean-switch": "Boolean (Schakelaar)", + "datakey-value-type-date-time": "Datum & Tijd", + "datakey-value-type-date": "Datum", + "datakey-value-type-time": "Tijd", + "datakey-value-type-select": "Keuzelijst", + "datakey-value-type-radio": "Radio", + "datakey-value-type-color": "Kleur", + "value-is-required": "Waarde is verplicht", + "ability-to-edit-attribute": "Mogelijkheid om attribuut te bewerken", + "ability-to-edit-attribute-editable": "Bewerkbaar (standaard)", + "ability-to-edit-attribute-disabled": "Uitgeschakeld", + "ability-to-edit-attribute-readonly": "Alleen-lezen", + "disable-on-datakey-name": "Uitschakelen bij false-waarde van andere datatoets (geef naam op)", + "field-appearance": "Veldweergave", + "appearance-fill": "Opvulling", + "appearance-outline": "Omlijning", + "subscript-sizing": "Subscript grootte", + "subscript-sizing-fixed": "Vast", + "subscript-sizing-dynamic": "Dynamisch", + "slide-toggle-settings": "Schakelaarinstellingen", + "slide-toggle-label-position": "Labelpositie van de schakelaar", + "slide-toggle-label-position-after": "Na", + "slide-toggle-label-position-before": "Voor", + "select-options": "Selecteer opties", + "no-select-options": "Geen selecteeropties geconfigureerd", + "add-select-option": "Voeg selecteeroptie toe", + "numeric-field-settings": "Instellingen voor numeriek veld", + "step-interval": "Stapinterval tussen waarden", + "error-messages": "Foutmeldingen", + "min-value-error-message": "'Minimale waarde' foutmelding", + "max-value-error-message": "'Maximale waarde' foutmelding", + "invalid-date-error-message": "'Ongeldige datum' foutmelding", + "invalid-JSON-error-message": "'Ongeldige JSON' foutmelding", + "icon-settings": "Pictograminstellingen", + "dialog-editor-settings": "Instellingen voor dialoogbewerker", + "use-custom-icon": "Gebruik aangepast pictogram", + "input-cell-icon": "Pictogram vóór invoerveld", + "value-conversion-settings": "Waardeconversie-instellingen", + "get-value-settings": "Waarde ophalen-instellingen", + "use-get-value-function": "Gebruik getValue-functie", + "get-value-function": "getValue-functie", + "set-value-settings": "Waarde instellen-instellingen", + "use-set-value-function": "Gebruik setValue-functie", + "set-value-function": "setValue-functie", + "json-invalid": "JSON-waarde heeft een ongeldig formaat", + "title": "Titel", + "cancel-button-label": "Label voor 'Annuleren'-knop", + "radio-button-settings": "Instellingen voor keuzerondje", + "color": "Kleur", + "columns": "Kolommen", + "radio-options": "Radio-opties", + "no-radio-options": "Geen radio-opties geconfigureerd", + "add-radio-option": "Voeg radio-optie toe", + "radio-label-position": "Labelpositie", + "radio-label-position-before": "Voor", + "radio-label-position-after": "Na" + }, + "invalid-qr-code-text": "Ongeldige invoertekst voor QR-code. Invoer moet van het type string zijn", + "qr-code": { + "use-qr-code-text-function": "Gebruik QR-code tekstfunctie", + "qr-code-text-pattern": "QR-code tekstpatroon (bijv. '${entityName} | ${keyName} - some text.')", + "qr-code-text-pattern-hint": "QR-code tekstpatroon gebruikt de waarde van de eerste gevonden sleutel in de entiteiten van de entiteitsalias.", + "qr-code-text-pattern-required": "QR-code tekstpatroon is verplicht.", + "qr-code-text-function": "QR-code tekstfunctie" + }, + "label-widget": { + "label-pattern": "Patroon", + "label-pattern-hint": "Tip: bijvoorbeeld 'Tekst ${keyName} eenheden.' of ${#<key index>} eenheden'", + "label-pattern-required": "Patroon is verplicht", + "label-position": "Positie (percentage t.o.v. achtergrond)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Achtergrondkleur", + "font-settings": "Lettertype-instellingen", + "background-image": "Achtergrondafbeelding", + "labels": "Labels", + "no-labels": "Geen labels geconfigureerd", + "add-label": "Label toevoegen" + }, + "navigation": { + "title": "Titel", + "navigation-path": "Navigatiepad", + "filter-type": "Filtertype", + "filter-type-all": "Alle items", + "filter-type-include": "Items opnemen", + "filter-type-exclude": "Items uitsluiten", + "items": "Items", + "enter-urls-to-filter": "Voer URL's in om te filteren..." + }, + "persistent-table": { + "rpc-id": "RPC-ID", + "message-type": "Berichttype", + "method": "Methode", + "params": "Parameters", + "created-time": "Aangemaakt op", + "expiration-time": "Vervaltijd", + "retries": "Herhalingen", + "status": "Status", + "filter": "Filter", + "refresh": "Vernieuwen", + "add": "RPC-verzoek toevoegen", + "details": "Details", + "delete": "Verwijderen", + "delete-request-title": "Persistent RPC-verzoek verwijderen", + "delete-request-text": "Weet je zeker dat je dit verzoek wilt verwijderen?", + "details-title": "Details RPC-ID:", + "additional-info": "Aanvullende informatie", + "response": "Antwoord", + "any-status": "Elke status", + "rpc-status-list": "RPC-statuslijst", + "no-request-prompt": "Geen verzoek om weer te geven", + "send-request": "Verzoek verzenden", + "add-title": "Persistent RPC-verzoek aanmaken", + "method-error": "Methode is verplicht.", + "timeout-error": "Minimale time-outwaarde is 5000 (5 seconden).", + "white-space-error": "Spaties zijn niet toegestaan.", + "rpc-status": { + "QUEUED": "IN WACHTRIJ", + "SENT": "VERZONDEN", + "DELIVERED": "GELEVERD", + "SUCCESSFUL": "GELUKT", + "TIMEOUT": "TIME-OUT", + "EXPIRED": "VERLOPEN", + "FAILED": "MISLUKT" + }, + "rpc-search-status-all": "ALLES", + "message-types": { + "false": "Tweewegs", + "true": "Eenwegs" + }, + "general-settings": "Algemene instellingen", + "enable-filter": "Filter inschakelen", + "enable-sticky-header": "Kop weergeven tijdens scrollen", + "enable-sticky-action": "Actiekolom weergeven tijdens scrollen", + "display-request-details": "Verzoekdetails weergeven", + "allow-send-request": "Sta RPC-verzoek toe", + "allow-delete-request": "Sta verwijderen van verzoek toe", + "columns-settings": "Kolominstellingen", + "display-columns": "Weer te geven kolommen", + "column": "Kolom", + "no-columns-found": "Geen kolommen gevonden", + "no-columns-matching": "'{{column}}' niet gevonden." + }, + "range-chart": { + "chart": "Grafiek", + "data-zoom": "Gegevenszoomen", + "range-chart-appearance": "Weergavebereik grafiek", + "range-colors": "Bereikkleuren", + "out-of-range-color": "Kleur buiten bereik", + "show-range-thresholds": "Bereikdrempels weergeven", + "range-thresholds-settings": "Instellingen voor bereikdrempels", + "fill-area": "Gebied vullen", + "fill-area-opacity": "Opaciteit van het gevulde gebied", + "range-chart-style": "Stijl van bereikgrafiek" + }, + "rpc": { + "value-settings": "Waardesinstellingen", + "initial-value": "Initiële waarde", + "retrieve-value-settings": "Ophalen aan/uit waarde instellingen", + "retrieve-value-method": "Waarde ophalen via methode", + "retrieve-value-method-none": "Niet ophalen", + "retrieve-value-method-rpc": "RPC-methode voor ophalen aanroepen", + "retrieve-value-method-attribute": "Abonneren op attribuut", + "retrieve-value-method-timeseries": "Abonneren op tijdreeks", + "attribute-value-key": "Attribuut sleutel", + "timeseries-value-key": "Tijdreeks sleutel", + "get-value-method": "RPC methode voor ophalen waarde", + "parse-value-function": "Waarde parsing functie", + "update-value-settings": "Waarde update instellingen", + "set-value-method": "RPC methode voor waarde instellen", + "convert-value-function": "Waarde conversie functie", + "rpc-settings": "RPC-instellingen", + "request-timeout": "RPC-verzoek time-out (ms)", + "persistent-rpc-settings": "Persistente RPC-instellingen", + "request-persistent": "Persistente RPC-verzoek", + "persistent-polling-interval": "Polling-interval (ms) voor RPC-respons", + "common-settings": "Algemene instellingen", + "switch-title": "Schakeltitel", + "show-on-off-labels": "Toon aan/uit labels", + "slide-toggle-label": "Schakelaarlabel", + "label-position": "Labelpositie", + "label-position-before": "Voor", + "label-position-after": "Na", + "slider-color": "Schuifregelaar kleur", + "slider-color-primary": "Primair", + "slider-color-accent": "Accent", + "slider-color-warn": "Waarschuwing", + "button-style": "Knopstijl", + "button-raised": "Verhoogde knop", + "button-primary": "Primaire kleur", + "button-background-color": "Achtergrondkleur van knop", + "button-text-color": "Tekstkleur van knop", + "widget-title": "Widgettitel", + "button-label": "Knoplabel", + "device-attribute-scope": "Apparaatattribuutscope", + "server-attribute": "Serverattribuut", + "shared-attribute": "Gedeeld attribuut", + "device-attribute-parameters": "Apparaatattribuutparameters", + "is-one-way-command": "Is eenrichtingscommando", + "rpc-method": "RPC-methode", + "rpc-method-params": "RPC-methode parameters", + "show-rpc-error": "RPC-uitvoeringsfout weergeven", + "led-title": "LED-titel", + "led-color": "LED-kleur", + "check-status-settings": "Statuscontrole-instellingen", + "perform-rpc-status-check": "RPC-statuscontrole uitvoeren", + "retrieve-led-status-value-method": "LED-status ophalen via methode", + "led-status-value-attribute": "Attribuut met LED-statuswaarde", + "led-status-value-timeseries": "Tijdreeks met LED-statuswaarde", + "check-status-method": "RPC-methode voor statuscontrole", + "parse-led-status-value-function": "LED-statuswaarde parsing functie", + "knob-title": "Knoptitel", + "min-value": "Minimale waarde", + "max-value": "Maximale waarde" + }, + "maps": { + "map-type": { + "type": "Kaarttype", + "map": "Kaart", + "image": "Afbeelding" + }, + "image": { + "image-source": "Afbeeldingsbron", + "image-source-image": "Afbeelding", + "image-source-entity-key": "Entiteitssleutel", + "source-entity-alias": "Alias van bronentiteit", + "image-url-key": "Afbeelding URL sleutel", + "image-url-key-required": "Afbeelding URL sleutel is verplicht" + }, + "control": { + "map-controls": "Kaartbediening", + "position": "Positie", + "position-topleft": "Linksboven", + "position-topright": "Rechtsboven", + "position-bottomleft": "Linksonder", + "position-bottomright": "Rechtsonder", + "zoom-actions": "Zoomacties", + "zoom-scroll": "Scrollen", + "zoom-double-click": "Dubbelklik", + "zoom-control-buttons": "Bedieningsknoppen", + "scale": "Schaal", + "scale-metric": "Metrisch", + "scale-imperial": "Imperiaal", + "switch-to-drag-mode-using-button": "Schakel naar sleepmodus via knop" + }, + "timeline": { + "control-panel": "Tijdlijnbedieningspaneel", + "time-step": "Tijdstap", + "speed-options": "Snelheidsopties", + "timestamp": "Tijdstempel", + "snap-to-real-location": "Vastzetten op werkelijke locatie", + "location-snap-filter-function": "Filterfunctie voor locatievergrendeling", + "no-trips-data-available": "Geen ritgegevens beschikbaar" + }, + "map-action": { + "map-action-buttons": "Kaartactieknoppen", + "label": "Label", + "icon": "Pictogram", + "color": "Kleur", + "action": "Actie", + "add-button": "Knop toevoegen", + "no-action-buttons-configured": "Geen actieknoppen geconfigureerd", + "remove-action-button": "Verwijder actieknop", + "map-action-button": "Kaartactieknop", + "button-requires": "Knop vereist label of pictogram" + }, + "common": { + "common-map-settings": "Algemene kaartinstellingen", + "fit-map-bounds": "Pas kaartweergave aan om alle markeringen te tonen", + "default-map-center-position": "Standaard centrale positie van de kaart", + "default-map-zoom-level": "Standaard zoomniveau van de kaart", + "entities-limit": "Beperking van het aantal te laden entiteiten" + }, + "layer": { + "label": "Label", + "layer": "Laag", + "layers": "Lagen", + "map-layers": "Kaartlagen", + "add-layer": "Laag toevoegen", + "layer-settings": "Laaginstellingen", + "remove-layer": "Laag verwijderen", + "no-layers": "Geen lagen geconfigureerd", + "roadmap": "Wegenkaart", + "satellite": "Satelliet", + "hybrid": "Hybride", + "reference": { + "reference-layer": "Referentielaag", + "no-layer": "Geen laag", + "openstreetmap-hybrid": "OpenStreetMap Hybride", + "world-edition-hybrid": "Wereldeditie Hybride", + "enhanced-contrast-hybrid": "Hybride met verbeterd contrast" + }, + "provider": { + "provider": "Provider", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WereldStraatKaart", + "esri-topo": "WereldTopoKaart", + "esri-imagery": "WereldBeeld", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "Wegenkaart", + "satellite": "Satelliet", + "hybrid": "Hybride", + "terrain": "Terrein" + }, + "here": { + "title": "HERE", + "normal-day": "Normale dag", + "normal-night": "Normale nacht", + "hybrid-day": "Hybride dag", + "terrain-day": "Terrein dag" + }, + "tencent": { + "title": "Tencent", + "normal": "Normaal", + "satellite": "Satelliet", + "terrain": "Terrein" + }, + "custom": { + "title": "Aangepast", + "tile-url": "Tegel-URL" + } + }, + "credentials": { + "credentials": "Inloggegevens", + "api-key": "API-sleutel" + } + }, + "overlays": { + "overlays": "Overlays", + "overlays-hint": "Configureer gegevensbronnen, uiterlijk, gedrag, bewerkingsopties en groepering voor kaartentiteiten", + "trips": "Reizen", + "markers": "Markeringen", + "polygons": "Polygonen", + "circles": "Cirkels" + }, + "data-layer": { + "source": "Bron", + "filter": "Filter", + "additional-data-keys": "Aanvullende gegevenssleutels", + "additional-datasources": "Aanvullende gegevensbronnen", + "additional-datasources-hint": "Gegevensbron voor toegang tot attributen of telemetrie van entiteiten die niet op de kaart worden weergegeven, bruikbaar in kaartoverlayfuncties.", + "more-datasources": "Meer gegevensbronnen", + "data-keys": "Gegevenssleutels", + "add-datasource": "Gegevensbron toevoegen", + "no-datasources": "Geen gegevensbronnen geconfigureerd", + "remove-datasource": "Gegevensbron verwijderen", + "behavior": "Gedrag", + "on-click": "Bij klikken", + "on-click-hint": "Actie die wordt uitgevoerd wanneer de gebruiker op het kaartitem klikt.", + "groups": "Groepen", + "groups-hint": "Lijst van groepsnamen toegewezen aan de overlay, gebruikt om de zichtbaarheid op de kaart te schakelen.", + "color": "Kleur", + "color-settings": "Kleurinstellingen", + "color-type-constant": "Constant", + "color-type-range": "Bereik", + "color-type-function": "Functie", + "color-range-source-key": "Kleurbereik bronkey", + "color-range-source-key-required": "Kleurbereik bronkey is vereist", + "color-range": "Kleurbereik", + "color-function": "Kleurfunctie", + "label": "Label", + "tooltip": "Tooltip", + "pattern-type-pattern": "Patroon", + "pattern-type-function": "Functie", + "label-pattern": "Label (voorbeelden: '${entityName}', '${entityName}: (Tekst ${keyName} eenheden.)')", + "label-function": "Labelfunctie", + "tooltip-pattern": "Tooltip (bijv. 'Tekst ${keyName} eenheden.' of Linktekst)", + "tooltip-function": "Tooltipfunctie", + "tooltip-trigger": "Tooltip-trigger", + "tooltip-trigger-click": "Tooltip tonen bij klik", + "tooltip-trigger-hover": "Tooltip tonen bij hover", + "auto-close-tooltips": "Tooltip automatisch sluiten", + "tooltip-offset": "Tooltip-offset", + "tooltip-offset-horizontal": "Horizontaal", + "tooltip-offset-vertical": "Verticaal", + "tooltip-tag-actions": "Tagacties", + "add-tooltip-tag-action": "Tagactie toevoegen", + "edit-tooltip-tag-action": "Tagactie bewerken", + "remove-tooltip-tag-action": "Tagactie verwijderen", + "action-add": "Toevoegen", + "action-edit": "Bewerken", + "action-move": "Verplaatsen", + "action-remove": "Verwijderen", + "edit-instruments": "Instrumenten", + "persist-location-attribute-scope": "Reikwijdte van attribuut om locatie op te slaan", + "enable-snapping": "Uitlijnen met andere punten inschakelen", + "enable-snapping-hint": "Lijnt automatisch nieuwe punten uit met bestaande vormen om tekenen eenvoudiger en nauwkeuriger te maken.", + "drag-drop-mode": "Sleepmodus", + "trip": { + "no-trips": "Geen reizen geconfigureerd", + "add-trip": "Reis toevoegen", + "trip-configuration": "Reisconfiguratie", + "remove-trip": "Reis verwijderen" + }, + "marker": { + "marker": "Marker", + "latitude-key": "Breedtegraad-key", + "longitude-key": "Lengtegraad-key", + "x-pos-key": "X-positie-key", + "y-pos-key": "Y-positie-key", + "latitude-key-required": "Breedtegraad-key is vereist", + "longitude-key-required": "Lengtegraad-key is vereist", + "x-pos-key-required": "X-positie-key is vereist", + "y-pos-key-required": "Y-positie-key is vereist", + "no-markers": "Geen markers geconfigureerd", + "add-marker": "Marker toevoegen", + "marker-configuration": "Markerconfiguratie", + "remove-marker": "Marker verwijderen", + "marker-type": "Markertype", + "marker-type-shape": "Vorm", + "marker-type-icon": "Pictogram", + "marker-type-image": "Afbeelding", + "shape": "Vorm", + "icon": "Pictogram", + "image": "Afbeelding", + "marker-shapes": "Markervormen", + "marker-icon": "Markerpictogram", + "marker-appearance": "Markeruiterlijk", + "marker-image": "Markerafbeelding", + "marker-image-type-image": "Afbeelding", + "marker-image-type-function": "Functie", + "custom-marker-image-size": "Aangepaste markerafbeeldingsgrootte", + "marker-image-function": "Markerafbeeldingfunctie", + "marker-images": "Markerafbeeldingen", + "marker-offset": "Markeroffset", + "offset-horizontal": "Horizontaal", + "offset-vertical": "Verticaal", + "rotate-marker": "Marker roteren", + "offset-angle": "Offsethoek", + "position-conversion": "Positieconversie", + "position-conversion-function": "Positieconversiefunctie, moet x,y-coördinaten tussen 0 en 1 als double teruggeven", + "clustering": { + "use-map-markers-clustering": "Gebruik clustering van kaartmarkers", + "zoom-on-cluster-click": "Inzoomen bij klikken op cluster", + "max-zoom": "Maximale zoomniveau waarbij een marker deel kan uitmaken van een cluster (0 - 18)", + "max-radius": "Maximale straal die een cluster dekt", + "zoom-animation": "Animatie van markers bij inzoomen", + "bounds-on-cluster-mouse-over": "Markergrenzen bij hoveren over cluster", + "spiderfy-max-zoom-level": "Spiderfy op maximaal zoomniveau (om alle clustermarkers te tonen)", + "load-optimization": "Laadoptimalisatie", + "chunked-load": "Gebruik chunks om markers toe te voegen zodat de pagina niet vastloopt", + "lazy-load": "Gebruik lazy load om markers toe te voegen", + "use-cluster-marker-color-function": "Gebruik kleurfunctie voor clustermarkers", + "marker-color-function": "Kleurfunctie voor marker" + }, + "edit": "Marker bewerken", + "remove-marker-for": "Verwijder marker voor '{{entityName}}'", + "place-marker": "Marker plaatsen", + "place-marker-hint": "Klik om marker te plaatsen", + "place-marker-hint-with-entity": "Klik om entiteit '{{entityName}}' te plaatsen" + }, + "path": { + "path": "Pad", + "path-decorator": "Paddecoratie", + "decorator-symbol": "Decoratiesymbool", + "decorator-symbol-arrow-head": "Pijl", + "decorator-symbol-dash": "Streep", + "decorator-arrangement": "Decoratieplaatsing", + "decorator-offset": "Begin", + "decorator-end-offset": "Einde", + "decorator-repeat": "Herhaal" + }, + "points": { + "points": "Punten", + "point-tooltip": "Tooltip bij punt" + }, + "shape": { + "fill": "Vulling", + "fill-type-color": "Kleur", + "fill-type-stripe": "Streep", + "fill-type-image": "Afbeelding", + "color": "Kleur", + "stripe": "Streep", + "image": "Afbeelding", + "stroke": "Omtrek", + "fill-image": "Vulafbeelding", + "fill-image-type-image": "Afbeelding", + "fill-image-type-function": "Functie", + "preserve-aspect-ratio": "Beeldverhouding behouden", + "opacity": "Dekking", + "angle": "Rotatiehoek", + "scale": "Schaal", + "fill-image-function": "Vulafbeeldingfunctie voor vorm", + "fill-images": "Vulafbeeldingen voor vorm", + "stripe-pattern": "Streeppatroon", + "first-stripe": "Eerste streep", + "second-stripe": "Tweede streep" + }, + "polygon": { + "polygon-key": "Polygon sleutel", + "polygon-key-required": "Polygon sleutel is verplicht", + "no-polygons": "Geen polygonen geconfigureerd", + "add-polygon": "Polygon toevoegen", + "polygon-configuration": "Polygonconfiguratie", + "remove-polygon": "Verwijder polygon", + "edit": "Bewerk polygon", + "remove-polygon-for": "Verwijder polygon voor '{{entityName}}'", + "cut": "Snijd polygongebied", + "rotate": "Roteer polygon", + "draw-rectangle": "Rechthoek tekenen", + "draw-polygon": "Polygon tekenen", + "polygon-place-first-point-cut-hint": "Klik om het eerste punt te plaatsen", + "continue-polygon-cut-hint": "Klik om verder te tekenen", + "finish-polygon-cut-hint": "Klik op eerste marker om te voltooien en op te slaan", + "polygon-place-first-point-hint": "Polygon: klik om eerste punt te plaatsen", + "polygon-place-first-point-hint-with-entity": "Polygon voor '{{entityName}}': klik om eerste punt te plaatsen", + "continue-polygon-hint": "Polygon: klik om verder te tekenen", + "continue-polygon-hint-with-entity": "Polygon voor '{{entityName}}': klik om verder te tekenen", + "finish-polygon-hint": "Polygon: klik op eerste marker om te voltooien", + "finish-polygon-hint-with-entity": "Polygon voor '{{entityName}}': klik op eerste marker om te voltooien en op te slaan", + "rectangle-place-first-point-hint": "Rechthoek: klik om eerste punt te plaatsen", + "rectangle-place-first-point-hint-with-entity": "Rechthoek voor '{{entityName}}': klik om eerste punt te plaatsen", + "finish-rectangle-hint": "Rechthoek: klik om tekenen te voltooien", + "finish-rectangle-hint-with-entity": "Rechthoek voor '{{entityName}}': klik om te voltooien en op te slaan" + }, + "circle": { + "circle-key": "Cirkel sleutel", + "circle-key-required": "Cirkel sleutel is verplicht", + "no-circles": "Geen cirkels geconfigureerd", + "add-circle": "Cirkel toevoegen", + "circle-configuration": "Cirkelconfiguratie", + "remove-circle": "Verwijder cirkel", + "edit": "Bewerk cirkel", + "remove-circle-for": "Verwijder cirkel voor '{{entityName}}'", + "draw-circle": "Teken cirkel", + "place-circle-center-hint-with-entity": "Cirkel voor '{{entityName}}': klik om middelpunt te plaatsen", + "place-circle-center-hint": "Cirkel: klik om middelpunt te plaatsen", + "finish-circle-hint-with-entity": "Cirkel voor '{{entityName}}': klik om te voltooien en op te slaan", + "finish-circle-hint": "Cirkel: klik om tekenen te voltooien" + }, + "select-entity": "Selecteer entiteit", + "select-entity-hint": "Tip: klik op de kaart na selectie om positie in te stellen" + }, + "select-entity": "Selecteer entiteit", + "select-entity-hint": "Tip: klik na selectie op de kaart om de positie in te stellen", + "tooltips": { + "placeMarker": "Klik om entiteit '{{entityName}}' te plaatsen", + "firstVertex": "Polygon voor '{{entityName}}': klik om eerste punt te plaatsen", + "firstVertex-cut": "Klik om eerste punt te plaatsen", + "continueLine": "Polygon voor '{{entityName}}': klik om verder te tekenen", + "continueLine-cut": "Klik om verder te tekenen", + "finishLine": "Klik op bestaande marker om te voltooien", + "finishPoly": "Polygon voor '{{entityName}}': klik op eerste marker om te voltooien en op te slaan", + "finishPoly-cut": "Klik op eerste marker om te voltooien en op te slaan", + "finishRect": "Polygon voor '{{entityName}}': klik om te voltooien en op te slaan", + "startCircle": "Cirkel voor '{{entityName}}': klik om middelpunt te plaatsen", + "finishCircle": "Cirkel voor '{{entityName}}': klik om te voltooien", + "placeCircleMarker": "Klik om cirkelmarker te plaatsen" + }, + "actions": { + "finish": "Voltooien", + "cancel": "Annuleren", + "removeLastVertex": "Verwijder laatste punt" + }, + "buttonTitles": { + "drawMarkerButton": "Entiteit plaatsen", + "drawPolyButton": "Maak polygon", + "drawLineButton": "Maak polyline", + "drawCircleButton": "Maak cirkel", + "drawRectButton": "Maak rechthoek", + "editButton": "Bewerkmodus", + "dragButton": "Slepen-modus", + "cutButton": "Snijd polygongebied", + "deleteButton": "Verwijderen", + "drawCircleMarkerButton": "Maak cirkelmarker", + "rotateButton": "Roteer polygon" + }, + "map-provider-settings": "Kaartproviderinstellingen", + "map-provider": "Kaartprovider", + "map-provider-google": "Google Maps", + "map-provider-openstreet": "OpenStreet Maps", + "map-provider-here": "HERE Maps", + "map-provider-image": "Afbeelding kaart", + "map-provider-tencent": "Tencent Maps", + "openstreet-provider": "OpenStreet kaartprovider", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Standaard)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Gebruik aangepaste provider", + "custom-provider-tile-url": "Tegel-URL van aangepaste provider", + "google-maps-api-key": "Google Maps API-sleutel", + "default-map-type": "Standaard kaarttype", + "google-map-type-roadmap": "Wegenkaart", + "google-map-type-satelite": "Satelliet", + "google-map-type-hybrid": "Hybride", + "google-map-type-terrain": "Terrein", + "map-layer": "Kaartlaag", + "here-map-normal-day": "HERE.normalDay (Standaard)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Inloggegevens", + "here-app-id": "HERE app ID", + "here-app-code": "HERE app-code", + "here-api-key": "HERE API-sleutel", + "here-use-new-version-api-3": "Gebruik API-versie 3", + "tencent-maps-api-key": "Tencent Maps API-sleutel", + "tencent-map-type-roadmap": "Wegenkaart", + "tencent-map-type-satelite": "Satelliet", + "tencent-map-type-hybrid": "Hybride", + "image-map-background": "Afbeelding als kaartachtergrond", + "image-map-background-from-entity-attribute": "Gebruik afbeelding van entiteitattribuut als kaartachtergrond", + "image-url-source-entity-alias": "Alias van entiteit met afbeeldings-URL", + "image-url-source-entity-attribute": "Attribuut van entiteit met afbeeldings-URL", + "common-map-settings": "Algemene kaartinstellingen", + "x-pos-key-name": "Naam X-positie sleutel", + "y-pos-key-name": "Naam Y-positie sleutel", + "latitude-key-name": "Naam van latitude sleutel", + "longitude-key-name": "Naam van longitude sleutel", + "default-map-zoom-level": "Standaard zoomniveau kaart (0 - 20)", + "default-map-center-position": "Standaard middenpositie kaart (0,0)", + "disable-scroll-zooming": "Scrollzoom uitschakelen", + "disable-double-click-zooming": "Dubbelklik-zoom uitschakelen", + "disable-zoom-control-buttons": "Zoomknoppen uitschakelen", + "fit-map-bounds": "Pas kaartgrenzen aan om alle markeringen te tonen", + "use-default-map-center-position": "Gebruik standaard kaartmiddenpositie", + "entities-limit": "Limiet op te laden entiteiten", + "markers-settings": "Markering instellingen", + "marker-offset-x": "X-offset markering relatief aan breedte", + "marker-offset-y": "Y-offset markering relatief aan hoogte", + "position-function": "Functie voor positierekening, moet x,y-coördinaten (0-1) teruggeven", + "draggable-marker": "Versleepbare markering", + "label": "Label", + "show-label": "Toon label", + "use-label-function": "Gebruik labelfunctie", + "label-pattern": "Label (voorbeeld: '${entityName}', '${entityName}: (Tekst ${keyName} eenheden.)')", + "label-function": "Labelfunctie", + "tooltip": "Tooltip", + "show-tooltip": "Toon tooltip", + "show-tooltip-action": "Actie voor tooltip weergeven", + "show-tooltip-action-click": "Toon tooltip bij klik (Standaard)", + "show-tooltip-action-hover": "Toon tooltip bij zweven", + "auto-close-tooltips": "Sluit tooltips automatisch", + "use-tooltip-function": "Gebruik tooltipfunctie", + "tooltip-pattern": "Tooltip (bijv. 'Tekst ${keyName} eenheden.' of Linktekst)", + "tooltip-function": "Tooltipfunctie", + "tooltip-offset-x": "X-offset tooltip relatief aan anker", + "tooltip-offset-y": "Y-offset tooltip relatief aan anker", + "color": "Kleur", + "use-color-function": "Gebruik kleurfunctie", + "color-function": "Kleurfunctie", + "marker-image": "Markering afbeelding", + "use-marker-image-function": "Gebruik markeringsafbeeldingsfunctie", + "custom-marker-image": "Aangepaste markeringsafbeelding", + "custom-marker-image-size": "Afmeting aangepaste markeringsafbeelding (px)", + "marker-image-function": "Markeringsafbeeldingsfunctie", + "marker-images": "Markeringsafbeeldingen", + "polygon-settings": "Polygooninstellingen", + "show-polygon": "Toon polygoon", + "polygon-key-name": "Naam polygoonsleutel", + "enable-polygon-edit": "Bewerkbare polygoon inschakelen", + "polygon-label": "Polygoonlabel", + "show-polygon-label": "Toon polygoonlabel", + "use-polygon-label-function": "Gebruik polygoonlabelfunctie", + "polygon-label-pattern": "Polygoonlabel (bijv. '${entityName}', '${entityName}: (Tekst ${keyName} eenheden.)')", + "polygon-label-function": "Polygoonlabelfunctie", + "polygon-tooltip": "Polygoontooltip", + "show-polygon-tooltip": "Toon polygoontooltip", + "auto-close-polygon-tooltips": "Sluit polygoontooltips automatisch", + "use-polygon-tooltip-function": "Gebruik polygoontooltipfunctie", + "polygon-tooltip-pattern": "Tooltip (bijv. 'Tekst ${keyName} eenheden.' of Linktekst)", + "polygon-tooltip-function": "Polygoontooltipfunctie", + "polygon-color": "Polygoonkleur", + "polygon-opacity": "Polygoontransparantie", + "use-polygon-color-function": "Gebruik polygoonkleurfunctie", + "polygon-color-function": "Polygoonkleurfunctie", + "polygon-stroke": "Polygoonrand", + "stroke-color": "Randkleur", + "stroke-opacity": "Randtransparantie", + "stroke-weight": "Randdikte", + "use-polygon-stroke-color-function": "Gebruik polygoonrandkleurfunctie", + "polygon-stroke-color-function": "Polygoonrandkleurfunctie", + "circle-settings": "Cirkelinstellingen", + "show-circle": "Toon cirkel", + "circle-key-name": "Cirkelsleutelnaam", + "enable-circle-edit": "Cirkelbewerking inschakelen", + "circle-label": "Cirkellabel", + "show-circle-label": "Toon cirkellabel", + "use-circle-label-function": "Gebruik cirkellabelfunctie", + "circle-label-pattern": "Cirkellabel (voorbeeld: '${entityName}', '${entityName}: (Tekst ${keyName} eenheden.)')", + "circle-label-function": "Cirkellabelfunctie", + "circle-tooltip": "Cirkeltooltip", + "show-circle-tooltip": "Toon cirkeltooltip", + "auto-close-circle-tooltips": "Sluit cirkeltooltips automatisch", + "use-circle-tooltip-function": "Gebruik cirkeltooltipfunctie", + "circle-tooltip-pattern": "Tooltip (bijv. 'Tekst ${keyName} eenheden.' of Linktekst)", + "circle-tooltip-function": "Cirkeltooltipfunctie", + "circle-fill-color": "Cirkelvulkleur", + "circle-fill-color-opacity": "Transparantie vulkleur cirkel", + "use-circle-fill-color-function": "Gebruik cirkelvulkleurfunctie", + "circle-fill-color-function": "Cirkelvulkleurfunctie", + "circle-stroke": "Cirkelrand", + "use-circle-stroke-color-function": "Gebruik cirkelrandkleurfunctie", + "circle-stroke-color-function": "Cirkelrandkleurfunctie", + "markers-clustering-settings": "Instellingen voor groepering van markeringen", + "use-map-markers-clustering": "Gebruik groepering van kaartmarkeringen", + "zoom-on-cluster-click": "Zoom bij klikken op een cluster", + "max-cluster-zoom": "Maximale zoomniveau voor clustering (0 - 18)", + "max-cluster-radius-pixels": "Maximale straal die een cluster dekt in pixels", + "cluster-zoom-animation": "Toon animatie bij inzoomen op markeringen", + "show-markers-bounds-on-cluster-mouse-over": "Toon grenzen van markeringen bij zweven over cluster", + "spiderfy-max-zoom-level": "Spiderfy op maximale zoomniveau (om alle markeringen te zien)", + "load-optimization": "Laadoptimalisatie", + "cluster-chunked-loading": "Gebruik batches om markeringen toe te voegen", + "cluster-markers-lazy-load": "Gebruik lazy loading voor markeringen", + "editor-settings": "Editorinstellingen", + "enable-snapping": "Snapping naar andere punten inschakelen", + "init-draggable-mode": "Kaart starten in sleepmodus", + "hide-all-edit-buttons": "Verberg alle bewerkingsknoppen", + "hide-draw-buttons": "Verberg tekentools", + "hide-edit-buttons": "Verberg bewerkingsknoppen", + "hide-remove-button": "Verberg verwijderknop", + "route-map-settings": "Routekaartinstellingen", + "trip-animation-settings": "Instellingen voor route-animatie", + "normalization-step": "Normalisatie stap (ms)", + "tooltip-background-color": "Achtergrondkleur tooltip", + "tooltip-font-color": "Letterkleur tooltip", + "tooltip-opacity": "Tooltiptransparantie (0-1)", + "auto-close-tooltip": "Tooltip automatisch sluiten", + "rotation-angle": "Extra rotatiehoek voor markering (graden)", + "path-settings": "Padinstellingen", + "path-color": "Padkleur", + "use-path-color-function": "Gebruik padkleurfunctie", + "path-color-function": "Padkleurfunctie", + "path-decorator": "Paddecoratie", + "use-path-decorator": "Gebruik paddecoratie", + "decorator-symbol": "Decoratiesymbool", + "decorator-symbol-arrow-head": "Pijl", + "decorator-symbol-dash": "Streep", + "decorator-symbol-size": "Grootte decoratiesymbool (px)", + "use-path-decorator-custom-color": "Gebruik aangepaste kleur voor decoratiesymbool", + "decorator-custom-color": "Aangepaste decoratiekleur", + "decorator-offset": "Decoratie-offset", + "end-decorator-offset": "Einde decoratie-offset", + "decorator-repeat": "Herhaling decoratie", + "points-settings": "Puntinstellingen", + "show-points": "Toon punten", + "point-color": "Puntkleur", + "point-size": "Puntgrootte (px)", + "use-point-color-function": "Gebruik puntkleurfunctie", + "point-color-function": "Puntkleurfunctie", + "use-point-as-anchor": "Gebruik punt als anker", + "point-as-anchor-function": "Functie voor punt als anker", + "independent-point-tooltip": "Onafhankelijke punttooltip", + "clustering-markers": "Markeringen groeperen", + "use-icon-create-function": "Gebruik markeringskleurfunctie", + "marker-color-function": "Markeringskleurfunctie" + }, + "markdown": { + "use-markdown-text-function": "Gebruik markdown/HTML-waardefunctie", + "markdown-text-function": "Markdown/HTML-waardefunctie", + "markdown-text-pattern": "Markdown/HTML-patroon (markdown of HTML met variabelen, bijv. '${entityName} of ${keyName} - wat tekst.')", + "apply-default-markdown-style": "Pas standaard markdown-stijl toe", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label": "Label", + "label-position": "Labelpositie", + "label-position-left": "Links", + "label-position-top": "Boven" + }, + "single-switch": { + "behavior": "Gedrag", + "layout": "Indeling", + "layout-right": "Rechts", + "layout-left": "Links", + "layout-centered": "Gecentreerd", + "auto-scale": "Automatisch schalen", + "label": "Label", + "icon": "Icoon", + "switch-color": "Schakelkleur", + "on": "Aan", + "off": "Uit", + "disabled": "Uitgeschakeld", + "tumbler-color": "Schuifkleur", + "on-label": "Label aan", + "off-label": "Label uit", + "switch": "Schakelaar" + }, + "slider": { + "behavior": "Gedrag", + "initial-value": "Initiële waarde", + "initial-value-hint": "Actie om de initiële waarde van de schuifregelaar op te halen.", + "on-value-change": "Bij waarde wijziging", + "on-value-change-hint": "Actie die wordt uitgevoerd wanneer de waarde van de schuifregelaar wordt gewijzigd.", + "layout": "Indeling", + "layout-default": "Standaard", + "layout-extended": "Uitgebreid", + "layout-simplified": "Vereenvoudigd", + "auto-scale": "Automatisch schalen", + "icon": "Icoon", + "value": "Waarde", + "range": "Bereik", + "min": "min", + "max": "max", + "range-ticks": "Bereik-stappen", + "tick-marks": "Stapmarkeringen", + "colors": "Kleuren", + "main": "Hoofd", + "background": "Achtergrond", + "left-icon": "Linkericoon", + "right-icon": "Rechtericoon", + "slider": "Schuifregelaar" + }, + "value-card": { + "layout": "Indeling", + "layout-square": "Vierkant", + "layout-vertical": "Verticaal", + "layout-centered": "Gecentreerd", + "layout-simplified": "Vereenvoudigd", + "layout-horizontal": "Horizontaal", + "layout-horizontal-reversed": "Horizontaal omgekeerd", + "label": "Label", + "icon": "Icoon", + "value": "Waarde", + "date": "Datum", + "value-card-style": "Waardekaartstijl", + "auto-scale": "Automatisch schalen" + }, + "label-card": { + "auto-scale": "Automatisch schalen", + "label": "Label", + "icon": "Icoon", + "label-card-style": "Labelkaartstijl" + }, + "label-value-card": { + "value": "Waarde", + "label-value-card-style": "Label- & waardekaartstijl" + }, + "liquid-level-card": { + "layout-simple": "Eenvoudig", + "layout-percentage": "Percentage", + "layout-absolute": "Absoluut", + "layout": "Indeling", + "background-overlay": "Waarde achtergrond overlay", + "total-volume": "Totaal volume", + "total-volume-units": "Totaal volume-eenheden", + "tank": "Tank", + "shape": "Vorm", + "datasource-units": "Bron-eenheden", + "widget-units": "Widget-eenheden", + "decimals": "Decimalen", + "liquid": "Vloeistof", + "liquid-color": "Vloeistofkleur", + "value": "Waarde", + "value-font": "Waardefont", + "level": "Niveau", + "last-update": "Laatste update", + "shape-by-attribute": "Tankvorm instellen via attribuutnaam", + "tooltip-background": "Achtergrondkleur", + "background-blur": "Achtergrondvervaging", + "tank-color": "Tankkleur", + "static": "Statisch", + "see-examples": "Zie voorbeelden", + "attribute": "Attribuut", + "shape-type": "Type", + "v-oval": "Verticaal ovaal", + "v-cylinder": "Verticale cilinder", + "v-capsule": "Verticale capsule", + "rectangle": "Rechthoek", + "h-oval": "Horizontaal ovaal", + "h-ellipse": "Horizontale ellips", + "h-dish-ends": "Horizontale afgeronde uiteinden", + "h-cylinder": "Horizontale cilinder", + "h-capsule": "Horizontale capsule", + "h-elliptical_2_1": "Horizontale 2:1 ellips", + "icon": "Kaarticoon", + "title": "Kaarttitel", + "units": "Eenheden", + "color-and-font": "Kleur en lettertype", + "shape-attribute-name": "Attribuutnaam", + "total-volume-required": "Totaal volume is vereist.", + "attribute-name-required": "Attribuutnaam is vereist.", + "attribute-key-not-set": "Attribuut '{{attributeName}}' is niet ingesteld", + "attribute-key-invalid": "Attribuut '{{attributeName}}' is ongeldig" + }, + "aggregated-value-card": { + "subtitle": "Ondertitel", + "chart": "Grafiek", + "values": "Waarden", + "value-appearance": "Weergave van waarde", + "position": "Positie", + "position-center": "Midden", + "position-right-top": "Rechtsboven", + "position-right-bottom": "Rechtsonder", + "position-left-top": "Linksboven", + "position-left-bottom": "Linksonder", + "font": "Lettertype", + "color": "Kleur", + "arrow": "Pijl", + "display-up-down-arrow": "Toon omhoog/omlaag-pijl", + "add-value": "Waarde toevoegen", + "remove-value": "Waarde verwijderen", + "no-values": "Geen waarden geconfigureerd", + "aggregation": "Aggregatie", + "aggregated-value-card-style": "Samengevoegde waarde kaartstijl", + "auto-scale": "Automatisch schalen" + }, + "value-chart-card": { + "layout": "Indeling", + "layout-left": "Links", + "layout-right": "Rechts", + "auto-scale": "Automatisch schalen", + "icon": "Icoon", + "value": "Waarde", + "chart": "Grafiek", + "value-chart-card-style": "Stijl van waarde-grafiekkaart" + }, + "progress-bar": { + "layout": "Indeling", + "layout-default": "Standaard", + "layout-simplified": "Vereenvoudigd", + "auto-scale": "Automatisch schalen", + "icon": "Icoon", + "value": "Waarde", + "range": "Bereik", + "min": "min", + "max": "max", + "range-ticks": "Tikken binnen bereik", + "bar": "Balk", + "bar-color": "Balkkleur", + "bar-background": "Achtergrond van de balk", + "progress-bar-card-style": "Stijl van voortgangsbalkkaart" + }, + "notification": { + "max-notification-display": "Maximaal aantal meldingen om weer te geven", + "counter": "Teller", + "counter-hint": "Teller wordt weergegeven als \"Widgettitel\" is ingeschakeld", + "icon": "Icoon", + "counter-value": "Waarde", + "counter-color": "Kleur", + "notification-button": "Meldingsknoppen", + "button-view-all": "Bekijk alles", + "button-filter": "Filter", + "type-filter": "Typefilter", + "button-mark-read": "Markeer alles als gelezen", + "notification-types": "Meldingstypes", + "notification-type": "Meldingstype", + "search-type": "Zoektype", + "any-type": "Elk type" + }, + "alarm-count": { + "alarm-count-card-style": "Stijl van alarmtellerkaart" + }, + "entity-count": { + "entity-count-card-style": "Stijl van entiteittellerkaart" + }, + "count": { + "layout": "Indeling", + "layout-column": "Kolom", + "layout-row": "Rij", + "label": "Label", + "icon": "Icoon", + "icon-background": "Achtergrond van icoon", + "value": "Waarde", + "chevron": "Chevron", + "auto-scale": "Automatisch schalen" + }, + "table": { + "common-table-settings": "Algemene tabelinstellingen", + "enable-search": "Zoeken inschakelen", + "enable-sticky-header": "Kop altijd weergeven", + "enable-sticky-action": "Actiekolom altijd weergeven", + "hidden-cell-button-display-mode": "Weergavemodus voor verborgen knopacties in cel", + "show-empty-space-hidden-action": "Lege ruimte tonen in plaats van verborgen knopactie", + "dont-reserve-space-hidden-action": "Geen ruimte reserveren voor verborgen actieknoppen", + "display-timestamp": "Tijdstempel", + "display-pagination": "Paginering weergeven", + "default-page-size": "Standaard paginagrootte", + "page-step-settings": "Instellingen paginastappen", + "page-step-count": "Aantal stappen", + "page-step-increment": "Stapincremente", + "page-step-count-format-message": "Moet een geheel getal zijn tussen 1 en 100.", + "page-step-increment-format-message": "Moet een geheel getal zijn, groter dan of gelijk aan 1.", + "use-entity-label-tab-name": "Gebruik entiteitlabel in tabbladnaam", + "hide-empty-lines": "Lege regels verbergen", + "row-style": "Rijstijl", + "use-row-style-function": "Gebruik rijstijlfunctie", + "row-style-function": "Rijstijlfunctie", + "cell-style": "Celstijl", + "use-cell-style-function": "Gebruik celstijlfunctie", + "cell-style-function": "Celstijlfunctie", + "cell-content": "Celinhoud", + "use-cell-content-function": "Gebruik celinhoudfunctie", + "cell-content-function": "Celinhoudfunctie", + "show-latest-data-column": "Laatste gegevenskolom tonen", + "latest-data-column-order": "Volgorde van laatste gegevenskolom", + "entities-table-title": "Titel van entiteitentabel", + "enable-select-column-display": "Kolomselectie inschakelen", + "display-entity-name": "Kolom met entiteitsnaam weergeven", + "entity-name-column-title": "Titel kolom entiteitsnaam", + "display-entity-label": "Kolom met entiteitlabel weergeven", + "entity-label-column-title": "Titel kolom entiteitlabel", + "display-entity-type": "Kolom met entiteitstype weergeven", + "default-sort-order": "Standaard sorteervolgorde", + "custom-title": "Aangepaste koptekst", + "column-width": "Kolombreedte (px of %)", + "default-column-visibility": "Standaard kolomzichtbaarheid", + "column-visibility-visible": "Zichtbaar", + "column-visibility-hidden": "Verborgen", + "column-visibility-hidden-mobile": "Verborgen op mobiel", + "column-selection-to-display": "Kolomselectie in 'Te tonen kolommen'", + "column-selection-to-display-enabled": "Ingeschakeld", + "column-selection-to-display-disabled": "Uitgeschakeld", + "alarms-table-title": "Titel van alarmtabel", + "enable-alarms-selection": "Alarmselectie inschakelen", + "enable-alarms-search": "Alarm zoeken inschakelen", + "enable-alarm-filter": "Alarmfilter inschakelen", + "display-alarm-details": "Alarmdetails tonen", + "allow-alarms-ack": "Erkenning van alarmen toestaan", + "allow-alarms-clear": "Opruiming van alarmen toestaan", + "display-alarm-activity": "Alarmactiviteit weergeven", + "allow-alarms-assign": "Toewijzing van alarmen toestaan", + "columns": "Kolommen", + "column-settings": "Kolominstellingen", + "remove-column": "Kolom verwijderen", + "add-column": "Kolom toevoegen", + "no-columns": "Geen kolommen geconfigureerd", + "columns-to-display": "Te tonen kolommen", + "table-header": "Tabelkop", + "header-buttons": "Koptekstknoppen", + "table-buttons": "Tabelknoppen", + "pagination": "Paginering", + "rows": "Rijen", + "timeseries-column-error": "Ten minste één tijdreekskolom moet worden opgegeven", + "alarm-column-error": "Ten minste één alarmkolom moet worden opgegeven", + "table-tabs": "Tabeltabs", + "show-cell-actions-menu-mobile": "Toon keuzemenu celacties in mobiele modus", + "disable-sorting": "Sortering uitschakelen" + }, + "latest-chart": { + "total": "Totaal", + "auto-scale": "Automatisch schalen", + "clockwise-layout": "Layout met de klok mee", + "sort-series": "Series sorteren op label", + "tooltip-value-type-absolute": "Absoluut", + "tooltip-value-type-percentage": "Percentage" + }, + "pie-chart": { + "pie-chart-appearance": "Uiterlijk van cirkeldiagram", + "label": "Label", + "border": "Rand", + "radius": "Straal", + "pie-chart-card-style": "Stijl van cirkeldiagramkaart" + }, + "radar-chart": { + "radar-appearance": "Uiterlijk van radardiagram", + "shape": "Vorm", + "shape-polygon": "Veelhoek", + "shape-circle": "Cirkel", + "color": "Kleur", + "line": "Lijn", + "points": "Punten", + "points-label": "Puntlabel", + "radar-axis": "Radar-as", + "axis-label": "Aslabel", + "ticks-label": "Label van streepjes", + "radar-chart-style": "Stijl van radardiagram" + }, + "time-series-chart": { + "chart": "Diagram", + "chart-style": "Diagramstijl", + "data-zoom": "Gegevens inzoomen", + "stack-mode": "Stapelmethode", + "stack-mode-hint": "Stapelt series in het diagram. Series met dezelfde eenheid worden boven elkaar geplaatst.", + "axes": "Assen", + "y-axes": "Y-assen", + "line-type": "Lijntype", + "line-width": "Lijnbreedte", + "type-line": "Lijn", + "type-bar": "Staaf", + "type-point": "Punt", + "no-aggregation-bar-width-strategy": "Staafbreedtestrategie voor niet-geaggregeerde gegevens", + "no-aggregation-bar-width-strategy-group": "Groep", + "no-aggregation-bar-width-strategy-separate": "Gescheiden", + "bar-group-width": "Staafgroepbreedte", + "bar-width": "Staafbreedte", + "bar-width-relative": "Percentage van tijdsvenster", + "bar-width-absolute": "Absoluut (ms)", + "comparison": { + "comparison": "Vergelijking", + "comparison-hint": "Vergelijking werkt alleen met historische gegevens!", + "show": "Tonen", + "settings": "Instellingen vergelijking", + "show-values-for-comparison": "Toon historische gegevens voor vergelijking", + "comparison-values-label": "Label van vergelijkingssleutel", + "comparison-values-label-auto": "Automatisch", + "comparison-data-color": "Kleur van vergelijkingsgegevens" + }, + "threshold": { + "thresholds": "Drempelwaarden", + "source": "Bron", + "key-value": "Sleutel / Waarde", + "no-thresholds": "Geen drempelwaarden geconfigureerd", + "add-threshold": "Drempel toevoegen", + "type-constant": "Constante", + "type-latest-key": "Sleutel", + "type-entity": "Entiteit", + "threshold-settings": "Instellingen drempelwaarde", + "remove-threshold": "Drempel verwijderen", + "threshold-value-required": "Drempelwaarde is vereist.", + "key-required": "Sleutel is vereist.", + "entity-key-required": "Entiteitssleutel is vereist.", + "line-appearance": "Lijnweergave", + "line-color": "Lijnkleur", + "start-symbol": "Startsymbool", + "end-symbol": "Eindsymbool", + "symbol-size": "Grootte", + "label": "Label", + "label-position-start": "Start", + "label-position-middle": "Midden", + "label-position-end": "Einde", + "label-position-inside-start": "Binnenkant begin", + "label-position-inside-start-top": "Binnenkant begin boven", + "label-position-inside-start-bottom": "Binnenkant begin onder", + "label-position-inside-middle": "Binnenkant midden", + "label-position-inside-middle-top": "Binnenkant midden boven", + "label-position-inside-middle-bottom": "Binnenkant midden onder", + "label-position-inside-end": "Binnenkant einde", + "label-position-inside-end-top": "Binnenkant einde boven", + "label-position-inside-end-bottom": "Binnenkant einde onder", + "label-background": "Labelachtergrond" + }, + "state": { + "states": "Toestanden", + "label": "Label", + "ticks-value": "Waarde van streepjes", + "source": "Bron", + "value-range": "Waarde / Bereik", + "no-states": "Geen toestanden geconfigureerd", + "add-state": "Toestand toevoegen", + "type-constant": "Constante", + "type-range": "Bereik", + "from": "Van", + "to": "Tot", + "remove-state": "Toestand verwijderen" + }, + "grid": { + "grid": "Raster", + "background-color": "Achtergrondkleur", + "border": "Rand" + }, + "axis": { + "axes": "Assen", + "x-axis": "X-as", + "y-axis": "Y-as", + "y-axis-settings": "Y-as instellingen", + "comparison-x-axis-settings": "Vergelijkings-X-as instellingen", + "remove-y-axis": "Y-as verwijderen", + "id": "Id", + "label": "Label", + "position": "Positie", + "position-left": "Links", + "position-right": "Rechts", + "position-top": "Boven", + "position-bottom": "Onder", + "tick-labels": "Streepjeslabels", + "ticks-formatter-function": "Streepjes formatter functie", + "ticks-generator-function": "Streepjes generator functie", + "show-ticks": "Streepjes tonen", + "show-line": "Lijn tonen", + "show-split-lines": "Scheidingslijnen tonen", + "show-split-lines-x-axis-hint": "Indien ingeschakeld, worden de verticale lijnen in de grafiek weergegeven.", + "show-split-lines-y-axis-hint": "Indien ingeschakeld, worden de horizontale lijnen in de grafiek weergegeven.", + "ticks-interval": "Streepjesinterval", + "ticks-interval-hint": "Segmenteringsinterval voor de as forceren.", + "split-number": "Aantal segmenten", + "split-number-hint": "Aantal segmenten waarin de as is opgedeeld.", + "min": "Min", + "max": "Max", + "show": "Tonen", + "add-y-axis": "Y-as toevoegen" + }, + "series": { + "legend-settings": "Legenda instellingen", + "show-in-legend": "In legenda tonen", + "show-in-legend-hint": "Toon seriesnaam en gegevens in de legenda.", + "hidden-by-default": "Standaard verborgen", + "hidden-by-default-hint": "Maak de serie standaard verborgen in de legenda.", + "series-type": "Serietype", + "type": "Type", + "type-line": "Lijn", + "type-bar": "Staaf", + "line": { + "line": "Lijn", + "show-line": "Lijn tonen", + "step-line": "Staplijn", + "step-type-start": "Start", + "step-type-middle": "Midden", + "step-type-end": "Einde", + "smooth-line": "Vloeiende lijn" + }, + "point": { + "points": "Punten", + "show-points": "Punten tonen", + "point-label": "Puntlabel", + "point-label-hint": "Toon een label met waarde boven het punt van de serie.", + "point-label-background": "Achtergrond van puntlabel", + "point-shape": "Vorm van het punt", + "point-size": "Puntgrootte" + } + } + }, + "wind-speed-direction": { + "layout": "Indeling", + "layout-default": "Standaard", + "layout-advanced": "Geavanceerd", + "layout-simplified": "Vereenvoudigd", + "values": "Waarden", + "wind-direction": "Windrichting", + "center-value": "Middelste waarde", + "icon": "Icoon", + "arrow": "Pijl", + "ticks": "Streepjes", + "labels-type": "Type labels", + "directional-names": "Richtingnamen", + "degrees": "Graden", + "major-ticks": "Grote streepjes", + "minor-ticks": "Kleine streepjes", + "wind-speed-direction-card-style": "Stijl voor windrichting en -snelheid kaart", + "ticks-color": "Kleur van streepjes", + "ticks-labels-type": "Type labels voor streepjes", + "arrow-color": "Kleur van de pijl" + }, + "value-source": { + "value-source": "Bron van waarde", + "predefined-value": "Constante", + "entity-attribute": "Entiteitsattribuut", + "value": "Waarde", + "value-required": "Waarde is verplicht.", + "key-required": "Sleutel is verplicht.", + "entity-key-required": "Entiteitssleutel is verplicht.", + "source-entity-alias": "Alias van bronentiteit", + "source-entity-attribute": "Attribuut van bronentiteit", + "type-constant": "Constante", + "type-latest-key": "Sleutel", + "type-entity": "Entiteit" + }, + "rpc-state": { + "initial-state": "Initiële toestand", + "initial-state-hint": "Actie om de initiële toestand (Aan/Uit) van de component op te halen.", + "disabled-state": "Uitgeschakelde toestand", + "disabled-state-hint": "Configureer de voorwaarde waaronder de component wordt uitgeschakeld.", + "turn-on": "Inschakelen", + "turn-on-hint": "Actie bij inschakelen van de schuifregelaar", + "turn-off": "Uitschakelen", + "turn-off-hint": "Actie bij uitschakelen van de schuifregelaar", + "on": "Aan", + "off": "Uit", + "disabled": "Uitgeschakeld" + }, + "value-action": { + "do-nothing": "Niets doen", + "execute-rpc": "RPC uitvoeren", + "get-attribute": "Attribuut ophalen", + "set-attribute": "Attribuut instellen", + "get-time-series": "Tijdreeks ophalen", + "get-alarm-status": "Alarmstatus ophalen", + "get-dashboard-state": "Dashboard status-id ophalen", + "get-dashboard-state-object": "Dashboard statusobject ophalen", + "add-time-series": "Tijdreeks toevoegen", + "execute-rpc-text": "Voer RPC-methode '{{methodName}}' uit", + "get-time-series-text": "Gebruik tijdreeks '{{key}}'", + "get-attribute-text": "Gebruik attribuut '{{key}}'", + "get-alarm-status-text": "Gebruik alarmstatus", + "get-dashboard-state-text": "Gebruik dashboardstatus", + "get-dashboard-state-object-text": "Gebruik dashboard statusobject", + "when-dashboard-state-is-text": "Wanneer dashboardstatus-id '{{state}}' is", + "when-dashboard-state-function-is-text": "Wanneer f(dashboard status-id) '{{state}}' is", + "when-dashboard-state-object-function-is-text": "Wanneer f(dashboard statusobject) '{{state}}' is", + "set-attribute-to-value-text": "Stel attribuut '{{key}}' in op: {{value}}", + "add-time-series-value-text": "Voeg '{{key}}' tijdreekswaarde toe: {{value}}", + "set-attribute-text": "Stel attribuut '{{key}}' in", + "add-time-series-text": "Voeg tijdreeks '{{key}}' toe", + "action": "Actie", + "value": "Waarde", + "init-value-hint": "Waarde die wordt ingesteld totdat het apparaat gegevens verzendt.", + "method": "Methode", + "method-name-required": "Methodenaam is verplicht.", + "request-timeout-ms": "RPC-verzoek time-out (ms)", + "request-timeout-required": "Time-out voor verzoek is verplicht.", + "min-request-timeout-error": "Time-outwaarde moet minimaal 5000 ms (5 seconden) zijn.", + "request-persistent": "Persistent RPC-verzoek", + "persistent-polling-interval": "Persistent polling-interval (ms)", + "persistent-polling-interval-hint": "Polling-interval (ms) voor antwoord op persistent RPC-commando", + "persistent-polling-interval-required": "Persistent polling-interval is verplicht.", + "min-persistent-polling-interval-error": "Polling-interval moet minimaal 1000 ms (1 seconde) zijn.", + "attribute-scope": "Attribuutscope", + "attribute-key": "Attribuutsleutel", + "attribute-key-required": "Attribuutsleutel is verplicht.", + "time-series-key": "Tijdreekssleutel", + "time-series-key-required": "Tijdreekssleutel is verplicht.", + "action-result-converter": "Resultaatomzetter voor actie", + "converter-none": "Geen", + "converter-function": "Functie", + "converter-constant": "Constante", + "converter-value": "Waarde", + "parse-value-function": "Waarde parseringsfunctie", + "state-when-result-is": "'{{state}}' wanneer resultaat is", + "parameters": "Parameters", + "convert-value-function": "Waardeconversiefunctie", + "error": { + "target-entity-is-not-set": "Doelentiteit is niet ingesteld!", + "failed-to-perform-action": "Kon de actie {{ actionLabel }} niet uitvoeren.", + "invalid-attribute-scope": "Attribuutscope {{scope}} wordt niet ondersteund voor {{entityType}} entiteit." + } + }, + "widget-font": { + "font-settings": "Lettertype-instellingen", + "font-family": "Lettertypefamilie", + "size": "Grootte", + "relative-font-size": "Relatieve lettergrootte (procenten)", + "font-style": "Stijl", + "font-style-normal": "Normaal", + "font-style-italic": "Cursief", + "font-style-oblique": "Schuin", + "font-weight": "Dikte", + "font-weight-normal": "Normaal", + "font-weight-bold": "Vet", + "font-weight-bolder": "Vetgedrukt", + "font-weight-lighter": "Lichter", + "color": "Kleur", + "shadow-color": "Schaduwkleur", + "preview": "Voorbeeld", + "line-height": "Regelhoogte", + "auto": "Auto" + }, + "home": { + "no-data-available": "Geen gegevens beschikbaar" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "Schijf", + "cpu-warning-text": "Hoge CPU-belasting. Optimaliseer de systeemprestaties om systeemfouten te voorkomen.", + "cpu-critical-text": "Kritieke CPU-belasting. Optimaliseer de systeemprestaties om systeemfouten te voorkomen.", + "ram-warning-text": "Weinig RAM beschikbaar. Optimaliseer de systeemprestaties of verhoog de RAM om systeemfouten te voorkomen.", + "ram-critical-text": "Kritiek laag RAM-geheugen. Optimaliseer de systeemprestaties of verhoog de RAM om systeemfouten te voorkomen.", + "disk-warning-text": "Weinig schijfruimte beschikbaar. Maak ruimte vrij of breid de schijfruimte uit om dataverlies te voorkomen.", + "disk-critical-text": "Kritiek weinig schijfruimte beschikbaar. Maak ruimte vrij of breid de schijfruimte uit om dataverlies te voorkomen." + }, + "cluster-info": { + "service-id": "Service-ID", + "service-type": "Servicetype", + "no-data": "Geen gegevens" + }, + "transport-messages": { + "title": "Transportberichten", + "info": "Alle berichten afkomstig van apparaten" + }, + "activity": { + "title": "Activiteit" + }, + "documentation": { + "title": "Documentatie", + "add-link": "Link toevoegen", + "add-link-title": "Documentatielink toevoegen", + "name": "Naam", + "name-required": "Naam is verplicht.", + "link": "Link", + "link-required": "Link is verplicht.", + "columns": "Kolommen" + }, + "quick-links": { + "title": "Snelle links", + "add-link": "Link toevoegen", + "add-link-title": "Snelle link toevoegen", + "quick-link": "Snelle link", + "quick-link-required": "Snelle link is verplicht.", + "no-links-matching": "Geen links gevonden die overeenkomen met '{{name}}'.", + "columns": "Kolommen" + }, + "recent-dashboards": { + "title": "Dashboards", + "last": "Laatst bekeken", + "starred": "Gemarkeerd", + "name": "Naam", + "last-viewed": "Laatst bekeken", + "no-last-viewed-dashboards": "Nog geen laatst bekeken dashboards" + }, + "configured-features": { + "title": "Geconfigureerde functies", + "info": "Status van functies die configuratie vereisen", + "email-feature": "E-mail", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2FA", + "feature-configured": "Functie is geconfigureerd.\nKlik om in te stellen", + "feature-not-configured": "Functie is niet geconfigureerd.\nKlik om in te stellen" + }, + "version-info": { + "title": "Versie", + "contact-us": "Neem contact op", + "current-version": "Huidige versie", + "current": "Huidig", + "available-version": "Beschikbare versie", + "available": "Beschikbaar", + "upgrade": "Upgraden", + "version-is-up-to-date": "Versie is up-to-date" + }, + "usage-info": { + "title": "Gebruik", + "entities": "Entiteiten", + "api-calls": "API-aanroepen" + }, + "functions": { + "title": "Functies", + "pe-feature-tooltip": "Alleen beschikbaar in ThingsBoard\nProfessional Edition", + "switch-to-pe": "Overschakelen naar PE", + "alarms": "Alarmen", + "dashboards": "Dashboards", + "entities-and-relations": "Entiteiten & Relaties", + "profiles": "Profielen", + "advanced-features": "Geavanceerde functies", + "notification-center": "Meldingscentrum", + "api-usage": "API-gebruik", + "customers": "Klanten", + "customers-hierarchy": "Klantenhiërarchie", + "roles-and-permissions": "Rollen & Rechten", + "groups": "Groepen", + "integrations": "Integraties", + "solution-templates": "Oplossingssjablonen", + "scheduler": "Planner", + "white-labeling": "White-labeling" + }, + "devices": { + "view-docs": "Bekijk documentatie", + "inactive": "Inactief", + "active": "Actief", + "total": "Totaal" + }, + "alarms": { + "critical": "Kritiek", + "assigned-to-me": "Aan mij toegewezen", + "total": "Totaal" + }, + "getting-started": { + "get-started": "Aan de slag", + "finish": "Voltooien", + "done-welcome-title": "Welkom aan boord", + "done-welcome-text": "Je hebt het geweldig gedaan!", + "sys-admin": { + "step1": { + "title": "Maak Tenant & Tenantbeheerder aan", + "content": "

    Een tenant is een individu of organisatie die apparaten en middelen bezit of beheert. Een tenant kan meerdere beheerders, klanten, apparaten en middelen hebben.

    De tenantbeheerder kan apparaten, middelen, klanten en dashboards beheren binnen het tenantaccount.

    Volg de documentatie om dit in te stellen:

    ", + "how-to-create-tenant": "Hoe Tenant & Tenantbeheerder aan te maken" + }, + "step2": { + "title": "Configureer functie: Mailserver", + "content": "

    Configuratie van de mailserver is essentieel voor gebruikersactivatie, wachtwoordherstel en het verzenden van alarmmeldingen.

    Volg de documentatie om dit in te stellen:

    ", + "how-to-configure-mail-server": "Hoe mailserver te configureren" + }, + "step3": { + "title": "Configureer functie: SMS-provider", + "content": "

    Configureer SMS-providers om klanten te waarschuwen voor alarmen via SMS.

    Volg de documentatie om dit in te stellen:

    ", + "how-to-configure-sms-provider": "Hoe SMS-provider te configureren" + }, + "step4": { + "title": "Configureer functie: White-labeling", + "content": "

    Pas eenvoudig het logo en kleurenschema van jouw bedrijf of product aan zonder codering of herstart van de dienst.

    Volg de documentatie om dit in te stellen:

    " + }, + "step5": { + "title": "Configureer functie: 2FA", + "content": "

    Verhoog de veiligheid van platformaccounts met twee-factor-authenticatie.

    Volg de documentatie om dit in te stellen:

    " + }, + "step6": { + "title": "Configureer functie: OAuth 2", + "content": "

    Vereenvoudig de aanmelding van tenant- en klantgebruikers met Single Sign-On via OAuth 2.0.

    Volg de documentatie om dit in te stellen:

    " + } + }, + "tenant-admin": { + "step1": { + "title": "Apparaat aanmaken", + "content": "

    Laten we je eerste apparaat via de UI aan het platform toevoegen. Volg de documentatie om dit te doen:

    ", + "how-to-create-device": "Hoe een apparaat aan te maken" + }, + "step2": { + "title": "Apparaat verbinden", + "content-before": "

    Om het apparaat te verbinden, heb je de apparaatreferenties nodig. Voor deze handleiding raden we aan om de standaard automatisch gegenereerde toegangstoken te gebruiken.

    • Ga naar de apparaattabel
    • Klik op de apparaatregel om de apparaatgegevens te openen
    • Druk op de knop \"Toegangstoken kopiëren\"

    Gebruik eenvoudige commando's om gegevens via HTTP te verzenden. Vergeet niet $ACCESS_TOKEN te vervangen door het toegangstoken van je apparaat:

    ", + "ubuntu": { + "install-curl": "Installeer cURL voor Ubuntu:" + }, + "macos": { + "install-curl": "Installeer cURL voor MacOS:" + }, + "windows": { + "install-curl": "Vanaf Windows 10 b17063 is cURL standaard beschikbaar." + }, + "replace-access-token": "Vervang $ACCESS_TOKEN door het toegangstoken van je apparaat:", + "content-after": "

    Je kunt ook andere protocollen gebruiken zoals MQTT, CoAP, enz.

    Volg de documentatie om dit te doen:

    ", + "how-to-connect-device": "Hoe een apparaat te verbinden" + }, + "step3": { + "title": "Dashboard aanmaken", + "content": "

    Maak een dashboard aan om gegevens van entiteiten zoals apparaten, middelen, enz. te visualiseren.

    Volg de documentatie om dit te doen:

    ", + "how-to-create-dashboard": "Hoe een dashboard aan te maken" + }, + "step4": { + "title": "Alarmregels configureren", + "alarm-rules": "Alarmregels", + "content": "

    Laten we een alarm genereren wanneer de temperatuur 25°C bereikt. Volg de documentatie om dit te doen:

    ", + "how-to-configure-alarm-rules": "Hoe alarmregels te configureren" + }, + "step5": { + "title": "Alarm aanmaken", + "content-before": "

    Om het alarm te activeren, verzend een nieuwe telemetrie waarde van 26°C of hoger.

    ", + "replace-access-token": "Vervang $ACCESS_TOKEN door het toegangstoken van je apparaat:", + "content-after": "

    Volg de documentatie om dit te doen:

    ", + "how-to-create-alarm": "Hoe een alarm aan te maken" + }, + "step6": { + "title": "Klant aanmaken en dashboard delen", + "content": "

    Door dashboards voor eindgebruikers aan te maken, kan een klantgebruiker alleen zijn eigen apparaten zien. Gegevens van andere klanten blijven verborgen.

    Volg de documentatie om dit te doen:

    " + } + } + } + }, + "icon": { + "icon": "Pictogram", + "icons": "Pictogrammen", + "select-icon": "Selecteer pictogram", + "material-icons": "Material pictogrammen", + "show-all": "Toon alle pictogrammen", + "search-icon": "Zoek pictogram", + "no-icons-found": "Geen pictogrammen gevonden voor '{{iconSearch}}'" + }, + "phone-input": { + "phone-input-label": "Telefoonnummer", + "phone-input-required": "Telefoonnummer is verplicht", + "phone-input-validation": "Telefoonnummer is ongeldig of niet mogelijk", + "phone-input-pattern": "Ongeldig telefoonnummer. Moet in E.164-formaat zijn, bijv. {{phoneNumber}}", + "phone-input-hint": "Telefoonnummer in E.164-formaat, bijv. {{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "Actieknop in cel", + "row-click": "Bij rijklik", + "cell-click": "Bij celklik", + "polygon-click": "Bij klik op polygoon", + "marker-click": "Bij klik op marker", + "circle-click": "Bij klik op cirkel", + "tooltip-tag-action": "Tooltip tag-actie", + "node-selected": "Bij geselecteerde knoop", + "element-click": "Bij klik op HTML-element", + "pie-slice-click": "Bij klik op taartdiagramsegment", + "row-double-click": "Bij dubbelklik op rij", + "cell-double-click": "Bij dubbelklik op cel", + "card-click": "Bij klik op kaart", + "click": "Bij klik" + } + }, + "paginator": { + "items-per-page": "Items per pagina:", + "first-page-label": "Eerste pagina", + "last-page-label": "Laatste pagina", + "next-page-label": "Volgende pagina", + "previous-page-label": "Vorige pagina", + "items-per-page-separator": "van" + }, + "language": { + "language": "Taal" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-no_NO.json b/ui-ngx/src/assets/locale/locale.constant-no_NO.json new file mode 100644 index 0000000000..9b2a91f75e --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-no_NO.json @@ -0,0 +1,9197 @@ +{ + "access": { + "unauthorized": "Uautorisert", + "unauthorized-access": "Uautorisert tilgang", + "unauthorized-access-text": "Du må logge inn for å få tilgang til denne ressursen!", + "access-forbidden": "Tilgang nektet", + "access-forbidden-text": "Du har ikke tilgangsrettigheter til denne plasseringen!
    Prøv å logge inn med en annen bruker hvis du fortsatt ønsker tilgang.", + "refresh-token-expired": "Økten er utløpt", + "refresh-token-failed": "Kunne ikke oppdatere økten", + "permission-denied": "Tillatelse nektet", + "permission-denied-text": "Du har ikke tillatelse til å utføre denne operasjonen!" + }, + "account": { + "account": "Konto", + "notification-settings": "Varslingsinnstillinger" + }, + "action": { + "activate": "Aktiver", + "suspend": "Suspender", + "save": "Lagre", + "saveAs": "Lagre som", + "move": "Flytt", + "cancel": "Avbryt", + "ok": "OK", + "delete": "Slett", + "add": "Legg til", + "yes": "Ja", + "no": "Nei", + "update": "Oppdater", + "remove": "Fjern", + "search": "Søk", + "clear-search": "Tøm søk", + "assign": "Tildel", + "unassign": "Fjern tildeling", + "share": "Del", + "make-private": "Gjør privat", + "apply": "Bruk", + "apply-changes": "Bruk endringer", + "edit-mode": "Redigeringsmodus", + "enter-edit-mode": "Gå til redigeringsmodus", + "decline-changes": "Avvis endringer", + "decline": "Avvis", + "close": "Lukk", + "back": "Tilbake", + "run": "Kjør", + "sign-in": "Logg inn!", + "edit": "Rediger", + "view": "Vis", + "create": "Opprett", + "drag": "Dra", + "refresh": "Oppdater", + "undo": "Angre", + "copy": "Kopier", + "paste": "Lim inn", + "copy-reference": "Kopier referanse", + "paste-reference": "Lim inn referanse", + "import": "Importer", + "export": "Eksporter", + "share-via": "Del via {{provider}}", + "select": "Velg", + "continue": "Fortsett", + "discard-changes": "Forkast endringer", + "download": "Last ned", + "next": "Neste", + "next-with-label": "Neste: {{label}}", + "read-more": "Les mer", + "hide": "Skjul", + "test": "Test", + "done": "Ferdig", + "print": "Skriv ut", + "restore": "Gjenopprett", + "confirm": "Bekreft", + "more": "Mer", + "less": "Mindre", + "skip": "Hopp over", + "send": "Send", + "reset": "Tilbakestill", + "show-more": "Vis mer", + "dont-show-again": "Ikke vis igjen", + "see-documentation": "Se dokumentasjon", + "clear": "Tøm", + "upload": "Last opp", + "delete-anyway": "Slett likevel", + "delete-selected": "Slett valgte", + "set": "Sett" + }, + "aggregation": { + "aggregation": "Aggregering", + "function": "Funksjon for data-aggregering", + "limit": "Maks verdier", + "group-interval": "Grupperingsintervall", + "min": "Min", + "max": "Maks", + "avg": "Gjennomsnitt", + "sum": "Sum", + "count": "Antall", + "none": "Ingen" + }, + "admin": { + "settings": "Innstillinger", + "general": "Generelt", + "general-settings": "Generelle innstillinger", + "home-settings": "Hjeminnstillinger", + "home": "Hjem", + "outgoing-mail": "E-postserver", + "outgoing-mail-settings": "Innstillinger for utgående e-postserver", + "system-settings": "Systeminnstillinger", + "test-mail-sent": "Testmeldingen ble sendt!", + "base-url": "Base-URL", + "base-url-required": "Base-URL er påkrevd.", + "prohibit-different-url": "Forby bruk av vertsnavn fra klientforespørsler", + "prohibit-different-url-hint": "Denne innstillingen bør være aktivert i produksjonsmiljøer. Kan forårsake sikkerhetsproblemer når den er deaktivert.", + "device-connectivity": { + "device-connectivity": "Enhetstilkobling", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPs", + "mqtt": "MQTT", + "mqtts": "MQTTs", + "coap": "COAP", + "coaps": "COAPs", + "hint": "Hvis felt for vert eller port er tomme, brukes standardverdi for protokoll.", + "host": "Vert", + "port": "Port", + "port-pattern": "Porten må være et positivt heltall.", + "port-range": "Porten må være i området fra 1 til 65535." + }, + "mail-from": "Avsenderadresse", + "mail-from-required": "Avsenderadresse er påkrevd.", + "smtp-protocol": "SMTP-protokoll", + "smtp-host": "SMTP-vert", + "smtp-host-required": "SMTP-vert er påkrevd.", + "smtp-port": "SMTP-port", + "smtp-port-required": "Du må angi en SMTP-port.", + "smtp-port-invalid": "Dette ser ikke ut som en gyldig SMTP-port.", + "timeout-msec": "Tidsavbrudd (ms)", + "timeout-required": "Tidsavbrudd er påkrevd.", + "timeout-invalid": "Dette ser ikke ut som en gyldig tidsavbruddverdi.", + "enable-tls": "Aktiver TLS", + "tls-version": "TLS-versjon", + "enable-proxy": "Aktiver proxy", + "proxy-host": "Proxy-vert", + "proxy-host-required": "Proxy-vert er påkrevd.", + "proxy-port": "Proxy-port", + "proxy-port-required": "Proxy-port er påkrevd.", + "proxy-port-range": "Proxy-porten må være i området 1 til 65535.", + "proxy-user": "Proxy-bruker", + "proxy-password": "Proxy-passord", + "change-password": "Endre passord", + "send-test-mail": "Send test-e-post", + "sms-provider": "SMS-leverandør", + "sms-provider-settings": "Innstillinger for SMS-leverandør", + "sms-provider-type": "SMS-leverandørtype", + "sms-provider-type-required": "SMS-leverandørtype er påkrevd.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID er påkrevd", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key er påkrevd", + "aws-region": "AWS-region", + "aws-region-required": "AWS-region er påkrevd", + "number-from": "Telefonnummer fra", + "number-from-required": "Telefonnummer fra er påkrevd.", + "number-to": "Telefonnummer til", + "number-to-required": "Telefonnummer til er påkrevd.", + "phone-number-hint": "Telefonnummer i E.164-format, f.eks. +19995550123", + "phone-number-hint-twilio": "Telefonnummer i E.164-format/Telefonnummerets SID/Meldingstjeneste SID, f.eks. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Ugyldig telefonnummer. Skal være i E.164-format, f.eks. +19995550123.", + "phone-number-pattern-twilio": "Ugyldig telefonnummer. Skal være i E.164-format/Telefonnummerets SID/Meldingstjeneste SID, f.eks. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS-melding", + "sms-message-required": "SMS-melding er påkrevd.", + "sms-message-max-length": "SMS-meldingen kan ikke være lengre enn 1600 tegn", + "twilio-account-sid": "Twilio-konto SID", + "twilio-account-sid-required": "Twilio-konto SID er påkrevd", + "twilio-account-token": "Twilio-konto Token", + "twilio-account-token-required": "Twilio-konto Token er påkrevd", + "send-test-sms": "Send test-SMS", + "test-sms-sent": "Test-SMS ble sendt!", + "security-settings": "Sikkerhetsinnstillinger", + "password-policy": "Passordpolicy", + "minimum-password-length": "Minimum passordlengde", + "minimum-password-length-required": "Minimum passordlengde er påkrevd", + "minimum-password-length-range": "Minimum passordlengde må være mellom 6 og 50", + "maximum-password-length": "Maksimum passordlengde", + "maximum-password-length-min": "Maksimum passordlengde må være minst 6", + "maximum-password-length-less-min": "Maksimum passordlengde må være større enn minimum", + "minimum-uppercase-letters": "Minimum antall store bokstaver", + "minimum-uppercase-letters-range": "Minimum antall store bokstaver kan ikke være negativt", + "minimum-lowercase-letters": "Minimum antall små bokstaver", + "minimum-lowercase-letters-range": "Minimum antall små bokstaver kan ikke være negativt", + "minimum-digits": "Minimum antall sifre", + "minimum-digits-range": "Minimum antall sifre kan ikke være negativt", + "minimum-special-characters": "Minimum antall spesialtegn", + "minimum-special-characters-range": "Minimum antall spesialtegn kan ikke være negativt", + "password-expiration-period-days": "Utløpsperiode for passord i dager", + "password-expiration-period-days-range": "Utløpsperiode for passord kan ikke være negativ", + "password-reuse-frequency-days": "Gjenbrukshyppighet for passord i dager", + "password-reuse-frequency-days-range": "Gjenbrukshyppighet for passord kan ikke være negativ", + "allow-whitespace": "Tillat mellomrom", + "force-reset-password-if-no-valid": "Tving tilbakestilling av passord hvis ugyldig", + "force-reset-password-if-no-valid-hint": "Vær forsiktig når du aktiverer denne funksjonen: brukere med ugyldige passord må tilbakestille passordet sitt via e-post.", + "general-policy": "Generell policy", + "max-failed-login-attempts": "Maks antall mislykkede innloggingsforsøk før kontoen låses", + "minimum-max-failed-login-attempts-range": "Maks antall mislykkede innloggingsforsøk kan ikke være negativt", + "user-lockout-notification-email": "Ved kontolåsing sendes varsel til e-post", + "user-activation-token-ttl": "Gyldighetstid for aktiveringslenke (timer)", + "user-activation-token-ttl-range": "Gyldighetstiden for aktiveringslenke må være mellom 1 og 24 timer", + "password-reset-token-ttl": "Gyldighetstid for tilbakestillingslenke (timer)", + "password-reset-token-ttl-range": "Gyldighetstiden for tilbakestillingslenke må være mellom 1 og 24 timer", + "mobile-secret-key-length": "Lengde på mobil hemmelig nøkkel", + "mobile-secret-key-length-range": "Lengden på den mobile hemmelige nøkkelen må være positiv", + "domain-name": "Domenenavn", + "domain-name-unique": "Domenenavn og protokoll må være unike.", + "domain-name-max-length": "Domenenavn skal være kortere enn 256 tegn", + "error-verification-url": "Et domenenavn skal ikke inneholde symbolene '/' og ':'. Eksempel: thingsboard.io", + "connection-settings": "Tilkoblingsinnstillinger", + "oauth2": { + "access-token-uri": "URI for tilgangstoken", + "access-token-uri-required": "URI for tilgangstoken er påkrevd.", + "activate-user": "Aktiver bruker", + "add-domain": "Legg til domene", + "delete-domain": "Slett domene", + "add-provider": "Legg til leverandør", + "delete-provider": "Slett leverandør", + "allow-user-creation": "Tillat opprettelse av brukere", + "always-fullscreen": "Alltid fullskjerm", + "authorization-uri": "Autoriserings-URI", + "authorization-uri-required": "Autoriserings-URI er påkrevd.", + "add-client": "Legg til OAuth 2.0-klient", + "client-details": "Detaljer for OAuth 2.0-klient", + "client": "OAuth 2.0-klient", + "clients": "OAuth 2.0-klienter", + "no-oauth2-clients": "Ingen OAuth 2.0-klienter funnet", + "search-oauth2-clients": "Søk i OAuth 2.0-klienter", + "delete-client-title": "Er du sikker på at du vil slette OAuth 2.0-klienten '{{clientName}}'?", + "delete-client-text": "Vær forsiktig, etter bekreftelse vil klienten og alle tilknyttede data ikke kunne gjenopprettes.", + "delete-mobile-app-title": "Er du sikker på at du vil slette mobilapplikasjonen '{{applicationName}}'?", + "delete-mobile-app-text": "Vær forsiktig, etter bekreftelse vil applikasjonen og alle tilhørende data ikke kunne gjenopprettes.", + "title": "Tittel", + "client-title-required": "Tittel er påkrevd", + "client-title-max-length": "Tittel må være kortere enn 100 tegn", + "advanced-settings": "Avanserte innstillinger", + "domain-details": "Detaljer for domene", + "no-domains": "Ingen domener funnet", + "search-domains": "Søk i domener", + "mobile-app-details": "Detaljer for mobilapplikasjon", + "add-mobile-app": "Legg til mobilapplikasjon", + "no-mobile-apps": "Ingen mobilapplikasjoner funnet", + "search-mobile-apps": "Søk i mobilapplikasjoner", + "send-token": "Send token", + "create-new": "Opprett ny", + "client-authentication-method": "Metode for klientautentisering", + "client-id": "Klient-ID", + "client-id-required": "Klient-ID er påkrevd.", + "client-id-max-length": "Klient-ID må være kortere enn 256 tegn", + "client-secret": "Klienthemmelighet", + "client-secret-required": "Klienthemmelighet er påkrevd.", + "client-secret-max-length": "Klienthemmelighet må være kortere enn 2049 tegn", + "custom-setting": "Egendefinerte innstillinger", + "customer-name-pattern": "Mønster for kundenavn", + "customer-name-pattern-max-length": "Mønster for kundenavn må være kortere enn 256 tegn", + "default-dashboard-name": "Standard dashboard-navn", + "default-dashboard-name-max-length": "Standard dashboard-navn må være kortere enn 256 tegn", + "delete-domain-text": "Vær forsiktig, etter bekreftelse vil domenet og all leverandørdata bli utilgjengelig.", + "delete-domain-title": "Er du sikker på at du vil slette domenet '{{domainName}}'?", + "delete-registration-text": "Vær forsiktig, etter bekreftelse vil leverandørdata bli utilgjengelig.", + "delete-registration-title": "Er du sikker på at du vil slette leverandøren '{{name}}'?", + "email-attribute-key": "E-postattributtnøkkel", + "email-attribute-key-required": "E-postattributtnøkkel er påkrevd.", + "email-attribute-key-max-length": "E-postattributtnøkkel må være kortere enn 32 tegn", + "first-name-attribute-key": "Fornavnattributtnøkkel", + "first-name-attribute-key-max-length": "Fornavnattributtnøkkel må være kortere enn 32 tegn", + "general": "Generelt", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Etternavnattributtnøkkel", + "last-name-attribute-key-max-length": "Etternavnattributtnøkkel må være kortere enn 32 tegn", + "login-button-icon": "Ikon for innloggingsknapp", + "login-button-label": "Leverandøretikett", + "login-button-label-placeholder": "Logg inn med $(Provider label)", + "login-button-label-required": "Etikett er påkrevd.", + "login-provider": "Innloggingsleverandør", + "mapper": "Mapper", + "new-domain": "Nytt domene", + "oauth2": "OAuth 2.0", + "password-max-length": "Passord må være kortere enn 256 tegn", + "redirect-uri-template": "Mønster for omdirigerings-URI", + "copy-redirect-uri": "Kopier omdirigerings-URI", + "registration-id": "Registrerings-ID", + "registration-id-required": "Registrerings-ID er påkrevd.", + "registration-id-unique": "Registrerings-ID må være unik i systemet.", + "scope": "Omfang", + "scope-required": "Omfang er påkrevd.", + "tenant-name-pattern": "Mønster for leietakernavn", + "tenant-name-pattern-required": "Mønster for leietakernavn er påkrevd.", + "tenant-name-pattern-max-length": "Mønster for leietakernavn må være kortere enn 256 tegn", + "tenant-name-strategy": "Strategi for leietakernavn", + "type": "Mappertype", + "uri-pattern-error": "Ugyldig URI-format.", + "url": "URL", + "url-pattern": "Ugyldig URL-format.", + "url-required": "URL er påkrevd.", + "url-max-length": "URL må være kortere enn 256 tegn", + "user-info-uri": "Brukerinformasjon URI", + "user-info-uri-required": "Brukerinformasjon URI er påkrevd.", + "username-max-length": "Brukernavn må være kortere enn 256 tegn", + "user-name-attribute-name": "Brukernavnattributtnøkkel", + "user-name-attribute-name-required": "Brukernavnattributtnøkkel er påkrevd", + "protocol": "Protokoll", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Aktiver OAuth 2.0-innstillinger", + "disable": "Deaktiver OAuth 2.0-innstillinger", + "edge": "Repliker til Edge", + "edge-enable": "Aktiver replikering til Edge", + "edge-disable": "Deaktiver replikering til Edge", + "domains": "Domener", + "mobile-apps": "Mobilapplikasjoner", + "mobile-package": "Applikasjonspakke", + "mobile-package-placeholder": "F.eks.: my.example.app", + "mobile-package-hint": "For Android: din unike applikasjons-ID. For iOS: produktets bundle-identifikator.", + "mobile-package-unique": "Applikasjonspakken må være unik.", + "mobile-package-required": "Applikasjonspakken er påkrevd.", + "mobile-package-max-length": "Applikasjonspakken må være kortere enn 256 tegn", + "mobile-package-spaces": "Applikasjonspakken skal ikke inneholde mellomrom", + "mobile-app-secret": "Applikasjonsnøkkel", + "mobile-app-secret-hint": "Base64-kodet streng som representerer minst 512 bits med data.", + "mobile-app-secret-required": "Applikasjonsnøkkel er påkrevd.", + "mobile-app-secret-min-length": "Applikasjonsnøkkelen må representere minst 512 bits med data.", + "mobile-app-secret-base64": "Applikasjonsnøkkelen må være i base64-format.", + "invalid-mobile-app-secret": "Applikasjonsnøkkelen må kun inneholde alfanumeriske tegn og være mellom 16 og 2048 tegn lang.", + "copy-mobile-app-secret": "Kopier applikasjonsnøkkel", + "delete-mobile-app": "Slett applikasjonsinformasjon", + "providers": "Leverandører", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Alle plattformer", + "smtp-provider": "SMTP-leverandør", + "allowed-platforms": "Tillatte plattformer", + "authentication": "Autentisering", + "basic": "Grunnleggende", + "provider": "Leverandør", + "redirect-url": "Omdirigerings-URI", + "domain-name": "Domenenavn", + "domain-name-required": "Domenenavn er påkrevd", + "redirect-url-template": "Mal for omdirigerings-URI", + "microsoft-tenant-id": "Katalog-ID (leietaker-ID)", + "microsoft-tenant-id-required": "Katalog-ID (leietaker-ID) er påkrevd", + "token-uri": "Token-URI", + "token-uri-required": "Token-URI er påkrevd", + "redirect-uri": "Omdirigerings-URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Tilpasset", + "generate-access-token": "Generer tilgangstoken", + "update-access-token": "Oppdater tilgangstoken", + "access-token-status": "Status for tilgangstoken:", + "token-status-generated": "generert", + "token-status-not-generated": "ikke generert" + }, + "smpp-provider": { + "smpp-version": "SMPP-versjon", + "smpp-host": "SMPP-vert", + "smpp-host-required": "SMPP-vert er påkrevd", + "smpp-port": "SMPP-port", + "smpp-port-required": "SMPP-port er påkrevd", + "system-id": "System-ID", + "system-id-required": "System-ID er påkrevd", + "password": "Passord", + "password-required": "Passord er påkrevd", + "type-settings": "Typeinnstillinger", + "source-settings": "Innstillinger for kilde", + "destination-settings": "Innstillinger for destinasjon", + "additional-settings": "Tilleggsinnstillinger", + "system-type": "Systemtype", + "bind-type": "Bindetype", + "service-type": "Tjenestetype", + "source-address": "Kildeadresse", + "source-ton": "Kilde-TON", + "source-npi": "Kilde-NPI", + "destination-ton": "Destinasjon TON (Type of Number)", + "destination-npi": "Destinasjon NPI (Numbering Plan Identification)", + "address-range": "Adresseområde", + "coding-scheme": "Kodingsskjema", + "bind-type-tx": "Sender", + "bind-type-rx": "Mottaker", + "bind-type-trx": "Transceiver", + "ton-unknown": "Ukjent", + "ton-international": "Internasjonal", + "ton-national": "Nasjonal", + "ton-network-specific": "Nettverksspesifikk", + "ton-subscriber-number": "Abonnentnummer", + "ton-alphanumeric": "Alfanumerisk", + "ton-abbreviated": "Forkortet", + "npi-unknown": "0 - Ukjent", + "npi-isdn": "1 - ISDN/telefonnummerplan (E163/E164)", + "npi-data-numbering-plan": "3 - Datanumreplan (X.121)", + "npi-telex-numbering-plan": "4 - Telexnummerplan (F.69)", + "npi-land-mobile": "6 - Landmobil (E.212)", + "npi-national-numbering-plan": "8 - Nasjonal nummerplan", + "npi-private-numbering-plan": "9 - Privat nummerplan", + "npi-ermes-numbering-plan": "10 - ERMES-nummerplan (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internett (IP)", + "npi-wap-client-id": "18 - WAP-klient-ID (defineres av WAP Forum)", + "scheme-smsc": "0 - SMSC Standardalfabet (ASCII for korte og lange koder, GSM for gratisnummer)", + "scheme-ia5": "1 - IA5 (ASCII for korte og lange koder, Latin 9 for gratisnummer (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Oktett uspesifisert (8-bit binær)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Oktett uspesifisert (8-bit binær)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Kyrillisk (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Hebraisk (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Piktogramkoding", + "scheme-music-codes": "10 - Musikkkoder (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Utvidet Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Koreansk grafisk tegnsett (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Velg kønavn", + "queue-name": "Navn", + "queue-name-required": "Kønavn er påkrevd!", + "queues": "Køer", + "queue-partitions": "Partisjoner", + "queue-submit-strategy": "Innsendingsstrategi", + "queue-processing-strategy": "Behandlingsstrategi", + "queue-configuration": "Køkonfigurasjon", + "repository-settings": "Depotinnstillinger", + "repository": "Depot", + "repository-url": "Depot-URL", + "repository-url-required": "Depot-URL er påkrevd.", + "default-branch": "Standard branch-navn", + "repository-read-only": "Kun lesbar", + "show-merge-commits": "Vis sammenslåingscommits", + "authentication-settings": "Autentiseringsinnstillinger", + "auth-method": "Autentiseringsmetode", + "auth-method-username-password": "Passord / tilgangstoken", + "auth-method-username-password-hint": "GitHub-brukere må bruke tilgangstoken med skrive-tilgang til depotet.", + "auth-method-private-key": "Privat nøkkel", + "password-access-token": "Passord / tilgangstoken", + "change-password-access-token": "Endre passord / tilgangstoken", + "private-key": "Privat nøkkel", + "drop-private-key-file-or": "Dra og slipp en fil med privat nøkkel eller", + "passphrase": "Passfrase", + "enter-passphrase": "Skriv inn passfrase", + "change-passphrase": "Endre passfrase", + "check-access": "Sjekk tilgang", + "check-repository-access-success": "Depottilgang er verifisert!", + "delete-repository-settings-title": "Er du sikker på at du vil slette depotinnstillingene?", + "delete-repository-settings-text": "Vær forsiktig, etter bekreftelse vil depotinnstillingene bli fjernet og versjonskontrollfunksjonen vil være utilgjengelig.", + "auto-commit-settings": "Innstillinger for auto-commit", + "auto-commit": "Auto-commit", + "auto-commit-entities": "Auto-commit-enheter", + "no-auto-commit-entities-prompt": "Ingen enheter konfigurert for auto-commit", + "delete-auto-commit-settings-title": "Er du sikker på at du vil slette auto-commit-innstillingene?", + "delete-auto-commit-settings-text": "Vær forsiktig, etter bekreftelse vil auto-commit-innstillingene bli fjernet og auto-commit vil bli deaktivert for alle enheter.", + "mobile-app": { + "mobile-app": "Mobilapp", + "mobile-app-qr-code-widget-settings": "Innstillinger for QR-kode-widget til mobilapp", + "applications": "Applikasjoner", + "default": "Standard", + "custom": "Tilpasset", + "android": "Android", + "ios": "iOS", + "appearance": "Utseende", + "appearance-on-home-page": "Visning på Hjem-siden", + "enabled": "Aktivert", + "disabled": "Deaktivert", + "badges": "Merker", + "label": "Etikett", + "label-required": "Etikett er påkrevd", + "label-max-length": "Etiketten må være 50 tegn eller færre", + "right": "Høyre", + "left": "Venstre", + "set": "Sett", + "preview": "Forhåndsvisning", + "connect-mobile-app": "Koble til mobilapp", + "use-system-settings": "Bruk systeminnstillinger" + }, + "2fa": { + "2fa": "Tofaktorautentisering", + "available-providers": "Tilgjengelige leverandører", + "issuer-name": "Utstederens navn", + "issuer-name-required": "Utstederens navn er påkrevd.", + "max-verification-failures-before-user-lockout": "Maks antall mislykkede verifiseringer før brukerkonto låses", + "max-verification-failures-before-user-lockout-pattern": "Antall mislykkede forsøk må være et positivt heltall.", + "number-of-checking-attempts": "Antall verifiseringsforsøk", + "number-of-checking-attempts-pattern": "Antall forsøk må være et positivt heltall.", + "number-of-checking-attempts-required": "Antall verifiseringsforsøk er påkrevd.", + "number-of-codes": "Antall koder", + "number-of-codes-pattern": "Antall koder må være et positivt heltall.", + "number-of-codes-required": "Antall koder er påkrevd.", + "provider": "Leverandør", + "retry-verification-code-period": "Ventetid før ny verifiseringskode (sek)", + "retry-verification-code-period-pattern": "Minste tid er 5 sek", + "retry-verification-code-period-required": "Ventetid før ny kode er påkrevd.", + "total-allowed-time-for-verification": "Totalt tillatt tid for verifisering (sek)", + "total-allowed-time-for-verification-pattern": "Minstetid er 60 sek", + "total-allowed-time-for-verification-required": "Totalt tillatt tid er påkrevd.", + "use-system-two-factor-auth-settings": "Bruk systemets tofaktorautentiseringsinnstillinger", + "verification-code-check-rate-limit": "Grense for verifiseringskodekontroll", + "verification-code-lifetime": "Levetid for verifiseringskode (sek)", + "verification-code-lifetime-pattern": "Levetiden må være et positivt heltall.", + "verification-code-lifetime-required": "Levetid for verifiseringskode er påkrevd.", + "verification-message-template": "Meldingsmal for verifisering", + "verification-limitations": "Begrensninger for verifisering", + "verification-message-template-pattern": "Verifiseringsmeldingen må inneholde mønsteret: ${code}", + "verification-message-template-required": "Meldingsmal for verifisering er påkrevd.", + "within-time": "Innen tid (sek)", + "within-time-pattern": "Tiden må være et positivt heltall.", + "within-time-required": "Tid er påkrevd." + }, + "jwt": { + "security-settings": "JWT sikkerhetsinnstillinger", + "issuer-name": "Utstederens navn", + "issuer-name-required": "Utstederens navn er påkrevd.", + "signings-key": "Signeringsnøkkel", + "signings-key-hint": "Base64-kodet streng som representerer minst 512 bits med data.", + "signings-key-required": "Signeringsnøkkel er påkrevd.", + "signings-key-min-length": "Signeringsnøkkelen må være minst 512 bits med data.", + "signings-key-base64": "Signeringsnøkkelen må være i base64-format.", + "expiration-time": "Utløpstid for token (sek)", + "expiration-time-required": "Utløpstid for token er påkrevd.", + "expiration-time-max": "Maksimalt tillatt tid er 2147483647 sekunder (68 år).", + "expiration-time-min": "Minimum tid er 60 sekunder (1 minutt).", + "refresh-expiration-time": "Utløpstid for oppfriskingstoken (sek)", + "refresh-expiration-time-required": "Utløpstid for oppfriskingstoken er påkrevd.", + "refresh-expiration-time-max": "Maksimalt tillatt tid er 2147483647 sekunder (68 år).", + "refresh-expiration-time-min": "Minimum tid er 900 sekunder (15 minutter).", + "refresh-expiration-time-less-token": "Oppfriskingstoken må ha lengre varighet enn token.", + "generate-key": "Generer nøkkel", + "info-header": "Alle brukere må logge inn på nytt", + "info-message": "Endring av JWT-signeringsnøkkel vil ugyldiggjøre alle utstedte tokens. Alle brukere må logge inn på nytt. Dette vil også påvirke skript som bruker Rest API/Websockets." + }, + "resources": "Ressurser", + "notifications": "Varsler", + "notifications-settings": "Innstillinger for varsler", + "slack-api-token": "Slack API-token", + "slack": "Slack", + "slack-settings": "Slack-innstillinger", + "mobile-settings": "Mobilinnstillinger", + "firebase-service-account-file": "Firebase service-konto legitimasjonsfil (JSON)", + "select-firebase-service-account-file": "Dra og slipp din Firebase service-konto legitimasjonsfil eller " + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarmer", + "all-alarms": "Alle alarmer", + "select-alarm": "Velg alarm", + "no-alarms-matching": "Ingen alarmer som samsvarer med '{{entity}}' ble funnet.", + "alarm-required": "Alarm er påkrevd", + "alarm-filter": "Alarmfilter", + "filter": "Filter", + "alarm-status": "Alarmstatus", + "alarm-status-list": "Liste over alarmstatus", + "any-status": "Enhver status", + "search-status": { + "ANY": "Enhver", + "ACTIVE": "Aktiv", + "CLEARED": "Fjernet", + "ACK": "Bekreftet", + "UNACK": "Ikke bekreftet" + }, + "display-status": { + "ACTIVE_UNACK": "Aktiv - ikke bekreftet", + "ACTIVE_ACK": "Aktiv - bekreftet", + "CLEARED_UNACK": "Fjernet - ikke bekreftet", + "CLEARED_ACK": "Fjernet - bekreftet" + }, + "no-alarms-prompt": "Ingen alarmer funnet", + "created-time": "Opprettelsestid", + "type": "Type", + "severity": "Alvorlighetsgrad", + "originator": "Opphav", + "originator-type": "Opphavstype", + "details": "Detaljer", + "originator-label": "Opphavsetikett", + "assign": "Tildel", + "assignments": "Tildelinger", + "assignee": "Tildelt", + "assignee-id": "Tildelt ID", + "assignee-first-name": "Tildelts fornavn", + "assignee-last-name": "Tildelts etternavn", + "assignee-email": "Tildelts e-post", + "unassigned": "Ikke tildelt", + "user-deleted": "Bruker slettet", + "assignee-not-set": "Alle", + "status": "Status", + "alarm-details": "Alarmdetaljer", + "start-time": "Starttid", + "assign-time": "Tidspunkt for tildeling", + "end-time": "Sluttid", + "ack-time": "Tidspunkt for bekreftelse", + "clear-time": "Fjernet tid", + "duration": "Varighet", + "alarm-severity": "Alvorlighetsgrad for alarm", + "alarm-severity-list": "Liste over alvorlighetsgrad for alarmer", + "any-severity": "Enhver alvorlighetsgrad", + "severity-critical": "Kritisk", + "severity-major": "Alvorlig", + "severity-minor": "Mindre", + "severity-warning": "Advarsel", + "severity-indeterminate": "Ubestemt", + "acknowledge": "Bekreft", + "clear": "Fjern", + "delete": "Slett", + "search": "Søk i alarmer", + "selected-alarms": "{ count, plural, =1 {1 alarm} other {# alarmer} } valgt", + "no-data": "Ingen data å vise", + "polling-interval": "Spørringsintervall for alarmer (sek)", + "polling-interval-required": "Spørringsintervall for alarmer er påkrevd.", + "min-polling-interval-message": "Minimum tillatt spørringsintervall er 1 sek.", + "aknowledge-alarms-title": "Bekreft { count, plural, =1 {1 alarm} other {# alarmer} }", + "aknowledge-alarms-text": "Er du sikker på at du vil bekrefte { count, plural, =1 {1 alarm} other {# alarmer} }?", + "aknowledge-alarm-title": "Bekreft alarm", + "aknowledge-alarm-text": "Er du sikker på at du vil bekrefte alarmen?", + "selected-alarms-are-acknowledged": "Valgte alarmer er allerede bekreftet", + "clear-alarms-title": "Fjern { count, plural, =1 {1 alarm} other {# alarmer} }", + "clear-alarms-text": "Er du sikker på at du vil fjerne { count, plural, =1 {1 alarm} other {# alarmer} }?", + "clear-alarm-title": "Fjern alarm", + "clear-alarm-text": "Er du sikker på at du vil fjerne alarmen?", + "delete-alarms-title": "Slett { count, plural, =1 {1 alarm} other {# alarmer} }", + "delete-alarms-text": "Er du sikker på at du vil slette { count, plural, =1 {1 alarm} other {# alarmer} }?", + "selected-alarms-are-cleared": "Valgte alarmer er allerede fjernet", + "alarm-status-filter": "Filter for alarmstatus", + "alarm-filter-title": "Alarmfilter", + "assigned": "Tildelt", + "filter-title": "Filter", + "max-count-load": "Maks antall alarmer å laste (0 - ubegrenset)", + "max-count-load-required": "Maks antall alarmer å laste er påkrevd.", + "max-count-load-error-min": "Minimumsverdi er 0.", + "fetch-size": "Hentestørrelse", + "fetch-size-required": "Hentestørrelse er påkrevd.", + "fetch-size-error-min": "Minimumsverdi er 10.", + "alarm-types": "Alarmtyper", + "alarm-type-list": "Liste over alarmtyper", + "any-type": "Enhver type", + "assigned-to-current-user": "Tildelt nåværende bruker", + "assigned-to-me": "Tildelt meg", + "search-propagated-alarms": "Søk etter propagerte alarmer", + "comments": "Alarmkommentarer", + "show-more": "Vis mer", + "additional-info": "Tilleggsinformasjon", + "alarm-type": "Alarmtype", + "enter-alarm-type": "Angi alarmtype", + "no-alarm-types-matching": "Ingen alarmtyper som samsvarer med '{{entitySubtype}}' ble funnet.", + "alarm-type-list-empty": "Ingen alarmtyper valgt." + }, + "alarm-activity": { + "add": "Legg til en kommentar...", + "alarm-comment": "Alarmkommentar", + "comments": "Kommentarer", + "delete-alarm-comment": "Vil du slette denne kommentaren?", + "refresh": "Oppdater", + "oldest-first": "Eldst først", + "newest-first": "Nyest først", + "activity": "Aktivitet", + "export": "Eksporter til CSV", + "author": "Forfatter", + "created-date": "Opprettelsesdato", + "edited-date": "Endret dato", + "text": "Tekst", + "system": "System" + }, + "alias": { + "add": "Legg til alias", + "edit": "Rediger alias", + "name": "Aliasnavn", + "name-required": "Aliasnavn er påkrevd", + "duplicate-alias": "Alias med samme navn eksisterer allerede.", + "filter-type-single-entity": "Enkelt enhet", + "filter-type-entity-list": "Enhetsliste", + "filter-type-entity-name": "Enhetsnavn", + "filter-type-entity-type": "Enhetstype", + "filter-type-state-entity": "Enhet fra dashboard-tilstand", + "filter-type-state-entity-description": "Enhet hentet fra dashboard-tilstandsparametere", + "filter-type-asset-type": "Asset-type", + "filter-type-asset-type-description": "Assets av typen '{{assetTypes}}'", + "filter-type-asset-type-and-name-description": "Assets av typen '{{assetTypes}}' og med navn som begynner med '{{prefix}}'", + "filter-type-device-type": "Enhetstype", + "filter-type-device-type-description": "Enheter av typen '{{deviceTypes}}'", + "filter-type-device-type-and-name-description": "Enheter av typen '{{deviceTypes}}' og med navn som begynner med '{{prefix}}'", + "filter-type-entity-view-type": "Enhetsvisningstype", + "filter-type-entity-view-type-description": "Enhetsvisninger av typen '{{entityViewTypes}}'", + "filter-type-entity-view-type-and-name-description": "Enhetsvisninger av typen '{{entityViewTypes}}' og med navn som begynner med '{{prefix}}'", + "filter-type-edge-type": "Kanttype", + "filter-type-edge-type-description": "Kanter av typen '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Kanter av typen '{{edgeTypes}}' og med navn som begynner med '{{prefix}}'", + "filter-type-relations-query": "Relasjonsforespørsel", + "filter-type-relations-query-description": "{{entities}} som har relasjonen {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Søk etter kanter", + "filter-type-edge-search-query-description": "Kanter med type {{edgeTypes}} som har relasjonen {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Søk etter assets", + "filter-type-asset-search-query-description": "Assets med typer {{assetTypes}} som har relasjonen {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Søk etter enheter", + "filter-type-device-search-query-description": "Enheter med typer {{deviceTypes}} som har relasjonen {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Søk etter enhetsvisning", + "filter-type-entity-view-search-query-description": "Enhetsvisninger med typer {{entityViewTypes}} som har relasjonen {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "API-bruksstatus", + "entity-filter": "Enhetsfilter", + "resolve-multiple": "Løs som flere enheter", + "resolve-multiple-hint": "Aktiver for å vise data fra alle filtrerte enheter samtidig.\nHvis deaktivert, viser widgeten kun data fra valgt enhet.", + "filter-type": "Filtertype", + "filter-type-required": "Filtertype er påkrevd.", + "entity-filter-no-entity-matched": "Ingen enheter samsvarer med spesifisert filter.", + "no-entity-filter-specified": "Ingen enhetsfilter spesifisert", + "root-state-entity": "Bruk dashboard-tilstandsenhet som rot", + "last-level-relation": "Hent kun siste relasjonsnivå", + "root-entity": "Rotenhet", + "state-entity-parameter-name": "Parameter for tilstandsenhet", + "default-state-entity": "Standard tilstandsenhet", + "default-entity-parameter-name": "Som standard", + "max-relation-level": "Maks relasjonsnivå", + "unlimited-level": "Ubegrenset nivå", + "state-entity": "Dashboard tilstandsenhet", + "all-entities": "Alle enheter", + "any-relation": "enhver" + }, + "asset": { + "asset": "Eiendel", + "assets": "Eiendeler", + "management": "Eiendelsadministrasjon", + "view-assets": "Vis eiendeler", + "add": "Legg til eiendel", + "asset-type-max-length": "Eiendelstypen må være kortere enn 256 tegn", + "assign-to-customer": "Tildel til kunde", + "assign-asset-to-customer": "Tildel eiendel(er) til kunde", + "assign-asset-to-customer-text": "Velg eiendelene som skal tildeles kunden", + "no-assets-text": "Ingen eiendeler funnet", + "assign-to-customer-text": "Velg kunden for å tildele eiendel(en)", + "public": "Offentlig", + "assignedToCustomer": "Tildelt kunde", + "make-public": "Gjør eiendelen offentlig", + "make-private": "Gjør eiendelen privat", + "unassign-from-customer": "Fjern fra kunde", + "delete": "Slett eiendel", + "asset-public": "Eiendelen er offentlig", + "asset-type": "Eiendelstype", + "asset-type-required": "Eiendelstype er påkrevd.", + "select-asset-type": "Velg eiendelstype", + "enter-asset-type": "Angi eiendelsprofil", + "any-asset": "Enhver eiendel", + "no-asset-types-matching": "Ingen eiendelstyper som samsvarer med '{{entitySubtype}}' ble funnet.", + "asset-type-list-empty": "Ingen eiendelstyper valgt.", + "asset-types": "Eiendelstyper", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "name-max-length": "Navnet må være kortere enn 256 tegn", + "label-max-length": "Etiketten må være kortere enn 256 tegn", + "description": "Beskrivelse", + "type": "Type", + "type-required": "Type er påkrevd.", + "details": "Detaljer", + "events": "Hendelser", + "add-asset-text": "Legg til ny eiendel", + "asset-details": "Eiendelsdetaljer", + "assign-assets": "Tildel eiendeler", + "assign-assets-text": "Tildel { count, plural, =1 {1 eiendel} other {# eiendeler} } til kunde", + "assign-asset-to-edge-title": "Tildel eiendel(er) til kant", + "assign-asset-to-edge-text": "Velg eiendeler som skal tildeles kanten", + "delete-assets": "Slett eiendeler", + "unassign-assets": "Fjern eiendeler", + "unassign-assets-action-title": "Fjern { count, plural, =1 {1 eiendel} other {# eiendeler} } fra kunde", + "assign-new-asset": "Tildel ny eiendel", + "delete-asset-title": "Er du sikker på at du vil slette eiendelen '{{assetName}}'?", + "delete-asset-text": "Vær forsiktig, etter bekreftelse vil eiendelen og all tilknyttet data ikke kunne gjenopprettes.", + "delete-assets-title": "Er du sikker på at du vil slette { count, plural, =1 {1 eiendel} other {# eiendeler} }?", + "delete-assets-action-title": "Slett { count, plural, =1 {1 eiendel} other {# eiendeler} }", + "delete-assets-text": "Vær forsiktig, etter bekreftelse vil alle valgte eiendeler og tilknyttet data bli fjernet permanent.", + "make-public-asset-title": "Er du sikker på at du vil gjøre eiendelen '{{assetName}}' offentlig?", + "make-public-asset-text": "Etter bekreftelse vil eiendelen og alle data gjøres offentlig og tilgjengelig for andre.", + "make-private-asset-title": "Er du sikker på at du vil gjøre eiendelen '{{assetName}}' privat?", + "make-private-asset-text": "Etter bekreftelse vil eiendelen og alle data bli private og ikke være tilgjengelige for andre.", + "unassign-asset-title": "Er du sikker på at du vil fjerne eiendelen '{{assetName}}'?", + "unassign-asset-text": "Etter bekreftelse vil eiendelen bli fjernet og ikke være tilgjengelig for kunden.", + "unassign-asset": "Fjern eiendel", + "unassign-assets-title": "Er du sikker på at du vil fjerne { count, plural, =1 {1 eiendel} other {# eiendeler} }?", + "unassign-assets-text": "Etter bekreftelse vil alle valgte eiendeler bli fjernet og ikke være tilgjengelige for kunden.", + "copyId": "Kopier eiendels-ID", + "idCopiedMessage": "Eiendels-ID er kopiert til utklippstavlen", + "select-asset": "Velg eiendel", + "no-assets-matching": "Ingen eiendeler som samsvarer med '{{entity}}' ble funnet.", + "asset-required": "Eiendel er påkrevd", + "name-starts-with": "Navneuttrykk for eiendel", + "help-text": "Bruk '%' etter behov: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "search": "Søk etter eiendeler", + "import": "Importer eiendeler", + "asset-file": "Eiendelsfil", + "label": "Etikett", + "assign-asset-to-edge": "Tildel eiendel(er) til kant", + "unassign-asset-from-edge": "Fjern eiendel", + "unassign-asset-from-edge-title": "Er du sikker på at du vil fjerne eiendelen '{{assetName}}'?", + "unassign-asset-from-edge-text": "Etter bekreftelse vil eiendelen bli fjernet og ikke være tilgjengelig for kanten.", + "unassign-assets-from-edge-title": "Er du sikker på at du vil fjerne { count, plural, =1 {1 eiendel} other {# eiendeler} }?", + "unassign-assets-from-edge-text": "Etter bekreftelse vil alle valgte eiendeler bli fjernet og ikke være tilgjengelige for kanten.", + "selected-assets": "{ count, plural, =1 {1 eiendel} other {# eiendeler} } valgt" + }, + "attribute": { + "attributes": "Attributter", + "latest-telemetry": "Siste telemetri", + "no-latest-telemetry": "Ingen siste telemetri", + "attributes-scope": "Omfang for enhetens attributter", + "scope-telemetry": "Telemetri", + "scope-latest-telemetry": "Siste telemetri", + "scope-client": "Klientattributter", + "scope-server": "Serverattributter", + "scope-shared": "Delte attributter", + "scope-client-short": "Klient", + "scope-server-short": "Server", + "scope-shared-short": "Delt", + "scope-latest-short": "Siste", + "scope-any": "Enhver", + "add": "Legg til attributt", + "key": "Nøkkel", + "key-max-length": "Nøkkelen må være kortere enn 256 tegn", + "last-update-time": "Siste oppdateringstid", + "key-required": "Attributtnøkkel er påkrevd.", + "value": "Verdi", + "value-required": "Attributtverdi er påkrevd.", + "telemetry-key-required": "Telemetrinøkkel er påkrevd", + "telemetry-value-required": "Telemetrivid er påkrevd", + "delete-attributes-title": "Er du sikker på at du vil slette { count, plural, =1 {1 attributt} other {# attributter} }?", + "delete-attributes-text": "Vær forsiktig, etter bekreftelse vil alle valgte attributter bli fjernet.", + "delete-attributes": "Slett attributter", + "enter-attribute-value": "Angi attributtverdi", + "show-on-widget": "Vis på widget", + "widget-mode": "Widget-modus", + "next-widget": "Neste widget", + "prev-widget": "Forrige widget", + "add-to-dashboard": "Legg til på dashboard", + "add-widget-to-dashboard": "Legg til widget på dashboard", + "selected-attributes": "{ count, plural, =1 {1 attributt} other {# attributter} } valgt", + "selected-telemetry": "{ count, plural, =1 {1 telemetrienhet} other {# telemetrienheter} } valgt", + "no-attributes-text": "Ingen attributter funnet", + "no-telemetry-text": "Ingen telemetri funnet", + "copy-key": "Kopier nøkkel", + "add-telemetry": "Legg til telemetri", + "copy-value": "Kopier verdi", + "delete-timeseries": { + "start-time": "Starttid", + "ends-on": "Slutter på", + "strategy": "Strategi", + "delete-strategy": "Slettestrategi", + "all-data": "Slett alle data", + "all-data-except-latest-value": "Slett alle data unntatt siste verdi", + "latest-value": "Slett siste verdi", + "all-data-for-time-period": "Slett alle data for tidsperiode", + "rewrite-latest-value": "Overskriv siste verdi" + } + }, + "api-usage": { + "api-features": "API-funksjoner", + "api-usage": "API-bruk", + "alarm": "Alarm", + "alarms-created": "Opprettede alarmer", + "queue-stats": "Køstatistikk", + "processing-failures-and-timeouts": "Behandlingsfeil og tidsavbrudd", + "exceptions": "Unntak", + "alarms-created-daily-activity": "Daglig aktivitet for opprettede alarmer", + "alarms-created-hourly-activity": "Timebasert aktivitet for opprettede alarmer", + "alarms-created-monthly-activity": "Månedlig aktivitet for opprettede alarmer", + "data-points": "Datapunkter", + "data-points-storage-days": "Lagringsdager for datapunkter", + "device-api": "Enhets-API", + "email": "E-post", + "email-messages": "E-postmeldinger", + "email-messages-daily-activity": "Daglig aktivitet for e-postmeldinger", + "email-messages-monthly-activity": "Månedlig aktivitet for e-postmeldinger", + "executions": "Utførelser", + "scripts": "Skript", + "scripts-hourly-activity": "Timebasert aktivitet for skript", + "scripts-daily-activity": "Daglig aktivitet for skript", + "scripts-monthly-activity": "Månedlig aktivitet for skript", + "javascript": "JavaScript", + "javascript-executions": "JavaScript-utførelser", + "tbel": "TBEL", + "tbel-executions": "TBEL-utførelser", + "latest-error": "Siste feil", + "messages": "Meldinger", + "notifications": "Varsler", + "notifications-email-sms": "Varsler (E-post/SMS)", + "notifications-hourly-activity": "Timebasert aktivitet for varsler", + "permanent-failures": "${entityName} Permanente feil", + "permanent-timeouts": "${entityName} Permanente tidsavbrudd", + "processing-failures": "${entityName} Behandlingsfeil", + "processing-timeouts": "${entityName} Behandlingstidsavbrudd", + "rule-chain": "Regelkjede", + "rule-engine": "Regelmotor", + "rule-engine-daily-activity": "Daglig aktivitet for regelmotor", + "rule-engine-executions": "Utførelser av regelmotor", + "rule-engine-hourly-activity": "Timebasert aktivitet for regelmotor", + "rule-engine-monthly-activity": "Månedlig aktivitet for regelmotor", + "rule-engine-statistics": "Statistikk for regelmotor", + "rule-node": "Regelnode", + "sms": "SMS", + "sms-messages": "SMS-meldinger", + "sms-messages-daily-activity": "Daglig aktivitet for SMS-meldinger", + "sms-messages-monthly-activity": "Månedlig aktivitet for SMS-meldinger", + "successful": "${entityName} Vellykket", + "telemetry": "Telemetri", + "telemetry-persistence": "Telemetrilagring", + "telemetry-persistence-daily-activity": "Daglig aktivitet for telemetrilagring", + "telemetry-persistence-hourly-activity": "Timebasert aktivitet for telemetrilagring", + "telemetry-persistence-monthly-activity": "Månedlig aktivitet for telemetrilagring", + "transport": "Transport", + "transport-daily-activity": "Daglig aktivitet for transport", + "transport-data-points": "Transportdatapunkter", + "transport-hourly-activity": "Timebasert aktivitet for transport", + "transport-messages": "Transportmeldinger", + "transport-monthly-activity": "Månedlig aktivitet for transport", + "view-details": "Vis detaljer", + "view-statistics": "Vis statistikk" + }, + "api-limit": { + "cassandra-queries": "Cassandra-forespørsler", + "entity-version-creation": "Oppretting av enhetsversjon", + "entity-version-load": "Innlasting av enhetsversjon", + "notification-requests": "Varslingsforespørsler", + "notification-requests-per-rule": "Varslingsforespørsler per regel", + "rest-api-requests": "REST API-forespørsler", + "rest-api-requests-per-customer": "REST API-forespørsler per kunde", + "transport-messages": "Transportmeldinger", + "transport-messages-per-device": "Transportmeldinger per enhet", + "transport-messages-per-gateway": "Transportmeldinger per gateway", + "transport-messages-per-gateway-device": "Transportmeldinger per gateway-enhet", + "ws-updates-per-session": "WebSocket-oppdateringer per økt", + "edge-events": "Edge-hendelser", + "edge-events-per-edge": "Edge-hendelser per edge", + "edge-uplink-messages": "Edge-opplinkmeldinger", + "edge-uplink-messages-per-edge": "Edge-opplinkmeldinger per edge" + }, + "audit-log": { + "audit": "Revisjon", + "audit-logs": "Revisjonslogger", + "timestamp": "Tidsstempel", + "entity-type": "Enhetstype", + "entity-name": "Enhetsnavn", + "user": "Bruker", + "type": "Type", + "status": "Status", + "details": "Detaljer", + "type-added": "Lagt til", + "type-deleted": "Slettet", + "type-updated": "Oppdatert", + "type-attributes-updated": "Attributter oppdatert", + "type-attributes-deleted": "Attributter slettet", + "type-rpc-call": "RPC-kall", + "type-credentials-updated": "Legitimasjon oppdatert", + "type-assigned-to-customer": "Tildelt kunde", + "type-unassigned-from-customer": "Fjernet fra kunde", + "type-assigned-to-edge": "Tildelt edge", + "type-unassigned-from-edge": "Fjernet fra edge", + "type-activated": "Aktivert", + "type-suspended": "Suspendert", + "type-credentials-read": "Legitimasjon lest", + "type-attributes-read": "Attributter lest", + "type-relation-add-or-update": "Relasjon oppdatert", + "type-relation-delete": "Relasjon slettet", + "type-relations-delete": "Alle relasjoner slettet", + "type-alarm-ack": "Alarm bekreftet", + "type-alarm-clear": "Alarm fjernet", + "type-alarm-delete": "Alarm slettet", + "type-alarm-assign": "Alarm tildelt", + "type-alarm-unassign": "Alarm fjernet", + "type-added-comment": "Kommentar lagt til", + "type-updated-comment": "Kommentar oppdatert", + "type-deleted-comment": "Kommentar slettet", + "type-login": "Innlogging", + "type-logout": "Utlogging", + "type-lockout": "Utestengt", + "status-success": "Vellykket", + "status-failure": "Mislykket", + "audit-log-details": "Detaljer for revisjonslogg", + "no-audit-logs-prompt": "Ingen logger funnet", + "action-data": "Handlingsdata", + "failure-details": "Feildetaljer", + "search": "Søk i revisjonslogger", + "clear-search": "Fjern søk", + "type-assigned-from-tenant": "Tildelt fra leietaker", + "type-assigned-to-tenant": "Tildelt til leietaker", + "type-provision-success": "Enhet klargjort", + "type-provision-failure": "Klargjøring av enhet mislyktes", + "type-timeseries-updated": "Telemetri oppdatert", + "type-timeseries-deleted": "Telemetri slettet", + "type-sms-sent": "SMS sendt" + }, + "debug-settings": { + "label": "Feilsøkingskonfigurasjon", + "on-failure": "Kun feil (24/7)", + "all-messages": "Alle meldinger ({{time}})", + "failures": "Feil", + "entity": "enhet", + "hint": { + "main-limited": "Ikke mer enn {{msg}} {{entity}} feilsøkingsmeldinger per {{time}} vil bli registrert.", + "on-failure": "Logg kun feilmeldinger.", + "all-messages": "Logg alle feilsøkingsmeldinger." + } + }, + "calculated-fields": { + "expression": "Uttrykk", + "no-found": "Ingen beregnede felt funnet", + "list": "{ count, plural, =1 {Ett beregnet felt} other {Liste med # beregnede felt} }", + "selected-fields": "{ count, plural, =1 {1 beregnet felt} other {# beregnede felt} } valgt", + "type": { + "simple": "Enkel", + "script": "Skript" + }, + "arguments": "Argumenter", + "decimals-by-default": "Desimaler som standard", + "debugging": "Feilsøking av beregnet felt", + "argument-name": "Argumentnavn", + "datasource": "Datakilde", + "add-argument": "Legg til argument", + "test-script-function": "Test skriptfunksjon", + "no-arguments": "Ingen argumenter konfigurert", + "argument-settings": "Innstillinger for argument", + "argument-current": "Gjeldende enhet", + "argument-current-tenant": "Gjeldende leietaker", + "argument-device": "Enhet", + "argument-asset": "Eiendel", + "argument-customer": "Kunde", + "argument-tenant": "Gjeldende leietaker", + "argument-type": "Argumenttype", + "see-debug-events": "Se feilsøkingshendelser", + "attribute": "Attributt", + "copy-argument-name": "Kopier argumentnavn", + "timeseries-key": "Tidsserienøkkel", + "device-name": "Enhetsnavn", + "latest-telemetry": "Siste telemetri", + "rolling": "Rullende tidsserie", + "attribute-scope": "Attributtomfang", + "server-attributes": "Serverattributter", + "client-attributes": "Klientattributter", + "shared-attributes": "Delte attributter", + "attribute-key": "Attributtnøkkel", + "default-value": "Standardverdi", + "limit": "Maksverdier", + "time-window": "Tidsvindu", + "customer-name": "Kundenavn", + "asset-name": "Eiendelsnavn", + "timeseries": "Tidsserie", + "output": "Utdata", + "create": "Opprett nytt beregnet felt", + "file": "Fil for beregnet felt", + "invalid-file-error": "Ugyldig filformat. Vennligst sørg for at filen er en gyldig JSON-fil.", + "import": "Importer beregnet felt", + "export": "Eksporter beregnet felt", + "export-failed-error": "Kan ikke eksportere beregnet felt: {{error}}", + "output-type": "Utdataformat", + "delete-title": "Er du sikker på at du vil slette det beregnede feltet '{{title}}'?", + "delete-text": "Vær forsiktig, etter bekreftelse vil det beregnede feltet og all relatert data ikke kunne gjenopprettes.", + "delete-multiple-title": "Er du sikker på at du vil slette { count, plural, =1 {1 beregnet felt} other {# beregnede felt} }?", + "delete-multiple-text": "Vær forsiktig, etter bekreftelse vil alle valgte beregnede felt bli fjernet og all relatert data vil ikke kunne gjenopprettes.", + "test-with-this-message": "Test med denne meldingen", + "hint": { + "arguments-simple-with-rolling": "Beregnede felt av enkel type bør ikke inneholde nøkler med rullende tidsserietype.", + "arguments-empty": "Argumenter skal ikke være tomme.", + "expression-required": "Uttrykk er påkrevd.", + "expression-invalid": "Uttrykk er ugyldig", + "expression-max-length": "Uttrykket må være kortere enn 255 tegn.", + "argument-name-required": "Argumentnavn er påkrevd.", + "argument-name-pattern": "Argumentnavn er ugyldig.", + "argument-name-duplicate": "Et argument med dette navnet finnes allerede.", + "argument-name-max-length": "Argumentnavn må være kortere enn 256 tegn.", + "argument-name-forbidden": "Argumentnavnet er reservert og kan ikke brukes.", + "argument-type-required": "Argumenttype er påkrevd.", + "max-args": "Maksimalt antall argumenter er nådd.", + "decimals-range": "Desimaler som standard må være et tall mellom 0 og 15.", + "expression": "Standarduttrykket viser hvordan man konverterer temperatur fra Fahrenheit til Celsius.", + "arguments-entity-not-found": "Målenheten for argument ikke funnet." + } + }, + "confirm-on-exit": { + "message": "Du har ulagrede endringer. Er du sikker på at du vil forlate denne siden?", + "html-message": "Du har ulagrede endringer.
    Er du sikker på at du vil forlate denne siden?", + "title": "Ulagrede endringer" + }, + "contact": { + "country": "Land", + "country-required": "Land er påkrevd.", + "city": "By", + "state": "Stat / Provins", + "postal-code": "Postnummer", + "postal-code-invalid": "Ugyldig format på postnummer.", + "address": "Adresse", + "address2": "Adresse 2", + "phone": "Telefon", + "email": "E-post", + "no-address": "Ingen adresse", + "no-country-found": "Ingen land funnet.", + "no-country-matching": "Ingen land som matcher '{{country}}' ble funnet.", + "state-max-length": "Statens navn må være kortere enn 256 tegn", + "phone-max-length": "Telefonnummeret må være kortere enn 256 tegn", + "city-max-length": "Spesifisert by må være kortere enn 256 tegn" + }, + "common": { + "name": "Navn", + "type": "Type", + "general": "Generelt", + "username": "Brukernavn", + "password": "Passord", + "data": "Data", + "timestamp": "Tidsstempel", + "enter-username": "Skriv inn brukernavn", + "enter-password": "Skriv inn passord", + "enter-search": "Skriv inn søk", + "created-time": "Opprettet tid", + "disabled": "Deaktivert", + "loading": "Laster...", + "proceed": "Fortsett", + "open-details-page": "Åpne detaljside", + "not-found": "Ikke funnet", + "value": "Verdi", + "documentation": "Dokumentasjon", + "time-left": "{{time}} igjen", + "output": "Utdata", + "suffix": { + "s": "s", + "ms": "ms" + }, + "hint": { + "name-required": "Navn er påkrevd.", + "name-pattern": "Navnet er ugyldig.", + "name-max-length": "Navnet må være kortere enn 256 tegn.", + "title-required": "Tittel er påkrevd.", + "title-pattern": "Tittelen er ugyldig.", + "title-max-length": "Tittelen må være kortere enn 256 tegn.", + "key-required": "Nøkkel er påkrevd.", + "key-pattern": "Nøkkelen er ugyldig.", + "key-max-length": "Nøkkelen må være kortere enn 256 tegn." + }, + "required-fields": "Påkrevde felt mangler" + }, + "content-type": { + "json": "Json", + "text": "Tekst", + "binary": "Binær (Base64)" + }, + "color": { + "color": "Farge" + }, + "customer": { + "customer": "Kunde", + "customers": "Kunder", + "management": "Kundeadministrasjon", + "dashboard": "Kundedashboard", + "dashboards": "Kundedashboarder", + "devices": "Kundeenheter", + "entity-views": "Kunde Entity Views", + "assets": "Kundeeiendeler", + "public-dashboards": "Offentlige dashboarder", + "public-devices": "Offentlige enheter", + "public-assets": "Offentlige eiendeler", + "public-entity-views": "Offentlige Entity Views", + "add": "Legg til kunde", + "delete": "Slett kunde", + "manage-customer-users": "Administrer kundebrukere", + "manage-customer-devices": "Administrer kundeenheter", + "manage-customer-dashboards": "Administrer kundedashboarder", + "manage-public-devices": "Administrer offentlige enheter", + "manage-public-dashboards": "Administrer offentlige dashboarder", + "manage-customer-assets": "Administrer kundeeiendeler", + "manage-customer-edges": "Administrer kunde edge-enheter", + "manage-public-assets": "Administrer offentlige eiendeler", + "add-customer-text": "Legg til ny kunde", + "no-customers-text": "Ingen kunder funnet", + "customer-details": "Kundedetaljer", + "delete-customer-title": "Er du sikker på at du vil slette kunden '{{customerTitle}}'?", + "delete-customer-text": "Vær forsiktig, etter bekreftelse vil kunden og all tilknyttet data ikke kunne gjenopprettes.", + "delete-customers-title": "Er du sikker på at du vil slette { count, plural, =1 {1 kunde} other {# kunder} }?", + "delete-customers-action-title": "Slett { count, plural, =1 {1 kunde} other {# kunder} }", + "delete-customers-text": "Vær forsiktig, etter bekreftelse vil alle valgte kunder bli fjernet og tilknyttet data vil ikke kunne gjenopprettes.", + "manage-users": "Administrer brukere", + "manage-assets": "Administrer eiendeler", + "manage-devices": "Administrer enheter", + "manage-dashboards": "Administrer dashboarder", + "title": "Tittel", + "title-required": "Tittel er påkrevd.", + "title-max-length": "Tittel bør være mindre enn 256 tegn", + "description": "Beskrivelse", + "details": "Detaljer", + "events": "Hendelser", + "copyId": "Kopier kunde-ID", + "idCopiedMessage": "Kunde-ID er kopiert til utklippstavlen", + "select-customer": "Velg kunde", + "no-customers-matching": "Ingen kunder som samsvarer med '{{entity}}' ble funnet.", + "customer-required": "Kunde er påkrevd", + "select-default-customer": "Velg standard kunde", + "default-customer": "Standard kunde", + "default-customer-required": "Standard kunde er påkrevd for å feilsøke dashboard på leiernivå", + "search": "Søk kunder", + "selected-customers": "{ count, plural, =1 {1 kunde} other {# kunder} } valgt", + "edges": "Kundens edge-instanser", + "manage-edges": "Administrer edge-enheter" + }, + "css-size": { + "size-value-required": "Størrelsesverdi er påkrevd", + "invalid-size-value": "Ugyldig størrelsesverdi" + }, + "date": { + "last-update-n-ago": "Sist oppdatert for N siden", + "last-update-n-ago-text": "Sist oppdatert {{ agoText }}", + "custom-date": "Egendefinert dato", + "format": "Format", + "preview": "Forhåndsvisning", + "auto": "Auto", + "time-granularity-formats": "Tidsgranularitetsformater", + "unit-year": "År", + "unit-month": "Måneder", + "unit-day": "Dager", + "unit-hour": "Timer", + "unit-minute": "Minutter", + "unit-second": "Sekunder", + "unit-millisecond": "Millisekunder" + }, + "datetime": { + "date-from": "Dato fra", + "time-from": "Tid fra", + "date-to": "Dato til", + "time-to": "Tid til", + "from": "Fra", + "to": "Til" + }, + "dashboard": { + "dashboard": "Dashbord", + "dashboards": "Dashbord", + "management": "Dashbordadministrasjon", + "view-dashboards": "Vis dashbord", + "add": "Legg til dashbord", + "assign-dashboard-to-customer": "Tildel dashbord til kunde", + "assign-dashboard-to-customer-text": "Velg dashbord som skal tildeles kunden", + "assign-to-customer-text": "Velg kunden som dashbordet skal tildeles", + "assign-to-customer": "Tildel til kunde", + "unassign-from-customer": "Fjern tildeling fra kunde", + "make-public": "Gjør dashbord offentlig", + "make-private": "Gjør dashbord privat", + "manage-assigned-customers": "Administrer tildelte kunder", + "assigned-customers": "Tildelte kunder", + "assign-to-customers": "Tildel dashbord til kunder", + "assign-to-customers-text": "Velg kunder som dashbordet skal tildeles", + "unassign-from-customers": "Fjern dashbord fra kunder", + "unassign-from-customers-text": "Velg kunder som dashbordet skal fjernes fra", + "no-dashboards-text": "Ingen dashbord funnet", + "no-widgets": "Ingen widgets konfigurert", + "add-widget": "Legg til ny widget", + "add-widget-button-text": "Legg til widget", + "title": "Tittel", + "image": "Dashbordbilde", + "mobile-app-settings": "Innstillinger for mobilapp", + "mobile-order": "Rekkefølge i mobilapp", + "mobile-hide": "Skjul dashbord i mobilapp", + "update-image": "Oppdater dashbordbilde", + "take-screenshot": "Ta skjermbilde", + "select-widget-title": "Velg widget", + "select-widget-value": "{{title}}: velg widget", + "select-widget-subtitle": "Liste over tilgjengelige widgettyper", + "delete": "Slett dashbord", + "title-required": "Tittel er påkrevd.", + "title-max-length": "Tittelen bør være under 256 tegn", + "description": "Beskrivelse", + "details": "Detaljer", + "dashboard-details": "Dashborddetaljer", + "add-dashboard-text": "Legg til nytt dashbord", + "assign-dashboards": "Tildel dashbord", + "assign-new-dashboard": "Tildel nytt dashbord", + "assign-dashboards-text": "Tildel { count, plural, =1 {1 dashbord} other {# dashbord} } til kunder", + "unassign-dashboards-action-text": "Fjern { count, plural, =1 {1 dashbord} other {# dashbord} } fra kunder", + "delete-dashboards": "Slett dashbord", + "unassign-dashboards": "Fjern dashbord", + "unassign-dashboards-action-title": "Fjern { count, plural, =1 {1 dashbord} other {# dashbord} } fra kunde", + "delete-dashboard-title": "Er du sikker på at du vil slette dashbordet '{{dashboardTitle}}'?", + "delete-dashboard-text": "Vær forsiktig, etter bekreftelse blir dashbordet og alle relaterte data ikke gjenopprettbare.", + "delete-dashboards-title": "Er du sikker på at du vil slette { count, plural, =1 {1 dashbord} other {# dashbord} }?", + "delete-dashboards-action-title": "Slett { count, plural, =1 {1 dashbord} other {# dashbord} }", + "delete-dashboards-text": "Vær forsiktig, etter bekreftelse blir alle valgte dashbord og relaterte data fjernet permanent.", + "unassign-dashboard-title": "Er du sikker på at du vil fjerne dashbordet '{{dashboardTitle}}' fra kunden?", + "unassign-dashboard-text": "Etter bekreftelse vil dashbordet ikke være tilgjengelig for kunden.", + "unassign-dashboard": "Fjern dashbord", + "unassign-dashboards-title": "Er du sikker på at du vil fjerne { count, plural, =1 {1 dashbord} other {# dashbord} }?", + "unassign-dashboards-text": "Etter bekreftelse blir alle valgte dashbord fjernet fra kunden.", + "public-dashboard-title": "Dashbordet er nå offentlig", + "public-dashboard-text": "Ditt dashbord {{dashboardTitle}} er nå offentlig og tilgjengelig via denne lenken:", + "public-dashboard-notice": "Merk: Husk å gjøre tilhørende enheter offentlige for å få tilgang til dataene.", + "make-private-dashboard-title": "Er du sikker på at du vil gjøre dashbordet '{{dashboardTitle}}' privat?", + "make-private-dashboard-text": "Etter bekreftelse blir dashbordet privat og ikke tilgjengelig for andre.", + "make-private-dashboard": "Gjør dashbord privat", + "socialshare-text": "'{{dashboardTitle}}' levert av ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' levert av ThingsBoard", + "select-dashboard": "Velg dashbord", + "no-dashboards-matching": "Ingen dashbord som samsvarer med '{{entity}}' ble funnet.", + "dashboard-required": "Dashbord er påkrevd.", + "select-existing": "Velg eksisterende dashbord", + "create-new": "Opprett nytt dashbord", + "new-dashboard-title": "Tittel for nytt dashbord", + "open-dashboard": "Åpne dashbord", + "set-background": "Angi bakgrunn", + "background-color": "Bakgrunnsfarge", + "background-image": "Bakgrunnsbilde", + "background-size-mode": "Bakgrunnsstørrelsesmodus", + "no-image": "Ingen bilde valgt", + "empty-image": "Ingen bilde", + "drop-image": "Slipp et bilde eller klikk for å velge en fil å laste opp.", + "maximum-upload-file-size": "Maksimal filstørrelse for opplasting: {{ size }}", + "cannot-upload-file": "Kan ikke laste opp fil", + "settings": "Innstillinger", + "move-all-widgets": "Flytt alle widgets", + "move-by": "Flytt med", + "cols": "kolonner", + "rows": "rader", + "layout": "Oppsett", + "layout-type-default": "Standard", + "layout-type-scada": "SCADA", + "layout-type-divider": "Skillelinje", + "layout-settings-type": "Oppsettinnstillinger: {{ type }} breakpoint", + "columns-count": "Antall kolonner", + "columns-count-required": "Antall kolonner er påkrevd.", + "min-columns-count-message": "Minimum 10 kolonner er tillatt.", + "max-columns-count-message": "Maksimum 1000 kolonner er tillatt.", + "min-layout-width": "Minimum oppsettbredde", + "columns-suffix": "kolonner", + "widgets-margins": "Mellomrom mellom widgets", + "margin-required": "Margverdi er påkrevd.", + "min-margin-message": "Minimum tillatt margverdi er 0.", + "max-margin-message": "Maksimum tillatt margverdi er 50.", + "horizontal-margin": "Horisontal marg", + "horizontal-margin-required": "Horisontal margverdi er påkrevd.", + "min-horizontal-margin-message": "Minimum tillatt horisontal margverdi er 0.", + "max-horizontal-margin-message": "Maksimum tillatt horisontal margverdi er 50.", + "vertical-margin": "Vertikal marg", + "vertical-margin-required": "Vertikal margverdi er påkrevd.", + "min-vertical-margin-message": "Minimum tillatt vertikal margverdi er 0.", + "max-vertical-margin-message": "Maksimum tillatt vertikal margverdi er 50.", + "apply-outer-margin": "Bruk marg på sidene av oppsettet", + "autofill-height": "Autofyll oppsetthøyde", + "mobile-layout": "Mobiloppsettinnstillinger", + "mobile-row-height": "Radhøyde for mobil", + "mobile-row-height-required": "Radhøydeverdi for mobil er påkrevd.", + "min-mobile-row-height-message": "Minimum 5 piksler er tillatt for radhøyde på mobil.", + "max-mobile-row-height-message": "Maksimum 200 piksler er tillatt for radhøyde på mobil.", + "row-height": "Radhøyde", + "row-height-required": "Radhøydeverdi er påkrevd.", + "min-row-height-message": "Minimum 5 piksler er tillatt for radhøyde.", + "max-row-height-message": "Maksimum 200 piksler er tillatt for radhøyde.", + "display-first-in-mobile-view": "Vis først i mobilvisning", + "title-settings": "Tittelinnstillinger", + "display-title": "Vis dashbordtittel", + "title-color": "Tittelfarge", + "toolbar-settings": "Verktøylinjeinnstillinger", + "hide-toolbar": "Skjul verktøylinje", + "toolbar-always-open": "Hold verktøylinje åpen", + "display-dashboards-selection": "Vis dashbordvalg", + "display-entities-selection": "Vis enhetsvalg", + "display-filters": "Vis filtre", + "display-dashboard-timewindow": "Vis tidsvindu", + "display-dashboard-export": "Vis eksport", + "display-update-dashboard-image": "Vis oppdater dashbordbilde", + "dashboard-logo-settings": "Logo-innstillinger for dashbord", + "display-dashboard-logo": "Vis logo i dashbordets fullskjermmodus", + "dashboard-logo-image": "Dashbordlogo bilde", + "advanced-settings": "Avanserte innstillinger", + "dashboard-css": "Dashbord CSS", + "import": "Importer dashbord", + "export": "Eksporter dashbord", + "export-failed-error": "Kan ikke eksportere dashbord: {{error}}", + "export-prompt": "Bygg inn bilder og ressurser i dashbord", + "create-new-dashboard": "Opprett nytt dashbord", + "dashboard-file": "Dashbordfil", + "invalid-dashboard-file-error": "Kan ikke importere dashbord: Ugyldig datastruktur.", + "dashboard-import-missing-aliases-title": "Konfigurer alias brukt av det importerte dashbordet", + "create-new-widget": "Opprett ny widget", + "import-widget": "Importer widget", + "widget-file": "Widgetfil", + "invalid-widget-file-error": "Kan ikke importere widget: Ugyldig datastruktur for widget.", + "widget-import-missing-aliases-title": "Konfigurer alias brukt av importert widget", + "open-toolbar": "Åpne dashbordverktøylinje", + "close-toolbar": "Lukk verktøylinje", + "configuration-error": "Konfigurasjonsfeil", + "alias-resolution-error-title": "Feil i dashbordalias-konfigurasjon", + "invalid-aliases-config": "Kan ikke finne noen enheter som samsvarer med noen av aliasfiltrene.
    Vennligst kontakt administrator for å løse dette problemet.", + "select-devices": "Velg enheter", + "assignedToCustomer": "Tildelt kunde", + "assignedToCustomers": "Tildelt kunder", + "public": "Offentlig", + "copyId": "Kopier dashbord-id", + "idCopiedMessage": "Dashbord-id har blitt kopiert til utklippstavlen", + "public-link": "Offentlig lenke", + "copy-public-link": "Kopier offentlig lenke", + "public-link-copied-message": "Offentlig dashbordlenke har blitt kopiert til utklippstavlen", + "manage-states": "Administrer dashbordtilstander", + "states": "Dashbordtilstander", + "states-short": "Tilstander", + "search-states": "Søk i dashbordtilstander", + "selected-states": "{ count, plural, =1 {1 dashbordtilstand} other {# dashbordtilstander} } valgt", + "edit-state": "Rediger dashbordtilstand", + "delete-state": "Slett dashbordtilstand", + "add-state": "Legg til dashbordtilstand", + "no-states-text": "Ingen tilstander funnet", + "state": "Dashbordtilstand", + "state-name": "Navn", + "state-name-required": "Navn på dashbordtilstand er påkrevd.", + "state-id": "Tilstands-id", + "state-id-required": "Tilstands-id for dashbord er påkrevd.", + "state-id-exists": "Dashbordtilstand med samme id finnes allerede.", + "is-root-state": "Rottilstand", + "delete-state-title": "Slett dashbordtilstand", + "delete-state-text": "Er du sikker på at du vil slette dashbordtilstanden med navn '{{stateName}}'?", + "show-details": "Vis detaljer", + "hide-details": "Skjul detaljer", + "select-state": "Velg måtilstand", + "state-controller": "Tilstandskontroller", + "state-controller-default": "statisk (utdatert)", + "search": "Søk i dashbord", + "selected-dashboards": "{ count, plural, =1 {1 dashbord} other {# dashbord} } valgt", + "home-dashboard": "Hjemmedashbord", + "home-dashboard-hide-toolbar": "Skjul verktøylinje for hjemmedashbord", + "unassign-dashboard-from-edge-text": "Etter bekreftelse vil dashbordet bli fjernet og ikke lenger være tilgjengelig for edge.", + "unassign-dashboards-from-edge-title": "Er du sikker på at du vil fjerne { count, plural, =1 {1 dashbord} other {# dashbord} }?", + "unassign-dashboards-from-edge-text": "Etter bekreftelse vil alle valgte dashbord bli fjernet og ikke lenger være tilgjengelig for edge.", + "assign-dashboard-to-edge": "Tildel dashbord til edge", + "assign-dashboard-to-edge-text": "Vennligst velg dashbordene du vil tildele til edge", + "non-existent-dashboard-state-error": "Dashbordtilstand med id \"{{ stateId }}\" ble ikke funnet", + "edit-mode": "Redigeringsmodus", + "duplicate-state-action": "Dupliser tilstand", + "breakpoint-value": "Brytpunkt ({{ value }})", + "breakpoints-id": { + "default": "Standard", + "xs": "Mobil (xs)", + "sm": "Nettbrett (sm)", + "md": "Bærbar (md)", + "lg": "Stasjonær (lg)", + "xl": "Stasjonær (xl)" + }, + "view-format-type-grid": "Rutenett", + "view-format-type-list": "Liste", + "view-format": "Visningsformat" + }, + "datakey": { + "settings": "Innstillinger", + "general": "Generelt", + "advanced": "Avansert", + "key": "Nøkkel", + "keys": "Nøkler", + "label": "Etikett", + "color": "Farge", + "units": "Spesialtegn for visning ved siden av verdi", + "decimals": "Antall sifre etter desimalpunkt", + "data-generation-func": "Datagenereringsfunksjon", + "use-data-post-processing-func": "Bruk funksjon for etterbehandling av data", + "configuration": "Datakonfigurasjon", + "timeseries": "Tidsserie", + "attributes": "Attributter", + "entity-field": "Enhetsfelt", + "alarm": "Alarmfelt", + "timeseries-required": "Enhetstidsserier er påkrevd.", + "timeseries-or-attributes-required": "Enhetstidsserier/-attributter er påkrevd.", + "alarm-fields-timeseries-or-attributes-required": "Alarmfelt eller enhetstidsserier/-attributter er påkrevd.", + "maximum-timeseries-or-attributes": "Maksimalt { count, plural, =1 {1 tidsserie/attributt tillatt.} other {# tidsserier/attributter tillatt} }", + "alarm-fields-required": "Alarmfelt er påkrevd.", + "function-types": "Funksjonstyper", + "function-type": "Funksjonstype", + "function-types-required": "Funksjonstyper er påkrevd.", + "data-keys": "Datataster", + "data-key": "Datatast", + "data-keys-required": "Datataster er påkrevd.", + "data-key-required": "Datatast er påkrevd.", + "alarm-keys": "Alarmdatataster", + "alarm-key": "Alarmdatatast", + "alarm-key-functions": "Alarmnøkkelfunksjoner", + "alarm-key-function": "Alarmnøkkelfunksjon", + "latest-keys": "Nyeste datataster", + "latest-key": "Nyeste datatast", + "latest-key-functions": "Nyeste nøkkelfunksjoner", + "latest-key-function": "Nyeste nøkkelfunksjon", + "timeseries-keys": "Tidsseriedatataster", + "timeseries-key": "Tidsseriedatatast", + "timeseries-key-functions": "Tidsserienøkkelfunksjoner", + "timeseries-key-function": "Tidsserienøkkelfunksjon", + "maximum-function-types": "Maksimalt { count, plural, =1 {1 funksjonstype tillatt.} other {# funksjonstyper tillatt} }", + "time-description": "tidsstempel for gjeldende verdi;", + "value-description": "gjeldende verdi;", + "prev-value-description": "resultat fra forrige funksjonskall;", + "time-prev-description": "tidsstempel for forrige verdi;", + "prev-orig-value-description": "opprinnelig forrige verdi;", + "aggregation": "Aggregering", + "aggregation-type-hint-common": "Av ytelsesgrunner er beregning av aggregerte verdier kun tilgjengelig for faste tidsintervaller som \"nåværende dag\", \"nåværende måned\" osv., og ikke tilgjengelig for glidende intervaller som 'siste 30 minutter' eller 'siste 24 timer'.", + "aggregation-type-none-hint": "Bruk siste verdi.", + "aggregation-type-min-hint": "Finn minste verdi blant datapunktene i valgt tidsvindu.", + "aggregation-type-max-hint": "Finn største verdi blant datapunktene i valgt tidsvindu.", + "aggregation-type-avg-hint": "Beregn gjennomsnittlig verdi blant datapunktene i valgt tidsvindu.", + "aggregation-type-sum-hint": "Summer alle verdier i datapunktene i valgt tidsvindu.", + "aggregation-type-count-hint": "Totalt antall datapunkter i valgt tidsvindu.", + "delta-calculation": "Delta-beregning", + "enable-delta-calculation": "Aktiver delta-beregning", + "enable-delta-calculation-hint": "Når aktivert, beregnes datanøkkelverdien basert på aggregerte verdier for valgt tidsvindu og spesifisert sammenligningsperiode. Av ytelsesgrunner er delta-beregning bare tilgjengelig for historiske tidsvinduer og ikke for sanntidsverdier.", + "delta-calculation-result": "Resultat av delta-beregning", + "delta-calculation-result-previous-value": "Forrige verdi", + "delta-calculation-result-delta-absolute": "Delta (absolutt)", + "delta-calculation-result-delta-percent": "Delta (prosent)", + "source": "Kilde", + "latest": "Nyeste", + "latest-value": "Nyeste verdi", + "delta": "delta", + "percent": "prosent", + "absolute": "absolutt" + }, + "datasource": { + "type": "Datakildetype", + "name": "Navn", + "label": "Etikett", + "add-datasource-prompt": "Vennligst legg til datakilde" + }, + "details": { + "details": "Detaljer", + "edit-mode": "Redigeringsmodus", + "edit-json": "Rediger JSON", + "toggle-edit-mode": "Bytt redigeringsmodus" + }, + "device": { + "device": "Enhet", + "device-required": "Enhet er påkrevd.", + "devices": "Enheter", + "management": "Enhetsadministrasjon", + "view-devices": "Vis enheter", + "device-alias": "Enhetsalias", + "device-type-max-length": "Enhetstype bør være mindre enn 256 tegn", + "aliases": "Enhetsaliaser", + "no-alias-matching": "'{{alias}}' ikke funnet.", + "no-aliases-found": "Ingen aliaser funnet.", + "no-key-matching": "'{{key}}' ikke funnet.", + "no-keys-found": "Ingen nøkler funnet.", + "create-new-alias": "Opprett en ny!", + "create-new-key": "Opprett en ny!", + "duplicate-alias-error": "Duplisert alias funnet '{{alias}}'.
    Enhetsaliaser må være unike innenfor dashbordet.", + "configure-alias": "Konfigurer alias '{{alias}}'", + "no-devices-matching": "Ingen enheter som matcher '{{entity}}' ble funnet.", + "alias": "Alias", + "alias-required": "Enhetsalias er påkrevd.", + "remove-alias": "Fjern enhetsalias", + "add-alias": "Legg til enhetsalias", + "name-starts-with": "Enhetsnavnuttrykk", + "help-text": "Bruk '%' etter behov: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "device-list": "Enhetsliste", + "use-device-name-filter": "Bruk filter", + "device-list-empty": "Ingen enheter valgt.", + "device-name-filter-required": "Enhetsnavnfilter er påkrevd.", + "device-name-filter-no-device-matched": "Ingen enheter som starter med '{{device}}' ble funnet.", + "add": "Legg til enhet", + "assign-to-customer": "Tildel til kunde", + "assign-device-to-customer": "Tildel enhet(er) til kunde", + "assign-device-to-customer-text": "Vennligst velg enhetene som skal tildeles kunden", + "make-public": "Gjør enhet offentlig", + "make-private": "Gjør enhet privat", + "no-devices-text": "Ingen enheter funnet", + "assign-to-customer-text": "Vennligst velg kunden for å tildele enheten(e)", + "device-details": "Enhetsdetaljer", + "add-device-text": "Legg til ny enhet", + "credentials": "Legitimasjon", + "manage-credentials": "Administrer legitimasjon", + "delete": "Slett enhet", + "assign-devices": "Tildel enheter", + "assign-devices-text": "Tildel { count, plural, =1 {1 enhet} other {# enheter} } til kunde", + "delete-devices": "Slett enheter", + "unassign-from-customer": "Fjern fra kunde", + "unassign-devices": "Fjern enheter", + "unassign-devices-action-title": "Fjern { count, plural, =1 {1 enhet} other {# enheter} } fra kunde", + "unassign-device-from-edge-title": "Er du sikker på at du vil fjerne enheten '{{deviceName}}'?", + "unassign-device-from-edge-text": "Etter bekreftelse vil enheten bli fjernet og vil ikke være tilgjengelig for edge.", + "unassign-devices-from-edge": "Fjern enheter fra edge", + "assign-new-device": "Tildel ny enhet", + "make-public-device-title": "Er du sikker på at du vil gjøre enheten '{{deviceName}}' offentlig?", + "make-public-device-text": "Etter bekreftelse vil enheten og alle dens data bli gjort offentlig og tilgjengelig for andre.", + "make-private-device-title": "Er du sikker på at du vil gjøre enheten '{{deviceName}}' privat?", + "make-private-device-text": "Etter bekreftelse vil enheten og alle dens data bli gjort privat og ikke tilgjengelig for andre.", + "view-credentials": "Vis legitimasjon", + "delete-device-title": "Er du sikker på at du vil slette enheten '{{deviceName}}'?", + "delete-device-text": "Vær forsiktig, etter bekreftelse vil enheten og alle relaterte data bli permanent slettet.", + "delete-devices-title": "Er du sikker på at du vil slette { count, plural, =1 {1 enhet} other {# enheter} }?", + "delete-devices-action-title": "Slett { count, plural, =1 {1 enhet} other {# enheter} }", + "delete-devices-text": "Vær forsiktig, etter bekreftelse vil alle valgte enheter og relaterte data bli permanent slettet.", + "unassign-device-title": "Er du sikker på at du vil fjerne enheten '{{deviceName}}'?", + "unassign-device-text": "Etter bekreftelse vil enheten bli fjernet og ikke lenger være tilgjengelig for kunden.", + "unassign-device": "Fjern enhet", + "unassign-devices-title": "Er du sikker på at du vil fjerne { count, plural, =1 {1 enhet} other {# enheter} }?", + "unassign-devices-text": "Etter bekreftelse vil alle valgte enheter bli fjernet og ikke lenger være tilgjengelige for kunden.", + "device-credentials": "Enhetslegitimasjon", + "loading-device-credentials": "Laster enhetslegitimasjon...", + "credentials-type": "Legitimasjonstype", + "access-token": "Tilgangstoken", + "access-token-required": "Tilgangstoken er påkrevd.", + "access-token-invalid": "Tilgangstoken må være mellom 1 og 32 tegn langt.", + "certificate-pem-format": "Sertifikat i PEM-format", + "certificate-pem-format-required": "Sertifikat er påkrevd.", + "copy-access-token": "Kopier tilgangstoken", + "copy-certificate": "Kopier sertifikat", + "copy-client-id": "Kopier klient-ID", + "copy-user-name": "Kopier brukernavn", + "copy-password": "Kopier passord", + "generate-client-id": "Generer klient-ID", + "generate-user-name": "Generer brukernavn", + "generate-password": "Generer passord", + "generate-access-token": "Generer tilgangstoken", + "lwm2m-security-config": { + "identity": "Klientidentitet", + "identity-required": "Klientidentitet er påkrevd.", + "identity-tooltip": "PSK-identifikatoren er en vilkårlig PSK-ID opptil 128 byte, som beskrevet i standarden [RFC7925].\nPSK-identifikatoren MÅ først konverteres til en tegnstreng og deretter kodes til oktetter ved bruk av UTF-8.", + "client-key": "Klientnøkkel", + "client-key-required": "Klientnøkkel er påkrevd.", + "client-key-tooltip-prk": "RPK offentlig nøkkel eller ID må være i standard [RFC7250] og kodes i Base64-format!", + "client-key-tooltip-psk": "PSK-nøkkel må være i standard [RFC4279] og i HexDesimal-format: 32, 64, 128 tegn!", + "endpoint": "Klientens endepunktnavn", + "endpoint-required": "Klientens endepunktnavn er påkrevd.", + "client-public-key": "Klientens offentlige nøkkel", + "client-public-key-hint": "Hvis klientens offentlige nøkkel er tom, vil det betrodde sertifikatet bli brukt", + "client-public-key-tooltip": "X509 offentlig nøkkel må være i DER-kodet X509v3-format, kun støtte EC-algoritme og kodes i Base64-format!", + "mode": "Sikkerhetskonfigurasjonsmodus", + "client-tab": "Klientsikkerhetskonfigurasjon", + "client-certificate": "Klientsertifikat", + "bootstrap-tab": "Bootstrap-klient", + "bootstrap-server": "Bootstrap-server", + "lwm2m-server": "LwM2M-server", + "client-publicKey-or-id": "Klient offentlig nøkkel eller ID", + "client-publicKey-or-id-required": "Klient offentlig nøkkel eller ID er påkrevd.", + "client-publicKey-or-id-tooltip-psk": "PSK-identifikatoren er en vilkårlig PSK-ID opptil 128 byte, som beskrevet i standarden [RFC7925].\nMÅ konverteres til en tegnstreng og kodes i UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "RPK offentlig nøkkel eller ID må være i standard [RFC7250] og kodes i Base64-format!", + "client-publicKey-or-id-tooltip-x509": "X509 offentlig nøkkel må være i DER-kodet X509v3-format, støtte kun EC-algoritme og kodes i Base64-format", + "client-secret-key": "Klientens hemmelige nøkkel", + "client-secret-key-required": "Klientens hemmelige nøkkel er påkrevd.", + "client-secret-key-tooltip-psk": "PSK-nøkkel må være i standard [RFC4279] og i HexDesimal-format: 32, 64, 128 tegn!", + "client-secret-key-tooltip-prk": "RPK hemmelig nøkkel må være i PKCS_8-format (DER-koding, standard [RFC5958]) og deretter kodes i Base64-format!", + "client-secret-key-tooltip-x509": "X509 hemmelig nøkkel må være i PKCS_8-format (DER-koding, standard [RFC5958]) og deretter kodes i Base64-format!" + }, + "client-id": "Klient-ID", + "client-id-pattern": "Inneholder ugyldig tegn.", + "user-name": "Brukernavn", + "user-name-required": "Brukernavn er påkrevd.", + "client-id-or-user-name-necessary": "Klient-ID og/eller brukernavn er nødvendige", + "password": "Passord", + "secret": "Hemmelighet", + "secret-required": "Hemmelighet er påkrevd.", + "device-type": "Enhetsprofil", + "device-type-required": "Enhetstype er påkrevd.", + "select-device-type": "Velg enhetstype", + "enter-device-type": "Skriv inn enhetsprofil", + "any-device": "Enhver enhet", + "no-device-types-matching": "Ingen enhetsprofiler som matcher '{{entitySubtype}}' ble funnet.", + "device-type-list-empty": "Ingen enhetsprofiler valgt!", + "device-profile-type-list-empty": "Minst én enhetsprofil må velges.", + "device-types": "Enhetstyper", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "name-max-length": "Navn bør være mindre enn 256 tegn", + "label-max-length": "Etikett bør være mindre enn 256 tegn", + "description": "Beskrivelse", + "label": "Etikett", + "events": "Hendelser", + "details": "Detaljer", + "copyId": "Kopier enhets-ID", + "copyAccessToken": "Kopier tilgangstoken", + "copy-mqtt-authentication": "Kopier MQTT-legitimasjon", + "idCopiedMessage": "Enhets-ID har blitt kopiert til utklippstavlen", + "accessTokenCopiedMessage": "Enhetens tilgangstoken har blitt kopiert til utklippstavlen", + "mqtt-authentication-copied-message": "MQTT-legitimasjonen for enheten har blitt kopiert til utklippstavlen", + "assignedToCustomer": "Tildelt kunde", + "unable-delete-device-alias-title": "Kan ikke slette enhetsalias", + "unable-delete-device-alias-text": "Enhetsalias '{{deviceAlias}}' kan ikke slettes da det brukes av følgende widget(er):
    {{widgetsList}}", + "is-gateway": "Er gateway", + "overwrite-activity-time": "Overstyr aktivitetstid for tilkoblet enhet", + "device-filter": "Enhetsfilter", + "device-filter-title": "Enhetsfilter", + "filter-title": "Filter", + "device-state": "Enhetstilstand", + "state": "Tilstand", + "any": "Enhver", + "active": "Aktiv", + "inactive": "Inaktiv", + "public": "Offentlig", + "device-public": "Enheten er offentlig", + "select-device": "Velg enhet", + "import": "Importer enhet", + "device-file": "Enhetsfil", + "search": "Søk etter enheter", + "selected-devices": "{ count, plural, =1 {1 enhet} other {# enheter} } valgt", + "device-configuration": "Enhetskonfigurasjon", + "transport-configuration": "Transportkonfigurasjon", + "wizard": { + "device-details": "Enhetsdetaljer" + }, + "unassign-devices-from-edge-title": "Er du sikker på at du vil fjerne tildelingen av { count, plural, =1 {1 enhet} other {# enheter} }?", + "unassign-devices-from-edge-text": "Etter bekreftelse vil alle valgte enheter bli fjernet fra tildelingen og ikke være tilgjengelige via edge.", + "time": "Tid", + "connectivity": { + "check-connectivity": "Sjekk tilkobling", + "device-created-check-connectivity": "Enhet opprettet. La oss sjekke tilkoblingen!", + "loading-check-connectivity-command": "Laster tilkoblingssjekk-kommandoer...", + "use-following-instructions": "Bruk følgende instruksjoner for å sende telemetri på vegne av enheten ved bruk av kommandolinje", + "execute-following-command": "Kjør følgende kommando", + "install-curl-windows": "Fra og med Windows 10 b17063 er cURL tilgjengelig som standard", + "install-curl-macos": "Fra og med Mac OS X 10.2 6C115 (Jaguar) er cURL tilgjengelig som standard", + "install-mqtt-windows": "Bruk instruksjonene for å laste ned, installere, konfigurere og kjøre mosquitto_pub", + "install-coap-client": "Bruk instruksjonene for å laste ned, installere, konfigurere og kjøre coap-client", + "install-necessary-client-tools": "Installer nødvendige klientverktøy", + "mqtts-x509-command": "Bruk følgende dokumentasjon for å koble enheten via MQTT med X509-autentisering", + "coaps-x509-command": "Bruk følgende dokumentasjon for å koble enheten via CoAP over DTLS med X509-autentisering", + "snmp-command": "Bruk følgende dokumentasjon for å koble enheten via SNMP.", + "sparkplug-command": "Bruk følgende dokumentasjon for å koble enheten via MQTT Sparkplug.", + "lwm2m-command": "Bruk følgende dokumentasjon for å koble enheten via LWM2M." + } + }, + "dynamic-form": { + "property": { + "properties": "Egenskaper", + "property": "Egenskap", + "id": "Id", + "name": "Navn", + "type": "Type", + "type-text": "Tekst", + "type-password": "Passord", + "type-textarea": "Tekstområde", + "type-number": "Tall", + "type-switch": "Bryter", + "type-select": "Velg", + "type-radios": "Radioknapper", + "type-datetime": "Dato/Tid", + "type-image": "Bilde", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "Markdown", + "type-color": "Farge", + "type-color-settings": "Fargeinnstillinger", + "type-font": "Skrift", + "type-units": "Enheter", + "type-icon": "Ikon", + "type-fieldset": "Feltsamling", + "type-array": "Array", + "type-html-section": "HTML-seksjon", + "group-title": "Grupptittel", + "no-properties": "Ingen egenskaper konfigurert", + "add-property": "Legg til egenskap", + "property-settings": "Egenskapsinnstillinger", + "remove-property": "Fjern egenskap", + "default-value": "Standardverdi", + "value-required": "Verdi kreves", + "number-settings": "Tallinnstillinger", + "min": "Min", + "max": "Maks", + "step": "Steg", + "selected-options-limit": "Begrensning for valgte alternativer", + "advanced-ui-settings": "Avanserte grensesnittinnstillinger", + "disable-on-property": "Deaktiver ved egenskap", + "display-condition-function": "Visningsbetingelsesfunksjon", + "sub-label": "Underetikett", + "vertical-divider-after": "Vertikal skillelinje etter", + "input-field-suffix": "Suffiks for inndatafelt", + "property-row-classes": "Radklasser for egenskap", + "property-field-classes": "Feltklasser for egenskap", + "not-unique-property-ids-error": "Egenskaps-IDer må være unike!", + "enable-multiple-select": "Aktiver flervalg", + "allow-empty-select-option": "Tillat tomt valg", + "select-options": "Valgalternativer", + "not-unique-select-option-value-error": "Verdier for valgalternativer må være unike!", + "value": "Verdi", + "label": "Etikett", + "add-option": "Legg til alternativ", + "no-options": "Ingen alternativer konfigurert", + "remove-option": "Fjern alternativ", + "textarea-rows": "Rader i tekstområde", + "help-id": "Hjelpe-ID", + "buttons-direction": "Knappers retning", + "direction-row": "Rad", + "direction-column": "Kolonne", + "radio-button-options": "Alternativer for radioknapper", + "datetime-type": "Feltetype for dato/tid", + "datetime-type-date": "Dato", + "datetime-type-time": "Tid", + "datetime-type-datetime": "Dato/Tid", + "enable-clear-button": "Aktiver fjern-knapp", + "html-section-settings": "Innstillinger for HTML-seksjon", + "html-section-classes": "Klasser for HTML-seksjon", + "html-section-content": "Innhold i HTML-seksjon", + "array-item": "Array-element", + "item-type": "Elementtype", + "item-name": "Elementnavn", + "no-items": "Ingen elementer" + }, + "clear-form": "Tøm skjema", + "clear-form-prompt": "Er du sikker på at du vil fjerne alle skjemaegenskaper?", + "import-form": "Importer skjema fra JSON", + "export-form": "Eksporter skjema til JSON", + "json-file": "JSON-fil", + "json-content": "JSON-innhold", + "invalid-form-json-file-error": "Kan ikke importere skjema fra JSON: Ugyldig datastruktur i JSON-skjema." + }, + "asset-profile": { + "asset-profile": "Eiendomsprofil", + "asset-profiles": "Eiendomsprofiler", + "all-asset-profiles": "Alle", + "add": "Legg til eiendomsprofil", + "edit": "Rediger eiendomsprofil", + "asset-profile-details": "Detaljer om eiendomsprofil", + "no-asset-profiles-text": "Ingen eiendomsprofiler funnet", + "search": "Søk i eiendomsprofiler", + "selected-asset-profiles": "{ count, plural, =1 {1 eiendomsprofil} other {# eiendomsprofiler} } valgt", + "no-asset-profiles-matching": "Ingen eiendomsprofiler samsvarer med '{{entity}}'.", + "asset-profile-required": "Eiendomsprofil er påkrevd", + "idCopiedMessage": "Eiendomsprofil-ID er kopiert til utklippstavlen", + "set-default": "Gjør eiendomsprofil til standard", + "delete": "Slett eiendomsprofil", + "copyId": "Kopier eiendomsprofil-ID", + "name-max-length": "Navnet må være mindre enn 256 tegn", + "new-device-profile-name": "Navn på eiendomsprofil", + "new-device-profile-name-required": "Navn på eiendomsprofil er påkrevd.", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "image": "Bilde av eiendomsprofil", + "description": "Beskrivelse", + "default": "Standard", + "default-rule-chain": "Standard regelkjede", + "default-edge-rule-chain": "Standard edge-regelkjede", + "default-edge-rule-chain-hint": "Brukes på edge som regelkjede for behandling av innkommende data for eiendommer av denne profilen", + "mobile-dashboard": "Mobil dashbord", + "mobile-dashboard-hint": "Brukes av mobilappen som dashbord for eiendomsdetaljer", + "select-queue-hint": "Velg fra rullegardinmenyen.", + "delete-asset-profile-title": "Er du sikker på at du vil slette eiendomsprofilen '{{assetProfileName}}'?", + "delete-asset-profile-text": "Vær forsiktig, etter bekreftelse vil eiendomsprofilen og alle tilknyttede data bli ugjenopprettelige.", + "delete-asset-profiles-title": "Er du sikker på at du vil slette { count, plural, =1 {1 eiendomsprofil} other {# eiendomsprofiler} }?", + "delete-asset-profiles-text": "Vær forsiktig, etter bekreftelse vil alle valgte eiendomsprofiler og alle tilknyttede data bli ugjenopprettelige.", + "set-default-asset-profile-title": "Er du sikker på at du vil gjøre eiendomsprofilen '{{assetProfileName}}' til standard?", + "set-default-asset-profile-text": "Etter bekreftelse vil eiendomsprofilen bli merket som standard og vil bli brukt for nye eiendommer uten spesifisert profil.", + "no-asset-profiles-found": "Ingen eiendomsprofiler funnet.", + "create-new-asset-profile": "Opprett en ny!", + "create-asset-profile": "Opprett ny eiendomsprofil", + "import": "Importer eiendomsprofil", + "export": "Eksporter eiendomsprofil", + "export-failed-error": "Kan ikke eksportere eiendomsprofil: {{error}}", + "asset-profile-file": "Eiendomsprofil-fil", + "invalid-asset-profile-file-error": "Kan ikke importere eiendomsprofil: Ugyldig datastruktur for eiendomsprofil." + }, + "device-profile": { + "device-profile": "Enhetsprofil", + "device-profiles": "Enhetsprofiler", + "all-device-profiles": "Alle", + "add": "Legg til enhetsprofil", + "edit": "Rediger enhetsprofil", + "device-profile-details": "Detaljer om enhetsprofil", + "no-device-profiles-text": "Ingen enhetsprofiler funnet", + "search": "Søk i enhetsprofiler", + "selected-device-profiles": "{ count, plural, =1 {1 enhetsprofil} other {# enhetsprofiler} } valgt", + "no-device-profiles-matching": "Ingen enhetsprofiler samsvarer med '{{entity}}'.", + "device-profile-required": "Enhetsprofil er påkrevd", + "idCopiedMessage": "Enhetsprofil-ID er kopiert til utklippstavlen", + "set-default": "Gjør enhetsprofil til standard", + "delete": "Slett enhetsprofil", + "copyId": "Kopier enhetsprofil-ID", + "name-max-length": "Navnet må være mindre enn 256 tegn", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "type": "Profiltype", + "type-required": "Profiltype er påkrevd.", + "type-default": "Standard", + "image": "Bilde av enhetsprofil", + "transport-type": "Transporttype", + "transport-type-required": "Transporttype er påkrevd.", + "transport-type-default": "Standard", + "transport-type-default-hint": "Støtter grunnleggende MQTT-, HTTP- og CoAP-transport", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Aktiverer avanserte MQTT-transportinnstillinger", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Aktiverer avanserte CoAP-transportinnstillinger", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M-transporttype", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Angi SNMP transportkonfigurasjon", + "transport-type-http": "HTTP", + "description": "Beskrivelse", + "default": "Standard", + "profile-configuration": "Profilkonfigurasjon", + "transport-configuration": "Transportkonfigurasjon", + "default-rule-chain": "Standard regelkjede", + "default-edge-rule-chain": "Standard edge-regelkjede", + "default-edge-rule-chain-hint": "Brukes på edge som regelkjede for behandling av innkommende data for enheter av denne profilen", + "mobile-dashboard": "Mobil dashbord", + "mobile-dashboard-hint": "Brukes av mobilappen som dashbord for enhetsdetaljer", + "select-queue-hint": "Velg fra rullegardinmenyen.", + "delete-device-profile-title": "Er du sikker på at du vil slette enhetsprofilen '{{deviceProfileName}}'?", + "delete-device-profile-text": "Vær forsiktig, etter bekreftelse vil enhetsprofilen og alle relaterte data inkludert tilknyttede OTA-oppdateringer bli ugjenopprettelige.", + "delete-device-profiles-title": "Er du sikker på at du vil slette { count, plural, =1 {1 enhetsprofil} other {# enhetsprofiler} }?", + "delete-device-profiles-text": "Vær forsiktig, etter bekreftelse vil alle valgte enhetsprofiler og alle relaterte data inkludert tilknyttede OTA-oppdateringer bli ugjenopprettelige.", + "set-default-device-profile-title": "Er du sikker på at du vil gjøre enhetsprofilen '{{deviceProfileName}}' til standard?", + "set-default-device-profile-text": "Etter bekreftelse vil enhetsprofilen bli merket som standard og brukes for nye enheter uten spesifisert profil.", + "no-device-profiles-found": "Ingen enhetsprofiler funnet.", + "create-new-device-profile": "Opprett en ny!", + "mqtt-device-topic-filters": "MQTT enhetstema-filtre", + "mqtt-device-topic-filters-unique": "MQTT enhetstema-filtre må være unike.", + "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN)-node.", + "mqtt-device-topic-filters-spark-plug-hint": "Tillat tilkoblinger fra EoN-noder med Sparkplug B nyttelast og temastruktur.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "SparkPlug-målinger som skal lagres som attributter.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "Navn på SparkPlug-målinger som vil bli lagret som enhetsattributter. Alle andre målinger lagres som telemetri.", + "mqtt-device-payload-type": "MQTT enhetsnyttelast", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Aktiver kompatibilitet med andre nyttelastformater.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Når aktivert, vil plattformen bruke Protobuf som standard nyttelastformat. Hvis parsing mislykkes, vil JSON benyttes. Nyttig for bakoverkompatibilitet ved firmwareoppdateringer. Kompatibilitetsmodus kan gi redusert ytelse, og bør deaktiveres etter fullført oppdatering.", + "mqtt-use-json-format-for-default-downlink-topics": "Bruk JSON-format for standard nedlink-temaer", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Når aktivert, vil plattformen bruke JSON til å sende attributter og RPC over temaene: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, osv. Påvirker ikke abonnementer via nye v2-temaer.", + "mqtt-send-ack-on-validation-exception": "Send PUBACK ved valideringsfeil", + "mqtt-send-ack-on-validation-exception-hint": "Som standard lukkes MQTT-økten ved valideringsfeil. Hvis aktivert, sendes isteden en bekreftelse.", + "snmp-add-mapping": "Legg til SNMP-mapping", + "snmp-mapping-not-configured": "Ingen mapping for OID til tidsserie/telemetri konfigurert", + "snmp-timseries-or-attribute-name": "Navn på tidsserie/attributt for mapping", + "snmp-timseries-or-attribute-type": "Type på tidsserie/attributt for mapping", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Nyttelasttype er påkrevd.", + "coap-device-type": "CoAP enhetstype", + "coap-device-payload-type": "CoAP enhetsnyttelast", + "coap-device-type-required": "CoAP enhetstype er påkrevd.", + "coap-device-type-default": "Standard", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Enkle [+] og fler-nivå [#] jokertegn støttes.", + "telemetry-topic-filter": "Telemetri temafilter", + "telemetry-topic-filter-required": "Telemetri temafilter er påkrevd.", + "attributes-topic-filter": "Publiseringstema for attributter", + "attributes-subscribe-topic-filter": "Abonnementstema for attributter", + "attributes-topic-filter-required": "Publiseringstema for attributter er påkrevd.", + "attributes-subscribe-topic-filter-required": "Abonnementstema for attributter er påkrevd", + "telemetry-proto-schema": "Telemetri proto skjema", + "telemetry-proto-schema-required": "Telemetri proto skjema er påkrevd.", + "attributes-proto-schema": "Attributter proto skjema", + "attributes-proto-schema-required": "Attributter proto skjema er påkrevd.", + "rpc-response-proto-schema": "RPC svar proto skjema", + "rpc-response-proto-schema-required": "RPC svar proto skjema er påkrevd.", + "rpc-response-topic-filter": "RPC svar temafilter", + "rpc-response-topic-filter-required": "RPC svar temafilter er påkrevd.", + "rpc-request-proto-schema": "RPC forespørsel proto skjema", + "rpc-request-proto-schema-required": "RPC forespørsel proto skjema er påkrevd.", + "rpc-request-proto-schema-hint": "RPC forespørsel må alltid ha feltene: string method = 1; int32 requestId = 2; og params = 3 av hvilken som helst datatype.", + "not-valid-pattern-topic-filter": "Ugyldig temafiltermønster", + "not-valid-single-character": "Ugyldig bruk av ett-nivås jokertegn", + "not-valid-multi-character": "Ugyldig bruk av fler-nivås jokertegn", + "single-level-wildcards-hint": "[+] passer for ethvert nivå i temafilteret. Eksempler: v1/devices/+/telemetry eller +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] kan erstatte hele temafilteret og må være siste tegn. Eksempler: # eller v1/devices/me/#.", + "alarm-rules": "Alarmregler", + "alarm-rules-with-count": "Alarmregler ({{count}})", + "no-alarm-rules": "Ingen alarmregler konfigurert", + "add-alarm-rule": "Legg til alarmregel", + "edit-alarm-rule": "Rediger alarmregel", + "alarm-type": "Alarmtype", + "alarm-type-required": "Alarmtype er påkrevd.", + "alarm-type-unique": "Alarmtype må være unik innenfor enhetsprofilens alarmregler.", + "alarm-type-max-length": "Alarmtype må være kortere enn 256 tegn", + "create-alarm-pattern": "Opprett {{alarmType}} alarm", + "create-alarm-rules": "Opprett alarmregler", + "no-create-alarm-rules": "Ingen opprett-betingelser konfigurert", + "add-create-alarm-rule-prompt": "Vennligst legg til alarmopprettingsregel", + "clear-alarm-rule": "Fjern alarmregel", + "no-clear-alarm-rule": "Ingen fjern-betingelse konfigurert", + "add-create-alarm-rule": "Legg til opprett-betingelse", + "add-clear-alarm-rule": "Legg til fjern-betingelse", + "select-alarm-severity": "Velg alarmalvorlighet", + "alarm-severity-required": "Alarmalvorlighet er påkrevd.", + "condition-duration": "Varighet for betingelse", + "condition-duration-value": "Varighetsverdi", + "condition-duration-time-unit": "Tidsenhet", + "condition-duration-value-range": "Varighetsverdi må være mellom 1 og 2147483647.", + "condition-duration-value-pattern": "Varighetsverdi må være heltall.", + "condition-duration-value-required": "Varighetsverdi er påkrevd.", + "condition-duration-time-unit-required": "Tidsenhet er påkrevd.", + "advanced-settings": "Avanserte innstillinger", + "alarm-rule-additional-info": "Tilleggsinformasjon", + "edit-alarm-rule-additional-info": "Rediger tilleggsinformasjon", + "alarm-rule-additional-info-placeholder": "Vennligst legg til kommentarer og justeringer som skal vises under Alarmdetaljer > Tilleggsinformasjon", + "alarm-rule-additional-info-hint": "Tips: bruk ${keyName} for å sette inn verdier fra attributter eller telemetri i alarmbetingelsen.", + "alarm-rule-mobile-dashboard": "Mobildashboard", + "alarm-rule-mobile-dashboard-hint": "Brukes av mobilappen som dashbord for alarmdetaljer", + "alarm-rule-no-mobile-dashboard": "Ingen dashbord valgt", + "propagate-alarm": "Propager alarm til relaterte enheter", + "alarm-rule-relation-types-list": "Relasjonstyper", + "alarm-rule-relation-types-list-hint": "Definerer relasjonstyper for filtrering. Hvis tomt, propageres til alle relaterte enheter.", + "propagate-alarm-to-owner": "Propager alarm til enhetens eier (Kunde eller Leietaker)", + "propagate-alarm-to-tenant": "Propager alarm til leietaker", + "alarm-rule-condition": "Alarmregelbetingelse", + "enter-alarm-rule-condition-prompt": "Vennligst legg til alarmregelbetingelse", + "edit-alarm-rule-condition": "Rediger alarmregelbetingelse", + "device-provisioning": "Enhetsklargjøring", + "provision-strategy": "Klargjøringsstrategi", + "provision-strategy-required": "Klargjøringsstrategi er påkrevd.", + "provision-strategy-disabled": "Deaktivert", + "provision-strategy-created-new": "Tillat å opprette nye enheter", + "provision-strategy-check-pre-provisioned": "Sjekk for forhåndsklargjorte enheter", + "provision-device-key": "Klargjøringsnøkkel", + "provision-device-key-required": "Klargjøringsnøkkel er påkrevd.", + "copy-provision-key": "Kopier klargjøringsnøkkel", + "provision-key-copied-message": "Klargjøringsnøkkel er kopiert til utklippstavlen", + "provision-device-secret": "Klargjøringshemmelighet", + "provision-device-secret-required": "Klargjøringshemmelighet er påkrevd.", + "copy-provision-secret": "Kopier klargjøringshemmelighet", + "provision-secret-copied-message": "Klargjøringshemmelighet er kopiert til utklippstavlen", + "provision-strategy-x509": { + "certificate-chain": "X509-sertifikatkjede", + "certificate-chain-hint": "X.509-sertifikatstrategi brukes for å klargjøre enheter med klientsertifikater i toveis TLS-kommunikasjon.", + "allow-create-new-devices": "Opprett nye enheter", + "allow-create-new-devices-hint": "Hvis valgt, vil nye enheter opprettes og klientsertifikatet vil brukes som enhetens legitimasjon.", + "certificate-value": "Sertifikat i PEM-format", + "certificate-value-required": "Sertifikat i PEM-format er påkrevd", + "cn-regex-variable": "CN Regulært uttrykksvariabel", + "cn-regex-variable-required": "CN Regulært uttrykksvariabel er påkrevd", + "cn-regex-variable-hint": "Brukes for å hente enhetsnavn fra enhetens X509-sertifikats Common Name." + }, + "condition": "Betingelse", + "condition-type": "Betingelsestype", + "condition-type-simple": "Enkel", + "condition-type-duration": "Varighet", + "condition-during": "Under {{during}}", + "condition-during-dynamic": "Under \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Gjentas", + "condition-type-required": "Betingelsestype er påkrevd.", + "condition-repeating-value": "Antall hendelser", + "condition-repeating-value-range": "Antall hendelser må være mellom 1 og 2147483647.", + "condition-repeating-value-pattern": "Antall hendelser må være heltall.", + "condition-repeating-value-required": "Antall hendelser er påkrevd.", + "condition-repeat-times": "Gjentas { count, plural, =1 {1 gang} other {# ganger} }", + "condition-repeat-times-dynamic": "Gjentas \"{ attribute }\" ({ count, plural, =1 {1 gang} other {# ganger} })", + "schedule-type": "Tidsplanstype", + "schedule-type-required": "Tidsplanstype er påkrevd.", + "schedule": "Tidsplan", + "edit-schedule": "Rediger alarmtidsplan", + "schedule-any-time": "Aktiv hele tiden", + "schedule-specific-time": "Aktiv på spesifisert tid", + "schedule-custom": "Tilpasset", + "schedule-day": { + "monday": "Mandag", + "tuesday": "Tirsdag", + "wednesday": "Onsdag", + "thursday": "Torsdag", + "friday": "Fredag", + "saturday": "Lørdag", + "sunday": "Søndag" + }, + "schedule-days": "Dager", + "schedule-time": "Tid", + "schedule-time-from": "Fra", + "schedule-time-to": "Til", + "schedule-days-of-week-required": "Minst én ukedag må være valgt.", + "create-device-profile": "Opprett ny enhetsprofil", + "import": "Importer enhetsprofil", + "export": "Eksporter enhetsprofil", + "export-failed-error": "Kan ikke eksportere enhetsprofil: {{error}}", + "device-profile-file": "Enhetsprofilfil", + "invalid-device-profile-file-error": "Kan ikke importere enhetsprofil: Ugyldig datastruktur.", + "power-saving-mode": "Strømsparingsmodus", + "power-saving-mode-type": { + "default": "Bruk enhetsprofilens strømsparingsmodus", + "psm": "Strømsparingsmodus (PSM)", + "drx": "Diskontinuerlig mottak (DRX)", + "edrx": "Utvidet diskontinuerlig mottak (eDRX)" + }, + "edrx-cycle": "eDRX-syklus", + "edrx-cycle-required": "eDRX-syklus er påkrevd.", + "edrx-cycle-pattern": "eDRX-syklus må være et positivt heltall.", + "edrx-cycle-min": "Minimum eDRX-syklus er {{ min }} sekunder.", + "paging-transmission-window": "Paging-overføringsvindu", + "paging-transmission-window-required": "Paging-overføringsvindu er påkrevd.", + "paging-transmission-window-pattern": "Paging-overføringsvindu må være et positivt heltall.", + "paging-transmission-window-min": "Minimum paging-overføringsvindu er {{ min }} sekunder.", + "psm-activity-timer": "PSM aktivitetstimer", + "psm-activity-timer-required": "PSM aktivitetstimer er påkrevd.", + "psm-activity-timer-pattern": "PSM aktivitetstimer må være et positivt heltall.", + "psm-activity-timer-min": "Minimum PSM aktivitetstimer er {{ min }} sekunder.", + "lwm2m": { + "object-list": "Objektliste", + "object-list-empty": "Ingen objekter valgt.", + "no-objects-found": "Ingen objekter funnet.", + "no-objects-matching": "Ingen objekter som samsvarer med '{{object}}' ble funnet.", + "model-tab": "LWM2M-modell", + "add-new-instances": "Legg til nye instanser", + "instances-list": "Instansliste", + "instances-list-required": "Instansliste er påkrevd.", + "instance-id-pattern": "Instans-ID må være et positivt heltall.", + "instance-id-max": "Maksimal verdi for instans-ID er {{max}}.", + "instance": "Instans", + "resource-label": "#ID Ressursnavn", + "observe-label": "Observer", + "attribute-label": "Attributt", + "telemetry-label": "Telemetri", + "edit-observe-select": "For å redigere observasjon, velg telemetri eller attributt", + "edit-attributes-select": "For å redigere attributter, velg telemetri eller attributt", + "no-attributes-set": "Ingen attributter satt", + "key-name": "Nøkkelnavn", + "key-name-required": "Nøkkelnavn er påkrevd", + "attribute-name": "Attributtnavn", + "attribute-name-required": "Attributtnavn er påkrevd.", + "attribute-value": "Attributtverdi", + "attribute-value-required": "Attributtverdi er påkrevd.", + "attribute-value-pattern": "Attributtverdi må være et positivt heltall.", + "edit-attributes": "Rediger attributter: {{ name }}", + "view-attributes": "Vis attributter: {{ name }}", + "add-attribute": "Legg til attributt", + "edit-attribute": "Rediger attributt", + "view-attribute": "Vis attributt", + "remove-attribute": "Fjern attributt", + "delete-server-text": "Vær forsiktig, etter bekreftelse vil serverkonfigurasjonen ikke kunne gjenopprettes.", + "delete-server-title": "Er du sikker på at du vil slette serveren?", + "mode": "Sikkerhetskonfigurasjonsmodus", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap-server (ShortId...)", + "lwm2m-server-legend": "LwM2M-server (ShortId...)", + "server": "Server", + "short-id": "Kort server-ID", + "short-id-tooltip": "Kort server-ID. Brukes for å koble til serverobjektinstans.\nDenne identifikatoren identifiserer hver LwM2M-server for klienten.\nRessursen MÅ være satt hvis Bootstrap-Server Ressursen har verdien 'false'.\nID:0 og ID:65535 MÅ IKKE brukes.", + "short-id-tooltip-bootstrap": "Kort server-ID. Brukes for å koble til serverobjektinstans.\nDenne identifikatoren identifiserer hver LwM2M-server for klienten.\nRessursen MÅ være satt hvis Bootstrap-Server Ressursen har verdien 'false'.", + "short-id-required": "Kort server-ID er påkrevd.", + "short-id-range": "Kort server-ID må være mellom {{ min }} og {{ max }}.", + "short-id-pattern": "Kort server-ID må være et positivt heltall.", + "lifetime": "Klientens registreringsvarighet", + "lifetime-required": "Klientens registreringsvarighet er påkrevd.", + "lifetime-pattern": "Registreringsvarighet må være et positivt heltall.", + "default-min-period": "Minste periode mellom to varslinger (s)", + "default-min-period-tooltip": "Standardverdi for Minimum Periode for Observasjon hvis parameteren ikke er satt.", + "default-min-period-required": "Minimumsperiode er påkrevd.", + "default-min-period-pattern": "Minimumsperiode må være et positivt heltall.", + "notification-storing": "Lagring av varsling når deaktivert eller frakoblet", + "binding": "Binding", + "binding-type": { + "u": "U: Klienten er alltid tilgjengelig via UDP.", + "m": "M: Klienten er alltid tilgjengelig via MQTT.", + "h": "H: Klienten er alltid tilgjengelig via HTTP.", + "t": "T: Klienten er alltid tilgjengelig via TCP.", + "s": "S: Klienten er alltid tilgjengelig via SMS.", + "n": "N: Klienten MÅ sende svar via Non-IP binding (støttes fra LWM2M 1.1).", + "uq": "UQ: UDP i kømodus (ikke støttet siden LWM2M 1.1)", + "uqs": "UQS: Både UDP og SMS aktive; UDP i kømodus, SMS i standardmodus (ikke støttet siden LWM2M 1.1)", + "tq": "TQ: TCP i kømodus (ikke støttet siden LWM2M 1.1)", + "tqs": "TQS: Både TCP og SMS aktive; TCP i kømodus, SMS i standardmodus (ikke støttet siden LWM2M 1.1)", + "sq": "SQ: SMS i kømodus (ikke støttet siden LWM2M 1.1)" + }, + "binding-tooltip": "Liste over bindingsmodus i LwM2M serverobjektet - /1/x/7.\nAngir støttede bindingsmodi i klienten.\nBør matche verdien i “Supported Binding and Modes” i Enhetsobjektet (/3/0/16).\nKun én transportbinding brukes per økt.", + "bootstrap-server": "Bootstrap-server", + "lwm2m-server": "LwM2M-server", + "include-bootstrap-server": "Inkluder Bootstrap-serveroppdateringer", + "bootstrap-update-title": "Bootstrap-serveren er allerede konfigurert. Er du sikker på at du vil ekskludere oppdateringene?", + "bootstrap-update-text": "Vær forsiktig, etter bekreftelse vil Bootstrap-serverkonfigurasjonen ikke kunne gjenopprettes.", + "server-host": "Vert", + "server-host-required": "Vert er påkrevd.", + "server-port": "Port", + "server-port-required": "Port er påkrevd.", + "server-port-pattern": "Port må være et positivt heltall.", + "server-port-range": "Port bør være i området 1 til 65535.", + "server-public-key": "Serverens offentlige nøkkel", + "server-public-key-required": "Serverens offentlige nøkkel er påkrevd.", + "client-hold-off-time": "Hold-av-tid", + "client-hold-off-time-required": "Hold-av-tid er påkrevd.", + "client-hold-off-time-pattern": "Hold-av-tid må være et positivt heltall.", + "client-hold-off-time-tooltip": "Klientens Hold-av-tid kun for bruk med Bootstrap-server", + "account-after-timeout": "Fortsett etter timeout", + "account-after-timeout-required": "Fortsett etter timeout er påkrevd.", + "account-after-timeout-pattern": "Verdien må være et positivt heltall.", + "account-after-timeout-tooltip": "Bootstrap-server fortsett etter timeout-verdien som angitt av denne ressursen.", + "server-type": "Servertype", + "add-new-server-title": "Legg til ny serverkonfigurasjon", + "add-server-config": "Legg til serverkonfigurasjon", + "add-lwm2m-server-config": "Legg til LwM2M-server", + "no-config-servers": "Ingen servere konfigurert", + "others-tab": "Andre innstillinger", + "client-strategy": "Klientstrategi ved tilkobling", + "client-strategy-label": "Strategi", + "client-strategy-only-observe": "Kun Observer-forespørsel til klienten etter første tilkobling", + "client-strategy-read-all": "Les alle ressurser og send Observer-forespørsel etter registrering", + "fw-update": "Firmware-oppdatering", + "fw-update-strategy": "Firmware-oppdateringsstrategi", + "fw-update-strategy-data": "Send firmware som binærfil via Objekt 19 og Ressurs 0 (Data)", + "fw-update-strategy-package": "Send firmware som binærfil via Objekt 5 og Ressurs 0 (Pakke)", + "fw-update-strategy-package-uri": "Generer unik CoAP-URL og send firmware via Objekt 5 og Ressurs 1 (Pakke URI)", + "sw-update": "Programvareoppdatering", + "sw-update-strategy": "Programvareoppdateringsstrategi", + "sw-update-strategy-package": "Send binærfil via Objekt 9 og Ressurs 2 (Pakke)", + "sw-update-strategy-package-uri": "Generer unik CoAP-URL og send programvare via Objekt 9 og Ressurs 3 (Pakke URI)", + "fw-update-resource": "Firmware-oppdatering CoAP-ressurs", + "fw-update-resource-required": "Firmware-oppdatering CoAP-ressurs er påkrevd.", + "sw-update-resource": "Programvareoppdatering CoAP-ressurs", + "sw-update-resource-required": "Programvareoppdatering CoAP-ressurs er påkrevd.", + "config-json-tab": "JSON-konfigurasjon for enhet", + "attributes-name": { + "min-period": "Minimumsperiode", + "max-period": "Maksimumsperiode", + "greater-than": "Større enn", + "less-than": "Mindre enn", + "step": "Steg", + "min-evaluation-period": "Minimum evalueringsperiode", + "max-evaluation-period": "Maksimum evalueringsperiode" + }, + "default-object-id": "Standard objektversjon (Attributt)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1" + } + }, + "snmp": { + "add-communication-config": "Legg til kommunikasjonskonfigurasjon", + "add-mapping": "Legg til tilordning", + "authentication-passphrase": "Autentiseringsfrase", + "authentication-passphrase-required": "Autentiseringsfrase er påkrevd.", + "authentication-protocol": "Autentiseringsprotokoll", + "authentication-protocol-required": "Autentiseringsprotokoll er påkrevd.", + "communication-configs": "Kommunikasjonskonfigurasjoner", + "community": "Community-streng", + "community-required": "Community-streng er påkrevd.", + "context-name": "Kontekstnavn", + "data-key": "Datanøkkel", + "data-key-required": "Datanøkkel er påkrevd.", + "data-type": "Datatype", + "data-type-required": "Datatype er påkrevd.", + "engine-id": "Engine-ID", + "host": "Vert", + "host-required": "Vert er påkrevd.", + "oid": "OID", + "oid-pattern": "Ugyldig OID-format.", + "oid-required": "OID er påkrevd.", + "please-add-communication-config": "Vennligst legg til kommunikasjonskonfigurasjon", + "please-add-mapping-config": "Vennligst legg til tilordningskonfigurasjon", + "port": "Port", + "port-format": "Ugyldig portformat.", + "port-required": "Port er påkrevd.", + "privacy-passphrase": "Personvernfrase", + "privacy-passphrase-required": "Personvernfrase er påkrevd.", + "privacy-protocol": "Personvernprotokoll", + "privacy-protocol-required": "Personvernprotokoll er påkrevd.", + "protocol-version": "Protokollversjon", + "protocol-version-required": "Protokollversjon er påkrevd.", + "querying-frequency": "Forespørselsfrekvens, ms", + "querying-frequency-invalid-format": "Forespørselsfrekvens må være et positivt heltall.", + "querying-frequency-required": "Forespørselsfrekvens er påkrevd.", + "retries": "Forsøk", + "retries-invalid-format": "Forsøk må være et positivt heltall.", + "retries-required": "Antall forsøk er påkrevd.", + "scope": "Omfang", + "scope-required": "Omfang er påkrevd.", + "security-name": "Sikkerhetsnavn", + "security-name-required": "Sikkerhetsnavn er påkrevd.", + "timeout-ms": "Tidsavbrudd, ms", + "timeout-ms-invalid-format": "Tidsavbrudd må være et positivt heltall.", + "timeout-ms-required": "Tidsavbrudd er påkrevd.", + "user-name": "Brukernavn", + "user-name-required": "Brukernavn er påkrevd." + } + }, + "dialog": { + "close": "Lukk dialog", + "error-message-title": "Feilmelding:", + "error-details-title": "Feildetaljer" + }, + "direction": { + "column": "Kolonne", + "row": "Rad" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Edge-installasjoner", + "instances": "Installasjoner", + "edge-file": "Edge-fil", + "name-max-length": "Navn må være mindre enn 256 tegn", + "label-max-length": "Etikett må være mindre enn 256 tegn", + "type-max-length": "Type må være mindre enn 256 tegn", + "management": "Edge-administrasjon", + "no-edges-matching": "Ingen edge-enheter som samsvarer med '{{entity}}' ble funnet.", + "add": "Legg til edge", + "no-edges-text": "Ingen edge-enheter funnet", + "edge-details": "Edge-detaljer", + "add-edge-text": "Legg til ny edge", + "delete": "Slett edge", + "delete-edge-title": "Er du sikker på at du vil slette edge '{{edgeName}}'?", + "delete-edge-text": "Vær forsiktig, etter bekreftelse vil edge og alle relaterte data bli uopprettelige.", + "delete-edges-title": "Er du sikker på at du vil slette { count, plural, =1 {1 edge} other {# edges} }?", + "delete-edges-text": "Vær forsiktig, etter bekreftelse vil alle valgte edge-enheter bli fjernet og alle relaterte data bli uopprettelige.", + "name": "Navn", + "name-starts-with": "Edge-navn starter med", + "name-required": "Navn er påkrevd.", + "description": "Beskrivelse", + "details": "Detaljer", + "events": "Hendelser", + "copy-id": "Kopier Edge-ID", + "id-copied-message": "Edge-ID har blitt kopiert til utklippstavlen", + "sync": "Synkroniser Edge", + "edge-required": "Edge er påkrevd", + "edge-type": "Edge-type", + "edge-type-required": "Edge-type er påkrevd.", + "event-action": "Hendelseshandling", + "entity-id": "Enhets-ID", + "select-edge-type": "Velg edge-type", + "assign-to-customer": "Tildel til kunde", + "assign-to-customer-text": "Vennligst velg kunden du vil tildele edge-enhet(er)", + "assign-edge-to-customer": "Tildel edge-enhet(er) til kunde", + "assign-edge-to-customer-text": "Vennligst velg edge-enheter du vil tildele til kunden", + "assignedToCustomer": "Tildelt kunde", + "edge-public": "Edge er offentlig", + "assigned-to-customer": "Tildelt til: {{customerTitle}}", + "unassign-from-customer": "Fjern tildeling fra kunde", + "unassign-edge-title": "Er du sikker på at du vil fjerne tildelingen av edge '{{edgeName}}'?", + "unassign-edge-text": "Etter bekreftelse vil edge bli fjernet og ikke lenger være tilgjengelig for kunden.", + "unassign-edges-title": "Er du sikker på at du vil fjerne tildelingen av { count, plural, =1 {1 edge} other {# edges} }?", + "unassign-edges-text": "Etter bekreftelse vil alle valgte edge-enheter bli fjernet og ikke lenger være tilgjengelig for kunden.", + "make-public": "Gjør edge offentlig", + "make-public-edge-title": "Er du sikker på at du vil gjøre edge '{{edgeName}}' offentlig?", + "make-public-edge-text": "Etter bekreftelse vil edge og alle tilhørende data bli offentlig og tilgjengelig for andre.", + "make-private": "Gjør edge privat", + "public": "Offentlig", + "make-private-edge-title": "Er du sikker på at du vil gjøre edge '{{edgeName}}' privat?", + "make-private-edge-text": "Etter bekreftelse vil edge og alle tilhørende data bli private og ikke tilgjengelige for andre.", + "import": "Importer edge", + "install-connect-instructions": "Installer og koble til-instruksjoner", + "install-connect-instructions-edge-created": "Edge opprettet! Se Installer og koble til-instruksjoner", + "loading-edge-instructions": "Laster edge-instruksjoner...", + "label": "Etikett", + "load-entity-error": "Kunne ikke laste data. Enheten er slettet.", + "assign-new-edge": "Tildel ny edge", + "unassign-from-edge": "Fjern tildeling fra edge", + "edge-key": "Edge-nøkkel", + "copy-edge-key": "Kopier Edge-nøkkel", + "edge-key-copied-message": "Edge-nøkkel har blitt kopiert til utklippstavlen", + "edge-secret": "Edge-hemmelighet", + "copy-edge-secret": "Kopier Edge-hemmelighet", + "edge-secret-copied-message": "Edge-hemmelighet har blitt kopiert til utklippstavlen", + "manage-assets": "Administrer eiendeler", + "manage-devices": "Administrer enheter", + "manage-entity-views": "Administrer enhetsvisninger", + "manage-dashboards": "Administrer dashbord", + "manage-rulechains": "Administrer regelkjeder", + "assets": "Edge-eiendeler", + "devices": "Edge-enheter", + "entity-views": "Edge-enhetsvisninger", + "dashboard": "Edge-dashbord", + "dashboards": "Edge-dashbord", + "rulechain-templates": "Regelkjedemaler", + "edge-rulechain-templates": "Edge regelkjedemaler", + "rulechains": "Edge regelkjeder", + "search": "Søk edge-enheter", + "selected-edges": "{ count, plural, =1 {1 edge} other {# edges} } valgt", + "any-edge": "Enhver edge", + "no-edge-types-matching": "Ingen edge-typer som samsvarer med '{{entitySubtype}}' ble funnet.", + "edge-type-list-empty": "Ingen edge-typer valgt.", + "edge-types": "Edge-typer", + "enter-edge-type": "Angi edge-type", + "deployed": "Distribuert", + "pending": "Ventende", + "downlinks": "Nedlinker", + "no-downlinks-prompt": "Ingen nedlinker funnet", + "sync-process-started-successfully": "Synkroniseringsprosess startet!", + "missing-related-rule-chains-title": "Edge mangler relaterte regelkjeder", + "missing-related-rule-chains-text": "Tildelte edge-regelkjeder bruker noder som videresender meldinger til regelkjeder som ikke er tildelt denne edge-enheten.

    Manglende regelkjeder:
    {{missingRuleChains}}", + "widget-datasource-error": "Denne widgeten støtter kun EDGE-enhetsdatakilde", + "upgrade-instructions": "Oppgraderingsinstruksjoner", + "connected": "Tilkoblet", + "disconnected": "Frakoblet" + }, + "edge-event": { + "type-dashboard": "Dashbord", + "type-asset": "Eiendel", + "type-device": "Enhet", + "type-device-profile": "Enhetsprofil", + "type-asset-profile": "Eiendelsprofil", + "type-entity-view": "Enhetsvisning", + "type-alarm": "Alarm", + "type-rule-chain": "Regelkjede", + "type-rule-chain-metadata": "Regelkjedemetadata", + "type-edge": "Edge", + "type-user": "Bruker", + "type-tenant": "Leietaker", + "type-tenant-profile": "Leietakerprofil", + "type-customer": "Kunde", + "type-relation": "Relasjon", + "type-widgets-bundle": "Widget-pakke", + "type-widgets-type": "Widget-type", + "type-admin-settings": "Admininnstillinger", + "type-ota-package": "OTA-pakke", + "type-queue": "Kø", + "action-type-added": "Lagt til", + "action-type-deleted": "Slettet", + "action-type-updated": "Oppdatert", + "action-type-post-attributes": "Send attributter", + "action-type-attributes-updated": "Attributter oppdatert", + "action-type-attributes-deleted": "Attributter slettet", + "action-type-timeseries-updated": "Tidsserie oppdatert", + "action-type-credentials-updated": "Legitimasjon oppdatert", + "action-type-assigned-to-customer": "Tildelt kunde", + "action-type-unassigned-from-customer": "Fratatt kunde", + "action-type-relation-add-or-update": "Relasjon lagt til eller oppdatert", + "action-type-relation-deleted": "Relasjon slettet", + "action-type-rpc-call": "RPC-kall", + "action-type-alarm-ack": "Alarm bekreftet", + "action-type-alarm-clear": "Alarm fjernet", + "action-type-alarm-assigned": "Alarm tildelt", + "action-type-alarm-unassigned": "Alarm fratatt", + "action-type-assigned-to-edge": "Tildelt Edge", + "action-type-unassigned-from-edge": "Fratatt Edge", + "action-type-credentials-request": "Legitimasjonsforespørsel", + "action-type-entity-merge-request": "Forespørsel om sammenslåing" + }, + "error": { + "unable-to-connect": "Kan ikke koble til serveren! Vennligst sjekk internettforbindelsen.", + "unhandled-error-code": "Ubehandlet feilkode: {{errorCode}}", + "unknown-error": "Ukjent feil" + }, + "entity": { + "entity": "Enhet", + "entities": "Enheter", + "entities-count": "Antall enheter", + "alarms-count": "Antall alarmer", + "aliases": "Enhetsaliaser", + "aliases-short": "Aliaser", + "entity-alias": "Enhetsalias", + "unable-delete-entity-alias-title": "Kan ikke slette enhetsalias", + "unable-delete-entity-alias-text": "Enhetsaliaset '{{entityAlias}}' kan ikke slettes fordi det brukes av følgende widget(er):
    {{widgetsList}}", + "duplicate-alias-error": "Duplikat alias funnet '{{alias}}'.
    Enhetsaliaser må være unike innenfor dashbordet.", + "missing-entity-filter-error": "Filter mangler for alias '{{alias}}'.", + "configure-alias": "Konfigurer aliaset '{{alias}}'", + "alias": "Alias", + "alias-required": "Enhetsalias er påkrevd.", + "remove-alias": "Fjern enhetsalias", + "add-alias": "Legg til enhetsalias", + "edit-alias": "Rediger enhetsalias", + "entity-list": "Enhetsliste", + "entity-type": "Enhetstype", + "entity-types": "Enhetstyper", + "entity-type-list": "Liste over enhetstyper", + "any-entity": "Enhver enhet", + "add-entity-type": "Legg til enhetstype", + "enter-entity-type": "Skriv inn enhetstype", + "no-entities-matching": "Ingen enheter som samsvarer med '{{entity}}' ble funnet.", + "no-entities-text": "Ingen enheter funnet", + "no-entity-types-matching": "Ingen enhetstyper som samsvarer med '{{entityType}}' ble funnet.", + "name-starts-with": "Navnuttrykk", + "help-text": "Bruk '%' etter behov: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "use-entity-name-filter": "Bruk filter", + "entity-list-empty": "Ingen enheter valgt.", + "entity-type-list-required": "Minst én enhetstype må velges.", + "entity-name-filter-required": "Enhetsnavnfilter er påkrevd.", + "entity-name-filter-no-entity-matched": "Ingen enheter som starter med '{{entity}}' ble funnet.", + "all-subtypes": "Alle", + "select-entities": "Velg enheter", + "no-aliases-found": "Ingen aliaser funnet.", + "no-alias-matching": "'{{alias}}' ble ikke funnet.", + "create-new-alias": "Opprett en ny!", + "create-new": "Opprett ny", + "key": "Nøkkel", + "key-name": "Nøkkelnavn", + "no-keys-found": "Ingen nøkler funnet.", + "no-key-matching": "'{{key}}' ble ikke funnet.", + "create-new-key": "Opprett en ny!", + "type": "Type", + "type-required": "Enhetstype er påkrevd.", + "type-device": "Enhet", + "type-devices": "Enheter", + "list-of-devices": "{ count, plural, =1 {Én enhet} other {Liste over # enheter} }", + "device-name-starts-with": "Enheter som starter med '{{prefix}}'", + "type-device-profile": "Enhetsprofil", + "type-device-profiles": "Enhetsprofiler", + "clear-selected-profiles": "Fjern valgte profiler", + "list-of-device-profiles": "{ count, plural, =1 {Én enhetsprofil} other {Liste over # enhetsprofiler} }", + "device-profile-name-starts-with": "Enhetsprofiler som starter med '{{prefix}}'", + "type-asset-profile": "Eiendelsprofil", + "type-asset-profiles": "Eiendelsprofiler", + "list-of-asset-profiles": "{ count, plural, =1 {Én eiendelsprofil} other {Liste over # eiendelsprofiler} }", + "asset-profile-name-starts-with": "Eiendelsprofiler som starter med '{{prefix}}'", + "type-asset": "Eiendel", + "type-assets": "Eiendeler", + "list-of-assets": "{ count, plural, =1 {Én eiendel} other {Liste over # eiendeler} }", + "asset-name-starts-with": "Eiendeler som starter med '{{prefix}}'", + "type-entity-view": "Enhetsvisning", + "type-entity-views": "Enhetsvisninger", + "list-of-entity-views": "{ count, plural, =1 {Én enhetsvisning} other {Liste over # enhetsvisninger} }", + "entity-view-name-starts-with": "Enhetsvisninger som starter med '{{prefix}}'", + "type-rule": "Regel", + "type-rules": "Regler", + "list-of-rules": "{ count, plural, =1 {Én regel} other {Liste over # regler} }", + "rule-name-starts-with": "Regler som starter med '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "list-of-plugins": "{ count, plural, =1 {Én plugin} other {Liste over # plugins} }", + "plugin-name-starts-with": "Plugins som starter med '{{prefix}}'", + "type-tenant": "Leietaker", + "type-tenants": "Leietakere", + "list-of-tenants": "{ count, plural, =1 {Én leietaker} other {Liste over # leietakere} }", + "tenant-name-starts-with": "Leietakere hvis navn starter med '{{prefix}}'", + "type-tenant-profile": "Leietakerprofil", + "type-tenant-profiles": "Leietakerprofiler", + "list-of-tenant-profiles": "{ count, plural, =1 {Én leietakerprofil} other {Liste over # leietakerprofiler} }", + "tenant-profile-name-starts-with": "Leietakerprofiler hvis navn starter med '{{prefix}}'", + "type-customer": "Kunde", + "type-customers": "Kunder", + "list-of-customers": "{ count, plural, =1 {Én kunde} other {Liste over # kunder} }", + "customer-name-starts-with": "Kunder hvis navn starter med '{{prefix}}'", + "type-user": "Bruker", + "type-users": "Brukere", + "list-of-users": "{ count, plural, =1 {Én bruker} other {Liste over # brukere} }", + "user-name-starts-with": "Brukere hvis navn starter med '{{prefix}}'", + "type-dashboard": "Dashbord", + "type-dashboards": "Dashbord", + "list-of-dashboards": "{ count, plural, =1 {Én dashbord} other {Liste over # dashbord} }", + "dashboard-name-starts-with": "Dashbord som starter med '{{prefix}}'", + "type-alarm": "Alarm", + "type-alarms": "Alarmer", + "list-of-alarms": "{ count, plural, =1 {Én alarm} other {Liste over # alarmer} }", + "alarm-name-starts-with": "Alarmer som starter med '{{prefix}}'", + "type-rulechain": "Regelkjede", + "type-rulechains": "Regelkjeder", + "list-of-rulechains": "{ count, plural, =1 {Én regelkjede} other {Liste over # regelkjeder} }", + "rulechain-name-starts-with": "Regelkjeder som starter med '{{prefix}}'", + "type-rulenode": "Regelnode", + "type-rulenodes": "Regelnoder", + "list-of-rulenodes": "{ count, plural, =1 {Én regelnode} other {Liste over # regelnoder} }", + "rulenode-name-starts-with": "Regelnoder som starter med '{{prefix}}'", + "type-current-customer": "Gjeldende kunde", + "type-current-tenant": "Gjeldende leietaker", + "type-current-user": "Gjeldende bruker", + "type-current-user-owner": "Gjeldende brukereier", + "type-calculated-field": "Beregnet felt", + "type-calculated-fields": "Beregnete felt", + "type-widgets-bundle": "Widget-pakke", + "type-widgets-bundles": "Widget-pakker", + "list-of-widgets-bundles": "{ count, plural, =1 {Én widget-pakke} other {Liste over # widget-pakker} }", + "type-widget": "Widget", + "type-widgets": "Widgeter", + "list-of-widgets": "{ count, plural, =1 {Én widget} other {Liste over # widgeter} }", + "search": "Søk i enheter", + "selected-entities": "{ count, plural, =1 {1 enhet} other {# enheter} } valgt", + "entity-name": "Enhetsnavn", + "entity-label": "Enhetsetikett", + "details": "Enhetsdetaljer", + "no-entities-prompt": "Ingen enheter funnet", + "no-data": "Ingen data å vise", + "columns-to-display": "Kolonner å vise", + "type-api-usage-state": "API-bruksstatus", + "type-edge": "Edge", + "type-edges": "Edge-enheter", + "list-of-edges": "{ count, plural, =1 {Én edge} other {Liste over # edge-enheter} }", + "edge-name-starts-with": "Edge-enheter som starter med '{{prefix}}'", + "version-conflict": { + "message": "Vil du overskrive eksisterende versjon eller forkaste endringer og laste inn den nyeste versjonen?", + "link": "Du kan laste ned din versjon av {{entityType}} ved å bruke denne", + "overwrite": "Overskriv versjon", + "discard": "Forkast endringer" + }, + "type-tb-resource": "Ressurs", + "type-tb-resources": "Ressurser", + "list-of-tb-resources": "{ count, plural, =1 {Én ressurs} other {Liste over # ressurser} }", + "type-ota-package": "OTA-pakke", + "type-rpc": "RPC", + "type-queue": "Kø", + "type-queue-stats": "Køstatistikk", + "type-queues-stats": "Statistikk for køer", + "type-notification": "Varsling", + "type-notification-rule": "Varslingsregel", + "type-notification-rules": "Varslingsregler", + "list-of-notification-rules": "{ count, plural, =1 {Én varslingsregel} other {Liste over # varslingsregler} }", + "type-notification-target": "Varslingsmottaker", + "type-notification-targets": "Varslingsmottakere", + "list-of-notification-targets": "{ count, plural, =1 {Én varslingsmottaker} other {Liste over # varslingsmottakere} }", + "type-notification-request": "Varslingsforespørsel", + "type-notification-template": "Varslingsmal", + "type-notification-templates": "Varslingsmaler", + "list-of-notification-templates": "{ count, plural, =1 {Én varslingsmal} other {Liste over # varslingsmaler} }", + "link": "lenke", + "type-oauth2-client": "OAuth 2.0-klient", + "type-oauth2-clients": "OAuth 2.0-klienter", + "list-of-oauth2-clients": "{ count, plural, =1 {Én OAuth 2.0-klient} other {Liste over # OAuth 2.0-klienter} }", + "type-domain": "Domene", + "type-domains": "Domener", + "list-of-domains": "{ count, plural, =1 {Én domene} other {Liste over # domener} }", + "type-mobile-app": "Mobilapplikasjon", + "type-mobile-apps": "Mobilapplikasjoner", + "list-of-mobile-apps": "{ count, plural, =1 {Én mobilapplikasjon} other {Liste over # mobilapplikasjoner} }", + "type-mobile-app-bundle": "Mobilpakke", + "type-mobile-app-bundles": "Mobilpakker", + "list-of-mobile-app-bundles": "{ count, plural, =1 {Én mobilpakke} other {Liste over # mobilpakker} }" + }, + "entity-field": { + "created-time": "Opprettelsestid", + "name": "Navn", + "type": "Type", + "first-name": "Fornavn", + "last-name": "Etternavn", + "email": "E-post", + "title": "Tittel", + "country": "Land", + "state": "Stat/Fylke", + "city": "By", + "address": "Adresse", + "address2": "Adresse 2", + "zip": "Postnummer", + "phone": "Telefon", + "label": "Etikett", + "queue-name": "Kønavn", + "service-id": "Tjeneste-ID", + "owner-name": "Eiernavn", + "owner-type": "Eiertype" + }, + "entity-view": { + "entity-view": "Entitetsvisning", + "entity-view-required": "Entitetsvisning er påkrevd.", + "entity-views": "Entitetsvisninger", + "management": "Administrasjon av entitetsvisning", + "view-entity-views": "Vis entitetsvisninger", + "entity-view-alias": "Alias for entitetsvisning", + "aliases": "Aliaser for entitetsvisning", + "no-alias-matching": "'{{alias}}' ble ikke funnet.", + "no-aliases-found": "Ingen aliaser funnet.", + "no-key-matching": "'{{key}}' ble ikke funnet.", + "no-keys-found": "Ingen nøkler funnet.", + "create-new-alias": "Opprett en ny!", + "create-new-key": "Opprett en ny!", + "duplicate-alias-error": "Duplikat alias funnet '{{alias}}'.
    Aliaser for entitetsvisning må være unike innenfor dashbordet.", + "configure-alias": "Konfigurer aliaset '{{alias}}'", + "no-entity-views-matching": "Ingen entitetsvisninger som matcher '{{entity}}' ble funnet.", + "public": "Offentlig", + "alias": "Alias", + "alias-required": "Alias for entitetsvisning er påkrevd.", + "remove-alias": "Fjern alias for entitetsvisning", + "add-alias": "Legg til alias for entitetsvisning", + "name-starts-with": "Navnuttrykk for entitetsvisning", + "help-text": "Bruk '%' etter behov: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "Liste over entitetsvisninger", + "use-entity-view-name-filter": "Bruk filter", + "entity-view-list-empty": "Ingen entitetsvisninger valgt.", + "entity-view-name-filter-required": "Navnfilter for entitetsvisning er påkrevd.", + "entity-view-name-filter-no-entity-view-matched": "Ingen entitetsvisninger som starter med '{{entityView}}' ble funnet.", + "add": "Legg til entitetsvisning", + "entity-view-public": "Entitetsvisningen er offentlig", + "assign-to-customer": "Tildel til kunde", + "assign-entity-view-to-customer": "Tildel entitetsvisning(er) til kunde", + "assign-entity-view-to-customer-text": "Velg entitetsvisningene som skal tildeles kunden", + "no-entity-views-text": "Ingen entitetsvisninger funnet", + "assign-to-customer-text": "Velg kunden for å tildele entitetsvisning(en)", + "entity-view-details": "Detaljer om entitetsvisning", + "add-entity-view-text": "Legg til ny entitetsvisning", + "delete": "Slett entitetsvisning", + "assign-entity-views": "Tildel entitetsvisninger", + "assign-entity-views-text": "Tildel { count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} } til kunde", + "delete-entity-views": "Slett entitetsvisninger", + "make-public": "Gjør entitetsvisning offentlig", + "make-private": "Gjør entitetsvisning privat", + "unassign-from-customer": "Fjern tildeling fra kunde", + "unassign-entity-views": "Fjern tildeling av entitetsvisninger", + "unassign-entity-views-action-title": "Fjern tildeling av { count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} } fra kunde", + "assign-new-entity-view": "Tildel ny entitetsvisning", + "delete-entity-view-title": "Er du sikker på at du vil slette entitetsvisningen '{{entityViewName}}'?", + "delete-entity-view-text": "Vær forsiktig, etter bekreftelsen vil entitetsvisningen og alle relaterte data ikke kunne gjenopprettes.", + "delete-entity-views-title": "Er du sikker på at du vil slette { count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} }?", + "delete-entity-views-action-title": "Slett { count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} }", + "delete-entity-views-text": "Vær forsiktig, etter bekreftelsen vil alle valgte entitetsvisninger bli fjernet og alle relaterte data ikke kunne gjenopprettes.", + "make-public-entity-view-title": "Er du sikker på at du vil gjøre entitetsvisningen '{{entityViewName}}' offentlig?", + "make-public-entity-view-text": "Etter bekreftelsen vil entitetsvisningen og alle dens data bli gjort offentlige og tilgjengelige for andre.", + "make-private-entity-view-title": "Er du sikker på at du vil gjøre entitetsvisningen '{{entityViewName}}' privat?", + "make-private-entity-view-text": "Etter bekreftelsen vil entitetsvisningen og alle dens data bli gjort private og ikke være tilgjengelige for andre.", + "unassign-entity-view-title": "Er du sikker på at du vil fjerne tildelingen av entitetsvisningen '{{entityViewName}}'?", + "unassign-entity-view-text": "Etter bekreftelsen vil entitetsvisningen bli fjernet og ikke være tilgjengelig for kunden.", + "unassign-entity-view": "Fjern tildeling av entitetsvisning", + "unassign-entity-views-title": "Er du sikker på at du vil fjerne tildelingen av { count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} }?", + "unassign-entity-views-text": "Etter bekreftelsen vil alle valgte entitetsvisninger bli fjernet og ikke være tilgjengelige for kunden.", + "entity-view-type": "Entitetsvisningstype", + "entity-view-type-required": "Entitetsvisningstype er påkrevd.", + "select-entity-view-type": "Velg entitetsvisningstype", + "enter-entity-view-type": "Skriv inn entitetsvisningstype", + "any-entity-view": "Enhver entitetsvisning", + "no-entity-view-types-matching": "Ingen entitetsvisningstyper som matcher '{{entitySubtype}}' ble funnet.", + "entity-view-type-list-empty": "Ingen entitetsvisningstyper valgt.", + "entity-view-types": "Entitetsvisningstyper", + "created-time": "Opprettelsestid", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "name-max-length": "Navn må være mindre enn 256 tegn", + "type-max-length": "Type for entitetsvisning må være mindre enn 256 tegn", + "description": "Beskrivelse", + "events": "Hendelser", + "details": "Detaljer", + "copyId": "Kopier entitetsvisningens ID", + "idCopiedMessage": "Entitetsvisningens ID er kopiert til utklippstavlen", + "assignedToCustomer": "Tildelt til kunde", + "unable-entity-view-device-alias-title": "Kan ikke slette alias for entitetsvisning", + "unable-entity-view-device-alias-text": "Enhetsalias '{{entityViewAlias}}' kan ikke slettes da det brukes av følgende widget(er):
    {{widgetsList}}", + "select-entity-view": "Velg entitetsvisning", + "start-ts": "Starttid", + "end-ts": "Sluttid", + "date-limits": "Datobegrensninger", + "client-attributes": "Klientattributter", + "shared-attributes": "Delte attributter", + "server-attributes": "Serverattributter", + "timeseries": "Tidsserier", + "client-attributes-placeholder": "Klientattributter", + "shared-attributes-placeholder": "Delte attributter", + "server-attributes-placeholder": "Serverattributter", + "timeseries-placeholder": "Tidsserier", + "target-entity": "Målenhet", + "attributes-propagation": "Attribusjonsdistribusjon", + "attributes-propagation-hint": "Entitetsvisning vil automatisk kopiere spesifiserte attributter fra målenheten hver gang du lagrer eller oppdaterer denne entitetsvisningen. Av ytelsesgrunner blir attributtene til målenheten ikke kopiert ved hver endring. Du kan aktivere automatisk kopiering ved å konfigurere \"copy to view\"-regelnode i regelkjeden og koble \"Post attributes\" og \"Attributes Updated\"-meldinger til den nye regelnoden.", + "timeseries-data": "Tidsseriedata", + "timeseries-data-hint": "Konfigurer tidsserienøkler for målenheten som skal være tilgjengelige for entitetsvisningen. Disse dataene er skrivebeskyttet.", + "search": "Søk etter entitetsvisninger", + "selected-entity-views": "{ count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} } valgt", + "assign-entity-view-to-edge": "Tildel entitetsvisning(er) til Edge", + "assign-entity-view-to-edge-text": "Vennligst velg entitetsvisningene som skal tildeles til Edge", + "unassign-entity-view-from-edge-title": "Er du sikker på at du vil fjerne tildelingen av entitetsvisningen '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "Etter bekreftelsen vil entitetsvisningen bli fjernet og ikke være tilgjengelig for Edge.", + "unassign-entity-views-from-edge-action-title": "Fjern tildeling av { count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} } fra Edge", + "unassign-entity-view-from-edge": "Fjern tildeling av entitetsvisning", + "unassign-entity-views-from-edge-title": "Er du sikker på at du vil fjerne tildelingen av { count, plural, =1 {1 entitetsvisning} other {# entitetsvisninger} }?", + "unassign-entity-views-from-edge-text": "Etter bekreftelsen vil alle valgte entitetsvisninger bli fjernet og ikke være tilgjengelige for Edge." + }, + "event": { + "event-type": "Hendelsestype", + "events-filter": "Hendelsesfilter", + "clean-events": "Fjern hendelser", + "type-error": "Feil", + "type-lc-event": "Livssyklus-hendelse", + "type-stats": "Statistikk", + "type-debug-rule-node": "Feilsøking", + "type-debug-rule-chain": "Feilsøking", + "type-debug-calculated-field": "Feilsøking", + "arguments": "Argumenter", + "result": "Resultat", + "no-events-prompt": "Ingen hendelser funnet", + "error": "Feil", + "alarm": "Alarm", + "event-time": "Hendelsestid", + "server": "Server", + "body": "Innhold", + "method": "Metode", + "type": "Type", + "metadata": "Metadata", + "message": "Melding", + "message-id": "Meldings-ID", + "copy-message-id": "Kopier meldings-ID", + "message-type": "Meldingstype", + "data-type": "Datatype", + "relation-type": "Relasjonstype", + "data": "Data", + "event": "Hendelse", + "status": "Status", + "success": "Vellykket", + "failed": "Mislykket", + "messages-processed": "Behandlede meldinger", + "max-messages-processed": "Maksimalt antall behandlede meldinger", + "min-messages-processed": "Minimum antall behandlede meldinger", + "errors-occurred": "Feil oppstod", + "max-errors-occurred": "Maksimalt antall feil", + "min-errors-occurred": "Minimum antall feil", + "min-value": "Minimumsverdi er 0.", + "all-events": "Alle", + "has-error": "Har feil", + "entity-id": "Enhets-ID", + "copy-entity-id": "Kopier enhets-ID", + "entity-type": "Enhetstype", + "clear-filter": "Fjern filter", + "clear-request-title": "Fjern alle hendelser", + "clear-request-text": "Er du sikker på at du vil fjerne alle hendelser?", + "started": "Startet", + "updated": "Oppdatert", + "stopped": "Stoppet" + }, + "extension": { + "extensions": "Utvidelser", + "selected-extensions": "{ count, plural, =1 {1 utvidelse} other {# utvidelser} } valgt", + "type": "Type", + "key": "Nøkkel", + "value": "Verdi", + "id": "Id", + "extension-id": "Utvidelses-ID", + "extension-type": "Utvidelsestype", + "transformer-json": "JSON *", + "unique-id-required": "Gjeldende utvidelses-ID finnes allerede.", + "delete": "Slett utvidelse", + "add": "Legg til utvidelse", + "edit": "Rediger utvidelse", + "delete-extension-title": "Er du sikker på at du vil slette utvidelsen '{{extensionId}}'?", + "delete-extension-text": "Vær forsiktig, etter bekreftelse vil utvidelsen og alle relaterte data bli permanent fjernet.", + "delete-extensions-title": "Er du sikker på at du vil slette { count, plural, =1 {1 utvidelse} other {# utvidelser} }?", + "delete-extensions-text": "Vær forsiktig, etter bekreftelse vil alle valgte utvidelser bli fjernet.", + "converters": "Konverterere", + "converter-id": "Konverterings-ID", + "configuration": "Konfigurasjon", + "converter-configurations": "Konverteringskonfigurasjoner", + "token": "Sikkerhetstoken", + "add-converter": "Legg til konverterer", + "add-config": "Legg til konfigurasjon", + "device-name-expression": "Uttrykksmønster for enhetsnavn", + "device-type-expression": "Uttrykksmønster for enhetstype", + "custom": "Tilpasset", + "to-double": "Til desimaltall", + "transformer": "Transformer", + "json-required": "Transformer JSON er påkrevd.", + "json-parse": "Kan ikke tolke transformer JSON.", + "attributes": "Attributter", + "add-attribute": "Legg til attributt", + "add-map": "Legg til mapping-element", + "timeseries": "Tidsserie", + "add-timeseries": "Legg til tidsserie", + "field-required": "Felt er påkrevd", + "brokers": "Meklere", + "add-broker": "Legg til megler", + "host": "Vert", + "port": "Port", + "port-range": "Port bør være i området fra 1 til 65535.", + "ssl": "SSL", + "credentials": "Legitimasjon", + "username": "Brukernavn", + "password": "Passord", + "retry-interval": "Forsøksintervall i millisekunder", + "anonymous": "Anonym", + "basic": "Grunnleggende", + "pem": "PEM", + "ca-cert": "CA-sertifikatfil *", + "private-key": "Privat nøkkelfil *", + "cert": "Sertifikatfil *", + "no-file": "Ingen fil valgt.", + "drop-file": "Slipp en fil eller klikk for å velge en fil for opplasting.", + "mapping": "Mapping", + "topic-filter": "Emnefilter", + "converter-type": "Konverteringstype", + "converter-json": "Json", + "json-name-expression": "JSON-uttrykk for enhetsnavn", + "topic-name-expression": "Emneuttrykk for enhetsnavn", + "json-type-expression": "JSON-uttrykk for enhetstype", + "topic-type-expression": "Emneuttrykk for enhetstype", + "attribute-key-expression": "Uttrykk for attributtnøkkel", + "attr-json-key-expression": "JSON-uttrykk for attributtnøkkel", + "attr-topic-key-expression": "Emneuttrykk for attributtnøkkel", + "request-id-expression": "Uttrykk for forespørsels-ID", + "request-id-json-expression": "JSON-uttrykk for forespørsels-ID", + "request-id-topic-expression": "Emneuttrykk for forespørsels-ID", + "response-topic-expression": "Uttrykk for svartemne", + "value-expression": "Verdiuttrykk", + "topic": "Emne", + "timeout": "Tidsavbrudd i millisekunder", + "converter-json-required": "Konverterings-JSON er påkrevd.", + "converter-json-parse": "Kan ikke tolke konverterings-JSON.", + "filter-expression": "Filteruttrykk", + "connect-requests": "Tilkoblingsforespørsler", + "add-connect-request": "Legg til tilkoblingsforespørsel", + "disconnect-requests": "Frakoblingsforespørsler", + "add-disconnect-request": "Legg til frakoblingsforespørsel", + "attribute-requests": "Attributtforespørsler", + "add-attribute-request": "Legg til attributtforespørsel", + "attribute-updates": "Attributtoppdateringer", + "add-attribute-update": "Legg til attributtoppdatering", + "server-side-rpc": "RPC på serverside", + "add-server-side-rpc-request": "Legg til RPC-forespørsel på serverside", + "device-name-filter": "Filter for enhetsnavn", + "attribute-filter": "Attributtfilter", + "method-filter": "Metodefilter", + "request-topic-expression": "Uttrykk for forespørselsemne", + "response-timeout": "Svarfrist i millisekunder", + "topic-expression": "Emneuttrykk", + "client-scope": "Klientområde", + "add-device": "Legg til enhet", + "opc-server": "Servere", + "opc-add-server": "Legg til server", + "opc-add-server-prompt": "Vennligst legg til server", + "opc-application-name": "Applikasjonsnavn", + "opc-application-uri": "Applikasjons-URI", + "opc-scan-period-in-seconds": "Skanningsperiode i sekunder", + "opc-security": "Sikkerhet", + "opc-identity": "Identitet", + "opc-keystore": "Nøkkellager", + "opc-type": "Type", + "opc-keystore-type": "Type", + "opc-keystore-location": "Plassering *", + "opc-keystore-password": "Passord", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Nøkkelpassord", + "opc-device-node-pattern": "Mønstergjenkjenning for enhetsnode", + "opc-device-name-pattern": "Mønstergjenkjenning for enhetsnavn", + "modbus-server": "Servere/slaver", + "modbus-add-server": "Legg til server/slave", + "modbus-add-server-prompt": "Vennligst legg til server/slave", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Koble automatisk til igjen", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "Navn på serielt port", + "modbus-encoding": "Koding", + "modbus-parity": "Paritet", + "modbus-baudrate": "Baudrate", + "modbus-databits": "Databiter", + "modbus-stopbits": "Stoppbiter", + "modbus-databits-range": "Databiter skal være mellom 7 og 8.", + "modbus-stopbits-range": "Stoppbiter skal være mellom 1 og 2.", + "modbus-unit-id": "Enhets-ID", + "modbus-unit-id-range": "Enhets-ID skal være mellom 1 og 247.", + "modbus-device-name": "Enhetsnavn", + "modbus-poll-period": "Spørringsperiode (ms)", + "modbus-attributes-poll-period": "Spørringsperiode for attributter (ms)", + "modbus-timeseries-poll-period": "Spørringsperiode for tidsserier (ms)", + "modbus-poll-period-range": "Spørringsperiode skal være en positiv verdi.", + "modbus-tag": "Tagg", + "modbus-function": "Funksjon", + "modbus-register-address": "Registeradresse", + "modbus-register-address-range": "Registeradresse skal være mellom 0 og 65535.", + "modbus-register-bit-index": "Bitindeks", + "modbus-register-bit-index-range": "Bitindeks skal være mellom 0 og 15.", + "modbus-register-count": "Registerantall", + "modbus-register-count-range": "Registerantall skal være en positiv verdi.", + "modbus-byte-order": "Byte-rekkefølge", + "sync": { + "status": "Status", + "sync": "Synkroniser", + "not-sync": "Ikke synkronisert", + "last-sync-time": "Siste synkroniseringstid", + "not-available": "Ikke tilgjengelig" + }, + "export-extensions-configuration": "Eksporter utvidelseskonfigurasjon", + "import-extensions-configuration": "Importer utvidelseskonfigurasjon", + "import-extensions": "Importer utvidelser", + "import-extension": "Importer utvidelse", + "export-extension": "Eksporter utvidelse", + "file": "Utvidelsesfil", + "invalid-file-error": "Ugyldig utvidelsesfil" + }, + "feature": { + "advanced-features": "Avanserte funksjoner" + }, + "filter": { + "add": "Legg til filter", + "edit": "Rediger filter", + "name": "Filternavn", + "name-required": "Filternavn er påkrevd.", + "duplicate-filter": "Filter med samme navn finnes allerede.", + "filters": "Filtre", + "unable-delete-filter-title": "Kan ikke slette filter", + "unable-delete-filter-text": "Filteret '{{filter}}' kan ikke slettes da det brukes av følgende widget(er):
    {{widgetsList}}", + "duplicate-filter-error": "Duplikatfilter funnet '{{filter}}'.
    Filtre må være unike innenfor dashbordet.", + "missing-key-filters-error": "Nøkkelfiltre mangler for filter '{{filter}}'.", + "filter": "Filter", + "editable": "Redigerbar", + "no-filters-found": "Ingen filtre funnet.", + "no-filter-text": "Ingen filter spesifisert", + "add-filter-prompt": "Vennligst legg til filter", + "no-filter-matching": "'{{filter}}' ikke funnet.", + "create-new-filter": "Opprett en ny!", + "create-new": "Opprett ny", + "filter-required": "Filter er påkrevd.", + "operation": { + "operation": "Operasjon", + "equal": "lik", + "not-equal": "ikke lik", + "starts-with": "starter med", + "ends-with": "slutter med", + "contains": "inneholder", + "not-contains": "inneholder ikke", + "greater": "større enn", + "less": "mindre enn", + "greater-or-equal": "større eller lik", + "less-or-equal": "mindre eller lik", + "and": "og", + "or": "eller", + "in": "i", + "not-in": "ikke i" + }, + "ignore-case": "ignorér store/små bokstaver", + "value": "Verdi", + "remove-filter": "Fjern filter", + "duplicate-filter-action": "Dupliser filter", + "preview": "Filterforhåndsvisning", + "no-filters": "Ingen filtre konfigurert", + "add-filter": "Legg til filter", + "add-complex-filter": "Legg til komplekst filter", + "add-complex": "Legg til komplekst", + "complex-filter": "Komplekst filter", + "edit-complex-filter": "Rediger komplekst filter", + "edit-filter-user-params": "Rediger brukerparametere for filterpredikat", + "filter-user-params": "Brukerparametere for filterpredikat", + "user-parameters": "Brukerparametere", + "display-label": "Etikett som vises", + "order-priority": "Prioritet for feltrekkefølge", + "key-filter": "Nøkkelfilter", + "key-filters": "Nøkkelfiltre", + "key-name": "Nøkkelnavn", + "key-name-required": "Nøkkelnavn er påkrevd.", + "key-type": { + "key-type": "Nøkkeltype", + "attribute": "Attributt", + "timeseries": "Tidsserie", + "entity-field": "Enhetsfelt", + "constant": "Konstant", + "client-attribute": "Klientattributt", + "server-attribute": "Serverattributt", + "shared-attribute": "Delt attributt" + }, + "value-type": { + "value-type": "Verditype", + "string": "Streng", + "numeric": "Numerisk", + "boolean": "Boolsk", + "date-time": "Dato og tid" + }, + "value-type-required": "Nøkkelverditype er påkrevd.", + "key-value-type-change-title": "Er du sikker på at du vil endre nøkkelverditype?", + "key-value-type-change-message": "Hvis du bekrefter ny verditype, vil alle angitte nøkkelfiltre bli fjernet.", + "no-key-filters": "Ingen nøkkelfiltre konfigurert", + "add-key-filter": "Legg til nøkkelfilter", + "remove-key-filter": "Fjern nøkkelfilter", + "edit-key-filter": "Rediger nøkkelfilter", + "date": "Dato", + "time": "Tid", + "current-tenant": "Nåværende leietaker", + "current-customer": "Nåværende kunde", + "current-user": "Nåværende bruker", + "current-device": "Nåværende enhet", + "default-value": "Standardverdi", + "default-comma-separated-values": "Standard kommaseparerte verdier", + "dynamic-source-type": "Dynamisk kildetype", + "dynamic-value": "Dynamisk verdi", + "no-dynamic-value": "Ingen dynamisk verdi", + "source-attribute": "Kildeattributt", + "switch-to-dynamic-value": "Bytt til dynamisk verdi", + "switch-to-default-value": "Bytt til standardverdi", + "inherit-owner": "Arv fra eier", + "source-attribute-not-set": "Hvis kildeattributt ikke er angitt" + }, + "fullscreen": { + "expand": "Utvid til fullskjerm", + "exit": "Avslutt fullskjerm", + "toggle": "Bytt fullskjermmodus", + "fullscreen": "Fullskjerm" + }, + "function": { + "function": "Funksjon" + }, + "gateway": { + "gateway-name": "Gateway-navn", + "gateway-name-required": "Gateway-navn er påkrevd.", + "gateways": "Gatewayer", + "create-new-gateway": "Opprett ny gateway", + "create-new-gateway-text": "Er du sikker på at du vil opprette en ny gateway med navn: '{{gatewayName}}'?", + "launch-command": "Startkommando", + "no-gateway-found": "Ingen gateway funnet.", + "no-gateway-matching": "'{{item}}' ikke funnet." + }, + "grid": { + "delete-item-title": "Er du sikker på at du vil slette dette elementet?", + "delete-item-text": "Vær forsiktig, etter bekreftelse vil dette elementet og alle relaterte data bli permanent fjernet.", + "delete-items-title": "Er du sikker på at du vil slette { count, plural, =1 {1 element} other {# elementer} }?", + "delete-items-action-title": "Slett { count, plural, =1 {1 element} other {# elementer} }", + "delete-items-text": "Vær forsiktig, etter bekreftelse vil alle valgte elementer bli fjernet og tilknyttede data gå tapt.", + "add-item-text": "Legg til nytt element", + "no-items-text": "Ingen elementer funnet", + "item-details": "Elementdetaljer", + "delete-item": "Slett element", + "delete-items": "Slett elementer", + "scroll-to-top": "Rull til toppen" + }, + "help": { + "goto-help-page": "Gå til hjelpeside", + "show-help": "Vis hjelp" + }, + "home": { + "home": "Hjem", + "profile": "Profil", + "logout": "Logg ut", + "menu": "Meny", + "avatar": "Avatar", + "open-user-menu": "Åpne brukermeny" + }, + "file-input": { + "browse-file": "Bla gjennom fil", + "browse-files": "Bla gjennom filer" + }, + "image": { + "gallery": "Bildegalleri", + "search": "Søk bilde", + "selected-images": "{ count, plural, =1 {1 bilde} other {# bilder} } valgt", + "created-time": "Opprettelsestid", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "resolution": "Oppløsning", + "size": "Størrelse", + "system": "System", + "download-image": "Last ned bilde", + "export-image": "Eksporter bilde til JSON", + "import-image": "Importer bilde fra JSON", + "upload-image": "Last opp bilde", + "edit-image": "Rediger bilde", + "image-details": "Bildedetaljer", + "no-images": "Ingen bilder funnet", + "delete-image": "Slett bilde", + "delete-image-title": "Er du sikker på at du vil slette bildet '{{imageTitle}}'?", + "delete-image-text": "Vær forsiktig, etter bekreftelse vil bildet ikke kunne gjenopprettes.", + "delete-images-title": "Er du sikker på at du vil slette { count, plural, =1 {1 bilde} other {# bilder} }?", + "delete-images-text": "Vær forsiktig, etter bekreftelse vil alle valgte bilder bli fjernet og data gå tapt.", + "list-mode": "Listevisning", + "grid-mode": "Rutenettvisning", + "image-preview": "Forhåndsvisning av bilde", + "update-image": "Oppdater bilde", + "export-failed-error": "Kan ikke eksportere bilde: {{error}}", + "image-json-file": "Bilde JSON-fil", + "invalid-image-json-file-error": "Kan ikke importere bilde fra JSON: Ugyldig bilde JSON-datastruktur.", + "image-is-in-use": "Bildet brukes av andre enheter", + "images-are-in-use": "Bildene brukes av andre enheter", + "image-is-in-use-text": "Bildet '{{title}}' ble ikke slettet fordi det brukes av følgende enheter:", + "images-are-in-use-text": "Ikke alle bilder ble slettet fordi de er i bruk av andre enheter.
    Du kan vise refererte enheter ved å klikke Referanser-knappen i bildepanelet.
    Hvis du fortsatt vil slette bildene, velg dem i tabellen nedenfor og klikk Slett valgte.", + "delete-image-in-use-text": "Hvis du fortsatt vil slette bildet, klikk Slett uansett.", + "system-entities": "Systemenheter:", + "entities": "enheter:", + "references": "Referanser", + "include-system-images": "Inkluder systembilder", + "clear-image": "Fjern bilde", + "no-image": "Ingen bilde", + "no-image-selected": "Ingen bilde valgt", + "browse-from-gallery": "Bla gjennom galleriet", + "set-link": "Angi lenke", + "image-link": "Bildelenke", + "link": "Lenke", + "copy-image-link": "Kopier bildelenke", + "embed-image": "Bygg inn bilde", + "embed-to-html": "Bygg inn i HTML", + "embed-to-html-hint": "Denne funksjonen vil gjøre lenken tilgjengelig for uautoriserte brukere.", + "embed-to-html-text": "Ved å bruke følgende kodebit, kan du bygge inn et bilde i komponenter basert på vanlig HTML.
    Disse inkluderer HTML-kort-widgets, celleinnhold-funksjoner osv.", + "embed-to-angular-template": "Bygg inn i Angular HTML-mal", + "embed-to-angular-template-text": "Ved å bruke følgende kodebit, kan du bygge inn et bilde i Angular HTML-mal for komponenter.
    Dette inkluderer Markdown-widgeten, HTML-seksjoner i widget-editoren, egendefinerte handlinger osv." + }, + "image-input": { + "drop-images-or": "Dra og slipp bilder eller", + "drag-and-drop": "Dra og slipp", + "or": "eller", + "browse": "Bla gjennom", + "no-images": "Ingen bilder valgt", + "images": "bilder" + }, + "import": { + "no-file": "Ingen fil valgt", + "drop-file": "Slipp en JSON-fil eller klikk for å velge en fil å laste opp.", + "drop-json-file-or": "Dra og slipp en JSON-fil eller", + "drop-file-csv": "Slipp en CSV-fil eller klikk for å velge en fil å laste opp.", + "drop-file-csv-or": "Dra og slipp en CSV-fil eller", + "column-value": "Verdi", + "column-title": "Tittel", + "column-example": "Eksempelverdi", + "column-key": "Attributt/telemetrinøkkel", + "credentials": "Legitimasjon", + "csv-delimiter": "CSV-avgrenser", + "csv-first-line-header": "Første linje inneholder kolonnenavn", + "csv-update-data": "Oppdater attributter/telemetri", + "details": "Detaljer", + "import-csv-number-columns-error": "En fil må inneholde minst to kolonner", + "import-csv-invalid-format-error": "Ugyldig filformat. Linje: '{{line}}'", + "column-type": { + "name": "Navn", + "type": "Type", + "label": "Etikett", + "column-type": "Kolonnetype", + "client-attribute": "Klientattributt", + "shared-attribute": "Delt attributt", + "server-attribute": "Serverattributt", + "timeseries": "Tidsserie", + "entity-field": "Enhetsfelt", + "access-token": "Tilgangstoken", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT-klient-ID", + "user-name": "MQTT-brukernavn", + "password": "MQTT-passord" + }, + "lwm2m": { + "client-endpoint": "LwM2M endepunktklientnavn", + "security-config-mode": "LwM2M sikkerhetskonfigurasjonsmodus", + "client-identity": "LwM2M klientidentitet", + "client-key": "LwM2M klientnøkkel", + "client-cert": "LwM2M klient offentlig nøkkel", + "bootstrap-server-security-mode": "LwM2M bootstrap server sikkerhetsmodus", + "bootstrap-server-secret-key": "LwM2M bootstrap server hemmelig nøkkel", + "bootstrap-server-public-key-id": "LwM2M bootstrap server offentlig nøkkel eller ID", + "lwm2m-server-security-mode": "LwM2M server sikkerhetsmodus", + "lwm2m-server-secret-key": "LwM2M server hemmelig nøkkel", + "lwm2m-server-public-key-id": "LwM2M server offentlig nøkkel eller ID" + }, + "snmp": { + "host": "SNMP-vert", + "port": "SNMP-port", + "version": "SNMP-versjon (v1, v2c eller v3)", + "community-string": "SNMP fellesskapsstreng" + }, + "isgateway": "Er gateway", + "activity-time-from-gateway-device": "Aktivitetstid fra gateway-enhet", + "description": "Beskrivelse", + "routing-key": "Edge-nøkkel", + "secret": "Edge-hemmelighet" + }, + "stepper-text": { + "select-file": "Velg en fil", + "configuration": "Importkonfigurasjon", + "column-type": "Velg kolonnetype", + "creat-entities": "Oppretter nye enheter" + }, + "message": { + "create-entities": "{{count}} nye enheter ble opprettet.", + "update-entities": "{{count}} enheter ble oppdatert.", + "error-entities": "Det oppsto en feil ved opprettelse av {{count}} enheter." + } + }, + "scada": { + "symbols": "SCADA-symboler", + "search": "Søk symbol", + "selected-symbols": "{ count, plural, =1 {1 symbol} other {# symboler} } valgt", + "download-symbol": "Last ned SCADA-symbol", + "export-symbol": "Eksporter SCADA-symbol til JSON", + "import-symbol": "Importer SCADA-symbol fra JSON", + "upload-symbol": "Last opp SCADA-symbol", + "update-symbol": "Oppdater SCADA-symbol", + "edit-symbol": "Rediger SCADA-symbol", + "symbol-details": "SCADA-symboldetaljer", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "Ingen symboler funnet", + "show-hidden-elements": "Vis skjulte elementer", + "hide-hidden-elements": "Skjul skjulte elementer", + "delete-symbol": "Slett SCADA-symbol", + "delete-symbol-title": "Er du sikker på at du vil slette SCADA-symbolet '{{imageTitle}}'?", + "delete-symbol-text": "Vær forsiktig, etter bekreftelse vil SCADA-symbolet ikke kunne gjenopprettes.", + "delete-symbols-title": "Er du sikker på at du vil slette { count, plural, =1 {1 SCADA-symbol} other {# SCADA-symboler} }?", + "delete-symbols-text": "Vær forsiktig, etter bekreftelse vil alle valgte SCADA-symboler og all tilhørende data bli slettet permanent.", + "include-system-symbols": "Inkluder systemsymboler", + "symbol-preview": "Forhåndsvisning av symbol", + "general": "Generelt", + "tags": "Tagger", + "properties": "Egenskaper", + "title": "Tittel", + "description": "Beskrivelse", + "search-tags": "Søk tagger", + "widget-size": "Widget-størrelse", + "cols": "kolonner", + "rows": "rader", + "state-render-function": "Tilstands-gjengivelsesfunksjon", + "preview": "Forhåndsvisning", + "preview-widget-action-text": "Widget-handling '{{type}}' ble utført!", + "no-symbol": "Ingen SCADA-symbol", + "no-symbol-selected": "Ingen SCADA-symbol valgt", + "clear-symbol": "Fjern SCADA-symbol", + "browse-symbol-from-gallery": "Bla gjennom SCADA-symbol fra galleri", + "zoom-in": "Zoom inn", + "zoom-out": "Zoom ut", + "create-widget": "Opprett widget", + "create-widget-from-symbol": "Opprett widget fra SCADA-symbol", + "hidden": "skjult", + "tag": { + "tag": "Tag", + "on-click-action": "Ved klikk-handling", + "no-tags": "Ingen tagger konfigurert", + "delete-tag-text": "Er du sikker på at du vil slette taggen
    {{tag}} fra {{elementType}}-elementet?", + "update-tag": "Oppdater tag", + "enter-tag": "Skriv inn tag", + "tag-settings": "Tag-innstillinger", + "remove-tag": "Fjern tag", + "add-tag": "Legg til tag" + }, + "behavior": { + "behavior": "Oppførsel", + "id": "ID", + "name": "Navn", + "type": "Type", + "no-behaviors": "Ingen oppførsler konfigurert", + "add-behavior": "Legg til oppførsel", + "type-action": "Handling", + "type-value": "Verdi", + "type-widget-action": "Widget-handling", + "behavior-settings": "Oppførselsinnstillinger", + "remove-behavior": "Fjern oppførsel", + "hint": "Hint", + "group-title": "Gruppetittel", + "value-type": "Verditype", + "default-value": "Standardverdi", + "true-label": "Etikett for sann", + "false-label": "Etikett for falsk", + "state-label": "Tilstandsetikett", + "default-payload": "Standard nyttelast", + "not-unique-behavior-ids-error": "Oppførsels-ID-er må være unike!", + "default-settings": "Standardinnstillinger" + }, + "symbol": { + "symbol": "SCADA-symbol", + "fluid-presence": "Væsketilstedeværelse", + "fluid-presence-hint": "Indikerer om væske er tilstede i røret.", + "fluid-present": "Væske tilstede", + "present": "Tilstede", + "absent": "Fraværende", + "flow-presence": "Strømningstilstedeværelse", + "flow-presence-hint": "Indikerer om væsken strømmer i røret.", + "flow-present": "Strømning tilstede", + "flow-direction": "Strømningsretning", + "flow-direction-hint": "Indikerer væskens strømningsretning.", + "forward": "Fremover", + "reverse": "Bakover", + "flow-animation-speed": "Strømningsanimasjonshastighet", + "flow-animation-speed-hint": "Desimaltall som angir animasjonshastigheten. 1 - normal hastighet, 0 - ingen animasjon, < 1 - saktere animasjon, > 1 - raskere animasjon.", + "leak": "Lekkasjer", + "leak-hint": "Indikerer om det er lekkasje i røret.", + "leak-present": "Lekkasjer tilstede", + "fluid-color": "Væskefarge", + "pipe-color": "Rørfarge", + "horizontal-pipe": "Horisontalt rør", + "vertical-pipe": "Vertikalt rør", + "horizontal-fluid-color": "Horisontal væskefarge", + "vertical-fluid-color": "Vertikal væskefarge", + "left-pipe": "Venstre rør", + "right-pipe": "Høyre rør", + "top-pipe": "Øvre rør", + "bottom-pipe": "Nedre rør", + "left-fluid-color": "Venstre væskefarge", + "right-fluid-color": "Høyre væskefarge", + "top-fluid-color": "Øvre væskefarge", + "bottom-fluid-color": "Nedre væskefarge", + "display": "Skjerm", + "display-format": "Skjermformat", + "value": "Verdi", + "decimals": "Desimaler", + "units": "Enheter", + "flow-meter-value-hint": "Desimaltall som vises på gjennomstrømningsmåleren", + "value-hint": "Desimaltall som indikerer nåverdi", + "running": "I drift", + "running-hint": "Indikerer om komponenten er i drift.", + "warning-state": "Varseltilstand", + "warning": "Advarsel", + "warning-click": "Advarsel klikk", + "warning-state-hint": "Indikerer om komponenten er i varseltilstand.", + "critical-state": "Kritisk tilstand", + "critical": "Kritisk", + "critical-click": "Kritisk klikk", + "critical-state-hint": "Indikerer om komponenten er i kritisk tilstand.", + "critical-state-animation": "Kritisk tilstandsanimasjon", + "critical-state-animation-hint": "Angir om blinkende animasjon aktiveres i kritisk tilstand.", + "warning-critical-state-animation": "Varsel/kritisk tilstandsanimasjon", + "warning-critical-state-animation-hint": "Angir om blinkende animasjon aktiveres i varsel eller kritisk tilstand.", + "animation": "Animasjon", + "broken": "Ødelagt", + "broken-hint": "Indikerer om komponenten er ødelagt.", + "on-display-click": "Ved skjermklikk", + "on-display-click-hint": "Handling som utløses når brukeren klikker på skjermen.", + "pipe": "Rør", + "default-border-color": "Standard kantfarge", + "active-border-color": "Aktiv kantfarge", + "warning-border-color": "Advarselskantfarge", + "critical-border-color": "Kritisk kantfarge", + "background-color": "Bakgrunnsfarge", + "rotation-animation-speed": "Rotasjonsanimasjonshastighet", + "rotation-animation-speed-hint": "Desimaltall som angir rotasjonshastighet. 1 - normal, 0 - ingen animasjon, < 1 - saktere, > 1 - raskere.", + "on-click": "Ved klikk", + "on-click-hint": "Handling som utløses når brukeren klikker på komponenten.", + "connectors-positions": "Koblingsposisjoner", + "right-connector": "Høyre kobling", + "right-top-connector": "Øverst til høyre kobling", + "right-bottom-connector": "Nederst til høyre kobling", + "left-connector": "Venstre kobling", + "left-top-connector": "Øverst til venstre kobling", + "left-bottom-connector": "Nederst til venstre kobling", + "top-left-connector": "Øvre venstre kobling", + "top-right-connector": "Øvre høyre kobling", + "top-connector": "Øvre kobling", + "bottom-connector": "Nedre kobling", + "running-color": "Driftsfarge", + "stopped-color": "Stoppet farge", + "stopped": "Stoppet", + "warning-color": "Advarselsfarge", + "critical-color": "Kritisk farge", + "opened": "Åpnet", + "opened-hint": "Indikerer om komponenten er i åpen tilstand.", + "open": "Åpne", + "open-hint": "Handling som utløses når brukeren klikker for å åpne komponenten.", + "close": "Lukk", + "close-hint": "Handling som utløses når brukeren klikker for å lukke komponenten.", + "close-state-animation": "Lukket tilstandsanimasjon", + "close-state-animation-hint": "Om blinkende animasjon skal aktiveres når komponenten er i lukket tilstand.", + "opened-color": "Åpen farge", + "closed-color": "Lukket farge", + "opened-rotation-angle": "Rotasjonsvinkel åpen", + "closed-rotation-angle": "Rotasjonsvinkel lukket", + "tank-capacity": "Tankkapasitet", + "tank-capacity-hint": "Desimaltall som angir total kapasitet i tanken.", + "current-volume": "Nåværende volum", + "current-volume-hint": "Desimaltall som angir gjeldende fylt volum.", + "tank-color": "Tankfarge", + "value-box": "Verdiboks", + "value-text": "Verdi tekst", + "scale": "Skala", + "transparent-mode": "Gjennomsiktig modus", + "major-ticks": "Hovedmarkeringer", + "intervals": "Intervaller", + "major-ticks-color": "Farge for hovedmarkeringer", + "normal": "Normal", + "minor-ticks": "Delmarkeringer", + "minor-ticks-color": "Farge for delmarkeringer", + "temperature": "Temperatur", + "temperature-hint": "Desimaltall som angir nåværende temperatur.", + "update-temperature": "Oppdater temperatur", + "update-temperature-hint": "Handling som aktiveres når brukeren klikker for å endre temperaturen.", + "run": "Kjør", + "run-hint": "Handling som aktiveres når brukeren klikker for å starte komponenten.", + "stop": "Stopp", + "stop-hint": "Handling som aktiveres når brukeren klikker for å stoppe komponenten.", + "temperature-step": "Temperaturtrinn", + "heat-pump-color": "Farge for varmepumpe", + "power-button-background": "Bakgrunn for strømbryter", + "value-box-background": "Bakgrunn for verdiboks", + "value-units": "Verdi-enheter", + "filtration-mode": "Filtreringsmodus", + "filtration-mode-hint": "Heltallsverdi som angir gjeldende filtreringsmodus.", + "filtration-mode-update": "Oppdater filtreringsmodus", + "filtration-mode-update-hint": "Handling som aktiveres når brukeren klikker for å endre filtreringsmodus.", + "filter-mode": "Filter", + "waste-mode": "Avfall", + "backwash-mode": "Tilbakespyling", + "recirculate-mode": "Resirkulering", + "rinse-mode": "Skylling", + "closed-mode": "Lukket", + "sand-filter-color": "Farge for sandfilter", + "mode-box-background": "Modusboks bakgrunn", + "border-color": "Kantfarge", + "label-color": "Etikettfarge", + "water-leak-hint": "Indikerer om det er lekkasje.", + "default-color": "Standardfarge", + "leak-color": "Lekkasjefarge", + "full-value": "Full verdi", + "full-value-hint": "Desimaltall som angir full verdi.", + "label": "Etikett", + "icon": "Ikon", + "button-color": "Knappfarge", + "on-label": "Tekst for 'På'", + "off-label": "Tekst for 'Av'", + "arrow-presence": "Pil tilstedeværelse", + "arrow-presence-hint": "Indikerer om pil er tilstede i koblingen.", + "arrow-present": "Pil tilstede", + "arrow-direction": "Strømningsretning", + "arrow-direction-hint": "Indikerer strømningsretning.", + "flow-animation": "Strømningsnærvær", + "flow-animation-hint": "Indikerer om væsken strømmer i koblingen.", + "flow": "Strøm", + "flow-line": "Linje", + "flow-line-style": "Linjestil", + "flow-style-hint": "Angi verdier for Dash og Gap slik at summen er delelig med 100 for perfekt synkronisering av animasjon.", + "flow-dash-cap": "Dash-kant", + "dash-cap-butt": "Rett", + "dash-cap-round": "Rund", + "dash-cap-square": "Kvadratisk", + "dash": "Strek", + "gap": "Mellomrom", + "main-line": "Hovedlinje", + "line": "Linje", + "line-color": "Linjefarge", + "arrow-color": "Pilfarge", + "target-value": "Målverdi", + "target-value-hint": "Angir målpunkt på skalaen.", + "min-max-value": "Min og maks verdi", + "min-value": "Min", + "max-value": "Maks", + "progress-bar": "Fremdriftsindikator", + "progress-arrow": "Fremdriftspil", + "warning-scale-color": "Advarselsskala farge", + "critical-scale-color": "Kritisk skala farge", + "scale-color": "Skalafarge", + "target": "Mål", + "high-warning-state": "Høy advarselstilstand", + "show-high-warning-scale": "Vis høy advarselsskala", + "high-warning-scale": "Høy advarselsskala", + "high-warning-state-hint": "Desimaltall angir et høyt advarselsområde opp til høy kritisk eller maksimal verdi.", + "low-warning-state": "Lav advarselstilstand", + "show-low-warning-scale": "Vis lav advarselsskala", + "low-warning-scale": "Lav advarselsskala", + "low-warning-state-hint": "Desimaltall angir et lavt advarselsområde ned til lav kritisk eller minimumsverdi.", + "high-critical-state": "Høy kritisk tilstand", + "show-high-critical-scale": "Vis høy kritisk skala", + "high-critical-scale": "Høy kritisk skala", + "high-critical-state-hint": "Desimaltall angir et høyt kritisk område opp til maksimal skala.", + "low-critical-state": "Lav kritisk tilstand", + "show-low-critical-scale": "Vis lav kritisk skala", + "low-critical-scale": "Lav kritisk skala", + "low-critical-state-hint": "Desimaltall angir et lavt kritisk område ned til minimumsskala.", + "filter-color": "Filterfarge", + "colors": "Farger", + "indicator-colors": "Indikatorfarger", + "enabled": "Aktivert", + "disabled": "Deaktivert", + "on": "PÅ", + "off": "AV", + "on-off-state": "På/Av-tilstand", + "on-off-state-hint": "Angir om komponenten er i På- eller Av-tilstand.", + "on-update-state": "Oppdater til På", + "on-update-state-hint": "Handling ved klikk for å oppdatere til På-tilstand.", + "off-update-state": "Oppdater til Av", + "off-update-state-hint": "Handling ved klikk for å oppdatere til Av-tilstand.", + "voltage": "Spenning", + "input-voltage": "Inngangsspenning", + "input-voltage-hint": "Desimaltall angir inngangsspenning.", + "output-voltage": "Utgangsspenning", + "output-voltage-hint": "Desimaltall angir utgangsspenning.", + "first-phase-voltage": "Første fase spenning", + "second-phase-voltage": "Andre fase spenning", + "third-phase-voltage": "Tredje fase spenning", + "phase-voltage-hint": "Desimaltall angir spenningsverdi for gjeldende fase", + "voltage-hint": "Desimaltall angir nåværende spenning", + "current-voltage-color": "Farge for nåværende spenning", + "phase-indicator-color": "Faseindikatorfarge", + "measured": "Målt", + "measured-hint": "Desimaltall angir energibruk i kilowattimer", + "day-rate": "Dagtakst", + "night-rate": "Nattakst", + "off-peak-rate": "Lavtrafikktakst", + "peak-rate": "Topptakst", + "export-rate": "Eksporttakst", + "operating-mode": "Driftsmodus", + "bypass-mode": "Forbikobling", + "operating-mode-hint": "Heltallsverdi som angir gjeldende driftsmodus (0 - AV, 1 - PÅ, 2 - FORBIKOBLING)", + "connected": "Tilkoblet", + "connected-hint": "Angir om komponenten er i tilkoblet tilstand.", + "disconnected": "Frakoblet", + "indicator": "Indikator", + "operation-mode": "Operasjonsmodus", + "operation-mode-hint": "Angir om inverter er i Nettdrift eller Invertermodus.", + "operation-mode-indicators-color": "Farge på driftsmodusindikatorer", + "mains-on-mode": "Nettdrift aktiv", + "inverter-on-mode": "Inverter aktiv", + "charging-mode": "Lademodus", + "charging-mode-hint": "Heltallsverdi som angir gjeldende lademodus (1 - Bulk, 2 - Absorpsjon, 3 - Vedlikehold)", + "charging-mode-indicators-color": "Farge på lademodusindikatorer", + "inverter-faults": "Feil", + "inverter-fault-indicators-color": "Farge på feilindikatorer", + "overload-fault": "Overbelastning", + "overload-fault-hint": "Angir om inverter er overbelastet.", + "low-battery-fault": "Lavt batteri", + "low-battery-fault-hint": "Angir om batteriet er for mye utladet.", + "temperature-fault": "Temperatur", + "temperature-fault-hint": "Angir høy temperatur i inverter.", + "triangle": "Trekant", + "socket": "Stikkontakt", + "left-button": "Venstre knapp", + "right-button": "Høyre knapp", + "alarm-colors": "Alarmfarger", + "hook-color": "Krokfarge" + } + }, + "item": { + "selected": "Valgt" + }, + "js-func": { + "no-return-error": "Funksjonen må returnere en verdi!", + "return-type-mismatch": "Funksjonen må returnere en verdi av typen '{{type}}'!", + "tidy": "Rydd", + "mini": "Mini", + "modules": "Moduler", + "remove-module": "Fjern modul", + "no-modules": "Ingen moduler konfigurert", + "add-module": "Legg til modul", + "module-alias": "Alias", + "invalid-module-alias-name": "Ugyldig aliasnavn", + "module-resource": "JS modulressurs", + "not-unique-module-aliases-error": "Modulaliaser må være unike!", + "show-module-info": "Vis modulinfo", + "show-module-source-code": "Vis modulens kildekode", + "module-members": "Modulmedlemmer", + "module-no-members": "Modulen har ingen eksporterte medlemmer", + "module-load-error": "Feil ved lasting av modul", + "source-code": "Kildekode", + "source-code-load-error": "Feil ved lasting av kildekode", + "no-js-module-text": "Ingen JS-moduler funnet", + "no-js-module-matching": "Ingen JS-moduler som matcher '{{module}}' ble funnet." + }, + "key-val": { + "key": "Nøkkel", + "value": "Verdi", + "remove-entry": "Fjern oppføring", + "add-entry": "Legg til oppføring", + "no-data": "Ingen oppføringer" + }, + "layout": { + "layout": "Oppsett", + "layouts": "Oppsett", + "manage": "Administrer oppsett", + "settings": "Oppsettinnstillinger", + "color": "Farge", + "main": "Hoved", + "right": "Høyre", + "left": "Venstre", + "select": "Velg måloppsett", + "percentage-width": "Prosentvis bredde (%)", + "fixed-width": "Fast bredde (px)", + "left-width": "Venstre kolonne (%)", + "right-width": "Høyre kolonne (%)", + "pick-fixed-side": "Fast side:", + "layout-fixed-width": "Fast bredde (px)", + "value-min-error": "Verdien må være større enn {{min}}{{unit}}", + "value-max-error": "Verdien må være mindre enn {{max}}{{unit}}", + "layout-fixed-width-required": "Fast bredde er påkrevd", + "right-width-percentage-required": "Prosentandel for høyre er påkrevd", + "left-width-percentage-required": "Prosentandel for venstre er påkrevd", + "divider": "Skillelinje", + "right-side": "Oppsett for høyre side", + "left-side": "Oppsett for venstre side", + "add-new-breakpoint": "Legg til nytt brytpunkt", + "breakpoint": "Brytpunkt", + "breakpoints": "Brytpunkter", + "copy-from": "Kopier fra", + "size": "Størrelse", + "delete-breakpoint-title": "Er du sikker på at du vil slette brytpunktet '{{name}}'?", + "delete-breakpoint-text": "Vennligst merk at etter bekreftelse vil brytpunktet ikke kunne gjenopprettes, og innstillingene vil tilbakestilles til standard brytpunkt." + }, + "legend": { + "direction": "Retning", + "position": "Posisjon", + "show-values": "Vis verdier", + "min-option": "Min", + "max-option": "Maks", + "average-option": "Gjennomsnitt", + "total-option": "Total", + "latest-option": "Siste", + "sort-legend": "Sorter datanøkler i forklaringen", + "show-max": "Vis maksverdi", + "show-min": "Vis minverdi", + "show-avg": "Vis gjennomsnittsverdi", + "show-total": "Vis totalverdi", + "show-latest": "Vis siste verdi", + "settings": "Forklaringsinnstillinger", + "min": "min", + "max": "maks", + "avg": "gj.sn", + "total": "total", + "latest": "siste", + "Min": "Min", + "Max": "Maks", + "Avg": "Gj.sn", + "Total": "Total", + "Latest": "Siste", + "comparison-time-ago": { + "previousInterval": "(forrige intervall)", + "customInterval": "(tilpasset intervall)", + "days": "(dag siden)", + "weeks": "(uke siden)", + "months": "(måned siden)", + "years": "(år siden)" + }, + "column-title": "Kolonnetittel", + "label": "Etikett", + "value": "Verdi" + }, + "login": { + "login": "Logg inn", + "request-password-reset": "Be om tilbakestilling av passord", + "reset-password": "Tilbakestill passord", + "create-password": "Opprett passord", + "two-factor-authentication": "Tofaktorautentisering", + "passwords-mismatch-error": "Passordene må være like!", + "password-again": "Gjenta passord", + "sign-in": "Vennligst logg inn", + "username": "Brukernavn (epost)", + "remember-me": "Husk meg", + "forgot-password": "Glemt passord?", + "password-reset": "Tilbakestilling av passord", + "expired-password-reset-message": "Dine legitimasjonsopplysninger er utløpt! Vennligst opprett et nytt passord.", + "new-password": "Nytt passord", + "new-password-again": "Bekreft nytt passord", + "password-link-sent-message": "Tilbakestillingslenke er sendt", + "email": "Epost", + "invalid-email-format": "Ugyldig epostformat.", + "login-with": "Logg inn med {{name}}", + "or": "eller", + "error": "Innloggingsfeil", + "verify-your-identity": "Bekreft din identitet", + "select-way-to-verify": "Velg en måte å verifisere på", + "resend-code": "Send koden på nytt", + "resend-code-wait": "Send ny kode om { time, plural, =1 {1 sekund} other {# sekunder} }", + "try-another-way": "Prøv en annen metode", + "totp-auth-description": "Skriv inn sikkerhetskoden fra autentiseringsappen din.", + "totp-auth-placeholder": "Kode", + "sms-auth-description": "En sikkerhetskode er sendt til telefonen din på {{contact}}.", + "sms-auth-placeholder": "SMS-kode", + "email-auth-description": "En sikkerhetskode er sendt til epostadressen din på {{contact}}.", + "email-auth-placeholder": "Epostkode", + "backup-code-auth-description": "Skriv inn en av backup-kodene dine.", + "backup-code-auth-placeholder": "Backup-kode", + "activation-link-expired": "Aktiveringslenken er utløpt", + "activation-link-expired-message": "Lenken for å aktivere profilen din er utløpt. Du kan gå tilbake til innloggingssiden for å motta en ny epost.", + "reset-password-link-expired": "Lenken for tilbakestilling av passord er utløpt", + "reset-password-link-expired-message": "Lenken for å tilbakestille passordet ditt er utløpt. Du kan gå tilbake til innloggingssiden for å motta en ny epost." + }, + "mobile": { + "add-application": "Legg til applikasjon", + "app-id": "App-ID", + "app-id-required": "App-ID er påkrevd", + "app-id-pattern": "Ugyldig format for App-ID", + "app-store-link": "App Store-lenke", + "app-store-link-required": "App Store-lenke er påkrevd", + "application-details": "Applikasjonsdetaljer", + "application-package": "Applikasjonspakke", + "application-secret": "Applikasjonsnøkkel", + "application-secret-required": "Applikasjonsnøkkel er påkrevd", + "application": "Applikasjon", + "applications": "Applikasjoner", + "copy-app-id": "Kopier App-ID", + "copy-app-store-link": "Kopier App Store-lenke", + "copy-application-package": "Kopier applikasjonspakke", + "copy-application-secret": "Kopier applikasjonsnøkkel", + "copy-google-play-link": "Kopier Google Play-lenke", + "copy-sha256-certificate-fingerprints": "Kopier SHA256-sertifikatfingeravtrykk", + "delete-application": "Slett applikasjon", + "delete-application-button-text": "Jeg forstår konsekvensene, slett applikasjonen", + "delete-application-text": "Denne handlingen kan ikke angres. Dette vil permanent slette applikasjonen din.
    Hvis du ikke ønsker å slette den permanent, kan du midlertidig suspendere applikasjonen.
    For å slette applikasjonen uansett, skriv \"{{phrase}}\" for å bekrefte.", + "delete-application-title-short": "Er du sikker på at du vil slette applikasjonen '{{name}}'?", + "delete-application-text-short": "Vær forsiktig, etter bekreftelsen vil applikasjonen og all relatert data være uopprettelig.", + "delete-application-phrase": "slett applikasjon", + "delete-applications-bundle-text": "Vær forsiktig, etter bekreftelsen vil mobilpakken og all relatert data være uopprettelig.", + "delete-applications-bundle-title": "Er du sikker på at du vil slette mobilpakken '{{bundleName}}'?", + "generate-application-secret": "Generer applikasjonsnøkkel", + "google-play-link": "Google Play-lenke", + "google-play-link-required": "Google Play-lenke er påkrevd", + "latest-version": "Nyeste versjon", + "min-version": "Minimum versjon", + "invalid-version-pattern": "Ugyldig versjonsformat. Bruk formatet: hoved.minor.patch (f.eks. 1.0.0).", + "mobile-center": "Mobil-senter", + "mobile-package": "Applikasjonspakke", + "mobile-package-max-length": "Applikasjonspakken må være under 256 tegn", + "mobile-package-required": "Applikasjonspakke er påkrevd.", + "mobile-package-pattern": "Ugyldig format for applikasjonspakke", + "no-application": "Ingen applikasjoner funnet", + "no-bundles": "Ingen pakker funnet", + "platform-type": "Plattformtype", + "search-application": "Søk applikasjoner", + "search-bundles": "Søk pakker", + "set": "Angi", + "sha256-certificate-fingerprints": "SHA256-sertifikatfingeravtrykk", + "sha256-certificate-fingerprints-required": "SHA256-sertifikatfingeravtrykk er påkrevd", + "sha256-certificate-fingerprints-pattern": "Ugyldig format for SHA256-sertifikatfingeravtrykk", + "show-hidden-pages": "Vis skjulte sider", + "status": "Status", + "status-type": { + "deprecated": "Utdatert", + "draft": "Utkast", + "published": "Publisert", + "suspended": "Suspendert" + }, + "store-information": "Butikkinformasjon", + "version-information": "Versjonsinformasjon", + "min-version-release-notes": "Utgivelsesnotater for minimum versjon", + "latest-version-release-notes": "Utgivelsesnotater for nyeste versjon", + "bundle": "Pakke", + "bundles": "Pakker", + "add-bundle": "Legg til pakke", + "title": "Tittel", + "title-required": "Tittel er påkrevd", + "title-cannot-contain-only-spaces": "Tittel kan ikke kun inneholde mellomrom", + "title-max-length": "Tittel må være under 256 tegn", + "oauth-clients": "OAuth 2.0-klienter", + "android-app": "Android-app", + "android-application": "Android-applikasjon", + "ios-app": "iOS-app", + "ios-application": "iOS-applikasjon", + "invalid-store-link": "Ugyldig butikkslenke", + "enable-oauth": "Aktiver OAuth 2.0", + "enable-self-registration": "Aktiver selvregistrering", + "edit-bundle": "Rediger pakke", + "description": "Beskrivelse", + "basic-settings": "Grunninnstillinger", + "no-application-matching": "Ingen applikasjon som matcher '{{entity}}' ble funnet.", + "no-bundle-matching": "Ingen pakke som matcher '{{entity}}' ble funnet.", + "application-required": "Applikasjon er påkrevd.", + "bundle-required": "Pakke er påkrevd.", + "no-application-text": "Ingen applikasjoner funnet", + "no-bundle-text": "Ingen pakke funnet", + "layout": "Oppsett", + "pages": "Sider", + "hide-all-pages": "Skjul alle sider", + "reset-to-default-pages": "Tilbakestill til standard sider", + "add-specific-page": "Legg til spesifikk side", + "visible": "Synlig", + "hidden": "Skjult", + "reset-to-page-default": "Tilbakestill side til standard", + "mobile-599": "Mobil (maks 599px)", + "tablet-959": "Nettbrett (maks 959px)", + "max-element-number": "Maks antall elementer", + "page-name": "Sidenavn", + "page-name-required": "Sidenavn er påkrevd.", + "page-name-cannot-contain-only-spaces": "Sidenavn kan ikke kun inneholde mellomrom.", + "page-name-max-length": "Sidenavn må være mindre enn 256 tegn", + "page-type": "Sidetype", + "pages-types": { + "dashboard": "Dashbord", + "web-view": "Webvisning", + "custom": "Egendefinert" + }, + "url": "URL", + "invalid-url-format": "Ugyldig URL-format", + "path": "Sti", + "invalid-path-format": "Ugyldig sti-format", + "custom-page": "Egendefinert side", + "edit-page": "Rediger side", + "edit-custom-page": "Rediger egendefinert side", + "delete-page": "Slett side", + "qr-code-widget": "QR-kode-widget", + "type-here": "Skriv her", + "configuration-dialog": "Konfigurasjonsdialog", + "configuration-app": "Konfigurasjonsapp", + "configuration-step": { + "prepare-environment-title": "Forbered utviklingsmiljø", + "prepare-environment-text": "Flutter ThingsBoard Mobile Application krever Flutter SDK. Følg instruksjonene for å sette opp Flutter SDK.", + "get-source-code-title": "Hent kildekode for appen", + "get-source-code-text": "Du kan hente kildekoden til Flutter ThingsBoard Mobile Application ved å klone den fra GitHub-repositoriet:", + "configure-api-title": "Konfigurer ThingsBoard API-endepunkt", + "configure-api-text": "Åpne prosjektet flutter_thingsboard_pe_app i editoren/IDE-en din. Rediger:", + "configure-api-hint": "Angi verdien av konstanten thingsBoardApiEndpoint slik at den samsvarer med API-endepunktet til din ThingsBoard-serverinstans. Ikke bruk \"localhost\" eller \"127.0.0.1\" som vertsnavn.", + "run-app-title": "Kjør appen", + "run-app-text": "Kjør appen som beskrevet i din IDE.\nHvis du bruker terminalen, kjør appen med følgende kommando:", + "more-information": "Detaljert informasjon finnes i vår Kom-i-gang dokumentasjon.", + "getting-started": "Kom i gang", + "configure-package-title": "Konfigurer applikasjonspakke", + "configure-package-text": "Du kan manuelt endre applikasjonspakken eller bruke tredjeparts CLI-verktøy.", + "configure-package-text-install": "For å installere Rename CLI-verktøyet, kjør følgende kommando:", + "configure-package-run-commands": "Kjør disse kommandoene i prosjektets rotmappe:" + } + }, + "notification": { + "action-button": "Handlingsknapp", + "action-type": "Handlingstype", + "active": "Aktiv", + "add-notification-recipients-group": "Legg til varslingsmottakergruppe", + "add-notification-template": "Legg til varslingsmal", + "add-recipient": "Legg til mottaker", + "add-recipients": "Legg til mottakere", + "add-rule": "Legg til regel", + "add-stage": "Legg til trinn", + "add-template": "Legg til mal", + "after": "Etter", + "alarm-assignment-trigger-settings": "Innstillinger for utløser ved alarmtilordning", + "alarm-comment-trigger-settings": "Innstillinger for utløser ved alarmkommentar", + "alarm-trigger-settings": "Innstillinger for alarmutløser", + "all": "Alle", + "api-feature-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle API-funksjoner", + "api-usage-trigger-settings": "Innstillinger for API-bruksutløser", + "new-platform-version-trigger-settings": "Innstillinger for ny plattformversjonsutløser", + "rate-limits-trigger-settings": "Innstillinger for utløser ved overskredet hastighetsgrense", + "task-processing-failure-trigger-settings": "Innstillinger for utløser ved feil i oppgavebehandling", + "at-least-one-should-be-selected": "Minst én må velges", + "basic-settings": "Grunnleggende innstillinger", + "button-text": "Knappetekst", + "button-text-required": "Knappetekst er påkrevd", + "button-text-max-length": "Knappetekst må være mindre enn eller lik {{ length }} tegn", + "compose": "Skriv", + "conversation": "Samtale", + "conversation-required": "Samtale er påkrevd", + "copy-notification-template": "Kopier varslingsmal", + "copy-rule": "Kopier regel", + "copy-template": "Kopier mal", + "create-new": "Opprett ny", + "created": "Opprettet", + "customize-messages": "Tilpass meldinger", + "delete-notification-text": "Vær forsiktig, etter bekreftelse vil varslingen ikke kunne gjenopprettes.", + "delete-notification-title": "Er du sikker på at du vil slette varslingen?", + "delete-notifications-text": "Vær forsiktig, etter bekreftelse vil varslingene ikke kunne gjenopprettes.", + "delete-notifications-title": "Er du sikker på at du vil slette { count, plural, =1 {1 varsling} other {# varslinger} }?", + "delete-recipient-text": "Vær forsiktig, etter bekreftelse vil mottakeren ikke kunne gjenopprettes.", + "delete-recipient-title": "Er du sikker på at du vil slette mottakeren '{{recipientName}}'?", + "delete-recipients-text": "Vær forsiktig, etter bekreftelse vil mottakerne ikke kunne gjenopprettes.", + "delete-recipients-title": "Er du sikker på at du vil slette { count, plural, =1 {1 mottaker} other {# mottakere} }?", + "delete-request-text": "Vær forsiktig, etter bekreftelse vil forespørselen ikke kunne gjenopprettes.", + "delete-request-title": "Er du sikker på at du vil slette forespørselen?", + "delete-requests-text": "Vær forsiktig, etter bekreftelse vil forespørslene ikke kunne gjenopprettes.", + "delete-requests-title": "Er du sikker på at du vil slette { count, plural, =1 {1 forespørsel} other {# forespørsler} }?", + "delete-rule-text": "Vær forsiktig, etter bekreftelse vil regelen ikke kunne gjenopprettes.", + "delete-rule-title": "Er du sikker på at du vil slette regelen '{{ruleName}}'?", + "delete-rules-text": "Vær forsiktig, etter bekreftelse vil reglene ikke kunne gjenopprettes.", + "delete-rules-title": "Er du sikker på at du vil slette { count, plural, =1 {1 regel} other {# regler} }?", + "delete-template-text": "Vær forsiktig, etter bekreftelse vil malen ikke kunne gjenopprettes.", + "delete-template-title": "Er du sikker på at du vil slette malen '{{templateName}}'?", + "delete-templates-text": "Vær forsiktig, etter bekreftelse vil malene ikke kunne gjenopprettes.", + "delete-templates-title": "Er du sikker på at du vil slette { count, plural, =1 {1 mal} other {# maler} }?", + "deleted": "Slettet", + "delivery-method": { + "delivery-method": "Leveringsmetode", + "email": "E-post", + "email-preview": "Forhåndsvisning av e-postvarsel", + "slack": "Slack", + "slack-preview": "Forhåndsvisning av Slack-varsel", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Forhåndsvisning av Microsoft Teams-varsel", + "sms": "SMS", + "sms-preview": "Forhåndsvisning av SMS-varsel", + "web": "Web", + "web-preview": "Forhåndsvisning av nettvarsel", + "mobile-app": "Mobilapp", + "mobile-app-preview": "Forhåndsvisning av mobilapp-varsel" + }, + "delivery-method-not-configure-click": "Leveringsmetode er ikke konfigurert. Klikk for å sette opp.", + "delivery-method-not-configure-contact": "Leveringsmetode er ikke konfigurert. Kontakt systemadministratoren din.", + "delivery-methods": "Leveringsmetoder", + "description": "Beskrivelse", + "device-activity-trigger-settings": "Innstillinger for utløser ved enhetsaktivitet", + "device-list-rule-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle enheter", + "device-profiles-list-rule-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle enhetsprofiler", + "disabled": "Deaktivert", + "edge-trigger-settings": "Innstillinger for Edge-utløser", + "edge-list-rule-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle edge-instanser", + "edit-notification-recipients-group": "Rediger varslingsmottakergruppe", + "edit-notification-template": "Rediger varslingsmal", + "edit-rule": "Rediger regel", + "edit-template": "Rediger mal", + "enabled": "Aktivert", + "entities-limit-trigger-settings": "Innstillinger for utløser basert på entitetsgrense", + "entity-action-trigger-settings": "Innstillinger for entitetshandlingsutløser", + "entity-type": "Enhetstype", + "escalation-chain": "Eskaleringskjede", + "failed-send": "Sendingsfeil", + "fails": "{ count, plural, =1 {1 feil} other {# feil} }", + "filter": "Filter", + "first-recipient": "Første mottaker", + "inactive": "Inaktiv", + "inbox": "Innboks", + "notification-inbox": "Varslinger / Innboks", + "input-field-support-templatization": "Inndatafelt støtter templatmaler.", + "input-fields-support-templatization": "Inndatafelt støtter templatmaler.", + "link": "Lenke", + "link-required": "Lenke er påkrevd", + "link-type": { + "dashboard": "Åpne dashbord", + "link": "Åpne URL-lenke" + }, + "loading-notifications": "Laster varsler...", + "management": "Varslingsadministrasjon", + "mark-all-as-read": "Merk alle som lest", + "mark-as-read": "Merk som lest", + "message": "Melding", + "message-required": "Melding er påkrevd", + "message-max-length": "Meldingen må være mindre enn eller lik {{ length }} tegn", + "name": "Navn", + "name-required": "Navn er påkrevd", + "new-notification": "Nytt varsel", + "no-inbox-notification": "Ingen varsler funnet", + "no-notification-request": "Ingen varslingsforespørsel", + "no-notification-templates": "Ingen varslingsmaler funnet", + "no-notifications-yet": "Ingen varsler ennå", + "no-recipients-notification": "Ingen mottakere til varsel", + "no-recipients-matching": "Ingen mottakere som samsvarer med '{{entity}}' ble funnet.", + "no-recipients-text": "Ingen mottakere funnet", + "no-rule": "Ingen regel konfigurert", + "no-rules-notification": "Ingen regler til varsel", + "no-severity-found": "Ingen alvorlighetsgrad funnet", + "no-severity-matching": "'{{severity}}' ble ikke funnet.", + "no-template-matching": "Ingen ressurser som samsvarer med '{{template}}' ble funnet.", + "not-found-slack-recipient": "Slack-mottaker ikke funnet", + "notification": "Varsel", + "notification-center": "Varslingssenter", + "notification-tap-action": "Handling ved trykk på varsel", + "notification-tap-action-hint": "Hvis deaktivert, vil standard alarmdashbord brukes", + "notify": "Varsle", + "notify-again": "Varsle igjen", + "notify-alarm-action": { + "acknowledged": "Alarm bekreftet", + "assigned": "Alarm tilordnet", + "cleared": "Alarm fjernet", + "created": "Alarm opprettet", + "severity-changed": "Alarm alvorlighetsgrad endret", + "unassigned": "Alarm fjernet fra tilordning" + }, + "notify-on": "Varsle ved", + "notify-on-comment-update": "Varsle ved kommentaroppdatering", + "notify-on-required": "Varsle ved er påkrevd", + "notify-on-unassign": "Varsle ved fjernet tilordning", + "notify-only-user-comments": "Varsle kun brukers kommentarer", + "only-rule-chain-lifecycle-failures": "Kun regelkjedens livssyklusfeil", + "only-rule-node-lifecycle-failures": "Kun regelnodens livssyklusfeil", + "platform-users": "Plattformbrukere", + "rate-limits": "Hastighetsgrenser", + "rate-limits-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle hastighetsgrenser", + "recipient": "Mottaker", + "recipient-group": "Mottakergruppe", + "recipient-type": { + "affected-tenant-administrators": "Berørte leietakeradministratorer", + "affected-user": "Berørt bruker", + "all-users": "Alle brukere", + "customer-users": "Kundebrukere", + "system-administrators": "Systemadministratorer", + "tenant-administrators": "Leietakeradministratorer", + "user-filters": "Brukerfilter", + "user-list": "Brukerliste", + "users-entity-owner": "Brukere til entitetens eier" + }, + "recipients": "Mottakere", + "notification-recipient": "Varslingsmottaker", + "notification-recipient-required": "Varslingsmottaker er påkrevd.", + "notification-recipients": "Varslinger / Mottakere", + "recipients-count": "{ count, plural, =1 {1 mottaker} other {# mottakere} }", + "recipients-required": "Mottakere er påkrevd", + "refresh-allow-delivery-method": "Oppdater tillatt leveringsmetode", + "request-search": "Forespørselsøk", + "request-status": { + "processing": "Behandler", + "scheduled": "Planlagt", + "sent": "Sendt" + }, + "review": "Gjennomgå", + "rule": "Regel", + "rule-chain-list-rule-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle regelkjeder", + "rule-engine-events-trigger-settings": "Innstillinger for regelmotorens hendelsesutløser", + "rule-engine-filter": "Regelmotorfilter", + "rule-name": "Regelnavn", + "rule-name-required": "Navn er påkrevd", + "rule-disable": "Deaktiver varslingsregel", + "rule-enable": "Aktiver varslingsregel", + "rule-node-filter": "Regelnodenfilter", + "rules": "Regler", + "notification-rules": "Varslinger / Regler", + "scheduler-later": "Planlegg for senere", + "search-notification": "Søk varsler", + "search-recipients": "Søk mottakere", + "search-rules": "Søk regler", + "search-templates": "Søk maler", + "see-documentation": "Se dokumentasjon", + "selected-notifications": "{ count, plural, =1 {1 varsel} other {# varsler} } valgt", + "selected-recipients": "{ count, plural, =1 {1 mottaker} other {# mottakere} } valgt", + "selected-requests": "{ count, plural, =1 {1 forespørsel} other {# forespørsler} } valgt", + "selected-rules": "{ count, plural, =1 {1 regel} other {# regler} } valgt", + "selected-template": "{ count, plural, =1 {1 mal} other {# maler} } valgt", + "send-notification": "Send varsel", + "sent": "Sendt", + "setup": "Oppsett", + "notification-sent": "Varslinger / Sendt", + "set-entity-from-notification": "Angi enhet fra varsel til dashbordtilstand", + "slack-chanel-type": "Slack-kanaltype", + "slack-chanel-types": { + "direct": "Direktemelding", + "private-channel": "Privat kanal", + "public-channel": "Offentlig kanal" + }, + "start-from-scratch": "Start fra bunnen", + "status": "Status", + "stop-escalation-alarm-status-become": "Stopp eskalering når alarmstatus blir:", + "subject": "Emne", + "subject-required": "Emne er påkrevd", + "subject-max-length": "Emnet må være mindre enn eller lik {{ length }} tegn", + "template": "Mal", + "template-name": "Malnavn", + "template-required": "Mal er påkrevd", + "template-type": { + "alarm": "Alarm", + "alarm-assignment": "Alarmtilordning", + "alarm-comment": "Alarmkommentar", + "api-usage-limit": "API-bruksgrense", + "device-activity": "Enhetsaktivitet", + "entities-limit": "Enhetsgrense", + "entity-action": "Enhetshandling", + "general": "Generelt", + "rule-engine-lifecycle-event": "Regelmotor livssyklushendelse", + "rule-node": "Regelnode", + "new-platform-version": "Ny plattformversjon", + "rate-limits": "Overskredet grense", + "edge-communication-failure": "Edge kommunikasjonsfeil", + "edge-connection": "Edge-tilkobling", + "task-processing-failure": "Feil ved oppgavebehandling" + }, + "templates": "Maler", + "notification-templates": "Varslinger / Maler", + "tenant-profiles-list-rule-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle leietakerprofiler", + "tenants-list-rule-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle leietakere", + "threshold": "Terskel", + "theme-color": "Temafarge", + "time": "Tid", + "track-rule-node-events": "Spor regelnodehendelser", + "trigger": { + "alarm": "Alarm", + "alarm-assignment": "Alarmtilordning", + "alarm-comment": "Alarmkommentar", + "api-usage-limit": "API-bruksgrense", + "device-activity": "Enhetsaktivitet", + "entities-limit": "Enhetsgrense", + "entity-action": "Enhetshandling", + "rule-engine-lifecycle-event": "Regelmotor livssyklus-hendelse", + "new-platform-version": "Ny plattformversjon", + "rate-limits": "Overskredet grense", + "edge-connection": "Edge-tilkobling", + "edge-communication-failure": "Edge kommunikasjonsfeil", + "task-processing-failure": "Feil ved oppgavebehandling", + "trigger": "Utløser", + "trigger-required": "Utløser er påkrevd" + }, + "type": "Type", + "unread": "Ulest", + "updated": "Oppdatert", + "use-deprecated-webhook-connectors": "Bruk utdaterte Webhook-koblinger", + "use-old-api": "Bruk gammel API", + "use-template": "Bruk mal", + "view-all": "Vis alle", + "warning": "Advarsel", + "webhook-url": "Webhook-URL", + "webhook-url-required": "Webhook-URL er påkrevd", + "workflow-url": "Arbeidsflyt-URL", + "workflow-url-required": "Arbeidsflyt-URL er påkrevd", + "channel-name": "Kanalnavn", + "channel-name-required": "Kanalnavn er påkrevd", + "settings": { + "notification-settings": "Varslingsinnstillinger", + "reset-all": "Tilbakestill alle innstillinger", + "reset-all-title": "Er du sikker på at du vil tilbakestille skjemaet?", + "reset-all-text": "Etter bekreftelse vil innstillingsskjemaet tilbakestilles til standardverdien og lagres.", + "type": "Type", + "enable-all": "Aktiver alle", + "disable-all": "Deaktiver alle", + "delivery-not-configured": "Leveringsmetode er ikke konfigurert" + } + }, + "ota-update": { + "add": "Legg til pakke", + "assign-firmware": "Tildelt fastvare", + "assign-firmware-required": "Tildelt fastvare er påkrevd", + "assign-software": "Tildelt programvare", + "assign-software-required": "Tildelt programvare er påkrevd", + "auto-generate-checksum": "Generer sjekksum automatisk", + "checksum": "Sjekksum", + "checksum-hint": "Hvis sjekksummen er tom, genereres den automatisk", + "checksum-algorithm": "Sjekksumalgoritme", + "checksum-copied-message": "Pakkesjekksummen er kopiert til utklippstavlen", + "change-firmware": "Endring av fastvaren kan føre til oppdatering av { count, plural, =1 {1 enhet} other {# enheter} }.", + "change-software": "Endring av programvaren kan føre til oppdatering av { count, plural, =1 {1 enhet} other {# enheter} }.", + "chose-compatible-device-profile": "Den opplastede pakken vil bare være tilgjengelig for enheter med valgt profil.", + "chose-firmware-distributed-device": "Velg fastvare som skal distribueres til enhetene", + "chose-software-distributed-device": "Velg programvare som skal distribueres til enhetene", + "content-type": "Innholdstype", + "copy-checksum": "Kopier sjekksum", + "copy-direct-url": "Kopier direkte URL", + "copyId": "Kopier pakke-ID", + "copied": "Kopiert!", + "delete": "Slett pakke", + "delete-ota-update-text": "Vær forsiktig, etter bekreftelse vil OTA-oppdateringen ikke kunne gjenopprettes.", + "delete-ota-update-title": "Er du sikker på at du vil slette OTA-oppdateringen '{{title}}'?", + "delete-ota-updates-text": "Vær forsiktig, etter bekreftelse vil alle valgte OTA-oppdateringer bli fjernet.", + "delete-ota-updates-title": "Er du sikker på at du vil slette { count, plural, =1 {1 OTA-oppdatering} other {# OTA-oppdateringer} }?", + "description": "Beskrivelse", + "direct-url": "Direkte URL", + "direct-url-copied-message": "Pakkens direkte URL er kopiert til utklippstavlen", + "direct-url-required": "Direkte URL er påkrevd", + "download": "Last ned pakke", + "drop-file": "Slipp en pakkefil eller klikk for å velge en fil for opplasting.", + "drop-package-file-or": "Dra og slipp en pakkefil eller", + "file-name": "Filnavn", + "file-size": "Filstørrelse", + "file-size-bytes": "Filstørrelse i bytes", + "idCopiedMessage": "Pakke-ID er kopiert til utklippstavlen", + "no-firmware-matching": "Ingen kompatible fastvare-OTA-oppdateringspakker som matcher '{{entity}}' ble funnet.", + "no-firmware-text": "Ingen kompatible fastvare-OTA-oppdateringspakker tilgjengelig.", + "no-packages-text": "Ingen pakker funnet", + "no-software-matching": "Ingen kompatible programvare-OTA-oppdateringspakker som matcher '{{entity}}' ble funnet.", + "no-software-text": "Ingen kompatible programvare-OTA-oppdateringspakker tilgjengelig.", + "ota-update": "OTA-oppdatering", + "ota-update-details": "Detaljer om OTA-oppdatering", + "ota-updates": "OTA-oppdateringer", + "package-file": "Pakkefil", + "package-type": "Pakketype", + "packages-repository": "Pakkelager", + "search": "Søk i pakker", + "selected-package": "{ count, plural, =1 {1 pakke} other {# pakker} } valgt", + "title": "Tittel", + "title-required": "Tittel er påkrevd.", + "title-max-length": "Tittelen må være kortere enn 256 tegn", + "types": { + "firmware": "Fastvare", + "software": "Programvare" + }, + "upload-binary-file": "Last opp binærfil", + "use-external-url": "Bruk ekstern URL", + "version": "Versjon", + "version-required": "Versjon er påkrevd.", + "version-tag": "Versjonsmerke", + "version-tag-hint": "Egendefinert merke må samsvare med versjonen rapportert av enheten din.", + "version-max-length": "Versjonen må være kortere enn 256 tegn", + "warning-after-save-no-edit": "Når pakken er lastet opp, vil du ikke kunne endre tittel, versjon, enhetsprofil eller pakketype." + }, + "position": { + "top": "Topp", + "bottom": "Bunn", + "left": "Venstre", + "right": "Høyre" + }, + "profile": { + "profile": "Profil", + "last-login-time": "Siste innlogging", + "change-password": "Bytt passord", + "current-password": "Nåværende passord", + "copy-jwt-token": "Kopier JWT-token", + "jwt-token": "JWT-token", + "token-valid-till": "Tokenet er gyldig til", + "tokenCopiedSuccessMessage": "JWT-token er kopiert til utklippstavlen", + "tokenCopiedWarnMessage": "JWT-tokenet er utløpt! Vennligst oppdater siden." + }, + "profiles": { + "profiles": "Profiler" + }, + "security": { + "security": "Sikkerhet", + "general-settings": "Generelle sikkerhetsinnstillinger", + "access-token": "Tilgangstoken", + "access-token-required": "Tilgangstoken er påkrevd", + "clientId": "Klient-ID", + "clientId-required": "Klient-ID er påkrevd", + "username": "Brukernavn", + "username-required": "Brukernavn er påkrevd", + "ca-cert": "CA-sertifikat", + "2fa": { + "2fa": "Tofaktorautentisering", + "2fa-description": "Tofaktorautentisering beskytter kontoen din mot uautorisert tilgang. Alt du trenger å gjøre er å skrive inn en sikkerhetskode når du logger inn.", + "authenticate-with": "Du kan autentisere med:", + "disable-2fa-provider-text": "Deaktivering av {{name}} vil gjøre kontoen din mindre sikker", + "disable-2fa-provider-title": "Er du sikker på at du vil deaktivere {{name}}?", + "get-new-code": "Få ny kode", + "main-2fa-method": "Bruk som hovedmetode for tofaktorautentisering", + "dialog": { + "activation-step-description-email": "Neste gang du logger inn, vil du bli bedt om å oppgi sikkerhetskoden som sendes til e-postadressen din.", + "activation-step-description-sms": "Neste gang du logger inn, vil du bli bedt om å oppgi sikkerhetskoden som sendes til telefonnummeret.", + "activation-step-description-totp": "Neste gang du logger inn, må du oppgi en tofaktorautentiseringskode.", + "activation-step-label": "Aktivering", + "backup-code-description": "Skriv ut kodene slik at du har dem tilgjengelig når du trenger dem for å logge inn. Du kan bruke hver kode én gang.", + "backup-code-warn": "Når du forlater denne siden, kan disse kodene ikke vises igjen. Lagre dem sikkert ved hjelp av alternativene nedenfor.", + "download-txt": "Last ned (txt)", + "email-step-description": "Skriv inn en e-postadresse som skal brukes som autentisator.", + "email-step-label": "E-post", + "enable-email-title": "Aktiver e-postautentisator", + "enable-sms-title": "Aktiver SMS-autentisator", + "enable-totp-title": "Aktiver autentiseringsapp", + "enter-verification-code": "Skriv inn den 6-sifrede koden her", + "get-backup-code-title": "Få reservekode", + "next": "Neste", + "scan-qr-code": "Skann denne QR-koden med autentiseringsappen din", + "send-code": "Send kode", + "sms-step-description": "Skriv inn et telefonnummer som skal brukes som autentisator.", + "sms-step-label": "Telefonnummer", + "success": "Suksess!", + "totp-step-description-install": "Du kan installere apper som Google Authenticator, Authy eller Duo.", + "totp-step-description-open": "Åpne autentiseringsappen på mobiltelefonen din.", + "totp-step-label": "Hent app", + "verification-code": "6-sifret kode", + "verification-code-invalid": "Ugyldig format for verifiseringskode", + "verification-code-incorrect": "Verifiseringskoden er feil", + "verification-code-many-request": "For mange forespørsler, sjekk verifiseringskode", + "verification-step-description": "Skriv inn den 6-sifrede koden vi nettopp sendte til {{address}}", + "verification-step-label": "Verifisering" + }, + "provider": { + "email": "E-post", + "email-description": "Bruk en sikkerhetskode sendt til e-postadressen din for å autentisere.", + "email-hint": "Autentiseringskoder sendes via e-post til {{ info }}", + "sms": "SMS", + "sms-description": "Bruk telefonen din for å autentisere. Vi sender deg en sikkerhetskode via SMS når du logger inn.", + "sms-hint": "Autentiseringskoder sendes via tekstmelding til {{ info }}", + "totp": "Autentiseringsapp", + "totp-description": "Bruk apper som Google Authenticator, Authy eller Duo på telefonen din for å autentisere. Den vil generere en sikkerhetskode for innlogging.", + "totp-hint": "Autentiseringsapp er satt opp for kontoen din", + "backup_code": "Reservekode", + "backup-code-description": "Disse utskrivbare engangskodene lar deg logge inn når du ikke har tilgang til telefonen din, for eksempel når du reiser.", + "backup-code-hint": "{{ info }} engangskoder er aktive for øyeblikket" + } + }, + "password-requirement": { + "at-least": "Minst:", + "character": "{ count, plural, =1 {1 tegn} other {# tegn} }", + "digit": "{ count, plural, =1 {1 siffer} other {# sifre} }", + "incorrect-password-try-again": "Feil passord. Prøv igjen", + "lowercase-letter": "{ count, plural, =1 {1 liten bokstav} other {# små bokstaver} }", + "new-passwords-not-match": "Nytt passord samsvarer ikke", + "password-should-not-contain-spaces": "Passordet ditt bør ikke inneholde mellomrom", + "password-not-meet-requirements": "Passordet oppfyller ikke kravene", + "password-requirements": "Passordkrav", + "password-should-difference": "Nytt passord bør være forskjellig fra nåværende", + "special-character": "{ count, plural, =1 {1 spesialtegn} other {# spesialtegn} }", + "uppercase-letter": "{ count, plural, =1 {1 stor bokstav} other {# store bokstaver} }", + "at-most": "Maksimalt:" + } + }, + "relation": { + "relations": "Relasjoner", + "direction": "Retning", + "clear-relation-type": "Fjern relasjonstype", + "search-direction": { + "FROM": "Fra", + "TO": "Til" + }, + "direction-type": { + "FROM": "fra", + "TO": "til" + }, + "from-relations": "Utgående relasjoner", + "to-relations": "Innkommende relasjoner", + "selected-relations": "{ count, plural, =1 {1 relasjon} other {# relasjoner} } valgt", + "type": "Type", + "to-entity-type": "Til enhetstype", + "to-entity-name": "Til enhetsnavn", + "from-entity-type": "Fra enhetstype", + "from-entity-name": "Fra enhetsnavn", + "to-entity": "Til enhet", + "from-entity": "Fra enhet", + "delete": "Slett relasjon", + "relation-type": "Relasjonstype", + "relation-type-required": "Relasjonstype er påkrevd.", + "relation-type-max-length": "Relasjonstype skal være mindre enn 256 tegn", + "any-relation-type": "Alle typer", + "add": "Legg til relasjon", + "edit": "Rediger relasjon", + "delete-to-relation-title": "Er du sikker på at du vil slette relasjon til enheten '{{entityName}}'?", + "delete-to-relation-text": "Vær oppmerksom, etter bekreftelse vil enheten '{{entityName}}' ikke lenger være relatert til den nåværende enheten.", + "delete-to-relations-title": "Er du sikker på at du vil slette { count, plural, =1 {1 relasjon} other {# relasjoner} }?", + "delete-to-relations-text": "Vær oppmerksom, etter bekreftelse vil alle valgte relasjoner bli fjernet og tilknyttede enheter vil ikke lenger være relatert til den nåværende enheten.", + "delete-from-relation-title": "Er du sikker på at du vil slette relasjon fra enheten '{{entityName}}'?", + "delete-from-relation-text": "Vær oppmerksom, etter bekreftelse vil nåværende enhet ikke lenger være relatert til enheten '{{entityName}}'.", + "delete-from-relations-title": "Er du sikker på at du vil slette { count, plural, =1 {1 relasjon} other {# relasjoner} }?", + "delete-from-relations-text": "Vær oppmerksom, etter bekreftelse vil alle valgte relasjoner bli fjernet og nåværende enhet vil ikke lenger være relatert til tilknyttede enheter.", + "remove-relation-filter": "Fjern relasjonsfilter", + "remove-filter": "Fjern filter", + "add-relation-filter": "Legg til relasjonsfilter", + "any-relation": "Enhver relasjon", + "relation-filters": "Relasjonsfiltre", + "additional-info": "Tilleggsinfo (JSON)", + "invalid-additional-info": "Kan ikke tolke tilleggsinfo som gyldig JSON.", + "no-relations-text": "Ingen relasjoner funnet", + "not": "Ikke" + }, + "resource": { + "add": "Legg til ressurs", + "all-types": "Alle", + "copyId": "Kopier ressurs-ID", + "delete": "Slett ressurs", + "delete-resource-text": "Vær oppmerksom, etter bekreftelse vil ressursen ikke kunne gjenopprettes.", + "delete-resource-title": "Er du sikker på at du vil slette ressursen '{{resourceTitle}}'?", + "delete-resources-action-title": "Slett { count, plural, =1 {1 ressurs} other {# ressurser} }", + "delete-resources-text": "Merk at de valgte ressursene, selv om de brukes i enhetsprofiler, vil bli slettet.", + "delete-resources-title": "Er du sikker på at du vil slette { count, plural, =1 {1 ressurs} other {# ressurser} }?", + "download": "Last ned ressurs", + "drop-file": "Slipp en ressursfil eller klikk for å velge en fil som skal lastes opp.", + "drop-resource-file-or": "Dra og slipp en ressursfil eller", + "empty": "Ressurs er tom", + "file-name": "Filnavn", + "idCopiedMessage": "Ressurs-ID har blitt kopiert til utklippstavlen", + "no-resource-matching": "Ingen ressurser som samsvarer med '{{widgetsBundle}}' ble funnet.", + "no-resource-text": "Ingen ressurser funnet", + "open-widgets-bundle": "Åpne widget-pakke", + "resource": "Ressurs", + "resource-file": "Ressursfil", + "resource-files": "Ressursfiler", + "resource-library-details": "Ressursdetaljer", + "resource-type": "Resursstype", + "resources-library": "Ressursbibliotek", + "search": "Søk etter ressurser", + "selected-resources": "{ count, plural, =1 {1 ressurs} other {# ressurser} } valgt", + "system": "System", + "title": "Tittel", + "title-required": "Tittel er påkrevd.", + "title-max-length": "Tittel skal være mindre enn 256 tegn", + "type": { + "jks": "JKS", + "js-module": "JS-modul", + "lwm2m-model": "LWM2M-modell", + "pkcs-12": "PKCS #12" + }, + "resource-sub-type": "Undertype", + "sub-type": { + "image": "bilde", + "scada-symbol": "Scada-symbol", + "extension": "Utvidelse", + "module": "Modul" + } + }, + "javascript": { + "add": "Legg til JavaScript-ressurs", + "delete": "Slett JavaScript-ressurs", + "delete-javascript-resource-text": "Vær oppmerksom, etter bekreftelse vil JavaScript-ressursen ikke kunne gjenopprettes.", + "delete-javascript-resource-title": "Er du sikker på at du vil slette JavaScript-ressursen '{{resourceTitle}}'?", + "delete-javascript-resources-action-title": "Slett JavaScript { count, plural, =1 {1 ressurs} other {# ressurser} }", + "delete-javascript-resources-text": "Merk at valgte JavaScript-ressurser, selv om de brukes i JavaScript-funksjoner, vil bli slettet.", + "delete-javascript-resources-title": "Er du sikker på at du vil slette JavaScript { count, plural, =1 {1 ressurs} other {# ressurser} }?", + "delete-javascript-resource-in-use-text": "Hvis du fortsatt vil slette JavaScript-ressursen, klikk på Slett uansett-knappen.", + "download": "Last ned JavaScript-ressurs", + "upload-from-file": "Last opp JavaScript fra fil", + "resource-file": "JavaScript-ressursfil", + "drop-file": "Slipp en JavaScript-fil eller klikk for å velge en fil for opplasting.", + "drop-resource-file-or": "Dra og slipp en JavaScript-fil eller", + "javascript-library": "JavaScript-bibliotek", + "javascript-type": "JavaScript-type", + "javascript-resource-details": "JavaScript-ressursdetaljer", + "javascript-resource-is-in-use": "JavaScript-ressurs brukes av andre enheter", + "javascript-resources-are-in-use": "JavaScript-ressurser brukes av andre enheter", + "javascript-resource-is-in-use-text": "JavaScript-ressursen '{{title}}' ble ikke slettet fordi den brukes av følgende enheter:", + "javascript-resources-are-in-use-text": "Ikke alle JavaScript-ressurser ble slettet fordi de brukes av andre enheter.
    Du kan vise refererte enheter ved å klikke på Referanser-knappen i den aktuelle ressursraden.
    Hvis du fortsatt vil slette disse JavaScript-ressursene, velg dem i tabellen nedenfor og klikk Slett valgte-knappen.", + "search": "Søk JavaScript-ressurser", + "selected-javascript-resources": "{ count, plural, =1 {1 JavaScript-ressurs} other {# JavaScript-ressurser} } valgt", + "no-javascript-resource-text": "Ingen JavaScript-ressurser funnet", + "all-types": "Alle", + "module-script": "Modulskript" + }, + "rpc": { + "error": { + "target-device-is-not-set": "Målenheten er ikke satt!", + "invalid-target-entity": "RPC-kommandoer støttes ikke av {{entityType}}-enheten.", + "failed-to-resolve-target-device": "Klarte ikke å finne målenheten!", + "request-timeout": "Forespørselen utløp", + "rpc-http-error": "Feil: {{status}} - {{statusText}}" + } + }, + "rulechain": { + "rulechain": "Regelkjede", + "rulechain-events": "Regelkjedebegivenheter", + "rulechains": "Regelkjeder", + "root": "Rot", + "delete": "Slett regelkjede", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "name-max-length": "Navn skal være mindre enn 256 tegn", + "description": "Beskrivelse", + "add": "Legg til regelkjede", + "set-root": "Gjør regelkjede til rot", + "set-root-rulechain-title": "Er du sikker på at du vil gjøre regelkjeden '{{ruleChainName}}' til rot?", + "set-root-rulechain-text": "Etter bekreftelse vil regelkjeden bli rot og håndtere alle innkommende transportmeldinger.", + "delete-rulechain-title": "Er du sikker på at du vil slette regelkjeden '{{ruleChainName}}'?", + "delete-rulechain-text": "Vær oppmerksom, etter bekreftelse vil regelkjeden og alle relaterte data ikke kunne gjenopprettes.", + "delete-rulechains-title": "Er du sikker på at du vil slette { count, plural, =1 {1 regelkjede} other {# regelkjeder} }?", + "delete-rulechains-action-title": "Slett { count, plural, =1 {1 regelkjede} other {# regelkjeder} }", + "delete-rulechains-text": "Vær oppmerksom, etter bekreftelse vil alle valgte regelkjeder bli fjernet og alle relaterte data ikke kunne gjenopprettes.", + "add-rulechain-text": "Legg til ny regelkjede", + "no-rulechains-text": "Ingen regelkjeder funnet", + "rulechain-details": "Regelkjededetaljer", + "details": "Detaljer", + "events": "Hendelser", + "system": "System", + "import": "Importer regelkjede", + "export": "Eksporter regelkjede", + "export-failed-error": "Kan ikke eksportere regelkjede: {{error}}", + "create-new-rulechain": "Opprett ny regelkjede", + "rulechain-file": "Regelkjedefil", + "invalid-rulechain-file-error": "Kan ikke importere regelkjede: Ugyldig datastruktur.", + "copyId": "Kopier regelkjede-ID", + "idCopiedMessage": "Regelkjede-ID er kopiert til utklippstavlen", + "select-rulechain": "Velg regelkjede", + "no-rulechains-matching": "Ingen regelkjeder som samsvarer med '{{entity}}' ble funnet.", + "rulechain-required": "Regelkjede er påkrevd", + "management": "Regelhåndtering", + "debug-mode": "Feilsøkingsmodus", + "search": "Søk regelkjeder", + "selected-rulechains": "{ count, plural, =1 {1 regelkjede} other {# regelkjeder} } valgt", + "open-rulechain": "Åpne regelkjede", + "edge-template-root": "Malrot", + "assign-to-edge": "Tildel til edge", + "edge-rulechain": "Edge regelkjede", + "unassign-rulechain-from-edge-text": "Etter bekreftelse vil regelkjeden bli fjernet og ikke lenger være tilgjengelig for edge.", + "unassign-rulechains-from-edge-title": "Er du sikker på at du vil fjerne tildelingen av { count, plural, =1 {1 regelkjede} other {# regelkjeder} }?", + "unassign-rulechains-from-edge-text": "Etter bekreftelse vil alle valgte regelkjeder bli fjernet og ikke lenger være tilgjengelige for edge.", + "assign-rulechain-to-edge-title": "Tildel regelkjede(r) til edge", + "assign-rulechain-to-edge-text": "Vennligst velg regelkjeder som skal tildeles edge", + "set-edge-template-root-rulechain": "Gjør regelkjede til edge-malrot", + "set-edge-template-root-rulechain-title": "Er du sikker på at du vil gjøre regelkjeden '{{ruleChainName}}' til edge-malrot?", + "set-edge-template-root-rulechain-text": "Etter bekreftelse vil regelkjeden bli edge-malrot og være rot-regelkjede for nylig opprettede edges.", + "invalid-rulechain-type-error": "Kan ikke importere regelkjede: Ugyldig regelkjedetype. Forventet type er {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Tildel regelkjede til edge(r) ved opprettelse", + "set-auto-assign-to-edge-title": "Er du sikker på at du vil tildele edge-regelkjeden '{{ruleChainName}}' til edge(r) ved opprettelse?", + "set-auto-assign-to-edge-text": "Etter bekreftelse vil edge-regelkjeden automatisk bli tildelt edge(r) ved opprettelse.", + "unset-auto-assign-to-edge": "Ikke tildel regelkjede til edge(r) ved opprettelse", + "unset-auto-assign-to-edge-title": "Er du sikker på at du ikke vil tildele edge-regelkjeden '{{ruleChainName}}' til edge(r) ved opprettelse?", + "unset-auto-assign-to-edge-text": "Etter bekreftelse vil edge-regelkjeden ikke lenger bli automatisk tildelt edge(r) ved opprettelse.", + "unassign-rulechain-title": "Er du sikker på at du vil fjerne tildelingen av regelkjeden '{{ruleChainName}}'?", + "unassign-rulechains": "Fjern tildeling av regelkjeder" + }, + "rulenode": { + "rule-node-events": "Regelnodehendelser", + "details": "Detaljer", + "events": "Hendelser", + "search": "Søk noder", + "open-node-library": "Åpne nodebibliotek", + "close-node-library": "Lukk nodebibliotek", + "add": "Legg til regelnode", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "name-max-length": "Navn skal være mindre enn 256 tegn", + "type": "Type", + "rule-node-description": "Regelnodebeskrivelse", + "delete": "Slett regelnode", + "select-all-objects": "Velg alle noder og forbindelser", + "deselect-all-objects": "Opphev valg av alle noder og forbindelser", + "delete-selected-objects": "Slett valgte noder og forbindelser", + "delete-selected": "Slett valgte", + "create-nested-rulechain": "Opprett nestet regelkjede", + "select-all": "Velg alle", + "copy-selected": "Kopier valgte", + "deselect-all": "Opphev alle valg", + "rulenode-details": "Regelnodedetaljer", + "debug-mode": "Feilsøkingsmodus", + "singleton": "Singleton", + "configuration": "Konfigurasjon", + "link": "Lenke", + "link-details": "Detaljer for regelnodelenke", + "add-link": "Legg til lenke", + "link-label": "Lenketikett", + "link-label-required": "Lenketikett er påkrevd.", + "custom-link-label": "Egendefinert lenketikett", + "custom-link-label-required": "Egendefinert lenketikett er påkrevd.", + "link-labels": "Lenketiketter", + "link-labels-required": "Lenketiketter er påkrevd.", + "no-link-labels-found": "Ingen lenketiketter funnet", + "no-link-label-matching": "'{{label}}' ble ikke funnet.", + "create-new-link-label": "Opprett en ny!", + "type-filter": "Filter", + "type-filter-details": "Filtrer innkommende meldinger med konfigurerte betingelser", + "type-enrichment": "Berikelse", + "type-enrichment-details": "Legg til tilleggsinformasjon i meldingsmetadata", + "type-transformation": "Transformasjon", + "type-transformation-details": "Endre meldingsinnhold og metadata", + "type-action": "Handling", + "type-action-details": "Utfør spesialhandling", + "type-external": "Ekstern", + "type-external-details": "Samhandler med eksternt system", + "type-rule-chain": "Regelkjede", + "type-rule-chain-details": "Sender innkommende meldinger til spesifisert regelkjede", + "type-flow": "Flyt", + "type-flow-details": "Organiserer meldingsflyt", + "type-input": "Inndata", + "type-input-details": "Logisk inndata for regelkjede, sender meldinger videre til neste relaterte regelnode", + "type-unknown": "Ukjent", + "type-unknown-details": "Uløst regelnode", + "directive-is-not-loaded": "Definert konfigurasjonsdirektiv '{{directiveName}}' er ikke tilgjengelig.", + "ui-resources-load-error": "Kunne ikke laste konfigurasjonsressurser for brukergrensesnitt.", + "invalid-target-rulechain": "Kan ikke løse målet regelkjede!", + "test-script-function": "Test skriptfunksjon", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", + "message": "Melding", + "message-type": "Meldingstype", + "select-message-type": "Velg meldingstype", + "message-type-required": "Meldingstype er påkrevd", + "metadata": "Metadata", + "metadata-required": "Metadataoppføringer kan ikke være tomme.", + "output": "Utdata", + "test": "Test", + "help": "Hjelp", + "reset-debug-settings": "Tilbakestill feilsøkingsinnstillinger i alle noder", + "test-with-this-message": "{{test}} med denne meldingen", + "queue-hint": "Velg en kø for å sende meldingen til en annen kø. 'Hoved'-kø brukes som standard.", + "queue-singleton-hint": "Velg en kø for å sende meldingen i flerinstansmiljøer. 'Hoved'-kø brukes som standard." + }, + "rule-node-config": { + "id": "Id", + "additional-info": "Tilleggsinformasjon", + "advanced-settings": "Avanserte innstillinger", + "create-entity-if-not-exists": "Opprett ny enhet hvis den ikke finnes", + "create-entity-if-not-exists-hint": "Hvis aktivert, vil en ny enhet med spesifiserte parametere bli opprettet med mindre den allerede finnes. Eksisterende enheter vil bli brukt som de er for relasjoner.", + "select-device-connectivity-event": "Velg enhetstilkoblingshendelse", + "entity-name-pattern": "Navnemønster", + "device-name-pattern": "Enhetsnavn", + "asset-name-pattern": "Eiendelsnavn", + "entity-view-name-pattern": "Entity view-navn", + "customer-title-pattern": "Kundens tittel", + "dashboard-name-pattern": "Dashboardtittel", + "user-name-pattern": "Brukerens e-post", + "edge-name-pattern": "Edge-navn", + "entity-name-pattern-required": "Navnemønster er påkrevd", + "entity-name-pattern-hint": "Navnemønsterfelt støtter templatization. Bruk $[messageKey] for å hente verdi fra meldingen og ${metadataKey} for å hente verdi fra metadata.", + "copy-message-type": "Kopier meldingstype", + "entity-type-pattern": "Typemønster", + "entity-type-pattern-required": "Typemønster er påkrevd", + "message-type-value": "Meldingstypeverdi", + "message-type-value-required": "Meldingstypeverdi er påkrevd", + "message-type-value-max-length": "Meldingstypeverdi skal være mindre enn 256", + "output-message-type": "Utgangsmeldingstype", + "entity-cache-expiration": "Utløpstid for enhetsbuffer (sek)", + "entity-cache-expiration-hint": "Angir maksimal tidsperiode for lagring av funnede enhetsoppføringer. 0 betyr at oppføringer aldri utløper.", + "entity-cache-expiration-required": "Utløpstid for enhetsbuffer er påkrevd.", + "entity-cache-expiration-range": "Utløpstid for enhetsbuffer må være større enn eller lik 0.", + "customer-name-pattern": "Kundens tittel", + "customer-name-pattern-required": "Kundens tittel er påkrevd", + "customer-name-pattern-hint": "Bruk $[messageKey] for å hente verdi fra meldingen og ${metadataKey} for å hente verdi fra metadata.", + "create-customer-if-not-exists": "Opprett ny kunde hvis den ikke finnes", + "unassign-from-customer": "Fjern tildeling fra spesifikk kunde hvis opphav er dashboard", + "unassign-from-customer-tooltip": "Bare dashboards kan tildeles flere kunder samtidig.\nHvis meldingens opphav er et dashboard, må du eksplisitt spesifisere kundens tittel for å fjerne tildeling.", + "customer-cache-expiration": "Utløpstid for kundebuffer (sek)", + "customer-cache-expiration-hint": "Angir maksimal tidsperiode for lagring av funnede kundeoppføringer. 0 betyr at oppføringer aldri utløper.", + "customer-cache-expiration-required": "Utløpstid for kundebuffer er påkrevd.", + "customer-cache-expiration-range": "Utløpstid for kundebuffer må være større enn eller lik 0.", + "interval-start": "Start på intervall", + "interval-end": "Slutt på intervall", + "time-unit": "Tidsenhet", + "fetch-mode": "Hentemodus", + "order-by-timestamp": "Sorter etter tidsstempel", + "limit": "Begrensning", + "limit-hint": "Minste verdi er 2, maks - 1000. For å hente én oppføring, velg hentemodus 'Første' eller 'Siste'.", + "limit-required": "Begrensning er påkrevd.", + "limit-range": "Begrensning må være mellom 2 og 1000.", + "time-unit-milliseconds": "Millisekunder", + "time-unit-seconds": "Sekunder", + "time-unit-minutes": "Minutter", + "time-unit-hours": "Timer", + "time-unit-days": "Dager", + "time-value-range": "Tillatt område fra 1 til 2147483647.", + "start-interval-value-required": "Start på intervall er påkrevd.", + "end-interval-value-required": "Slutt på intervall er påkrevd.", + "filter": "Filter", + "switch": "Bryter", + "math-templatization-tooltip": "Dette feltet støtter templatization. Bruk $[messageKey] for å hente verdi fra meldingen og ${metadataKey} for metadata.", + "add-message-type": "Legg til meldingstype", + "select-message-types-required": "Minst én meldingstype må være valgt.", + "select-message-types": "Velg meldingstyper", + "no-message-types-found": "Ingen meldingstyper funnet", + "no-message-type-matching": "'{{messageType}}' ble ikke funnet.", + "create-new-message-type": "Opprett en ny.", + "message-types-required": "Meldingstyper er påkrevd.", + "client-attributes": "Klientattributter", + "shared-attributes": "Delte attributter", + "server-attributes": "Serverattributter", + "attributes-keys": "Attributtnøkler", + "attributes-keys-required": "Attributtnøkler er påkrevd", + "attributes-scope": "Attributtområde", + "attributes-scope-value": "Verdi for attributtområde", + "attributes-scope-value-copy": "Kopier verdi for attributtområde", + "attributes-scope-hint": "Bruk metadata-nøkkelen 'scope' for å sette attributtområdet dynamisk per melding. Dette overstyrer konfigurasjonsverdi hvis satt.", + "notify-device": "Tving varsling til enhet", + "send-attributes-updated-notification": "Send varsel om oppdaterte attributter", + "send-attributes-updated-notification-hint": "Send varsling om oppdaterte attributter som en egen melding til regelmotorens kø.", + "send-attributes-deleted-notification": "Send varsel om slettede attributter", + "send-attributes-deleted-notification-hint": "Send varsling om slettede attributter som en egen melding til regelmotorens kø.", + "update-attributes-only-on-value-change": "Lagre attributter kun ved endring", + "update-attributes-only-on-value-change-hint": "Oppdaterer attributtene ved hver innkommende melding uavhengig av verdiendring. Øker API-bruk og reduserer ytelse.", + "update-attributes-only-on-value-change-hint-enabled": "Oppdaterer attributtene kun hvis deres verdi har endret seg. Hvis verdien ikke er endret, sendes ingen tidsstempeloppdatering eller endringsvarsel.", + "fetch-credentials-to-metadata": "Hent legitimasjon til metadata", + "notify-device-on-update-hint": "Hvis aktivert, tving varsling til enheten om oppdatering av delte attributter. Hvis deaktivert, styres varslingsatferden av parameteren 'notifyDevice' fra metadataen i den innkommende meldingen. For å deaktivere varslingen må meldingsmetadata inneholde parameteren 'notifyDevice' satt til 'false'. Alle andre tilfeller vil utløse varsling til enheten.", + "notify-device-on-delete-hint": "Hvis aktivert, tving varsling til enheten om fjerning av delte attributter. Hvis deaktivert, styres varslingsatferden av parameteren 'notifyDevice' fra metadataen i den innkommende meldingen. For å aktivere varslingen må meldingsmetadata inneholde parameteren 'notifyDevice' satt til 'true'. I alle andre tilfeller vil varslingen ikke bli utløst.", + "latest-timeseries": "Siste tidsseriedataknøkler", + "timeseries-keys": "Tidsserienøkler", + "timeseries-keys-required": "Minst én tidsserienøkkel må velges.", + "add-timeseries-key": "Legg til tidsserienøkkel", + "add-message-field": "Legg til meldingsfelt", + "relation-search-parameters": "Søkeparametere for relasjoner", + "relation-parameters": "Relasjonsparametere", + "add-metadata-field": "Legg til metadatafelt", + "data-keys": "Meldingsfeltnavn", + "copy-from": "Kopier fra", + "data-to-metadata": "Data til metadata", + "metadata-to-data": "Metadata til data", + "use-regular-expression-hint": "Bruk regulære uttrykk for å kopiere nøkler etter mønster.\n\nTips & triks:\nTrykk 'Enter' for å fullføre inntasting av feltnavn.\nTrykk 'Backspace' for å slette feltnavn. Flere feltnavn støttes.", + "interval": "Intervall", + "interval-required": "Intervall er påkrevd", + "interval-hint": "Dedupliseringsintervall i sekunder.", + "interval-min-error": "Minste tillatte verdi er 1", + "max-pending-msgs": "Maks antall ventende meldinger", + "max-pending-msgs-hint": "Maksimalt antall meldinger som lagres i minnet per unik dedupliserings-ID.", + "max-pending-msgs-required": "Maks antall ventende meldinger er påkrevd", + "max-pending-msgs-max-error": "Maks tillatte verdi er 1000", + "max-pending-msgs-min-error": "Minste tillatte verdi er 1", + "max-retries": "Maks antall forsøk", + "max-retries-required": "Maks antall forsøk er påkrevd", + "max-retries-hint": "Maksimalt antall forsøk på å sende dedupliserte meldinger til køen. Det er 10 sekunders forsinkelse mellom hvert forsøk.", + "max-retries-max-error": "Maks tillatte verdi er 100", + "max-retries-min-error": "Minste tillatte verdi er 0", + "strategy": "Strategi", + "strategy-required": "Strategi er påkrevd", + "strategy-all-hint": "Returner alle meldinger som kom i løpet av dedupliseringsperioden som én enkelt JSON-tabellmelding. Hver oppføring representerer et objekt med indre egenskaper 'msg' og 'metadata'.", + "strategy-first-hint": "Returner første melding som kom i løpet av dedupliseringsperioden.", + "strategy-last-hint": "Returner siste melding som kom i løpet av dedupliseringsperioden.", + "first": "Første", + "last": "Siste", + "all": "Alle", + "output-msg-type-hint": "Meldingstypen for dedupliseringsresultatet.", + "queue-name-hint": "Navnet på køen hvor dedupliseringsresultatet publiseres.", + "keys": "Nøkler", + "keys-required": "Nøkler er påkrevd", + "rename-keys-in": "Gi nytt navn til nøkler i", + "data": "Data", + "message": "Melding", + "metadata": "Metadata", + "current-key-name": "Nåværende nøkkelnavn", + "key-name-required": "Nøkkelnavn er påkrevd", + "new-key-name": "Nytt nøkkelnavn", + "new-key-name-required": "Nytt nøkkelnavn er påkrevd", + "metadata-keys": "Metadatafeltnavn", + "json-path-expression": "JSON path-uttrykk", + "json-path-expression-required": "JSON path-uttrykk er påkrevd", + "json-path-expression-hint": "JSONPath spesifiserer en sti til et element eller en gruppe elementer i en JSON-struktur. '$' representerer rotnivået i objektet eller arrayen.", + "relations-query": "Relasjonsspørring", + "device-relations-query": "Enhetsrelasjonsspørring", + "max-relation-level": "Maks relasjonsnivå", + "max-relation-level-error": "Verdien må være større enn 0 eller ikke spesifisert.", + "max-relation-level-invalid": "Verdien må være et heltall.", + "relation-type": "Relasjonstype", + "relation-type-pattern": "Relasjonstypemønster", + "relation-type-pattern-required": "Relasjonstypemønster er påkrevd", + "relation-types-list": "Relasjonstyper som skal videreføres", + "relation-types-list-hint": "Hvis ingen relasjonstyper for videreføring er valgt, vil alarmer bli videreført uten filtrering på relasjonstype.", + "unlimited-level": "Ubegrenset nivå", + "latest-telemetry": "Siste telemetri", + "add-telemetry-key": "Legg til telemetrinøkkel", + "delete-from": "Slett fra", + "use-regular-expression-delete-hint": "Bruk regulære uttrykk for å slette nøkler etter mønster.\n\nTips & triks:\nTrykk 'Enter' for å fullføre inntasting av feltnavn.\nTrykk 'Backspace' for å slette feltnavn.\nFlere feltnavn støttes.", + "fetch-into": "Hent til", + "attr-mapping": "Attributttilordning:", + "source-attribute": "Kildeattributtnøkkel", + "source-attribute-required": "Kildeattributtnøkkel er påkrevd.", + "source-telemetry": "Kildetelemetrinøkkel", + "source-telemetry-required": "Kildetelemetrinøkkel er påkrevd.", + "target-key": "Målnøkkel", + "target-key-required": "Målnøkkel er påkrevd.", + "attr-mapping-required": "Minst én tilordning må spesifiseres.", + "fields-mapping": "Felttilordning", + "fields-mapping-hint": "Hvis meldingsfeltet er satt til $entityId, vil opprinnelsesenhetens ID bli lagret i den angitte tabellkolonnen.", + "relations-query-config-direction-suffix": "opphav", + "profile-name": "Profilnavn", + "fetch-circle-parameter-info-from-metadata-hint": "Metadatafeltet '{{perimeterKeyName}}' må være definert i følgende format: {"latitude":48.196, "longitude":24.6532, "radius":100.0, "radiusUnit":"METER"}", + "fetch-poligon-parameter-info-from-metadata-hint": "Metadatafeltet '{{perimeterKeyName}}' må være definert i følgende format: [[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "Bruk $[messageKey] for å hente verdi fra meldingen og ${metadataKey} for å hente verdi fra metadata.", + "fields-mapping-required": "Minst én felttilordning må spesifiseres.", + "at-least-one-field-required": "Minst ett inndatafelt må ha angitte verdier.", + "originator-fields-sv-map-hint": "Målnøkkelfeltene støtter templatization. Bruk $[messageKey] for å hente verdi fra meldingen og ${metadataKey} for å hente verdi fra metadata.", + "sv-map-hint": "Kun målnøkkelfeltene støtter templatization. Bruk $[messageKey] for å hente verdi fra meldingen og ${metadataKey} for å hente verdi fra metadata.", + "source-field": "Kildefelt", + "source-field-required": "Kildefelt er påkrevd.", + "originator-source": "Opprinnelseskilde", + "new-originator": "Ny opprinnelsesenhet", + "originator-customer": "Kunde", + "originator-tenant": "Leietaker", + "originator-related": "Relatert enhet", + "originator-alarm-originator": "Alarmens opprinnelse", + "originator-entity": "Enhet etter navnemønster", + "clone-message": "Klon melding", + "transform": "Transformer", + "default-ttl": "Standard TTL", + "default-ttl-required": "Standard TTL er påkrevd.", + "default-ttl-hint": "Regelnode henter TTL (Time-to-Live) verdi fra meldingsmetadata. Hvis verdien ikke er tilstede, brukes den konfigurerte TTL. Hvis verdien er satt til 0, benyttes TTL fra leietakerprofilen.", + "default-ttl-zero-hint": "TTL vil ikke bli brukt hvis verdien er satt til 0.", + "min-default-ttl-message": "Kun minimumsverdi 0 er tillatt for TTL.", + "generation-parameters": "Generasjonsparametere", + "message-count": "Grense for genererte meldinger (0 - ubegrenset)", + "message-count-required": "Grense for genererte meldinger er påkrevd.", + "min-message-count-message": "Kun minimumsverdi 0 er tillatt for antall meldinger.", + "period-seconds": "Periode i sekunder", + "period-seconds-required": "Periode er påkrevd.", + "generation-frequency-seconds": "Genereringsfrekvens i sekunder", + "generation-frequency-required": "Genereringsfrekvens er påkrevd.", + "min-generation-frequency-message": "Minimum 60 sekunder er påkrevd.", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "Bruk periode i sekunder-mønster", + "use-metadata-period-in-seconds-patterns-hint": "Hvis valgt, vil regelnode bruke periodeintervall i sekunder fra meldingsmetadata eller -data, forutsatt at intervaller er oppgitt i sekunder.", + "period-in-seconds-pattern": "Mønster for periode i sekunder", + "period-in-seconds-pattern-required": "Mønster for periode i sekunder er påkrevd", + "min-period-seconds-message": "Minimum 60 sekunder periode er tillatt.", + "originator": "Opprinnelsesenhet", + "message-body": "Meldingsinnhold", + "message-metadata": "Meldingsmetadata", + "generate": "Generer", + "current-rule-node": "Gjeldende Regelnode", + "current-tenant": "Gjeldende Leietaker", + "generator-function": "Genereringsfunksjon", + "test-generator-function": "Test genereringsfunksjon", + "generator": "Generator", + "test-filter-function": "Test filterfunksjon", + "test-switch-function": "Test byttefunksjon", + "test-transformer-function": "Test transformasjonsfunksjon", + "transformer": "Transformer", + "alarm-create-condition": "Betingelse for å opprette alarm", + "test-condition-function": "Test betingelsesfunksjon", + "alarm-clear-condition": "Betingelse for å fjerne alarm", + "alarm-details-builder": "Alarmdetaljbygger", + "test-details-function": "Test detaljfunksjon", + "alarm-type": "Alarmtype", + "select-entity-types": "Velg enhetstyper", + "alarm-type-required": "Alarmtype er påkrevd.", + "alarm-severity": "Alarmeringsgrad", + "alarm-severity-required": "Alarmeringsgrad er påkrevd", + "alarm-severity-pattern": "Alarmeringsgradsmønster", + "alarm-status-filter": "Alarmstatusfilter", + "alarm-status-list-empty": "Listen over alarmstatuser er tom", + "no-alarm-status-matching": "Ingen matchende alarmstatus funnet.", + "propagate": "Videreformidle alarm til relaterte enheter", + "propagate-to-owner": "Videreformidle alarm til enhetseier (Kunde eller Leietaker)", + "propagate-to-tenant": "Videreformidle alarm til Leietaker", + "condition": "Betingelse", + "details": "Detaljer", + "to-string": "Til tekst", + "test-to-string-function": "Test konvertering til tekst", + "from-template": "Fra", + "from-template-required": "Fra-feltet er påkrevd", + "message-to-metadata": "Melding til metadata", + "metadata-to-message": "Metadata til melding", + "from-message": "Fra melding", + "from-metadata": "Fra metadata", + "to-template": "Til", + "to-template-required": "Til-mal er påkrevd", + "mail-address-list-template-hint": "Kommaseparert adresseliste, bruk ${metadataKey} for verdi fra metadata, $[messageKey] for verdi fra meldingsinnholdet", + "cc-template": "Kopi (Cc)", + "bcc-template": "Blindkopi (Bcc)", + "subject-template": "Emne", + "subject-template-required": "Emnemal er påkrevd", + "body-template": "Brødtekst", + "body-template-required": "Brødtekstmal er påkrevd", + "dynamic-mail-body-type": "Dynamisk e-post brødteksttype", + "mail-body-type": "E-post brødteksttype", + "body-type-template": "Brødteksttype-mal", + "reply-routing-configuration": "Konfigurasjon for svarruting", + "rpc-reply-routing-configuration-hint": "Disse konfigurasjonsparametrene angir metadata-nøkkelnavnene som brukes for å identifisere tjeneste, økt og forespørsel ved sending av svar tilbake.", + "reply-routing-configuration-hint": "Disse konfigurasjonsparametrene angir metadata-nøkkelnavnene som brukes for å identifisere tjeneste og forespørsel ved sending av svar tilbake.", + "request-id-metadata-attribute": "Forespørsels-ID", + "service-id-metadata-attribute": "Tjeneste-ID", + "session-id-metadata-attribute": "Økt-ID", + "timeout-sec": "Tidsavbrudd i sekunder", + "timeout-required": "Tidsavbrudd er påkrevd", + "min-timeout-message": "Kun minimumsverdi 0 er tillatt for tidsavbrudd.", + "endpoint-url-pattern": "Endepunkt URL-mønster", + "endpoint-url-pattern-required": "Endepunkt URL-mønster er påkrevd", + "request-method": "Forespørselsmetode", + "use-simple-client-http-factory": "Bruk enkel HTTP-klientfabrikk", + "ignore-request-body": "Uten forespørselsinnhold", + "parse-to-plain-text": "Parse til ren tekst", + "parse-to-plain-text-hint": "Hvis valgt, vil meldingsinnholdet i forespørselens kropp bli transformert fra JSON-streng til ren tekst, f.eks. msg = \"Hello,\\t\"world\"\" vil bli tolket som Hello, \"world\"", + "read-timeout": "Lesetidsavbrudd i millisekunder", + "read-timeout-hint": "Verdien 0 betyr uendelig tidsavbrudd", + "max-parallel-requests-count": "Maksimalt antall parallelle forespørsler", + "max-parallel-requests-count-hint": "Verdien 0 spesifiserer ingen begrensning i parallell prosessering", + "max-response-size": "Maksimal svarstørrelse (i KB)", + "max-response-size-hint": "Maksimalt minne allokert for å buffre data ved dekoding eller koding av HTTP-meldinger, som f.eks. JSON eller XML-payloads", + "headers": "Overskrifter", + "headers-hint": "Bruk ${metadataKey} for verdi fra metadata, $[messageKey] for verdi fra meldingsinnholdet i header/verdi-feltene", + "header": "Overskrift", + "header-required": "Overskrift er påkrevd", + "value": "Verdi", + "value-required": "Verdi er påkrevd", + "topic-pattern": "Emnemønster", + "key-pattern": "Nøkkelmønster", + "key-pattern-hint": "Valgfritt. Hvis et gyldig partisjonsnummer er spesifisert, vil det bli brukt ved sending av posten. Hvis ingen partisjon er spesifisert, brukes nøkkelen i stedet. Hvis ingen av delene er spesifisert, vil en partisjon bli tilordnet i rundgang.", + "topic-pattern-required": "Emnemønster er påkrevd", + "topic": "Emne", + "topic-required": "Emne er påkrevd", + "bootstrap-servers": "Bootstrap-servere", + "bootstrap-servers-required": "Verdi for bootstrap-servere er påkrevd", + "other-properties": "Andre egenskaper", + "key": "Nøkkel", + "key-required": "Nøkkel er påkrevd", + "retries": "Automatisk antall forsøk ved feil", + "min-retries-message": "Kun minimum 0 forsøk er tillatt.", + "batch-size-bytes": "Batch-størrelse produsert i byte", + "min-batch-size-bytes-message": "Kun minimum batch-størrelse 0 er tillatt.", + "linger-ms": "Bufferlokaltid (ms)", + "min-linger-ms-message": "Kun minimum 0 ms er tillatt.", + "buffer-memory-bytes": "Maks bufferstørrelse for klient i byte", + "min-buffer-memory-message": "Kun minimum 0 bufferstørrelse er tillatt.", + "memory-buffer-size-range": "Minnebufferstørrelse må være mellom 0 og {{max}} KB", + "acks": "Antall bekreftelser", + "topic-arn-pattern": "Emne ARN-mønster", + "topic-arn-pattern-required": "Emne ARN-mønster er påkrevd", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID er påkrevd", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key er påkrevd", + "aws-region": "AWS Region", + "aws-region-required": "AWS Region er påkrevd", + "exchange-name-pattern": "Byttemønster", + "routing-key-pattern": "Ruter-nøkkelmønster", + "message-properties": "Meldingsegenskaper", + "host": "Vert", + "host-required": "Vert er påkrevd", + "port": "Port", + "port-required": "Port er påkrevd", + "port-range": "Port må være i området 1 til 65535.", + "virtual-host": "Virtuell vert", + "username": "Brukernavn", + "password": "Passord", + "automatic-recovery": "Automatisk gjenoppretting", + "connection-timeout-ms": "Tilkobling tidsavbrudd (ms)", + "min-connection-timeout-ms-message": "Kun minimumsverdi 0 ms er tillatt.", + "handshake-timeout-ms": "Håndtrykk tidsavbrudd (ms)", + "min-handshake-timeout-ms-message": "Kun minimumsverdi 0 ms er tillatt.", + "client-properties": "Klientegenskaper", + "queue-url-pattern": "Kø-URL-mønster", + "queue-url-pattern-required": "Kø-URL-mønster er påkrevd", + "delay-seconds": "Forsinkelse (sekunder)", + "min-delay-seconds-message": "Kun minimum 0 sekunder er tillatt.", + "max-delay-seconds-message": "Kun maksimum 900 sekunder er tillatt.", + "name": "Navn", + "name-required": "Navn er påkrevd", + "queue-type": "Køtype", + "sqs-queue-standard": "Standard", + "sqs-queue-fifo": "FIFO", + "gcp-project-id": "GCP prosjekt-ID", + "gcp-project-id-required": "GCP prosjekt-ID er påkrevd", + "gcp-service-account-key": "GCP tjenestekonto nøkkelfil", + "gcp-service-account-key-required": "GCP tjenestekonto nøkkelfil er påkrevd", + "pubsub-topic-name": "Emnenavn", + "pubsub-topic-name-required": "Emnenavn er påkrevd", + "message-attributes": "Meldingsegenskaper", + "message-attributes-hint": "Bruk ${metadataKey} for verdi fra metadata, $[messageKey] for verdi fra meldingsinnholdet i navn/verdi-feltene", + "connect-timeout": "Tilkobling tidsavbrudd (sek)", + "connect-timeout-required": "Tilkobling tidsavbrudd er påkrevd.", + "connect-timeout-range": "Tilkobling tidsavbrudd må være mellom 1 og 200.", + "client-id": "Klient-ID", + "client-id-hint": "Valgfritt. La stå tomt for automatisk generert klient-ID. Vær forsiktig når du angir klient-ID. De fleste MQTT-meglere tillater ikke flere tilkoblinger med samme klient-ID. For å koble til slike meglere må klient-ID-en din være unik. Når plattformen kjører i mikrotjenestemodus, startes en kopi av regelnoden i hver mikrotjeneste. Dette vil automatisk føre til flere MQTT-klienter med samme ID og kan forårsake feil i regelnoden. For å unngå slike feil, aktiver alternativet \"Legg til tjeneste-ID som suffiks til klient-ID\" nedenfor.", + "append-client-id-suffix": "Legg til tjeneste-ID som suffiks til klient-ID", + "client-id-suffix-hint": "Valgfritt. Brukes når \"Klient-ID\" er spesifisert. Hvis valgt, vil tjeneste-ID bli lagt til klient-ID som et suffiks. Hjelper med å unngå feil i mikrotjenestemodus.", + "device-id": "Enhets-ID", + "device-id-required": "Enhets-ID er påkrevd.", + "clean-session": "Ren økt", + "enable-ssl": "Aktiver SSL", + "credentials": "Legitimasjon", + "credentials-type": "Legitimasjonstype", + "credentials-type-required": "Legitimasjonstype er påkrevd.", + "credentials-anonymous": "Anonym", + "credentials-basic": "Grunnleggende", + "credentials-pem": "PEM", + "credentials-pem-hint": "Minst serverens CA-sertifikatfil eller et par med klientsertifikat og privat nøkkelfil er påkrevd", + "credentials-sas": "Delt tilgangssignatur", + "sas-key": "SAS-nøkkel", + "sas-key-required": "SAS-nøkkel er påkrevd.", + "hostname": "Vertsnavn", + "hostname-required": "Vertsnavn er påkrevd.", + "azure-ca-cert": "CA-sertifikatfil", + "username-required": "Brukernavn er påkrevd.", + "password-required": "Passord er påkrevd.", + "ca-cert": "Serverens CA-sertifikatfil", + "private-key": "Klientens private nøkkelfil", + "cert": "Klientsertifikatfil", + "no-file": "Ingen fil valgt.", + "drop-file": "Slipp en fil eller klikk for å velge en fil for opplasting.", + "private-key-password": "Passord for privat nøkkel", + "use-system-smtp-settings": "Bruk systemets SMTP-innstillinger", + "use-metadata-dynamic-interval": "Bruk dynamisk intervall", + "metadata-dynamic-interval-hint": "Feltene for start og slutt på intervall støtter templatization. Den substituerte verdien må være angitt i millisekunder. Bruk $[messageKey] for verdi fra meldingen og ${metadataKey} for verdi fra metadata.", + "use-metadata-interval-patterns-hint": "Hvis valgt, bruker regelnoden start- og sluttintervallmønstre fra meldingsmetadata eller data, forutsatt at intervallene er i millisekunder.", + "use-message-alarm-data": "Bruk alarmdata fra meldingen", + "overwrite-alarm-details": "Overskriv alarmdetaljer", + "use-alarm-severity-pattern": "Bruk alvorlighetsmønster for alarm", + "check-all-keys": "Sjekk at alle spesifiserte felt er til stede", + "check-all-keys-hint": "Hvis valgt, kontrolleres det at alle spesifiserte nøkler finnes i meldingsdata og metadata.", + "check-relation-to-specific-entity": "Sjekk relasjon til spesifikk enhet", + "check-relation-to-specific-entity-tooltip": "Hvis aktivert, sjekkes relasjon med en spesifikk enhet. Ellers sjekkes relasjon med hvilken som helst enhet. I begge tilfeller er oppslag basert på konfigurert retning og type.", + "check-relation-hint": "Sjekker om det eksisterer en relasjon til en spesifikk eller hvilken som helst enhet basert på retning og relasjonstype.", + "delete-relation-with-specific-entity": "Slett relasjon med spesifikk enhet", + "delete-relation-with-specific-entity-hint": "Hvis aktivert, slettes kun relasjonen med én spesifikk enhet. Ellers slettes relasjonen med alle samsvarende enheter.", + "delete-relation-hint": "Sletter relasjon fra avsenderen av innkommende melding til spesifisert enhet eller enhetsliste basert på retning og type.", + "remove-current-relations": "Fjern nåværende relasjoner", + "remove-current-relations-hint": "Fjerner nåværende relasjoner fra avsenderen av meldingen basert på retning og type.", + "change-originator-to-related-entity": "Endre avsender til relatert enhet", + "change-originator-to-related-entity-hint": "Brukes til å behandle innsendt melding som en melding fra en annen enhet.", + "start-interval": "Start på intervall", + "end-interval": "Slutt på intervall", + "start-interval-required": "Start på intervall er påkrevd.", + "end-interval-required": "Slutt på intervall er påkrevd.", + "smtp-protocol": "Protokoll", + "smtp-host": "SMTP-vert", + "smtp-host-required": "SMTP-vert er påkrevd.", + "smtp-port": "SMTP-port", + "smtp-port-required": "Du må oppgi en SMTP-port.", + "smtp-port-range": "SMTP-port må være i området 1 til 65535.", + "timeout-msec": "Tidsavbrudd (ms)", + "min-timeout-msec-message": "Kun minimumsverdi 0 ms er tillatt.", + "enter-username": "Skriv inn brukernavn", + "enter-password": "Skriv inn passord", + "enable-tls": "Aktiver TLS", + "tls-version": "TLS-versjon", + "enable-proxy": "Aktiver proxy", + "use-system-proxy-properties": "Bruk systemets proxy-innstillinger", + "proxy-host": "Proxy-vert", + "proxy-host-required": "Proxy-vert er påkrevd.", + "proxy-port": "Proxy-port", + "proxy-port-required": "Proxy-port er påkrevd.", + "proxy-port-range": "Proxy-port må være i området 1 til 65535.", + "proxy-user": "Proxy-bruker", + "proxy-password": "Proxy-passord", + "proxy-scheme": "Proxy-skjema", + "numbers-to-template": "Telefonnumre til mal", + "numbers-to-template-required": "Telefonnumre til mal er påkrevd", + "numbers-to-template-hint": "Kommaseparerte telefonnumre, bruk ${metadataKey} for verdi fra metadata, $[messageKey] for verdi fra meldingsinnholdet", + "sms-message-template": "SMS-meldingsmal", + "sms-message-template-required": "SMS-meldingsmal er påkrevd", + "use-system-sms-settings": "Bruk systemets SMS-leverandørinnstillinger", + "min-period-0-seconds-message": "Kun minimumsverdi 0 sekunder er tillatt.", + "max-pending-messages": "Maksimalt antall ventende meldinger", + "max-pending-messages-required": "Maksimalt antall ventende meldinger er påkrevd.", + "max-pending-messages-range": "Maksimalt antall ventende meldinger må være i området 1 til 100000.", + "originator-types-filter": "Filter for opphavstyper", + "interval-seconds": "Intervall i sekunder", + "interval-seconds-required": "Intervall er påkrevd.", + "int-range": "Verdien må ikke overstige maksimumsgrensen for heltall (2147483648)", + "min-interval-seconds-message": "Bare minimumsintervall på 1 sekund er tillatt.", + "output-timeseries-key-prefix": "Prefiks for utgående tidsserienøkkel", + "output-timeseries-key-prefix-required": "Prefiks for utgående tidsserienøkkel er påkrevd.", + "separator-hint": "Du må trykke \"Enter\" for å fullføre feltinput.", + "select-details": "Velg detaljer", + "entity-details-id": "ID", + "entity-details-title": "Tittel", + "entity-details-country": "Land", + "entity-details-state": "Fylke", + "entity-details-city": "By", + "entity-details-zip": "Postnummer", + "entity-details-address": "Adresse", + "entity-details-address2": "Adresse 2", + "entity-details-additional_info": "Tilleggsinformasjon", + "entity-details-phone": "Telefon", + "entity-details-email": "E-post", + "email-sender": "Avsender av e-post", + "fields-to-check": "Felt som skal kontrolleres", + "add-detail": "Legg til detalj", + "check-all-keys-tooltip": "Hvis aktivert, kontrolleres tilstedeværelse av alle felt som er oppført i meldingen og metadataens feltnavn i innkommende melding og dens metadata.", + "fields-to-check-hint": "Trykk \"Enter\" for å fullføre feltinput. Flere feltnavn støttes.", + "entity-details-list-empty": "Minst én detalj må velges.", + "alarm-status": "Alarmstatus", + "alarm-required": "Minst én alarmstatus må velges.", + "no-entity-details-matching": "Ingen samsvarende enhetsdetaljer funnet.", + "custom-table-name": "Egendefinert tabellnavn", + "custom-table-name-required": "Tabellnavn er påkrevd", + "custom-table-hint": "Tabellen må være opprettet i din Cassandra-klynge og navnet må starte med prefikset 'cs_tb_' for å unngå innsending til TBs felles tabeller. Angi tabellnavnet uten prefikset 'cs_tb_'.", + "message-field": "Meldingsfelt", + "message-field-required": "Meldingsfelt er påkrevd.", + "table-col": "Tabellkolonne", + "table-col-required": "Tabellkolonne er påkrevd.", + "latitude-field-name": "Felt for breddegrad", + "longitude-field-name": "Felt for lengdegrad", + "latitude-field-name-required": "Felt for breddegrad er påkrevd.", + "longitude-field-name-required": "Felt for lengdegrad er påkrevd.", + "fetch-perimeter-info-from-metadata": "Hent perimeterinformasjon fra metadata", + "fetch-perimeter-info-from-metadata-tooltip": "Hvis perimeter-typen er satt til 'Polygon' vil verdien i metadataparameteren '{{perimeterKeyName}}' brukes direkte som perimeterdefinisjon. Hvis typen er 'Circle', vil feltet '{{perimeterKeyName}}' tolkes for å hente ut 'latitude', 'longitude', 'radius' og 'radiusUnit'.", + "perimeter-key-name": "Perimeternøkkelnavn", + "perimeter-key-name-hint": "Navnet på metadatafeltet som inneholder perimeterinformasjon.", + "perimeter-key-name-required": "Perimeternøkkelnavn er påkrevd.", + "perimeter-circle": "Sirkel", + "perimeter-polygon": "Polygon", + "perimeter-type": "Perimetertype", + "circle-center-latitude": "Senter breddegrad", + "circle-center-latitude-required": "Senter breddegrad er påkrevd.", + "circle-center-longitude": "Senter lengdegrad", + "circle-center-longitude-required": "Senter lengdegrad er påkrevd.", + "range-unit-meter": "Meter", + "range-unit-kilometer": "Kilometer", + "range-unit-foot": "Fot", + "range-unit-mile": "Mil", + "range-unit-nautical-mile": "Nautisk mil", + "range-units": "Avstandsenheter", + "range-units-required": "Avstandsenheter er påkrevd.", + "range": "Rekkevidde", + "range-required": "Rekkevidde er påkrevd.", + "polygon-definition": "Polygondefinisjon", + "polygon-definition-required": "Polygondefinisjon er påkrevd.", + "polygon-definition-hint": "Bruk følgende format for manuell definisjon av polygon: [[lat1,lon1],[lat2,lon2], ... ,[latN,lonN]].", + "min-inside-duration": "Minste varighet innenfor", + "min-inside-duration-value-required": "Minste varighet innenfor er påkrevd", + "min-inside-duration-time-unit": "Tidsenhet for minste varighet innenfor", + "min-outside-duration": "Minste varighet utenfor", + "min-outside-duration-value-required": "Minste varighet utenfor er påkrevd", + "min-outside-duration-time-unit": "Tidsenhet for minste varighet utenfor", + "tell-failure-if-absent": "Rapporter feil", + "tell-failure-if-absent-hint": "Hvis minst én valgt nøkkel ikke eksisterer, vil utgående melding rapportere \"Feil\".", + "get-latest-value-with-ts": "Hent tidsstempel for siste telemetriverdier", + "get-latest-value-with-ts-hint": "Hvis valgt, vil de nyeste telemetriverdiene også inkludere tidsstempel, f.eks: \"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", + "ignore-null-strings": "Ignorer tomme tekstverdier", + "ignore-null-strings-hint": "Hvis valgt, vil regelnoden ignorere enhetsfelt med tomme verdier.", + "add-metadata-key-values-as-kafka-headers": "Legg til metadata nøkkel-verdi par som Kafka header", + "add-metadata-key-values-as-kafka-headers-hint": "Hvis valgt, legges nøkkel-verdi-par fra meldingsmetadata til utgående header som byte-array med forhåndsdefinert tegnkoding.", + "charset-encoding": "Tegnkoding", + "charset-encoding-required": "Tegnkoding er påkrevd.", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "Kønavnet kan velges fra en nedtrekksliste eller angi et egendefinert navn.", + "device-profile-node-hint": "Nyttig dersom du har varighet- eller gjentakelsesbetingelser for å sikre kontinuitet i alarmstatusvurderingen.", + "persist-alarm-rules": "Lagre status for alarmregler", + "persist-alarm-rules-hint": "Hvis aktivert, vil regelnoden lagre behandlingsstatus i databasen.", + "fetch-alarm-rules": "Hent status for alarmregler", + "fetch-alarm-rules-hint": "Hvis aktivert, vil regelnoden gjenopprette behandlingsstatus ved initiering og sikre at alarmer reises også etter serveromstarter. Ellers gjenopprettes status ved første melding fra enheten.", + "input-value-key": "Nøkkel for inndata", + "input-value-key-required": "Nøkkel for inndata er påkrevd.", + "output-value-key": "Nøkkel for utdata", + "output-value-key-required": "Nøkkel for utdata er påkrevd.", + "number-of-digits-after-floating-point": "Antall desimaler", + "number-of-digits-after-floating-point-range": "Antall desimaler må være i området 0 til 15.", + "failure-if-delta-negative": "Rapporter feil hvis differansen er negativ", + "failure-if-delta-negative-tooltip": "Regelnoden tvinger behandlingsfeil dersom differanseverdien er negativ.", + "use-caching": "Bruk caching", + "use-caching-tooltip": "Regelnoden cacher verdien til \"{{inputValueKey}}\" fra innkommende melding for bedre ytelse. Merk at cachen ikke oppdateres hvis verdien endres andre steder.", + "add-time-difference-between-readings": "Legg til tidsforskjell mellom \"{{inputValueKey}}\" målinger", + "add-time-difference-between-readings-tooltip": "Hvis aktivert, legges \"{{periodValueKey}}\" til utgående melding.", + "period-value-key": "Tidsperiode-nøkkel", + "period-value-key-required": "Tidsperiode-nøkkel er påkrevd.", + "general-pattern-hint": "Bruk ${metadataKey} for verdi fra metadata, $[messageKey] for verdi fra meldingskropp.", + "alarm-severity-pattern-hint": "Bruk ${metadataKey} for verdi fra metadata, $[messageKey] for verdi fra meldingsinnholdet. Alarmalvorlighetsgrad bør være system (CRITICAL, MAJOR osv.)", + "output-node-name-hint": "Regelnodens navn tilsvarer relasjonstype for utgående melding og brukes for å videresende meldinger til andre regelnoder i samme regelkjede.", + "use-server-ts": "Bruk serverens tidsstempel", + "use-server-ts-hint": "Bruk serverens nåværende tidsstempel for tidsseriedata uten eksplisitt stempel. Hjelper med å opprettholde riktig rekkefølge ved behandling av meldinger fra flere kilder.", + "kv-map-pattern-hint": "Alle inndatafelt støtter templatisering. Bruk $[messageKey] for verdi fra melding og ${metadataKey} for verdi fra metadata.", + "kv-map-single-pattern-hint": "Feltet støtter templatisering. Bruk $[messageKey] for verdi fra melding og ${metadataKey} for verdi fra metadata.", + "shared-scope": "Delt omfang", + "server-scope": "Serveromfang", + "client-scope": "Klientomfang", + "attribute-type": "Attributt", + "attribute-type-description": "Hent attributtverdi fra databasen", + "attribute-type-result-description": "Lagre resultat som enhetsattributt i databasen", + "constant-type": "Konstant", + "constant-type-description": "Definer konstant verdi", + "time-series-type": "Tidsserie", + "time-series-type-description": "Hent siste tidsserieverdi fra databasen", + "time-series-type-result-description": "Lagre resultat som enhetens tidsserie i databasen", + "message-body-type": "Melding", + "message-body-type-description": "Hent argumentverdi fra innkommende melding", + "message-body-type-result-description": "Legg til resultat i utgående melding", + "message-metadata-type": "Metadata", + "message-metadata-type-description": "Hent argumentverdi fra innkommende meldingsmetadata", + "message-metadata-result-description": "Legg til resultat i utgående meldingsmetadata", + "argument-tile": "Argumenter", + "no-arguments-prompt": "Ingen argumenter konfigurert", + "result-title": "Resultat", + "functions-field-input": "Funksjoner", + "no-option-found": "Ingen alternativ funnet", + "argument-source-field-input": "Kilde", + "argument-source-field-input-required": "Argumentkilde er påkrevd.", + "argument-key-field-input": "Nøkkel", + "argument-key-field-input-required": "Argumentnøkkel er påkrevd.", + "constant-value-field-input": "Konstant verdi", + "constant-value-field-input-required": "Konstant verdi er påkrevd.", + "attribute-scope-field-input": "Attributtomfang", + "attribute-scope-field-input-required": "Attributtomfang er påkrevd.", + "default-value-field-input": "Standardverdi", + "type-field-input": "Type", + "type-field-input-required": "Type er påkrevd.", + "key-field-input": "Nøkkel", + "add-entity-type": "Legg til enhetstype", + "add-device-profile": "Legg til enhetsprofil", + "key-field-input-required": "Nøkkel er påkrevd.", + "number-floating-point-field-input": "Antall desimaler", + "number-floating-point-field-input-hint": "Bruk 0 for å konvertere resultatet til heltall", + "add-to-message-field-input": "Legg til i melding", + "add-to-metadata-field-input": "Legg til i metadata", + "custom-expression-field-input": "Matematisk uttrykk", + "custom-expression-field-input-required": "Matematisk uttrykk er påkrevd", + "custom-expression-field-input-hint": "Angi et matematisk uttrykk som skal evalueres. Standarduttrykket viser hvordan man konverterer Fahrenheit til Celsius", + "retained-message": "Beholdt", + "attributes-mapping": "Attributtkartlegging", + "latest-telemetry-mapping": "Nyeste telemetrikartlegging", + "add-mapped-attribute-to": "Legg til kartlagte attributter i", + "add-mapped-latest-telemetry-to": "Legg til nyeste kartlagte telemetri i", + "add-mapped-fields-to": "Legg til kartlagte felt i", + "add-selected-details-to": "Legg til valgte detaljer i", + "clear-selected-types": "Fjern valgte typer", + "clear-selected-details": "Fjern valgte detaljer", + "clear-selected-fields": "Fjern valgte felt", + "clear-selected-keys": "Fjern valgte nøkler", + "geofence-configuration": "Geogjerde-konfigurasjon", + "coordinate-field-names": "Koordinatfeltnavn", + "coordinate-field-hint": "Regelnoden forsøker å hente de angitte feltene fra meldingen. Hvis de ikke finnes, blir de hentet fra metadata.", + "presence-monitoring-strategy": "Tilstedeværelsesstrategi", + "presence-monitoring-strategy-on-first-message": "Ved første melding", + "presence-monitoring-strategy-on-each-message": "Ved hver melding", + "presence-monitoring-strategy-on-first-message-hint": "Rapporterer status 'Inside' eller 'Outside' på første melding etter at konfigurert minimumsvarighet har passert siden forrige 'Entered' eller 'Left' statusoppdatering.", + "presence-monitoring-strategy-on-each-message-hint": "Rapporterer status 'Inside' eller 'Outside' på hver melding etter status 'Entered' eller 'Left'.", + "fetch-credentials-to": "Hent legitimasjon til", + "add-originator-attributes-to": "Legg til avsenders attributter i", + "originator-attributes": "Avsenders attributter", + "fetch-latest-telemetry-with-timestamp": "Hent siste telemetri med tidsstempel", + "fetch-latest-telemetry-with-timestamp-tooltip": "Hvis valgt, legges siste telemetriverdier til utgående metadata med tidsstempel, f.eks: \"{{latestTsKeyName}}\": \"{\"ts\":1574329385897, \"value\":42}\"", + "tell-failure": "Rapporter feil hvis noen attributter mangler", + "tell-failure-tooltip": "Hvis minst én valgt nøkkel ikke eksisterer, vil utgående melding rapportere \"Feil\".", + "created-time": "Opprettelsestid", + "chip-help": "Trykk 'Enter' for å fullføre {{inputName}} inntasting.\nTrykk 'Backspace' for å slette {{inputName}}.\nFlere verdier støttes.", + "detail": "detalj", + "field-name": "feltnavn", + "device-profile": "enhetsprofil", + "entity-type": "enhetstype", + "message-type": "meldingstype", + "timeseries-key": "tidsserienøkkel", + "type": "Type", + "first-name": "Fornavn", + "last-name": "Etternavn", + "label": "Etikett", + "originator-fields-mapping": "Kartlegging av avsenders felt", + "add-mapped-originator-fields-to": "Legg til kartlagte avsenderfelt i", + "fields": "Felt", + "skip-empty-fields": "Hopp over tomme felt", + "skip-empty-fields-tooltip": "Felt med tomme verdier vil ikke bli lagt til i utgående melding/metadata.", + "fetch-interval": "Henteintervall", + "fetch-strategy": "Hentestrategi", + "fetch-timeseries-from-to": "Hent tidsserie fra {{startInterval}} {{startIntervalTimeUnit}} siden til {{endInterval}} {{endIntervalTimeUnit}} siden.", + "fetch-timeseries-from-to-invalid": "Ugyldig tidsseriehenting (\"Startintervall\" må være mindre enn \"Sluttintervall\").", + "use-metadata-dynamic-interval-tooltip": "Hvis valgt, vil regelnoden bruke dynamisk start og slutt på intervallet basert på melding og metadata.", + "all-mode-hint": "Ved valg av hentemodus \"Alle\" vil regelnoden hente telemetri innenfor angitt intervall med konfigurerbare parametere.", + "first-mode-hint": "Ved valg av hentemodus \"Første\" vil regelnoden hente telemetri nærmest startpunktet for intervallet.", + "last-mode-hint": "Ved valg av hentemodus \"Siste\" vil regelnoden hente telemetri nærmest sluttpunktet for intervallet.", + "ascending": "Stigende", + "descending": "Synkende", + "min": "Minimum", + "max": "Maksimum", + "average": "Gjennomsnitt", + "sum": "Sum", + "count": "Antall", + "none": "Ingen", + "last-level-relation-tooltip": "Begrenser søk etter relaterte enheter til nivået definert i maks relasjonsnivå.", + "last-level-device-relation-tooltip": "Begrenser søk etter relaterte enheter til nivået definert i maks relasjonsnivå.", + "data-to-fetch": "Data som skal hentes", + "mapping-of-customers": "Kundekartlegging", + "map-fields-required": "Alle kartleggingsfelter er påkrevd.", + "attributes": "Attributter", + "related-device-attributes": "Relaterte enheters attributter", + "add-selected-attributes-to": "Legg til valgte attributter i", + "device-profiles": "Enhetsprofiler", + "mapping-of-tenant": "Leietakerkartlegging", + "add-attribute-key": "Legg til attributtnøkkel", + "message-template": "Meldingsmal", + "message-template-required": "Meldingsmal er påkrevd", + "use-system-slack-settings": "Bruk systemets Slack-innstillinger", + "slack-api-token": "Slack API-token", + "slack-api-token-required": "Slack API-token er påkrevd", + "keys-mapping": "Nøkkel-kartlegging", + "add-key": "Legg til nøkkel", + "recipients": "Mottakere", + "message-subject-and-content": "Emne og innhold for melding", + "template-rules-hint": "Begge felter støtter templatizering. Bruk $[messageKey] for verdier fra melding og ${metadataKey} for metadata.", + "originator-customer-desc": "Bruk kunde fra meldingens avsender som ny avsender.", + "originator-tenant-desc": "Bruk gjeldende leietaker som ny avsender.", + "originator-related-entity-desc": "Bruk relatert enhet som ny avsender. Basert på konfigurerte relasjonstyper og retning.", + "originator-alarm-originator-desc": "Bruk alarmens avsender som ny avsender. Kun hvis meldingsavsender er en alarm.", + "originator-entity-by-name-pattern-desc": "Bruk enhet fra databasen basert på navnmønster som ny avsender.", + "email-from-template-hint": "Bruk $[messageKey] for melding og ${metadataKey} for metadata.", + "recipients-block-main-hint": "Komma-separert adresseliste. Alle felt støtter templatizering.", + "forward-msg-default-rule-chain": "Videresend melding til avsenders standard regelkjede", + "forward-msg-default-rule-chain-tooltip": "Hvis aktivert, videresendes meldingen til regelkjede fra profil, eller standard regelkjede hvis definert.", + "exclude-zero-deltas": "Ekskluder null-deltaer fra utgående melding", + "exclude-zero-deltas-hint": "Legger kun til \"{{outputValueKey}}\" hvis verdien er ulik null.", + "exclude-zero-deltas-time-difference-hint": "Legger kun til \"{{outputValueKey}}\" og \"{{periodValueKey}}\" hvis \"{{outputValueKey}}\" ikke er null.", + "search-direction-from": "Fra avsender til målenhet", + "search-direction-to": "Fra målenhet til avsender", + "del-relation-direction-from": "Fra avsender", + "del-relation-direction-to": "Til avsender", + "target-entity": "Målenhet", + "function-configuration": "Funksjonskonfigurasjon", + "function-name": "Funksjonsnavn", + "function-name-required": "Funksjonsnavn er påkrevd.", + "qualifier": "Kvalifikator", + "qualifier-hint": "Standard kvalifikator \"$LATEST\" brukes hvis ikke spesifisert.", + "aws-credentials": "AWS-legitimasjon", + "connection-timeout": "Tilkoblingstimeout", + "connection-timeout-required": "Tilkoblingstimeout er påkrevd.", + "connection-timeout-min": "Minimum er 0.", + "connection-timeout-hint": "Ventetid i sekunder for opprettelse av tilkobling. 0 betyr uendelig og frarådes.", + "request-timeout": "Forespørselstimeout", + "request-timeout-required": "Forespørselstimeout er påkrevd", + "request-timeout-min": "Minimum er 0", + "request-timeout-hint": "Ventetid i sekunder for forespørsel før timeout. 0 betyr uendelig og frarådes.", + "units": "Enheter", + "tell-failure-aws-lambda": "Rapporter feil ved feil i AWS Lambda-funksjon", + "tell-failure-aws-lambda-hint": "Tvinger feilmelding hvis AWS Lambda-funksjonen kaster unntak.", + "basic-mode": "Grunnleggende", + "advanced-mode": "Avansert", + "save-time-series": { + "processing-settings": "Behandlingsinnstillinger", + "processing-settings-hint": "Definer hvordan innkommende meldinger behandles. Grunnleggende behandlingsinnstillinger lar deg velge forhåndskonfigurerte strategier, mens avanserte innstillinger lar deg velge individuelle behandlingsstrategier for hver handling.", + "advanced-settings-hint": "Vær forsiktig når du konfigurerer behandlingsstrategier. Enkelte kombinasjoner kan føre til uventet atferd.", + "strategy": "Strategi", + "deduplication-interval": "Dedupliseringsintervall", + "deduplication-interval-required": "Dedupliseringsintervall er påkrevd", + "deduplication-interval-min-max-range": "Dedupliseringsintervall må være minst 1 sekund og maks 1 dag", + "strategy-type": { + "every-message": "Ved hver melding", + "skip": "Hopp over", + "deduplicate": "Dedupliser", + "web-sockets-only": "Kun WebSockets" + }, + "time-series": "Tidsserie", + "latest": "Siste verdier", + "web-sockets": "WebSockets", + "calculated-fields": "Beregnete felt" + }, + "save-attribute": { + "processing-settings": "Behandlingsinnstillinger", + "processing-settings-hint": "Definer hvordan innkommende meldinger behandles. Grunnleggende behandlingsinnstillinger lar deg velge forhåndskonfigurerte strategier, mens avanserte innstillinger lar deg velge individuelle behandlingsstrategier for hver handling.", + "advanced-settings-hint": "Vær forsiktig når du konfigurerer behandlingsstrategier. Enkelte kombinasjoner kan føre til uventet atferd.", + "strategy": "Strategi", + "deduplication-interval": "Dedupliseringsintervall", + "deduplication-interval-required": "Dedupliseringsintervall er påkrevd", + "deduplication-interval-min-max-range": "Dedupliseringsintervall må være minst 1 sekund og maks 1 dag", + "scope": "Omfang", + "strategy-type": { + "every-message": "Ved hver melding", + "skip": "Hopp over", + "deduplicate": "Dedupliser", + "web-sockets-only": "Kun WebSockets" + }, + "attributes": "Attributter" + }, + "key-val": { + "key": "Nøkkel", + "value": "Verdi", + "see-examples": "Se eksempler.", + "remove-entry": "Fjern oppføring", + "remove-mapping-entry": "Fjern kartleggingsoppføring", + "add-mapping-entry": "Legg til kartlegging", + "add-entry": "Legg til oppføring", + "copy-key-values-from": "Kopier nøkkel-verdi-par fra", + "delete-key-values": "Slett nøkkel-verdi-par", + "delete-key-values-from": "Slett nøkkel-verdi-par fra", + "at-least-one-key-error": "Minst én nøkkel må være valgt.", + "unique-key-value-pair-error": "'{{keyText}}' må være forskjellig fra '{{valText}}'!" + }, + "mail-body-types": { + "plain-text": "Ren tekst", + "html": "HTML", + "dynamic": "Dynamisk", + "use-body-type-template": "Bruk mal for kroppstype", + "plain-text-description": "Enkel, uformatert tekst uten spesiell stil eller formatering.", + "html-text-description": "Lar deg bruke HTML-tag'er for formatering, lenker og bilder i meldingskroppen.", + "dynamic-text-description": "Lar deg bruke ren tekst eller HTML dynamisk basert på templatization-funksjon.", + "after-template-evaluation-hint": "Etter evaluering må verdien være true for HTML og false for ren tekst." + } + }, + "timezone": { + "timezone": "Tidssone", + "select-timezone": "Velg tidssone", + "no-timezones-matching": "Ingen tidssoner som samsvarer med '{{timezone}}' ble funnet.", + "timezone-required": "Tidssone er påkrevd.", + "browser-time": "Nettlesertid" + }, + "queue": { + "queue-name": "Kø", + "no-queues-found": "Ingen køer funnet.", + "no-queues-matching": "Ingen køer som samsvarer med '{{queue}}' ble funnet.", + "select-name": "Velg kønavn", + "name": "Navn", + "name-required": "Kønavn er påkrevd!", + "name-unique": "Kønavnet er ikke unikt!", + "name-pattern": "Kønavnet inneholder tegn som ikke er ASCII-bokstaver, '.', '_' eller '-'!", + "queue-required": "Kø er påkrevd!", + "topic-required": "Køens emne er påkrevd!", + "poll-interval-required": "Spørringsintervall er påkrevd!", + "poll-interval-min-value": "Spørringsintervall kan ikke være mindre enn 1", + "partitions-required": "Partisjoner er påkrevd!", + "partitions-min-value": "Antall partisjoner kan ikke være mindre enn 1", + "pack-processing-timeout-required": "Behandlingstid er påkrevd", + "pack-processing-timeout-min-value": "Behandlingstid kan ikke være mindre enn 1", + "batch-size-required": "Batchstørrelse er påkrevd!", + "batch-size-min-value": "Batchstørrelse kan ikke være mindre enn 1", + "retries-required": "Antall forsøk er påkrevd!", + "retries-min-value": "Antall forsøk kan ikke være negativt", + "failure-percentage-required": "Feilprosent er påkrevd!", + "failure-percentage-min-value": "Feilprosent kan ikke være mindre enn 0", + "failure-percentage-max-value": "Feilprosent kan ikke være høyere enn 100", + "pause-between-retries-required": "Pause mellom forsøk er påkrevd!", + "pause-between-retries-min-value": "Pause mellom forsøk kan ikke være mindre enn 1", + "max-pause-between-retries-required": "Maks pause mellom forsøk er påkrevd!", + "max-pause-between-retries-min-value": "Maks pause mellom forsøk kan ikke være mindre enn 1", + "submit-strategy-type-required": "Sendingsstrategi er påkrevd!", + "processing-strategy-type-required": "Behandlingsstrategi er påkrevd!", + "queues": "Køer", + "selected-queues": "{ count, plural, =1 {1 kø} other {# køer} } valgt", + "delete-queue-title": "Er du sikker på at du vil slette køen '{{queueName}}'?", + "delete-queues-title": "Er du sikker på at du vil slette { count, plural, =1 {1 kø} other {# køer} }?", + "delete-queue-text": "Vær forsiktig, etter bekreftelsen vil køen og alle relaterte data ikke kunne gjenopprettes.", + "delete-queues-text": "Etter bekreftelsen vil alle valgte køer bli slettet og ikke være tilgjengelige.", + "search": "Søk i kø", + "add": "Legg til kø", + "details": "Kødetaljer", + "topic": "Emne", + "submit-settings": "Sendingsinnstillinger", + "submit-strategy": "Strategitype *", + "grouping-parameter": "Grupperingsparameter", + "processing-settings": "Behandlingsinnstillinger for forsøk", + "processing-strategy": "Behandlingstype *", + "retries-settings": "Innstillinger for forsøk", + "polling-settings": "Spørringsinnstillinger", + "batch-processing": "Batchbehandling", + "poll-interval": "Spørringsintervall", + "partitions": "Partisjoner", + "immediate-processing": "Umiddelbar behandling", + "consumer-per-partition": "Send spørring for hver forbruker", + "consumer-per-partition-hint": "Aktiver separat forbruker per partisjon", + "duplicate-msg-to-all-partitions": "Dupliser melding til alle partisjoner", + "processing-timeout": "Behandling innen, ms", + "batch-size": "Batchstørrelse", + "retries": "Antall forsøk (0 – ubegrenset)", + "failure-percentage": "Feilmeldinger for å hoppe over forsøk, %", + "pause-between-retries": "Nytt forsøk innen, sek", + "max-pause-between-retries": "Ekstra nytt forsøk innen, sek", + "delete": "Slett kø", + "copyId": "Kopier kø-ID", + "idCopiedMessage": "Kø-ID er kopiert til utklippstavlen", + "description": "Beskrivelse", + "description-hint": "Denne teksten vises i købeskrivelsen i stedet for valgt strategi", + "alt-description": "Sendingsstrategi: {{submitStrategy}}, Behandlingsstrategi: {{processingStrategy}}", + "custom-properties": "Egendefinerte egenskaper", + "custom-properties-hint": "Egendefinerte egenskaper for oppretting av kø (emne), f.eks. 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "Sekvensiell per opprinnelse", + "sequential-by-originator-hint": "Ny melding for f.eks. enhet A sendes ikke før forrige melding for enhet A er bekreftet", + "sequential-by-tenant-label": "Sekvensiell per leietaker", + "sequential-by-tenant-hint": "Ny melding for f.eks. leietaker A sendes ikke før forrige melding for leietaker A er bekreftet", + "sequential-label": "Sekvensiell", + "sequential-hint": "Ny melding sendes ikke før forrige melding er bekreftet", + "burst-label": "Burst", + "burst-hint": "Alle meldinger sendes til regelkjeder i den rekkefølgen de ankommer", + "batch-label": "Batch", + "batch-hint": "Ny batch sendes ikke før forrige batch er bekreftet", + "skip-all-failures-label": "Hopp over alle feil", + "skip-all-failures-hint": "Ignorer alle feil", + "skip-all-failures-and-timeouts-label": "Hopp over alle feil og tidsavbrudd", + "skip-all-failures-and-timeouts-hint": "Ignorer alle feil og tidsavbrudd", + "retry-all-label": "Prøv alle på nytt", + "retry-all-hint": "Prøv alle meldinger fra behandlingspakken på nytt", + "retry-failed-label": "Prøv mislykkede på nytt", + "retry-failed-hint": "Prøv alle mislykkede meldinger fra behandlingspakken på nytt", + "retry-timeout-label": "Prøv tidsavbrudd på nytt", + "retry-timeout-hint": "Prøv alle tidsavbrutte meldinger fra behandlingspakken på nytt", + "retry-failed-and-timeout-label": "Prøv mislykkede og tidsavbrudd på nytt", + "retry-failed-and-timeout-hint": "Prøv alle mislykkede og tidsavbrutte meldinger fra behandlingspakken på nytt" + } + }, + "queue-statistics": { + "queue-statistics": "Køstatistikk", + "no-queue-statistics-matching": "Ingen køstatistikk som samsvarer med '{{entity}}' ble funnet.", + "queue-statistics-required": "Køstatistikk er påkrevd.", + "list-of-queue-statistics": "{ count, plural, =1 {Én køstatistikk} other {Liste over # køstatistikker} }", + "selected-queue-statistics": "{ count, plural, =1 {1 køstatistikk} other {# køstatistikker} } valgt", + "no-queue-statistics-text": "Ingen køstatistikk funnet", + "queue-statistics-starts-with": "Køstatistikk med navn som starter med '{{prefix}}'" + }, + "server-error": { + "general": "Generell serverfeil", + "authentication": "Autentiseringsfeil", + "jwt-token-expired": "JWT-token utløpt", + "tenant-trial-expired": "Leietakerens prøveperiode er utløpt", + "credentials-expired": "Legitimasjon utløpt", + "permission-denied": "Tillatelse nektet", + "invalid-arguments": "Ugyldige argumenter", + "bad-request-params": "Feil forespørselsparametere", + "item-not-found": "Element ikke funnet", + "too-many-requests": "For mange forespørsler", + "too-many-updates": "For mange oppdateringer" + }, + "tenant": { + "tenant": "Leietaker", + "tenants": "Leietakere", + "management": "Leietakeradministrasjon", + "add": "Legg til leietaker", + "admins": "Administratorer", + "manage-tenant-admins": "Administrer leietakeradministratorer", + "delete": "Slett leietaker", + "add-tenant-text": "Legg til ny leietaker", + "no-tenants-text": "Ingen leietakere funnet", + "tenant-details": "Leietakerdetaljer", + "title-max-length": "Tittel må være mindre enn 256 tegn", + "delete-tenant-title": "Er du sikker på at du vil slette leietakeren '{{tenantTitle}}'?", + "delete-tenant-text": "Vær forsiktig, etter bekreftelsen vil leietakeren og alle relaterte data ikke kunne gjenopprettes.", + "delete-tenants-title": "Er du sikker på at du vil slette { count, plural, =1 {1 leietaker} other {# leietakere} }?", + "delete-tenants-action-title": "Slett { count, plural, =1 {1 leietaker} other {# leietakere} }", + "delete-tenants-text": "Vær forsiktig, etter bekreftelsen vil alle valgte leietakere bli fjernet og alle relaterte data vil ikke kunne gjenopprettes.", + "title": "Tittel", + "title-required": "Tittel er påkrevd.", + "description": "Beskrivelse", + "details": "Detaljer", + "events": "Hendelser", + "copyId": "Kopier leietaker-ID", + "idCopiedMessage": "Leietaker-ID er kopiert til utklippstavlen", + "select-tenant": "Velg leietaker", + "no-tenants-matching": "Ingen leietakere som samsvarer med '{{entity}}' ble funnet.", + "tenant-required": "Leietaker er påkrevd", + "search": "Søk etter leietakere", + "selected-tenants": "{ count, plural, =1 {1 leietaker} other {# leietakere} } valgt", + "isolated-tb-rule-engine": "Bruk isolerte ThingsBoard Rule Engine-køer", + "isolated-tb-rule-engine-details": "Hver leietaker får dedikerte Rule Engine-køer" + }, + "tenant-profile": { + "tenant-profile": "Leietakerprofil", + "tenant-profiles": "Leietakerprofiler", + "add": "Legg til leietakerprofil", + "add-profile": "Legg til profil", + "debug": "Feilsøk", + "edit": "Rediger leietakerprofil", + "tenant-profile-details": "Detaljer for leietakerprofil", + "no-tenant-profiles-text": "Ingen leietakerprofiler funnet", + "name-max-length": "Navn må være mindre enn 256 tegn", + "search": "Søk leietakerprofiler", + "selected-tenant-profiles": "{ count, plural, =1 {1 leietakerprofil} other {# leietakerprofiler} } valgt", + "no-tenant-profiles-matching": "Ingen leietakerprofiler som samsvarer med '{{entity}}' ble funnet.", + "tenant-profile-required": "Leietakerprofil er påkrevd", + "idCopiedMessage": "Leietakerprofil-ID er kopiert til utklippstavlen", + "set-default": "Gjør leietakerprofil til standard", + "delete": "Slett leietakerprofil", + "copyId": "Kopier leietakerprofil-ID", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "data": "Profildata", + "profile-configuration": "Profilkonfigurasjon", + "description": "Beskrivelse", + "default": "Standard", + "delete-tenant-profile-title": "Er du sikker på at du vil slette leietakerprofilen '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Vær forsiktig, etter bekreftelse vil leietakerprofilen og alle relaterte data ikke kunne gjenopprettes.", + "delete-tenant-profiles-title": "Er du sikker på at du vil slette { count, plural, =1 {1 leietakerprofil} other {# leietakerprofiler} }?", + "delete-tenant-profiles-text": "Vær forsiktig, etter bekreftelse vil alle valgte leietakerprofiler bli fjernet og alle relaterte data vil ikke kunne gjenopprettes.", + "set-default-tenant-profile-title": "Er du sikker på at du vil gjøre leietakerprofilen '{{tenantProfileName}}' til standard?", + "set-default-tenant-profile-text": "Etter bekreftelse vil leietakerprofilen bli markert som standard og vil bli brukt for nye leietakere uten angitt profil.", + "no-tenant-profiles-found": "Ingen leietakerprofiler funnet.", + "create-new-tenant-profile": "Opprett en ny!", + "create-tenant-profile": "Opprett ny leietakerprofil", + "import": "Importer leietakerprofil", + "export": "Eksporter leietakerprofil", + "export-failed-error": "Kan ikke eksportere leietakerprofil: {{error}}", + "tenant-profile-file": "Leietakerprofilfil", + "invalid-tenant-profile-file-error": "Kan ikke importere leietakerprofil: Ugyldig datastruktur for leietakerprofil.", + "advanced-settings": "Avanserte innstillinger", + "entities": "Enheter", + "rule-engine": "Regelmotor", + "time-to-live": "Levetid", + "calculated-fields": "Beregnete felt", + "alarms-and-notifications": "Alarmer og varsler", + "ota-files-in-bytes": "Filer", + "ws-title": "WS", + "unlimited": "(0 - ubegrenset)", + "maximum-devices": "Maksimalt antall enheter", + "maximum-devices-required": "Maksimalt antall enheter er påkrevd.", + "maximum-devices-range": "Maksimalt antall enheter kan ikke være negativt", + "maximum-assets": "Maksimalt antall eiendeler", + "maximum-assets-required": "Maksimalt antall eiendeler er påkrevd.", + "maximum-assets-range": "Maksimalt antall eiendeler kan ikke være negativt", + "maximum-customers": "Maksimalt antall kunder", + "maximum-customers-required": "Maksimalt antall kunder er påkrevd.", + "maximum-customers-range": "Maksimalt antall kunder kan ikke være negativt", + "maximum-users": "Maksimalt antall brukere", + "maximum-users-required": "Maksimalt antall brukere er påkrevd.", + "maximum-users-range": "Maksimalt antall brukere kan ikke være negativt", + "maximum-dashboards": "Maksimalt antall dashbord", + "maximum-dashboards-required": "Maksimalt antall dashbord er påkrevd.", + "maximum-dashboards-range": "Maksimalt antall dashbord kan ikke være negativt", + "maximum-edges": "Maksimalt antall kanter", + "maximum-edges-required": "Maksimalt antall kanter er påkrevd.", + "maximum-edges-range": "Maksimalt antall kanter kan ikke være negativt", + "maximum-rule-chains": "Maksimalt antall regelkjeder", + "maximum-rule-chains-required": "Maksimalt antall regelkjeder er påkrevd.", + "maximum-rule-chains-range": "Maksimalt antall regelkjeder kan ikke være negativt", + "maximum-resources-sum-data-size": "Maksimal total størrelse på ressursfiler (bytes)", + "maximum-resources-sum-data-size-required": "Maksimal total størrelse på ressursfiler er påkrevd.", + "maximum-resources-sum-data-size-range": "Maksimal total størrelse på ressursfiler kan ikke være negativ", + "maximum-resource-size": "Maksimal størrelse på ressursfil (bytes)", + "maximum-resource-size-required": "Maksimal størrelse på ressursfil er påkrevd", + "maximum-resource-size-range": "Maksimal størrelse på ressursfil kan ikke være negativ", + "maximum-ota-packages-sum-data-size": "Maksimal total størrelse på OTA-pakker (bytes)", + "maximum-ota-package-sum-data-size-required": "Maksimal total størrelse på OTA-pakker er påkrevd.", + "maximum-ota-package-sum-data-size-range": "Maksimal total størrelse på OTA-pakker kan ikke være negativ", + "maximum-debug-duration-min": "Maksimal feilsøkingsvarighet (min)", + "maximum-debug-duration-min-range": "Maksimal feilsøkingsvarighet kan ikke være negativ", + "rest-requests-for-tenant": "REST-forespørsler for leietaker", + "transport-tenant-telemetry-msg-rate-limit": "Transport telemetrimeldinger for leietaker", + "transport-tenant-telemetry-data-points-rate-limit": "Transport datapunkter for leietaker", + "transport-device-msg-rate-limit": "Transport enhetsmeldinger", + "transport-device-telemetry-msg-rate-limit": "Transport enhetstelemetrimeldinger", + "transport-device-telemetry-data-points-rate-limit": "Transport enhetstelemetridatapunkter", + "transport-gateway-msg-rate-limit": "Transport gateway-meldinger", + "transport-gateway-telemetry-msg-rate-limit": "Transport gateway-telemetrimeldinger", + "transport-gateway-telemetry-data-points-rate-limit": "Transport gateway-telemetridatapunkter", + "transport-gateway-device-msg-rate-limit": "Transport gateway-enhetsmeldinger", + "transport-gateway-device-telemetry-msg-rate-limit": "Transport gateway-enhetstelemetrimeldinger", + "transport-gateway-device-telemetry-data-points-rate-limit": "Transport gateway-enhetstelemetridatapunkter", + "tenant-entity-export-rate-limit": "Oppretting av enhetsversjon", + "tenant-entity-import-rate-limit": "Lasting av enhetsversjon", + "tenant-notification-request-rate-limit": "Varslingsforespørsler", + "tenant-notification-requests-per-rule-rate-limit": "Varslingsforespørsler per regel", + "max-calculated-fields": "Maksimalt antall beregnede felt per enhet", + "max-calculated-fields-range": "Maksimalt antall beregnede felt per enhet kan ikke være negativt", + "max-calculated-fields-required": "Maksimalt antall beregnede felt per enhet er påkrevd", + "max-data-points-per-rolling-arg": "Maksimalt antall datapunkter i rullerende argumenter", + "max-data-points-per-rolling-arg-range": "Maksimalt antall datapunkter i rullerende argumenter kan ikke være negativt", + "max-data-points-per-rolling-arg-required": "Maksimalt antall datapunkter i rullerende argumenter er påkrevd", + "max-arguments-per-cf": "Maksimalt antall argumenter per beregnet felt", + "max-arguments-per-cf-range": "Maksimalt antall argumenter per beregnet felt kan ikke være negativt", + "max-arguments-per-cf-required": "Maksimalt antall argumenter per beregnet felt er påkrevd", + "max-state-size": "Maksimal tilstandsstørrelse i KB", + "max-state-size-range": "Maksimal tilstandsstørrelse i KB kan ikke være negativ", + "max-state-size-required": "Maksimal tilstandsstørrelse i KB er påkrevd", + "max-value-argument-size": "Maksimal størrelse på enkeltverdiargument i KB", + "max-value-argument-size-range": "Maksimal størrelse på enkeltverdiargument i KB kan ikke være negativ", + "max-value-argument-size-required": "Maksimal størrelse på enkeltverdiargument i KB er påkrevd", + "max-transport-messages": "Maksimalt antall transportmeldinger", + "max-transport-messages-required": "Maksimalt antall transportmeldinger er påkrevd.", + "max-transport-messages-range": "Maksimalt antall transportmeldinger kan ikke være negativt", + "max-transport-data-points": "Maksimalt antall transportdatapunkter", + "max-transport-data-points-required": "Maksimalt antall transportdatapunkter er påkrevd.", + "max-transport-data-points-range": "Maksimalt antall transportdatapunkter kan ikke være negativt", + "max-r-e-executions": "Maksimalt antall regelmotorutførelser", + "max-r-e-executions-required": "Maksimalt antall regelmotorutførelser er påkrevd.", + "max-r-e-executions-range": "Maksimalt antall regelmotorutførelser kan ikke være negativt", + "max-j-s-executions": "Maksimalt antall JavaScript-utførelser", + "max-j-s-executions-required": "Maksimalt antall JavaScript-utførelser er påkrevd.", + "max-j-s-executions-range": "Maksimalt antall JavaScript-utførelser kan ikke være negativt", + "max-tbel-executions": "Maksimalt antall TBEL-utførelser", + "max-tbel-executions-required": "Maksimalt antall TBEL-utførelser er påkrevd.", + "max-tbel-executions-range": "Maksimalt antall TBEL-utførelser kan ikke være negativt", + "max-d-p-storage-days": "Maksimalt antall dager for lagring av datapunkter", + "max-d-p-storage-days-required": "Maksimalt antall dager for lagring av datapunkter er påkrevd.", + "max-d-p-storage-days-range": "Maksimalt antall dager for lagring av datapunkter kan ikke være negativt", + "default-storage-ttl-days": "Standard lagring TTL dager", + "default-storage-ttl-days-required": "Standard lagring TTL dager er påkrevd.", + "default-storage-ttl-days-range": "Standard lagring TTL dager kan ikke være negativt", + "alarms-ttl-days": "Alarmer TTL dager", + "alarms-ttl-days-required": "Alarmer TTL dager er påkrevd", + "alarms-ttl-days-days-range": "Alarmer TTL dager kan ikke være negativt", + "rpc-ttl-days": "RPC TTL dager", + "rpc-ttl-days-required": "RPC TTL dager er påkrevd", + "rpc-ttl-days-days-range": "RPC TTL dager kan ikke være negativt", + "queue-stats-ttl-days": "TTL-dager for køstatistikk", + "queue-stats-ttl-days-required": "TTL-dager for køstatistikk er påkrevd", + "queue-stats-ttl-days-range": "TTL-dager for køstatistikk kan ikke være negativt", + "rule-engine-exceptions-ttl-days": "TTL-dager for regelmotorunntak", + "rule-engine-exceptions-ttl-days-required": "TTL-dager for regelmotorunntak er påkrevd", + "rule-engine-exceptions-ttl-days-range": "TTL-dager for regelmotorunntak kan ikke være negativt", + "max-rule-node-executions-per-message": "Maksimalt antall regelnodeutførelser per melding", + "max-rule-node-executions-per-message-required": "Maksimalt antall regelnodeutførelser per melding er påkrevd.", + "max-rule-node-executions-per-message-range": "Maksimalt antall regelnodeutførelser per melding kan ikke være negativt", + "max-emails": "Maksimalt antall sendte e-poster", + "max-emails-required": "Maksimalt antall sendte e-poster er påkrevd.", + "max-emails-range": "Maksimalt antall sendte e-poster kan ikke være negativt", + "sms-enabled": "SMS aktivert", + "max-sms": "Maksimalt antall sendte SMS", + "max-sms-required": "Maksimalt antall sendte SMS er påkrevd.", + "max-sms-range": "Maksimalt antall sendte SMS kan ikke være negativt", + "max-created-alarms": "Maksimalt antall opprettede alarmer", + "max-created-alarms-required": "Maksimalt antall opprettede alarmer er påkrevd.", + "max-created-alarms-range": "Maksimalt antall opprettede alarmer kan ikke være negativt", + "no-queue": "Ingen kø konfigurert", + "add-queue": "Legg til kø", + "queues-with-count": "Køer ({{count}})", + "tenant-rest-limits": "REST-forespørsler for leietaker", + "customer-rest-limits": "REST-forespørsler for kunde", + "incorrect-pattern-for-rate-limits": "Formatet er kommaseparerte par av kapasitet og periode (i sekunder) med kolon mellom, f.eks. 100:1,2000:60", + "too-small-value-zero": "Verdien må være større enn 0", + "too-small-value-one": "Verdien må være større enn 1", + "queue-size-is-limited-by-system-configuration": "Størrelsen på køen er også begrenset av systemkonfigurasjonen.", + "cassandra-tenant-limits-configuration": "Cassandra-spørring for leietaker", + "ws-limit-max-sessions-per-tenant": "Maksimalt antall økter per leietaker", + "ws-limit-max-sessions-per-customer": "Maksimalt antall økter per kunde", + "ws-limit-max-sessions-per-regular-user": "Maksimalt antall økter per vanlig bruker", + "ws-limit-max-sessions-per-public-user": "Maksimalt antall økter per offentlig bruker", + "ws-limit-queue-per-session": "Maksimal størrelse på meldingskø per økt", + "ws-limit-max-subscriptions-per-tenant": "Maksimalt antall abonnementer per leietaker", + "ws-limit-max-subscriptions-per-customer": "Maksimalt antall abonnementer per kunde", + "ws-limit-max-subscriptions-per-regular-user": "Maksimalt antall abonnementer per vanlig bruker", + "ws-limit-max-subscriptions-per-public-user": "Maksimalt antall abonnementer per offentlig bruker", + "ws-limit-updates-per-session": "WS-oppdateringer per økt", + "rate-limits": { + "add-limit": "Legg til grense", + "advanced-settings": "Avanserte innstillinger", + "edit-limit": "Rediger grense", + "but-less-than": "men mindre enn", + "calculated-field-debug-event-rate-limit": "Feilsøkingshendelser for beregnet felt", + "edit-calculated-field-debug-event-rate-limit": "Rediger grense for feilsøkingshendelser for beregnet felt", + "edit-transport-tenant-msg-title": "Rediger grense for transportmeldinger for leietaker", + "edit-transport-tenant-telemetry-msg-title": "Rediger grense for transporttelemetrimedlinger for leietaker", + "edit-transport-tenant-telemetry-data-points-title": "Rediger grense for transporttelemetridatapunkter for leietaker", + "edit-transport-device-msg-title": "Rediger grense for transportmeldinger for enhet", + "edit-transport-device-telemetry-msg-title": "Rediger grense for telemetrimedlinger for enhet", + "edit-transport-device-telemetry-data-points-title": "Rediger grense for telemetridatapunkter for enhet", + "edit-transport-gateway-msg-title": "Rediger grense for transportmeldinger for gateway", + "edit-transport-gateway-telemetry-msg-title": "Rediger grense for telemetrimedlinger for gateway", + "edit-transport-gateway-telemetry-data-points-title": "Rediger grense for telemetridatapunkter for gateway", + "edit-transport-gateway-device-msg-title": "Rediger grense for gateway-enhetsmeldinger", + "edit-transport-gateway-device-telemetry-msg-title": "Rediger grense for gateway-enhetstelemetrimedlinger", + "edit-transport-gateway-device-telemetry-data-points-title": "Rediger grense for gateway-enhetstelemetridatapunkter", + "edit-tenant-rest-limits-title": "Rediger REST-forespørsler for leietaker", + "edit-customer-rest-limits-title": "Rediger REST-forespørsler for kunde", + "edit-ws-limit-updates-per-session-title": "Rediger grense for WS-oppdateringer per økt", + "edit-cassandra-tenant-limits-configuration-title": "Rediger Cassandra-spørringsgrenser for leietaker", + "edit-tenant-entity-export-rate-limit-title": "Rediger grense for eksport av enhetsversjoner", + "edit-tenant-entity-import-rate-limit-title": "Rediger grense for import av enhetsversjoner", + "edit-tenant-notification-request-rate-limit-title": "Rediger grense for varslingsforespørsler", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Rediger grense for varslingsforespørsler per regel", + "edit-edge-events-rate-limit": "Rediger grense for edge-hendelser", + "edit-edge-events-per-edge-rate-limit": "Rediger grense for edge-hendelser per edge", + "edge-events-rate-limit": "Edge-hendelser", + "edge-events-per-edge-rate-limit": "Edge-hendelser per edge", + "edit-edge-uplink-messages-rate-limit": "Rediger grense for opplinks-meldinger fra edge", + "edit-edge-uplink-messages-per-edge-rate-limit": "Rediger grense for opplinks-meldinger per edge", + "edge-uplink-messages-rate-limit": "Opplinks-meldinger fra edge", + "edge-uplink-messages-per-edge-rate-limit": "Opplinks-meldinger per edge", + "messages-per": "meldinger per", + "not-set": "Ikke angitt", + "number-of-messages": "Antall meldinger", + "number-of-messages-required": "Antall meldinger er påkrevd.", + "number-of-messages-min": "Minimumsverdi er 1.", + "preview": "Forhåndsvisning", + "per-seconds": "Per sekunder", + "per-seconds-required": "Tidstakt er påkrevd.", + "per-seconds-min": "Minimumsverdi er 1.", + "rate-limits": "Hastighetsgrenser", + "remove-limit": "Fjern grense", + "transport-tenant-msg": "Transportmeldinger for leietaker", + "transport-tenant-telemetry-msg": "Transporttelemetrimedlinger for leietaker", + "transport-tenant-telemetry-data-points": "Transporttelemetridatapunkter for leietaker", + "transport-device-msg": "Transportmeldinger for enhet", + "transport-device-telemetry-msg": "Telemetrimedlinger for enhet", + "transport-device-telemetry-data-points": "Telemetridatapunkter for enhet", + "transport-gateway-msg": "Transportmeldinger for gateway", + "transport-gateway-telemetry-msg": "Telemetrimedlinger for gateway", + "transport-gateway-telemetry-data-points": "Telemetridatapunkter for gateway", + "transport-gateway-device-msg": "Meldinger for gateway-enhet", + "transport-gateway-device-telemetry-msg": "Telemetrimedlinger for gateway-enhet", + "transport-gateway-device-telemetry-data-points": "Telemetridatapunkter for gateway-enhet", + "sec": "sek" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, =1 {1 sekund} other {# sekunder} }", + "minutes-interval": "{ minutes, plural, =1 {1 minutt} other {# minutter} }", + "hours-interval": "{ hours, plural, =1 {1 time} other {# timer} }", + "days-interval": "{ days, plural, =1 {1 dag} other {# dager} }", + "days": "Dager", + "hours": "Timer", + "minutes": "Minutter", + "seconds": "Sekunder", + "advanced": "Avansert", + "custom": "Egendefinert", + "predefined": { + "yesterday": "I går", + "day-before-yesterday": "Dagen før i går", + "this-day-last-week": "Denne dagen forrige uke", + "previous-week": "Forrige uke (søn - lør)", + "previous-week-iso": "Forrige uke (man - søn)", + "previous-month": "Forrige måned", + "previous-quarter": "Forrige kvartal", + "previous-half-year": "Forrige halvår", + "previous-year": "Forrige år", + "current-hour": "Nåværende time", + "current-day": "Nåværende dag", + "current-day-so-far": "Nåværende dag så langt", + "current-week": "Nåværende uke (søn - lør)", + "current-week-iso": "Nåværende uke (man - søn)", + "current-week-so-far": "Nåværende uke så langt (søn - lør)", + "current-week-iso-so-far": "Nåværende uke så langt (man - søn)", + "current-month": "Nåværende måned", + "current-month-so-far": "Nåværende måned så langt", + "current-quarter": "Nåværende kvartal", + "current-quarter-so-far": "Nåværende kvartal så langt", + "current-half-year": "Nåværende halvår", + "current-half-year-so-far": "Nåværende halvår så langt", + "current-year": "Nåværende år", + "current-year-so-far": "Nåværende år så langt" + }, + "type": { + "week": "Uke (søn - lør)", + "week-iso": "Uke (man - søn)", + "month": "Måned", + "quarter": "Kvartal" + } + }, + "timeunit": { + "milliseconds": "Millisekunder", + "seconds": "Sekunder", + "minutes": "Minutter", + "hours": "Timer", + "days": "Dager" + }, + "timewindow": { + "timewindow": "Tidsvindu", + "timewindow-settings": "Innstillinger for tidsvindu", + "years": "{ years, plural, =1 { år } other {# år } }", + "years-short": "{{ years }}å", + "months": "{ months, plural, =1 { måned } other {# måneder } }", + "months-short": "{{ months }}M", + "weeks": "{ weeks, plural, =1 { uke } other {# uker } }", + "weeks-short": "{{ weeks }}u", + "days": "{ days, plural, =1 { dag } other {# dager } }", + "days-short": "{{ days }}d", + "hours": "{ hours, plural, =0 { time } =1 {1 time } other {# timer } }", + "hr": "{{ hr }} t", + "hr-short": "{{ hr }}t", + "minutes": "{ minutes, plural, =0 { minutt } =1 {1 minutt } other {# minutter } }", + "min": "{{ min }} min", + "min-short": "{{ min }}m", + "seconds": "{ seconds, plural, =0 { sekund } =1 {1 sekund } other {# sekunder } }", + "sec": "{{ sec }} sek", + "sec-short": "{{ sec }}s", + "short": { + "years": "{ years, plural, =1 {1 år } other {# år } }", + "days": "{ days, plural, =1 {1 dag } other {# dager } }", + "hours": "{ hours, plural, =1 {1 time } other {# timer } }", + "minutes": "{{minutes}} min", + "seconds": "{{seconds}} sek" + }, + "realtime": "Sanntid", + "history": "Historikk", + "last-prefix": "siste", + "period": "fra {{ startTime }} til {{ endTime }}", + "edit": "Rediger tidsvindu", + "date-range": "Datoperiode", + "for-all-time": "For all tid", + "last": "Siste", + "time-period": "Tidsperiode", + "hide": "Skjul", + "interval": "Intervall", + "just-now": "Akkurat nå", + "just-now-lower": "akkurat nå", + "ago": "siden", + "style": "Tidsvindu stil", + "icon": "Ikon", + "icon-position": "Ikonposisjon", + "icon-position-left": "Venstre", + "icon-position-right": "Høyre", + "font": "Skrifttype", + "color": "Farge", + "displayTypePrefix": "Vis prefiks for sanntid/historikk", + "preview": "Forhåndsvisning", + "relative": "Relativ", + "range": "Område", + "hide-timewindow-section": "Skjul tidsvindu-seksjonen for sluttbrukere", + "hide-last-interval": "Skjul siste intervall for sluttbrukere", + "hide-relative-interval": "Skjul relativt intervall for sluttbrukere", + "hide-fixed-interval": "Skjul fast intervall for sluttbrukere", + "hide-aggregation": "Skjul aggregering for sluttbrukere", + "hide-group-interval": "Skjul grupperingsintervall for sluttbrukere", + "hide-max-values": "Skjul maksverdier for sluttbrukere", + "hide-timezone": "Skjul tidssone for sluttbrukere", + "disable-custom-interval": "Deaktiver egendefinert intervallvalg", + "edit-aggregation-functions-list": "Rediger liste over aggregeringsfunksjoner", + "edit-aggregation-functions-list-hint": "Tilgjengelige alternativer kan spesifiseres.", + "allowed-aggregation-functions": "Tillatte aggregeringsfunksjoner", + "edit-intervals-list": "Rediger liste over intervaller", + "allowed-agg-intervals": "Grupperingsintervaller", + "default-agg-interval": "Standard grupperingsintervall", + "edit-intervals-list-hint": "Liste over tilgjengelige intervallalternativer kan spesifiseres.", + "edit-grouping-intervals-list-hint": "Det er mulig å konfigurere listen over grupperingsintervaller og standardintervall.", + "all": "Alle" + }, + "tooltip": { + "trigger": "Utløser", + "trigger-point": "Punkt", + "trigger-axis": "Akse", + "label": "Etikett", + "value": "Verdi", + "date": "Dato", + "show-date-time-interval": "Vis dato- og tidsintervall", + "show-date-time-interval-hint": "Vis dato- og tidsintervall i henhold til dataaggregeringen.", + "background-color": "Bakgrunnsfarge", + "background-blur": "Bakgrunnsuskarphet" + }, + "unit": { + "millimeter": "Millimeter", + "centimeter": "Centimeter", + "angstrom": "Ångström", + "nanometer": "Nanometer", + "micrometer": "Mikrometer", + "meter": "Meter", + "kilometer": "Kilometer", + "inch": "Tommer", + "foot": "Fot", + "yard": "Yard", + "mile": "Mil", + "nautical-mile": "Nautisk mil", + "astronomical-unit": "Astronomisk enhet", + "reciprocal-metre": "Invers meter", + "meter-per-meter": "Meter per meter", + "steradian": "Steradian", + "thou": "Thou", + "barleycorn": "Byggkorn", + "hand": "Hånd", + "chain": "Kjede", + "furlong": "Furlong", + "league": "League", + "fathom": "Favn", + "cable": "Kabel", + "link": "Lenke", + "rod": "Stang", + "nanogram": "Nanogram", + "microgram": "Mikrogram", + "milligram": "Milligram", + "gram": "Gram", + "kilogram": "Kilogram", + "tonne": "Tonn", + "ounce": "Unse", + "pound": "Pund", + "stone": "Stein", + "hundredweight-count": "Hundreweight (USA)", + "short-tons": "Kort tonn", + "dalton": "Dalton", + "grain": "Korn", + "drachm": "Drachm", + "quarter": "Kvartal", + "slug": "Slug", + "carat": "Karat", + "cubic-millimeter": "Kubikkmillimeter", + "cubic-centimeter": "Kubikkcentimeter", + "cubic-meter": "Kubikkmeter", + "cubic-kilometer": "Kubikkilometer", + "microliter": "Mikroliter", + "milliliter": "Milliliter", + "liter": "Liter", + "hectoliter": "Hektoliter", + "cubic-inch": "Kubikktommer", + "cubic-foot": "Kubikkfot", + "cubic-yard": "Kubikkyard", + "fluid-ounce": "Fluid unse", + "pint": "Pint", + "quart": "Kvart", + "gallon": "Gallon", + "oil-barrels": "Oljetønne", + "cubic-meter-per-kilogram": "Kubikkmeter per kilogram", + "gill": "Gill", + "hogshead": "Hogshead", + "teaspoon": "Teskje", + "tablespoon": "Spiseskje", + "cup": "Kopp", + "celsius": "Celsius", + "kelvin": "Kelvin", + "rankine": "Rankine", + "fahrenheit": "Fahrenheit", + "percent": "Prosent", + "meter-per-second": "Meter per sekund", + "kilometer-per-hour": "Kilometer i timen", + "foot-per-second": "Fot per sekund", + "mile-per-hour": "Mil i timen", + "knot": "Knop", + "millimeters-per-minute": "Millimeter per minutt", + "kilometer-per-hour-squared": "Kilometer i timen i kvadrat", + "foot-per-second-squared": "Fot per sekund i kvadrat", + "pascal": "Pascal", + "kilopascal": "Kilopascal", + "megapascal": "Megapascal", + "gigapascal": "Gigapascal", + "millibar": "Millibar", + "bar": "Bar", + "kilobar": "Kilobar", + "newton": "Newton", + "newton-meter": "Newtonmeter", + "foot-pounds": "Fot-pund", + "inch-pounds": "Tommer-pund", + "newton-per-meter": "Newton per meter", + "atmospheres": "Atmosfærer", + "pounds-per-square-inch": "Pund per kvadrattomme", + "torr": "Torr", + "inches-of-mercury": "Tommer kvikksølv", + "pascal-per-square-meter": "Pascal per kvadratmeter", + "pound-per-square-inch": "Pund per kvadrattomme", + "newton-per-square-meter": "Newton per kvadratmeter", + "kilogram-force-per-square-meter": "Kilogram-kraft per kvadratmeter", + "pascal-per-square-centimeter": "Pascal per kvadratcentimeter", + "ton-force-per-square-inch": "Tonn-kraft per kvadrattomme", + "kilonewton-per-square-meter": "Kilonewton per kvadratmeter", + "newton-per-square-millimeter": "Newton per kvadratmillimeter", + "microjoule": "Mikrojoule", + "millijoule": "Millijoule", + "joule": "Joule", + "kilojoule": "Kilojoule", + "megajoule": "Megajoule", + "gigajoule": "Gigajoule", + "watt-hour": "Wattime", + "kilowatt-hour": "Kilowattime", + "electron-volts": "Elektronvolt", + "joules-per-coulomb": "Joule per Coulomb", + "british-thermal-unit": "Britisk termisk enhet", + "foot-pound": "Fot-pund", + "calorie": "Kalori", + "small-calorie": "Liten kalori", + "kilocalorie": "Kilokalori", + "joule-per-kelvin": "Joule per Kelvin", + "joule-per-kilogram-kelvin": "Joule per kilogram-Kelvin", + "joule-per-kilogram": "Joule per kilogram", + "watt-per-meter-kelvin": "Watt per meter-Kelvin", + "joule-per-cubic-meter": "Joule per kubikkmeter", + "therm": "Therm", + "electric-dipole-moment": "Elektrisk dipolmoment", + "magnetic-dipole-moment": "Magnetisk dipolmoment", + "debye": "Debye", + "coulomb-per-square-meter-per-volt": "Coulomb per kvadratmeter per volt", + "milliwatt": "Milliwatt", + "microwatt": "Mikrowatt", + "watt": "Watt", + "kilowatt": "Kilowatt", + "megawatt": "Megawatt", + "gigawatt": "Gigawatt", + "metric-horsepower": "Metrisk hestekraft", + "milliwatt-per-square-centimeter": "Milliwatt per kvadratcentimeter", + "watt-per-square-centimeter": "Watt per kvadratcentimeter", + "kilowatt-per-square-centimeter": "Kilowatt per kvadratcentimeter", + "milliwatt-per-square-meter": "Milliwatt per kvadratmeter", + "watt-per-square-meter": "Watt per kvadratmeter", + "kilowatt-per-square-meter": "Kilowatt per kvadratmeter", + "watt-per-square-inch": "Watt per kvadrattomme", + "kilowatt-per-square-inch": "Kilowatt per kvadrattomme", + "horsepower": "Hestekraft", + "btu-per-hour": "Britiske termiske enheter/time", + "coulomb": "Coulomb", + "millicoulomb": "Millicoulomb", + "microcoulomb": "Mikrocoulomb", + "picocoulomb": "Picocoulomb", + "coulomb-per-meter": "Coulomb per meter", + "coulomb-per-cubic-meter": "Coulomb per kubikkmeter", + "coulomb-per-square-meter": "Coulomb per kvadratmeter", + "square-millimeter": "Kvadratmillimeter", + "square-centimeter": "Kvadratcentimeter", + "square-meter": "Kvadratmeter", + "hectare": "Hektar", + "square-kilometer": "Kvadratkilometer", + "square-inch": "Kvadrattomme", + "square-foot": "Kvadratfot", + "square-yard": "Kvadratyard", + "acre": "Akre", + "square-mile": "Kvadratmil", + "are": "Ar", + "barn": "Barn", + "circular-inch": "Sirkulær tomme", + "milliampere-hour": "Milliampere-time", + "ampere-hours": "Ampere-timer", + "kiloampere-hours": "Kiloampere-timer", + "nanoampere": "Nanoampere", + "picoampere": "Picoampere", + "microampere": "Mikroampere", + "milliampere": "Milliampere", + "ampere": "Ampere", + "microampere-per-square-centimeter": "Mikroampere per kvadratcentimeter", + "ampere-per-square-meter": "Ampere per kvadratmeter", + "ampere-per-meter": "Ampere per meter", + "oersted": "Oersted", + "bohr-magneton": "Bohr-magneton", + "ampere-meter-squared": "Ampere-meter kvadrert", + "nanovolt": "Nanovolt", + "picovolt": "Picovolt", + "volt": "Volt", + "dbmV": "dBmV", + "dbm": "dBm", + "volt-meter": "Volt-meter", + "kilovolt-meter": "Kilovolt-meter", + "megavolt-meter": "Megavolt-meter", + "microvolt-meter": "Mikrovolt-meter", + "millivolt-meter": "Millivolt-meter", + "nanovolt-meter": "Nanovolt-meter", + "ohm": "Ohm", + "microohm": "Mikroohm", + "milliohm": "Milliohm", + "kilohm": "Kiloohm", + "megohm": "Megohm", + "gigohm": "Gigohm", + "hertz": "Hertz", + "kilohertz": "Kilohertz", + "megahertz": "Megahertz", + "gigahertz": "Gigahertz", + "rpm": "Omdreininger per minutt", + "candela-per-square-meter": "Candela per kvadratmeter", + "candela": "Candela", + "lumen": "Lumen", + "lux": "Lux", + "foot-candle": "Fot-candela", + "lumen-per-square-meter": "Lumen per kvadratmeter", + "lux-second": "Lux-sekund", + "lumen-second": "Lumen-sekund", + "lumens-per-watt": "Lumen per watt", + "mole": "Mol", + "nanomole": "Nanomol", + "micromole": "Mikromol", + "millimole": "Millimol", + "kilomole": "Kilomol", + "mole-per-cubic-meter": "Mol per kubikkmeter", + "rssi": "RSSI", + "ppm": "Deler per million", + "ppb": "Deler per milliard", + "micrograms-per-cubic-meter": "Mikrogram per kubikkmeter", + "aqi": "Luftkvalitetsindeks (AQI)", + "gram-per-cubic-meter": "Gram per kubikkmeter", + "gram-per-kilogram": "Spesifikk fuktighet", + "millimeters-per-second": "Millimeter per sekund", + "neper": "Neper", + "bel": "Bel", + "decibel": "Desibel", + "meters-per-second-squared": "Meter per sekund kvadrert", + "becquerel": "Becquerel", + "curie": "Curie", + "gray": "Gray", + "sievert": "Sievert", + "roentgen": "Røntgen", + "cps": "Antall per sekund", + "rad": "Rad", + "rem": "Rem", + "dps": "Desintegrasjoner per sekund", + "rutherford": "Rutherford", + "coulombs-per-kilogram": "Coulomb per kilogram", + "becquerels-per-cubic-meter": "Becquerel per kubikkmeter", + "curies-per-liter": "Curie per liter", + "becquerels-per-second": "Becquerel per sekund", + "curies-per-second": "Curie per sekund", + "gy-per-second": "Gray per sekund", + "watt-per-steradian": "Watt per steradian", + "watt-per-square-metre-steradian": "Watt per kvadratmeter-steradian", + "ph-level": "pH-nivå", + "turbidity": "Turbiditet", + "mg-per-liter": "Milligram per liter", + "microsiemens-per-centimeter": "Mikrosiemens per centimeter", + "millisiemens-per-meter": "Millisimens per meter", + "siemens-per-meter": "Siemens per meter", + "kilogram-per-cubic-meter": "Kilogram per kubikkmeter", + "gram-per-cubic-centimeter": "Gram per kubikkcentimeter", + "kilogram-per-square-meter": "Kilogram per kvadratmeter", + "milligram-per-milliliter": "Milligram per milliliter", + "milligram-per-cubic-meter": "Milligram per kubikkmeter", + "pound-per-cubic-foot": "Pund per kubikkfot", + "ounces-per-cubic-inch": "Unse per kubikktomme", + "tons-per-cubic-yard": "Tonn per kubikkyard", + "particle-density": "Partikkeltetthet", + "kilometers-per-liter": "Kilometer per liter", + "miles-per-gallon": "Miles per gallon", + "liters-per-100-km": "Liter per 100 km", + "gallons-per-mile": "Galloner per mile", + "liters-per-hour": "Liter per time", + "gallons-per-hour": "Galloner per time", + "beats-per-minute": "Slag per minutt", + "millimeters-of-mercury": "Millimeter kvikksølv", + "milligrams-per-deciliter": "Milligram per desiliter", + "g-force": "G-kraft", + "kilonewton": "Kilonewton", + "kilogram-force": "Kilogramkraft", + "pound-force": "Pundkraft", + "kilopound-force": "Kilopundkraft", + "dyne": "Dyne", + "poundal": "Poundal", + "kip": "Kip", + "gal": "Gal", + "gravity": "Gravitasjon", + "hectopascal": "Hektopascal", + "atmosphere": "Atmosfære", + "millibars": "Millibar", + "inch-of-mercury": "Tommer kvikksølv", + "richter-scale": "Richterskala", + "second": "Sekund", + "minute": "Minutt", + "hour": "Time", + "day": "Dag", + "week": "Uke", + "month": "Måned", + "year": "År", + "cubic-foot-per-minute": "Kubikkfot per minutt", + "cubic-meters-per-hour": "Kubikkmeter per time", + "cubic-meters-per-second": "Kubikkmeter per sekund", + "liter-per-second": "Liter per sekund", + "liter-per-minute": "Liter per minutt", + "gallons-per-minute": "Galloner per minutt", + "cubic-foot-per-second": "Kubikkfot per sekund", + "milliliters-per-minute": "Milliliter per minutt", + "bit": "Bit", + "byte": "Byte", + "kilobyte": "Kilobyte", + "megabyte": "Megabyte", + "gigabyte": "Gigabyte", + "terabyte": "Terabyte", + "petabyte": "Petabyte", + "exabyte": "Eksabyte", + "zettabyte": "Zettabyte", + "yottabyte": "Yottabyte", + "bit-per-second": "Bit per sekund", + "kilobit-per-second": "Kilobit per sekund", + "megabit-per-second": "Megabit per sekund", + "gigabit-per-second": "Gigabit per sekund", + "terabit-per-second": "Terabit per sekund", + "byte-per-second": "Byte per sekund", + "kilobyte-per-second": "Kilobyte per sekund", + "megabyte-per-second": "Megabyte per sekund", + "gigabyte-per-second": "Gigabyte per sekund", + "degree": "Grad", + "radian": "Radian", + "gradian": "Gradian", + "revolution": "Revolusjon", + "siemens": "Siemens", + "millisiemens": "Millisiemens", + "microsiemens": "Mikrosiemens", + "kilosiemens": "Kilosiemens", + "megasiemens": "Megasiemens", + "gigasiemens": "Gigasiemens", + "farad": "Farad", + "millifarad": "Millifarad", + "microfarad": "Mikrofarad", + "nanofarad": "Nanofarad", + "picofarad": "Pikofarad", + "kilofarad": "Kilofarad", + "megafarad": "Megafarad", + "gigafarad": "Gigafarad", + "terfarad": "Terfarad", + "farad-per-meter": "Farad per meter", + "tesla": "Tesla", + "gauss": "Gauss", + "kilogauss": "Kilogauss", + "millitesla": "Millitesla", + "microtesla": "Mikrotesla", + "nanotesla": "Nanotesla", + "kilotesla": "Kilotesla", + "megatesla": "Megatesla", + "millitesla-square-meters": "Millitesla kvadratmeter", + "gamma": "Gamma", + "lambda": "Lambda", + "square-meter-per-second": "Kvadratmeter per sekund", + "square-centimeter-per-second": "Kvadratcentimeter per sekund", + "stoke": "Stoke", + "centistokes": "Centistokes", + "square-foot-per-second": "Kvadratfot per sekund", + "square-inch-per-second": "Kvadrattomme per sekund", + "pascal-second": "Pascal-sekund", + "centipoise": "Centipoise", + "poise": "Poise", + "reynolds": "Reynolds", + "pound-per-foot-hour": "Pund per fot-time", + "newton-second-per-square-meter": "Newton-sekund per kvadratmeter", + "dyne-second-per-square-centimeter": "Dyne-sekund per kvadratcentimeter", + "kilogram-per-meter-second": "Kilogram per meter-sekund", + "tesla-square-meters": "Tesla kvadratmeter", + "maxwell": "Maxwell", + "tesla-per-meter": "Tesla per meter", + "gauss-per-centimeter": "Gauss per centimeter", + "weber": "Weber", + "microweber": "Mikroweber", + "milliweber": "Milliweber", + "gauss-square-centimeter": "Gauss-kvadratcentimeter", + "kilogauss-square-centimeter": "Kilogauss-kvadratcentimeter", + "henry": "Henry", + "millihenry": "Millihenry", + "microhenry": "Mikrohenry", + "nanohenry": "Nanohenry", + "henry-per-meter": "Henry per meter", + "tesla-meter-per-ampere": "Tesla meter per ampere", + "gauss-per-oersted": "Gauss per Oersted", + "kilogram-per-mole": "Kilogram per mol", + "gram-per-mole": "Gram per mol", + "milligram-per-mole": "Milligram per mol", + "joule-per-mole": "Joule per mol", + "joule-per-mole-kelvin": "Joule per mol-Kelvin", + "millivolts-per-meter": "Millivolt per meter", + "volts-per-meter": "Volt per meter", + "kilovolts-per-meter": "Kilovolt per meter", + "radian-per-second": "Radian per sekund", + "radian-per-second-squared": "Radian per sekund i kvadrat", + "revolutions-per-minute-per-second": "Vinkelakselerasjon", + "deg-per-second": "grader/s", + "degrees-brix": "Grader Brix", + "katal": "Katal", + "katal-per-cubic-metre": "Katal per kubikkmeter" + }, + "user": { + "user": "Bruker", + "users": "Brukere", + "customer-users": "Kundebrukere", + "tenant-admins": "Leietakeradministratorer", + "sys-admin": "Systemadministrator", + "tenant-admin": "Leietakeradministrator", + "customer": "Kunde", + "anonymous": "Anonym", + "add": "Legg til bruker", + "delete": "Slett bruker", + "add-user-text": "Legg til ny bruker", + "no-users-text": "Ingen brukere funnet", + "user-details": "Brukerdetaljer", + "delete-user-title": "Er du sikker på at du vil slette brukeren '{{userEmail}}'?", + "delete-user-text": "Vær forsiktig, etter bekreftelsen vil brukeren og alle relaterte data bli ugjenopprettelige.", + "delete-users-title": "Er du sikker på at du vil slette { count, plural, =1 {1 bruker} other {# brukere} }?", + "delete-users-action-title": "Slett { count, plural, =1 {1 bruker} other {# brukere} }", + "delete-users-text": "Vær forsiktig, etter bekreftelsen vil alle valgte brukere bli fjernet og alle relaterte data bli ugjenopprettelige.", + "activation-email-sent-message": "Aktiverings-e-post ble sendt!", + "resend-activation": "Send aktivering på nytt", + "email": "E-post", + "email-required": "E-post er påkrevd.", + "invalid-email-format": "Ugyldig e-postformat.", + "first-name": "Fornavn", + "last-name": "Etternavn", + "description": "Beskrivelse", + "default-dashboard": "Standard dashbord", + "always-fullscreen": "Alltid fullskjerm", + "select-user": "Velg bruker", + "no-users-matching": "Ingen brukere som samsvarer med '{{entity}}' ble funnet.", + "user-required": "Bruker er påkrevd", + "activation-method": "Aktiveringsmetode", + "display-activation-link": "Vis aktiveringslenke", + "send-activation-mail": "Send aktiveringsmail", + "activation-link": "Brukeraktiveringslenke", + "activation-link-text": "For å aktivere brukeren, bruk følgende aktiveringslenke (utløper om {{activationLinkTtl}}) :", + "copy-activation-link": "Kopier aktiveringslenke", + "activation-link-copied-message": "Brukerens aktiveringslenke er kopiert til utklippstavlen", + "details": "Detaljer", + "login-as-tenant-admin": "Logg inn som leietakeradministrator", + "login-as-customer-user": "Logg inn som kundebruker", + "search": "Søk brukere", + "selected-users": "{ count, plural, =1 {1 bruker} other {# brukere} } valgt", + "disable-account": "Deaktiver brukerkonto", + "enable-account": "Aktiver brukerkonto", + "enable-account-message": "Brukerkontoen ble aktivert!", + "disable-account-message": "Brukerkontoen ble deaktivert!", + "copyId": "Kopier bruker-ID", + "idCopiedMessage": "Bruker-ID er kopiert til utklippstavlen", + "user-list": "Brukerliste", + "user-list-required": "Brukerliste er påkrevd" + }, + "value": { + "type": "Verditype", + "string": "Streng", + "string-value": "Strengverdi", + "string-value-required": "Strengverdi er påkrevd", + "integer": "Heltall", + "integer-value": "Heltallsverdi", + "integer-value-required": "Heltallsverdi er påkrevd", + "invalid-integer-value": "Ugyldig heltallsverdi", + "double": "Desimaltall", + "double-value": "Desimaltallsverdi", + "double-value-required": "Desimaltallsverdi er påkrevd", + "boolean": "Boolsk", + "boolean-value": "Boolsk verdi", + "false": "Usann", + "true": "Sann", + "long": "Langt heltall", + "json": "JSON", + "json-value": "JSON-verdi", + "json-value-invalid": "JSON-verdi har et ugyldig format", + "json-value-required": "JSON-verdi er påkrevd." + }, + "version-control": { + "version-control": "Versjonskontroll", + "management": "Håndtering av versjonskontroll", + "search": "Søk i versjoner", + "branch": "Gren", + "default": "Standard", + "select-branch": "Velg gren", + "branch-required": "Gren er påkrevd", + "create-entity-version": "Opprett entitetsversjon", + "version-name": "Versjonsnavn", + "version-name-required": "Versjonsnavn er påkrevd", + "author": "Forfatter", + "export-relations": "Eksporter relasjoner", + "export-attributes": "Eksporter attributter", + "export-credentials": "Eksporter legitimasjon", + "export-calculated-fields": "Eksporter beregnede felt", + "entity-versions": "Entitetsversjoner", + "versions": "Versjoner", + "created-time": "Opprettet tidspunkt", + "version-id": "Versjons-ID", + "no-entity-versions-text": "Ingen entitetsversjoner funnet", + "no-versions-text": "Ingen versjoner funnet", + "copy-full-version-id": "Kopier full versjons-ID", + "create-version": "Opprett versjon", + "creating-version": "Oppretter versjon... Vennligst vent", + "nothing-to-commit": "Ingen endringer å forplikte", + "restore-version": "Gjenopprett versjon", + "restore-entity-from-version": "Gjenopprett entitet fra versjon '{{versionName}}'", + "restoring-entity-version": "Gjenoppretter entitetsversjon... Vennligst vent", + "load-relations": "Last inn relasjoner", + "load-attributes": "Last inn attributter", + "load-credentials": "Last inn legitimasjon", + "load-calculated-fields": "Last inn beregnede felt", + "compare-with-current": "Sammenlign med nåværende", + "diff-entity-with-version": "Forskjell med entitetsversjon '{{versionName}}'", + "previous-difference": "Forrige forskjell", + "next-difference": "Neste forskjell", + "current": "Nåværende", + "differences": "{ count, plural, =1 {1 forskjell} other {# forskjeller} }", + "create-entities-version": "Opprett versjon for entiteter", + "default-sync-strategy": "Standard synkroniseringsstrategi", + "sync-strategy-merge": "Flett", + "sync-strategy-overwrite": "Overskriv", + "entities-to-export": "Entiteter å eksportere", + "entities-to-restore": "Entiteter å gjenopprette", + "sync-strategy": "Synkroniseringsstrategi", + "all-entities": "Alle entiteter", + "no-entities-to-export-prompt": "Vennligst spesifiser entiteter å eksportere", + "no-entities-to-restore-prompt": "Vennligst spesifiser entiteter å gjenopprette", + "add-entity-type": "Legg til entitetstype", + "remove-all": "Fjern alle", + "version-create-result": "{ added, plural, =0 {Ingen entiteter} =1 {1 entitet} other {# entiteter} } lagt til.
    { modified, plural, =0 {Ingen entiteter} =1 {1 entitet} other {# entiteter} } endret.
    { removed, plural, =0 {Ingen entiteter} =1 {1 entitet} other {# entiteter} } fjernet.", + "remove-other-entities": "Fjern andre entiteter", + "find-existing-entity-by-name": "Finn eksisterende entitet etter navn", + "restore-entities-from-version": "Gjenopprett entiteter fra versjon '{{versionName}}'", + "restoring-entities-from-version": "Gjenoppretter entiteter... Vennligst vent", + "no-entities-restored": "Ingen entiteter gjenopprettet", + "created": "{{created}} opprettet", + "updated": "{{updated}} oppdatert", + "deleted": "{{deleted}} slettet", + "remove-other-entities-confirm-text": "Vær forsiktig! Dette vil permanent slette alle nåværende entiteter
    som ikke finnes i versjonen du ønsker å gjenopprette.

    Vennligst skriv \"remove other entities\" for å bekrefte.", + "auto-commit-to-branch": "auto-commit til {{ branch }} gren", + "default-create-entity-version-name": "{{entityName}} oppdatering", + "sync-strategy-merge-hint": "Oppretter eller oppdaterer valgte entiteter i arkivet. Alle andre entiteter i arkivet endres ikke.", + "sync-strategy-overwrite-hint": "Oppretter eller oppdaterer valgte entiteter i arkivet. Alle andre entiteter i arkivet slettet.", + "device-credentials-conflict": "Klarte ikke å laste inn enheten med ekstern ID {{entityId}}
    på grunn av at samme legitimasjon allerede finnes i databasen for en annen enhet.
    Vurder å deaktivere last inn legitimasjon innstillingen i gjenopprettingsskjemaet.", + "missing-referenced-entity": "Klarte ikke å laste inn {{sourceEntityTypeName}} med ekstern ID {{sourceEntityId}}
    fordi den refererer til manglende {{targetEntityTypeName}} med ID {{targetEntityId}}.", + "runtime-failed": "Feilet: {{message}}", + "auto-commit-settings-read-only-hint": "Auto-commit-funksjonen fungerer ikke med skrivebeskyttet alternativ aktivert i arkivinnstillingene.", + "rollback-on-error": "Tilbakestill ved feil", + "rollback-on-error-hint": "Hvis du har et stort antall entiteter å gjenopprette, vurder å deaktivere dette alternativet for å øke ytelsen.\n Merk at hvis en feil oppstår under versjonslasting, vil allerede lagrede entiteter (med relasjoner, attributter osv.) forbli som de er" + }, + "widget": { + "widget-library": "Widget-bibliotek", + "widget-bundle": "Widget-pakke", + "all-bundles": "Alle pakker", + "select-widgets-bundle": "Velg widget-pakke", + "widgets": "Widgeter", + "all-widgets": "Alle widgeter", + "widget": "Widget", + "select-widget": "Velg widget", + "no-widgets-matching": "Ingen widgeter som matcher '{{entity}}' ble funnet.", + "no-widgets": "Ingen widgeter ennå", + "no-widgets-text": "Ingen widgeter funnet", + "management": "Widget-administrasjon", + "editor": "Widget-redigerer", + "confirm-to-exit-editor-html": "Du har ulagrede widget-innstillinger.
    Er du sikker på at du vil forlate denne siden?", + "widget-type-not-found": "Problem med å laste widget-konfigurasjon.
    Sannsynligvis er den tilknyttede widget-typen fjernet.", + "widget-type-load-error": "Widgeten ble ikke lastet på grunn av følgende feil:", + "remove": "Fjern widget", + "delete": "Slett widget", + "edit": "Rediger widget", + "remove-widget-title": "Er du sikker på at du vil fjerne widgeten '{{widgetTitle}}'?", + "remove-widget-text": "Etter bekreftelse vil widgeten og alle relaterte data bli uopprettelige.", + "replace-reference-with-widget-copy": "Erstatt referanse med widget-kopi", + "timeseries": "Tidsserie", + "search-data": "Søk etter data", + "no-data-found": "Ingen data funnet", + "latest": "Siste verdier", + "rpc": "Kontroll-widget", + "alarm": "Alarm-widget", + "static": "Statisk widget", + "timeseries-short": "serie", + "latest-short": "siste", + "rpc-short": "kontroll", + "alarm-short": "alarm", + "static-short": "statisk", + "select-widget-type": "Velg widget-type", + "missing-widget-title-error": "Widget-tittel må spesifiseres!", + "widget-saved": "Widget lagret", + "unable-to-save-widget-error": "Kan ikke lagre widget! Widget har feil!", + "save": "Lagre widget", + "saveAs": "Lagre widget som", + "move": "Flytt widget", + "save-widget-as": "Lagre widget som", + "save-widget-as-text": "Vennligst skriv inn ny widget-tittel", + "toggle-fullscreen": "Bytt til fullskjerm", + "run": "Kjør widget", + "widget-title": "Widget-tittel", + "title": "Tittel", + "title-required": "Widget-tittel er påkrevd.", + "title-max-length": "Tittelen må være kortere enn 256 tegn", + "system": "System", + "type": "Widget-type", + "resources": "Ressurser", + "resource-url": "JavaScript/CSS-URL", + "resource-is-extension": "Er utvidelse", + "remove-resource": "Fjern ressurs", + "add-resource": "Legg til ressurs", + "html": "HTML", + "tidy": "Rydd opp", + "css": "CSS", + "settings-form": "Innstillingsskjema", + "data-key-settings-form": "Datatastenes innstillingsskjema", + "latest-data-key-settings-form": "Siste datatast-innstillingsskjema", + "widget-settings": "Widget-innstillinger", + "description": "Beskrivelse", + "tags": "Tagger", + "image-preview": "Bildevisning", + "settings-form-selector": "Velger for innstillingsskjema", + "data-key-settings-form-selector": "Velger for datatast-innstillingsskjema", + "latest-data-key-settings-form-selector": "Velger for siste datatast-innstillingsskjema", + "all": "Alle", + "actual": "Faktisk", + "scada": "SCADA-symbol", + "deprecated": "Foreldet", + "has-basic-mode": "Har grunnleggende modus", + "basic-mode-form-selector": "Velger for grunnleggende modus-skjema", + "basic-mode": "Grunnleggende", + "advanced-mode": "Avansert", + "javascript": "JavaScript", + "js": "JS", + "delete-widget-title": "Er du sikker på at du vil slette widgeten '{{widgetName}}'?", + "delete-widget-text": "Etter bekreftelse vil widgeten og alle relaterte data bli uopprettelige.", + "delete-widgets-title": "Er du sikker på at du vil slette { count, plural, =1 {1 widget} other {# widgeter} }?", + "delete-widgets-text": "Vær forsiktig, etter bekreftelse vil alle valgte widgeter bli fjernet og alle relaterte data vil bli uopprettelige.", + "delete-widget": "Slett widget", + "widget-template-load-failed-error": "Kunne ikke laste widget-mal!", + "details": "Detaljer", + "widget-details": "Widget-detaljer", + "add": "Legg til widget", + "add-existing-widget": "Legg til eksisterende widget", + "add-new-widget": "Legg til ny widget", + "search-widgets": "Søk i widgeter", + "selected-widgets": "{ count, plural, =1 {1 widget} other {# widgeter} } valgt", + "undo": "Angre widget-endringer", + "export": "Eksporter widget", + "export-prompt": "Bygg inn widget-bilder og ressurser", + "export-widgets": "Eksporter widgeter", + "export-widgets-prompt": "Bygg inn widget-bilder og ressurser", + "import": "Importer widget", + "no-data": "Ingen data å vise i widget", + "data-overflow": "Widget viser {{count}} av {{total}} enheter", + "alarm-data-overflow": "Widget viser alarmer for {{allowedEntities}} (maksimalt tillatt) enheter av totalt {{totalEntities}} enheter", + "search": "Søk widget", + "filter": "Widget-filtertype", + "loading-widgets": "Laster widgeter...", + "widget-template-error": "Ugyldig HTML-mal for widget.", + "reference": "Referanse" + }, + "widget-action": { + "header-button": "Widget-hodeknapp", + "do-nothing": "Gjør ingenting", + "open-dashboard-state": "Naviger til ny dashbordtilstand", + "update-dashboard-state": "Oppdater gjeldende dashbordtilstand", + "open-dashboard": "Naviger til annet dashbord", + "custom": "Egendefinert handling", + "custom-pretty": "Egendefinert handling (med HTML-mal)", + "custom-pretty-error-title": "Egendefinert dialogfeil", + "custom-pretty-template-error": "Ugyldig egendefinert dialogmal.", + "custom-pretty-controller-error": "Feil oppstod ved evaluering av egendefinert dialogfunksjon.", + "mobile-action": "Mobilhandling", + "target-dashboard-state": "Mål-dashbordtilstand", + "target-dashboard-state-required": "Mål-dashbordtilstand er påkrevd", + "set-entity-from-widget": "Sett enhet fra widget", + "target-dashboard": "Mål-dashbord", + "select-target-dashboard": "Velg mål-dashbord", + "target-dashboard-required": "Mål-dashbord er påkrevd.", + "open-right-layout": "Åpne høyre dashbordoppsett (mobilvisning)", + "state-display-type": "Visningsvalg for dashbordtilstand", + "open-normal": "Normal", + "open-in-separate-dialog": "Åpne i separat dialog", + "open-in-popover": "Åpne i popover", + "dialog-title": "Dialogtittel", + "dialog-hide-dashboard-toolbar": "Skjul dashbordverktøylinje i dialog", + "dialog-width": "Dialogbredde i prosent relativt til visningsbredde", + "dialog-height": "Dialoghøyde i prosent relativt til visningshøyde", + "dialog-size-range-error": "Dialogstørrelsen i prosent må være mellom 1 og 100.", + "popover-preferred-placement": "Foretrukket popover-plassering", + "popover-placement-top": "Topp", + "popover-placement-topLeft": "Øverst til venstre", + "popover-placement-topRight": "Øverst til høyre", + "popover-placement-right": "Høyre", + "popover-placement-rightTop": "Høyre topp", + "popover-placement-rightBottom": "Høyre bunn", + "popover-placement-bottom": "Bunn", + "popover-placement-bottomLeft": "Nederst til venstre", + "popover-placement-bottomRight": "Nederst til høyre", + "popover-placement-left": "Venstre", + "popover-placement-leftTop": "Venstre topp", + "popover-placement-leftBottom": "Venstre bunn", + "popover-hide-on-click-outside": "Skjul popover ved klikk utenfor", + "popover-hide-dashboard-toolbar": "Skjul dashbordverktøylinje i popover", + "popover-width": "Popover-bredde", + "popover-height": "Popover-høyde", + "popover-style": "Popover-stil", + "open-new-browser-tab": "Åpne i ny nettleserfane", + "open-URL": "Åpne URL", + "URL": "URL", + "url-required": "URL er påkrevd.", + "mobile": { + "device-provision": "Enhetsprovisjon", + "action-type": "Mobilhandlingstype", + "select-action-type": "Velg mobilhandlingstype", + "action-type-required": "Mobilhandlingstype er påkrevd", + "take-picture-from-gallery": "Velg bilde fra galleri", + "take-photo": "Ta bilde", + "map-direction": "Åpne kartveibeskrivelse", + "map-location": "Åpne kartplassering", + "scan-qr-code": "Skann QR-kode", + "make-phone-call": "Ring telefonsamtale", + "get-location": "Hent telefonens posisjon", + "take-screenshot": "Ta skjermbilde" + }, + "custom-action-function": "Egendefinert handlingsfunksjon", + "custom-pretty-function": "Egendefinert handling (med HTML-mal) funksjon", + "map-item-type": "Kartobjekttype", + "map-item": { + "marker": "Markør", + "polygon": "Polygon", + "rectangle": "Rektangel", + "circle": "Sirkel" + }, + "place-map-item": "Plasser kartobjekt", + "map-item-tooltip": { + "customize-map-item-tooltips": "Tilpass kartobjektverktøytips", + "place-marker": "Plasser markør", + "start-draw-rectangle": "Start å tegne rektangel", + "finish-draw-rectangle": "Fullfør tegning av rektangel", + "start-draw-polygon": "Start å tegne polygon", + "continue-draw-polygon": "Fortsett å tegne polygon", + "finish-draw-polygon": "Fullfør tegning av polygon", + "start-draw-circle": "Start å tegne sirkel", + "finish-draw-circle": "Fullfør tegning av sirkel" + } + }, + "widgets-bundle": { + "current": "Gjeldende pakke", + "widgets-bundles": "Widgetpakker", + "widgets-bundle-widgets": "Widgets i pakken", + "add": "Legg til widgetpakke", + "delete": "Slett widgetpakke", + "title": "Tittel", + "title-required": "Tittel er påkrevd.", + "title-max-length": "Tittelen må være kortere enn 256 tegn", + "description": "Beskrivelse", + "image-preview": "Bildeeksempel", + "scada": "SCADA-widgetpakke", + "order": "Rekkefølge", + "add-widgets-bundle-text": "Legg til ny widgetpakke", + "no-widgets-bundles-text": "Ingen widgetpakker funnet", + "empty": "Widgetpakken er tom", + "details": "Detaljer", + "widgets-bundle-details": "Widgetpakkedetaljer", + "delete-widgets-bundle-title": "Er du sikker på at du vil slette widgetpakken '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Vær forsiktig, etter bekreftelse vil widgetpakken og alle relaterte data bli permanent slettet.", + "delete-widgets-bundles-title": "Er du sikker på at du vil slette { count, plural, =1 {1 widgetpakke} other {# widgetpakker} }?", + "delete-widgets-bundles-action-title": "Slett { count, plural, =1 {1 widgetpakke} other {# widgetpakker} }", + "delete-widgets-bundles-text": "Vær forsiktig, etter bekreftelse vil alle valgte widgetpakker og tilhørende data bli permanent slettet.", + "no-widgets-bundles-matching": "Ingen widgetpakker som samsvarer med '{{widgetsBundle}}' ble funnet.", + "widgets-bundle-required": "Widgetpakke er påkrevd.", + "system": "System", + "import": "Importer widgetpakke", + "export": "Eksporter widgetpakke", + "export-widgets-bundle-widgets-prompt": "Inkluder widgets i pakken i eksporterte data (ellers eksporteres kun refererte widget-FQN-er)", + "export-failed-error": "Kan ikke eksportere widgetpakke: {{error}}", + "create-new-widgets-bundle": "Opprett ny widgetpakke", + "widgets-bundle-file": "Widgetpakkefil", + "invalid-widgets-bundle-file-error": "Kan ikke importere widgetpakke: Ugyldig datastruktur.", + "search": "Søk i widgetpakker", + "selected-widgets-bundles": "{ count, plural, =1 {1 widgetpakke} other {# widgetpakker} } valgt", + "open-widgets-bundle": "Åpne widgetpakke", + "loading-widgets-bundles": "Laster widgetpakker...", + "create-new": "Opprett ny widgetpakke" + }, + "widget-config": { + "data": "Data", + "settings": "Innstillinger", + "advanced": "Avansert", + "appearance": "Utseende", + "widget-card": "Widget-kort", + "mobile": "Mobil", + "title": "Tittel", + "title-tooltip": "Tittelverktøytips", + "general-settings": "Generelle innstillinger", + "display-title": "Vis widget-tittel", + "card-title": "Korttittel", + "drop-shadow": "Skyggeeffekt", + "enable-fullscreen": "Aktiver fullskjerm", + "background-color": "Bakgrunnsfarge", + "text-color": "Tekstfarge", + "border-radius": "Hjørneavrunding", + "padding": "Polstring", + "margin": "Marg", + "widget-style": "Widget-stil", + "widget-css": "Widget CSS", + "title-style": "Tittelstil", + "mobile-mode-settings": "Mobilmodus", + "order": "Rekkefølge", + "height": "Høyde", + "mobile-hide": "Skjul widget i mobilmodus", + "desktop-hide": "Skjul widget i skrivebordsmodus", + "units": "Spesialsymbol for visning ved siden av verdi", + "units-by-default": "Enheter som standard", + "decimals": "Antall sifre etter desimaltegn", + "decimals-by-default": "Desimaler som standard", + "default-data-key-parameter-hint": "Denne parameteren gjelder for alle widget-verdier med mindre den overstyres av datanøkkelkonfigurasjon", + "units-short": "Enheter", + "decimals-short": "Desimaler", + "decimals-suffix": "desimaler", + "digits-suffix": "sifre", + "timewindow": "Tidsvindu", + "use-dashboard-timewindow": "Bruk dashbordets tidsvindu", + "use-widget-timewindow": "Bruk widgetens tidsvindu", + "display-timewindow": "Vis tidsvindu", + "legend": "Forklaring", + "display-legend": "Vis forklaring", + "datasources": "Datakilder", + "datasource": "Datakilde", + "maximum-datasources": "Maksimalt { count, plural, =1 {1 datakilde er tillatt.} other {# datakilder er tillatt} }", + "timeseries-key-error": "Minst én tidsseriedatanøkkel må spesifiseres", + "datasource-type": "Type", + "datasource-parameters": "Parametere", + "remove-datasource": "Fjern datakilde", + "add-datasource": "Legg til datakilde", + "target-device": "Målenhet", + "alarm-source": "Alarmkilde", + "actions": "Handlinger", + "action": "Handling", + "add-action": "Legg til handling", + "search-actions": "Søk i handlinger", + "no-actions-text": "Ingen handlinger funnet", + "action-source": "Handlingskilde", + "select-action-source": "Velg handlingskilde", + "action-source-required": "Handlingskilde er påkrevd.", + "column-index": "Kolonneindeks", + "select-column-index": "Velg kolonneindeks", + "column-index-required": "Kolonneindeks er påkrevd.", + "not-set": "Ikke angitt", + "action-name": "Navn", + "action-name-required": "Handlingsnavn er påkrevd.", + "action-name-not-unique": "En annen handling med samme navn eksisterer allerede.\nHandlingsnavn må være unikt innen samme handlingskilde.", + "action-icon": "Ikon", + "header-button": { + "button-settings": "Knappinnstillinger", + "button-type": "Knapptype", + "button-type-basic": "Grunnleggende", + "button-type-raised": "Hevet", + "button-type-stroked": "Streket", + "button-type-flat": "Flat", + "button-type-icon": "Ikon", + "button-type-mini-fab": "FAB", + "colors": "Farger", + "color": "Farge", + "background": "Bakgrunn", + "border": "Kantlinje", + "advanced-button-style": "Avansert knappestil", + "button-style": "Knappestil" + }, + "show-hide-action-using-function": "Vis/skjul handling ved bruk av funksjon", + "show-action-function": "Vis handlingsfunksjon", + "action-type": "Type", + "action-type-required": "Handlingstype er påkrevd.", + "edit-action": "Rediger handling", + "delete-action": "Slett handling", + "delete-action-title": "Slett widget-handling", + "delete-action-text": "Er du sikker på at du vil slette widget-handlingen med navnet '{{actionName}}'?", + "title-icon": "Tittelikon", + "display-icon": "Vis tittelikon", + "card-icon": "Kortikon", + "icon": "Ikon", + "icon-color": "Ikonfarge", + "icon-size": "Ikonstørrelse", + "advanced-settings": "Avanserte innstillinger", + "data-settings": "Datainnstillinger", + "limits": "Begrensninger", + "no-data-display-message": "Alternativ melding for \"Ingen data å vise\"", + "data-page-size": "Maksimalt antall enheter per datakilde", + "settings-component-not-found": "Innstillingsskjema-komponent ikke funnet for velger '{{selector}}'", + "preview": "Forhåndsvisning", + "set": "Sett", + "set-message": "Angi melding", + "advanced-title-style": "Avansert titteldesign", + "card-style": "Kortdesign", + "text": "Tekst", + "background": "Bakgrunn", + "advanced-widget-style": "Avansert widget-stil", + "card-buttons": "Kortknapper", + "show-card-buttons": "Vis kortknapper", + "card-border-radius": "Hjørneavrunding for kort", + "card-padding": "Polstring for kort", + "card-appearance": "Kortutseende", + "color": "Farge", + "tooltip": "Verktøytips", + "units-required": "Enhet er påkrevd.", + "list-layout": "Listeoppsett", + "layout": "Oppsett", + "resize-options": "Alternativer for størrelseendring", + "resizable": "Justerbar", + "preserve-aspect-ratio": "Bevar størrelsesforhold" + }, + "widget-type": { + "import": "Importer widgettype", + "export": "Eksporter widgettype", + "export-failed-error": "Kan ikke eksportere widget: {{error}}", + "widget-file": "Widgetfil", + "invalid-widget-file-error": "Kan ikke importere widget: Ugyldig widgetdatastruktur." + }, + "markdown": { + "edit": "Rediger", + "preview": "Forhåndsvis", + "copy-code": "Klikk for å kopiere", + "copied": "Kopiert!" + }, + "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "Konfigurasjonen avhenger av widgeten for QR-kode for mobilapp i plattformens hovedinnstillinger", + "get-it-on-google-play": "Få den på Google Play", + "download-on-the-app-store": "Last ned på App Store" + }, + "action-button": { + "behavior": "Oppførsel", + "on-click": "Ved klikk", + "on-click-hint": "Handling utløst når knappen klikkes", + "first-button-click": "Første knappetrykk", + "first-button-click-hint": "Handling ved trykk på første knapp.", + "second-button-click": "Andre knappetrykk", + "second-button-click-hint": "Handling ved trykk på andre knapp.", + "button-click-hint": "Handling ved trykk på widget." + }, + "command-button": { + "behavior": "Oppførsel", + "on-click": "Ved klikk", + "on-click-hint": "Handling utført når knappen klikkes." + }, + "power-button": { + "behavior": "Oppførsel", + "power-on": "Strøm 'På'", + "power-on-hint": "Handling for å slå PÅ komponenten.", + "power-off": "Strøm 'Av'", + "power-off-hint": "Handling for å slå AV komponenten.", + "on-label": "På", + "off-label": "Av", + "layout": "Oppsett", + "layout-default": "Standard", + "layout-simplified": "Forenklet", + "layout-outlined": "Med ramme", + "layout-default-volume": "Standard.Volum", + "layout-simplified-volume": "Forenklet.Volum", + "layout-outlined-volume": "Med ramme.Volum", + "layout-default-icon": "Standard.Ikon", + "layout-simplified-icon": "Forenklet.Ikon", + "layout-outlined-icon": "Med ramme.Ikon", + "main": "Hoved", + "background": "Bakgrunn", + "button-icon-on": "Knappikon 'På'", + "button-icon-off": "Knappikon 'Av'", + "power-on-colors": "Farger for 'På'", + "power-off-colors": "Farger for 'Av'", + "disabled-colors": "Deaktiverte farger", + "button": "Knapp" + }, + "toggle-button": { + "behavior": "Oppførsel", + "checked": "Avkrysset", + "unchecked": "Ikke avkrysset", + "check": "Kryss av", + "check-hint": "Handling for å krysse av komponenten.", + "uncheck": "Fjern avkrysning", + "uncheck-hint": "Handling for å fjerne avkrysning fra komponenten.", + "auto-scale": "Automatisk skalering", + "horizontal-fill": "Horisontal fylling", + "vertical-fill": "Vertikal fylling", + "button-appearance": "Knapputseende" + }, + "segmented-button": { + "layout": "Oppsett", + "layout-squared": "Firkantet", + "layout-rounded": "Avrundet", + "card-border": "Kortkant", + "button-appearance": "Knapputseende", + "first": "Første", + "second": "Andre", + "color-styles": "Fargestiler", + "selected": "Valgt", + "unselected": "Ikke valgt" + }, + "button": { + "layout": "Oppsett", + "outlined": "Med ramme", + "filled": "Fylt", + "underlined": "Understreket", + "basic": "Grunnleggende", + "auto-scale": "Automatisk skalering", + "label": "Etikett", + "icon": "Ikon", + "border-radius": "Kantavrunding", + "color-palette": "Fargepalett", + "main": "Hoved", + "background": "Bakgrunn", + "border": "Kant", + "custom-styles": "Egendefinerte stiler", + "clear-style": "Fjern stil", + "shadow": "Skygge", + "enabled": "Aktivert", + "disabled": "Deaktivert", + "preview": "Forhåndsvisning", + "copy-style-from": "Kopier stil fra" + }, + "value-stepper": { + "behavior": "Oppførsel", + "simplified": "Forenklet", + "filled": "Fylt", + "outlined": "Med ramme", + "volume": "Volum", + "initial-state": "Starttilstand", + "initial-state-hint": "Handling for å hente startverdi.", + "disabled-state": "Deaktivert tilstand", + "disabled-state-hint": "Konfigurer betingelse for når komponenten er deaktivert.", + "right-button-click": "Høyre knappetrykk", + "right-button-click-hint": "Handling ved trykk på høyre knapp.", + "left-button-click": "Venstre knappetrykk", + "left-button-click-hint": "Handling ved trykk på venstre knapp.", + "auto-scale": "Automatisk skalering", + "value-range": "Område", + "min-range": "Min", + "max-range": "Maks", + "value-increment-decrement-step": "Trinn for verdiøkning/-reduksjon", + "value": "Verdi", + "value-box-background": "Bakgrunn for verdiboks", + "border": "Kant", + "button-appearance": "Knapputseende", + "left": "Venstre", + "right": "Høyre", + "left-button": "Venstre knapp", + "right-button": "Høyre knapp", + "icon": "Ikon", + "color-palette": "Fargepalett", + "main": "Hoved", + "background": "Bakgrunn", + "button-icon-on": "Knappikon 'På'", + "button-on-colors": "Farger for 'På'", + "disabled-colors": "Farger for deaktivert" + }, + "button-state": { + "activated-state": "Aktivert tilstand", + "activated-state-hint": "Konfigurer betingelse for når knappen er aktiv.", + "disabled-state": "Deaktivert tilstand", + "disabled-state-hint": "Konfigurer betingelse for når knappen er deaktivert.", + "selected-state": "Valgt knapp", + "selected-state-hint": "Konfigurer betingelse for når knappen er valgt.", + "enabled": "Aktivert", + "hovered": "Holdt over", + "pressed": "Trykket", + "activated": "Aktivert", + "disabled": "Deaktivert", + "initial": "Første knapp", + "first": "Første", + "second": "Andre" + }, + "background": { + "background": "Bakgrunn", + "background-settings": "Bakgrunnsinnstillinger", + "background-type-image": "Bilde", + "background-type-color": "Farge", + "image-url": "Bilde-URL", + "overlay": "Overlegg", + "enable-overlay": "Aktiver overlegg", + "blur": "Uklarhet", + "preview": "Forhåndsvisning" + }, + "bar-chart": { + "bar-appearance": "Stolpeutseende", + "label-on-bar": "Etikett på stolpe", + "value-on-bar": "Verdi på stolpe", + "bar-chart-style": "Stolpediagramstil", + "bar-axis": "Stolpeakse" + }, + "polar-area-chart": { + "polar-axis": "Polarakse", + "start-angle": "Startvinkel", + "polar-area-chart-style": "Polardiagramstil" + }, + "battery-level": { + "layout": "Oppsett", + "layout-vertical-solid": "Vertikal. Hel", + "layout-horizontal-solid": "Horisontal. Hel", + "layout-vertical-divided": "Vertikal. Delt", + "layout-horizontal-divided": "Horisontal. Delt", + "icon": "Ikon", + "value": "Verdi", + "auto-scale": "Automatisk skalering", + "battery-level-color": "Batterinivåfarge", + "battery-shape-color": "Batteriformfarge", + "battery-level-card-style": "Stil for batterinivåkort", + "sections-count": "Antall seksjoner" + }, + "signal-strength": { + "value": "Verdi", + "last-update": "Siste oppdatering", + "no-signal": "Ingen signal", + "layout": "Oppsett", + "layout-wifi": "Wi-Fi", + "layout-cellular-bar": "Mobilstolpe", + "icon": "Ikon", + "date": "Dato", + "active-bars-color": "Farge for aktive signalstolper", + "inactive-bars-color": "Farge for inaktive signalstolper", + "signal-strength-card-style": "Stil for signalstyrkekort", + "no-signal-rssi-value": "\"Ingen signal\" rssi-verdi" + }, + "status-widget": { + "behavior": "Oppførsel", + "layout": "Oppsett", + "layout-default": "Standard", + "layout-center": "Sentrert", + "layout-icon": "Ikon", + "on": "På", + "off": "Av", + "label": "Etikett", + "status": "Status", + "icon": "Ikon", + "color-palette": "Fargepalett", + "disabled-color-palette": "Fargepalett for deaktivert", + "primary": "Primær", + "primary-color-hint": "Farge på ikon og etikett", + "secondary": "Sekundær", + "secondary-color-hint": "Farge på status", + "background": "Bakgrunn" + }, + "chart": { + "common-settings": "Felles innstillinger", + "enable-stacking-mode": "Aktiver stablemodus", + "selection": "Tidsområdevalg", + "enable-selection-mode": "Aktiver valgmodus", + "line-shadow-size": "Skyggestørrelse for linje", + "display-smooth-lines": "Vis glatte (buede) linjer", + "default-bar-width": "Standard stolpebredde for ikke-aggregerte data (millisekunder)", + "bar-alignment": "Justering av stolper", + "bar-alignment-left": "Venstre", + "bar-alignment-right": "Høyre", + "bar-alignment-center": "Senter", + "default-font": "Standard skrift", + "default-font-size": "Standard skriftstørrelse", + "default-font-color": "Standard skriftfarge", + "thresholds-line-width": "Standard linjebredde for alle terskler", + "tooltip-settings": "Verktøytipsinnstillinger", + "tooltip": "Verktøytips", + "show-tooltip": "Vis verktøytips", + "hover-individual-points": "Hold over individuelle punkter", + "show-cumulative-values": "Vis kumulative verdier i stablemodus", + "hide-zero-false-values": "Skjul null-/false-verdier i verktøytips", + "tooltip-value-format-function": "Funksjon for format av verktøytipsverdi", + "grid-settings": "Rutenettinnstillinger", + "show-vertical-lines": "Vis vertikale linjer", + "show-horizontal-lines": "Vis horisontale linjer", + "grid-outline-border-width": "Rutenettramme-/kantbredde (px)", + "primary-color": "Primærfarge", + "background-color": "Bakgrunnsfarge", + "ticks-color": "Farge på aksemarkeringer", + "xaxis-settings": "Innstillinger for X-akse", + "axis-title": "Aksetittel", + "xaxis-tick-labels-settings": "Innstillinger for X-akseetiketter", + "show-tick-labels": "Vis akseetiketter", + "yaxis-settings": "Innstillinger for Y-akse", + "min-scale-value": "Minimumsverdi på skala", + "max-scale-value": "Maksimumsverdi på skala", + "yaxis-tick-labels-settings": "Innstillinger for Y-akseetiketter", + "tick-step-size": "Trinnstørrelse mellom markeringer", + "number-of-decimals": "Antall desimaler som vises", + "ticks-formatter-function": "Formatteringsfunksjon for markeringer", + "comparison-settings": "Sammenligningsinnstillinger", + "enable-comparison": "Aktiver sammenligning", + "time-for-comparison": "Sammenligningsperiode", + "time-for-comparison-previous-interval": "Forrige intervall (standard)", + "time-for-comparison-days": "Dager siden", + "time-for-comparison-weeks": "Uker siden", + "time-for-comparison-months": "Måneder siden", + "time-for-comparison-years": "År siden", + "time-for-comparison-custom-interval": "Egendefinert intervall", + "custom-interval-value": "Egendefinert intervallverdi (ms)", + "comparison-x-axis-settings": "Innstillinger for sammenlignings-X-akse", + "axis-position": "Akseposisjon", + "axis-position-top": "Topp (standard)", + "axis-position-bottom": "Bunn", + "custom-legend-settings": "Egendefinerte tegnforklaringsinnstillinger", + "enable-custom-legend": "Aktiver egendefinert tegnforklaring (lar deg bruke attributt-/tidsseriedata i nøkkelnavn)", + "key-name": "Nøkkelnavn", + "key-name-required": "Nøkkelnavn er påkrevd", + "key-type": "Nøkkeltype", + "key-type-attribute": "Attributt", + "key-type-timeseries": "Tidsserie", + "label-keys-list": "Nøkkelliste brukt i etiketter", + "no-label-keys": "Ingen nøkkel er konfigurert", + "add-label-key": "Legg til ny nøkkel", + "line-width": "Linjebredde", + "color": "Farge", + "data-is-hidden-by-default": "Data er skjult som standard", + "disable-data-hiding": "Deaktiver dataskjuling", + "remove-from-legend": "Fjern datanøkkel fra tegnforklaring", + "exclude-from-stacking": "Ekskluder fra stabling (tilgjengelig i \"Stabling\"-modus)", + "line-settings": "Linjens innstillinger", + "show-line": "Vis linje", + "fill-line": "Fyll linje", + "fill-line-opacity": "Fyll opasitet", + "points-settings": "Punktinnstillinger", + "show-points": "Vis punkter", + "points-line-width": "Linjebredde for punkter", + "points-radius": "Radius for punkter", + "point-shape": "Punktform", + "point-shape-circle": "Sirkel", + "point-shape-cross": "Kryss", + "point-shape-diamond": "Diamant", + "point-shape-square": "Firkant", + "point-shape-triangle": "Trekant", + "point-shape-custom": "Egendefinert funksjon", + "point-shape-draw-function": "Tegnefunksjon for punktform", + "show-separate-axis": "Vis separat akse", + "axis-position-left": "Venstre", + "axis-position-right": "Høyre", + "thresholds": "Terskler", + "no-thresholds": "Ingen terskler konfigurert", + "add-threshold": "Legg til terskel", + "show-values-for-comparison": "Vis historiske verdier for sammenligning", + "comparison-values-label": "Etikett for historiske verdier", + "comparison-line-color": "Farge på sammenligningslinje", + "threshold-settings": "Terskelinnstillinger", + "use-as-threshold": "Bruk nøkkelverdi som terskel", + "threshold-line-width": "Linjebredde for terskel", + "threshold-color": "Terskelfarge", + "common-pie-settings": "Felles sektordiagraminnstillinger", + "radius": "Radius", + "inner-radius": "Indre radius", + "tilt": "Helling", + "common-pie-settings-range-error": "Verdien må være mellom 0 og 1", + "stroke-settings": "Strekinnstillinger", + "width-pixels": "Bredde (piksler)", + "show-labels": "Vis etiketter", + "animation-settings": "Animasjonsinnstillinger", + "animated-pie": "Aktiver sektordiagramanimasjon (eksperimentell)", + "border-settings": "Kantinnstillinger", + "border-width": "Kantbredde", + "border-color": "Kantfarge", + "legend-settings": "Tegnforklaringsinnstillinger", + "display-legend": "Vis tegnforklaring", + "labels-font-color": "Skriftfarge for etiketter", + "series": "Serier", + "add-series": "Legg til serie", + "series-settings": "Serieinnstillinger", + "remove-series": "Fjern serie", + "no-series": "Ingen serier konfigurert", + "no-series-error": "Minst én serie må angis", + "chart-appearance": "Diagramutseende", + "vertical-grid-lines": "Vertikale rutenettlinjer", + "horizontal-grid-lines": "Horisontale rutenettlinjer", + "chart-background": "Diagrambakgrunn", + "grid-lines-color": "Farge på rutenettlinjer", + "border": "Kant", + "axis": "Akse", + "vertical-axis": "Vertikal akse", + "ticks": "Aksemarkeringer", + "horizontal-axis": "Horisontal akse", + "shape-empty-circle": "Tom sirkel", + "shape-circle": "Sirkel", + "shape-rect": "Rektangel", + "shape-round-rect": "Avrundet rektangel", + "shape-triangle": "Trekant", + "shape-diamond": "Diamant", + "shape-pin": "Stift", + "shape-arrow": "Pil", + "shape-none": "Ingen", + "line-type-solid": "Heltrukken", + "line-type-dashed": "Stiplet", + "line-type-dotted": "Prikket", + "label-position-top": "Topp", + "label-position-bottom": "Bunn", + "label-position-outside": "Utenfor", + "label-position-inside": "Innenfor", + "fill": "Fyll", + "fill-type-none": "Ingen", + "fill-type-solid": "Heltrukken", + "fill-type-opacity": "Gjennomsiktighet", + "fill-type-gradient": "Gradient", + "background": "Bakgrunn", + "opacity": "Gjennomsiktighet", + "gradient-stops": "Gradientstopp", + "gradient-start": "start", + "gradient-end": "slutt", + "animation": { + "animation": "Animasjon", + "animation-threshold": "Animasjonsterskel", + "animation-duration": "Animasjonsvarighet", + "animation-easing": "Animasjonsovergang", + "animation-delay": "Animasjonsforsinkelse", + "update-animation-duration": "Oppdater animasjonsvarighet", + "update-animation-easing": "Oppdater animasjonsovergang", + "update-animation-delay": "Oppdater animasjonsforsinkelse" + }, + "chart-axis": { + "scale": "Skala", + "scale-min": "min", + "scale-max": "maks", + "scale-auto": "Auto" + }, + "bar": { + "show-border": "Vis kant", + "border-width": "Kantbredde", + "border-radius": "Kantavrunding", + "bar-width": "Stolpebredde", + "label": "Etikett", + "label-hint": "Vis etikett over stolpen.", + "series-label-hint": "Vis etikett med verdi over stolpen.", + "label-background": "Etikettbakgrunn" + } + }, + "color": { + "color-settings": "Fargeinnstillinger", + "color-type-constant": "Konstant", + "color-type-gradient": "Gradient", + "color-type-range": "Område", + "color-type-function": "Funksjon", + "color": "Farge", + "value-range": "Verdiområde", + "from": "Fra", + "to": "Til", + "color-function": "Fargefunksjon", + "copy-color-settings-from": "Kopier fargeinnstillinger fra", + "copy-from": "Kopier fra", + "settings-type": "Innstillingstype", + "basic-mode": "Grunnleggende", + "advanced-mode": "Avansert", + "entity-alias": "Enhetsalias", + "entity-attribute": "Enhetsegenskap", + "gradient-color": "Gradientfarge", + "gradient-color-min": "Farge", + "gradient-start": "Startfarge for gradient", + "gradient-start-min": "Start", + "gradient-end": "Sluttfarge for gradient", + "gradient-end-min": "Slutt", + "start-value": "Startverdi", + "end-value": "Sluttverdi", + "gradient-type": "Gradienttype" + }, + "dashboard-state": { + "dashboard-state-settings": "Innstillinger for dashbordtilstand", + "dashboard-state": "Dashbordtilstands-ID", + "autofill-state-layout": "Fyll automatisk høyde for tilstandsoppsett", + "default-margin": "Standard marg for widgeter", + "default-background-color": "Standard bakgrunnsfarge", + "sync-parent-state-params": "Synkroniser tilstandsparametere med overordnet dashbord" + }, + "date-range-navigator": { + "date-range-picker-settings": "Innstillinger for datoperiodevelger", + "hide-date-range-picker": "Skjul datoperiodevelger", + "picker-one-panel": "Datoperiodevelger med ett panel", + "picker-auto-confirm": "Automatisk bekreftelse i datoperiodevelger", + "picker-show-template": "Vis mal for datoperiodevelger", + "first-day-of-week": "Første ukedag", + "interval-settings": "Innstillinger for intervall", + "hide-interval": "Skjul intervall", + "initial-interval": "Startintervall", + "interval-hour": "Time", + "interval-day": "Dag", + "interval-week": "Uke", + "interval-two-weeks": "2 uker", + "interval-month": "Måned", + "interval-three-months": "3 måneder", + "interval-six-months": "6 måneder", + "step-settings": "Innstillinger for trinn", + "hide-step-size": "Skjul trinnstørrelse", + "initial-step-size": "Startverdi for trinnstørrelse", + "hide-labels": "Skjul etiketter", + "use-session-storage": "Bruk sesjonslagring", + "localizationMap": { + "Sun": "Søn", + "Mon": "Man", + "Tue": "Tir", + "Wed": "Ons", + "Thu": "Tor", + "Fri": "Fre", + "Sat": "Lør", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "Mai", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Okt", + "Nov": "Nov", + "Dec": "Des", + "January": "Januar", + "February": "Februar", + "March": "Mars", + "April": "April", + "June": "Juni", + "July": "Juli", + "August": "August", + "September": "September", + "October": "Oktober", + "November": "November", + "December": "Desember", + "Custom Date Range": "Egendefinert datoperiode", + "Date Range Template": "Mal for datoperiode", + "Today": "I dag", + "Yesterday": "I går", + "This Week": "Denne uken", + "Last Week": "Forrige uke", + "This Month": "Denne måneden", + "Last Month": "Forrige måned", + "Year": "År", + "This Year": "Dette året", + "Last Year": "Forrige år", + "Date picker": "Datovelger", + "Hour": "Time", + "Day": "Dag", + "Week": "Uke", + "2 weeks": "2 uker", + "Month": "Måned", + "3 months": "3 måneder", + "6 months": "6 måneder", + "Custom interval": "Egendefinert intervall", + "Interval": "Intervall", + "Step size": "Trinnstørrelse", + "Ok": "OK" + } + }, + "doughnut": { + "doughnut-appearance": "Smultring-diagram utseende", + "layout": "Layout", + "layout-default": "Standard", + "layout-with-total": "Med total", + "central-total-value": "Sentral totalverdi", + "doughnut-card-style": "Stilkort for smultring-diagram" + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Innstillinger for hierarkidata", + "relations-query-function": "Relasjonsspørringsfunksjon for node", + "has-children-function": "Funksjon for å sjekke om node har barn", + "node-state-settings": "Innstillinger for nodetilstand", + "node-opened-function": "Funksjon for åpnet node som standard", + "node-disabled-function": "Funksjon for deaktivert node", + "display-settings": "Visningsinnstillinger", + "node-icon-function": "Funksjon for nodeikon", + "node-text-function": "Funksjon for nodetekst", + "sort-settings": "Sorteringsinnstillinger", + "nodes-sort-function": "Funksjon for sortering av noder" + }, + "edge": { + "display-default-title": "Vis standard tittel" + }, + "gateway": { + "general-settings": "Generelle innstillinger", + "widget-title": "Widget-tittel", + "default-archive-file-name": "Standard filnavn for arkiv", + "device-type-for-new-gateway": "Enhetstype for ny gateway", + "messages-settings": "Innstillinger for meldinger", + "save-config-success-message": "Meldingstekst om at gateway-konfigurasjonen ble lagret", + "device-name-exists-message": "Meldingstekst når en enhet med angitt navn allerede finnes", + "gateway-title": "Gateway-skjema", + "read-only": "Kun lesbar", + "events-title": "Tittel for gateway-hendelser", + "events-filter": "Filter for hendelser", + "event-key-contains": "Hendelsesnøkkel inneholder...", + "show-connector": "Vis for tilkoblingen", + "connector-state-param-key": "Parameter for tilkoblingstilstand", + "message": "Melding", + "level": "Nivå", + "created-time": "Opprettet tidspunkt" + }, + "gauge": { + "default-color": "Standardfarge", + "radial-gauge-settings": "Innstillinger for radialmåler", + "ticks-settings": "Innstillinger for inndelinger", + "min-value": "Minimumsverdi", + "max-value": "Maksimumsverdi", + "min-value-short": "min", + "max-value-short": "maks", + "start-ticks-angle": "Startvinkel for inndelinger", + "ticks-angle": "Vinkel for inndelinger", + "major-ticks": "Hovedinndelinger", + "major-ticks-count": "Antall hovedinndelinger", + "major-ticks-color": "Farge for hovedinndelinger", + "minor-ticks": "Mellominndelinger", + "minor-ticks-count": "Antall mellominndelinger", + "minor-ticks-color": "Farge for mellominndelinger", + "tick-numbers-font": "Skrift for tall på inndelinger", + "unit-title-settings": "Innstillinger for enhetstittel", + "show-unit-title": "Vis enhetstittel", + "unit-title": "Enhetstittel", + "title-font": "Skrift for titteltekst", + "units-settings": "Innstillinger for enheter", + "units-font": "Skrift for enhetstekst", + "value-box-settings": "Innstillinger for verdiboks", + "show-value-box": "Vis verdiboks", + "value-box": "Verdiboks", + "value-int": "Antall sifre i heltallsdelen av verdien", + "value-text": "Verdi tekst", + "value-text-shadow": "Skygge på verditekst", + "value-font": "Skrift for verditekst", + "rect-stroke-color-start": "Startfarge for rektangelkontur (gradient)", + "rect-stroke-color-end": "Sluttfarge for rektangelkontur (gradient)", + "background-color": "Bakgrunnsfarge", + "shadow-color": "Skyggefarge", + "value-box-rect-stroke-color": "Konturfarge for verdiboks", + "value-box-rect-stroke-color-end": "Sluttfarge for verdiboks-kontur (gradient)", + "value-box-background-color": "Bakgrunnsfarge for verdiboks", + "value-box-shadow-color": "Skyggefarge for verdiboks", + "plate-settings": "Platinntillinger", + "show-plate-border": "Plateramme", + "plate-color": "Platefarge", + "needle-settings": "Innstillinger for nål", + "needle-circle-size": "Størrelse på nålsirkel", + "needle-color": "Nålfarge", + "needle-color-start": "Nålfarge – start gradient", + "needle-color-end": "Nålfarge – slutt gradient", + "needle-color-shadow-up": "Skyggefarge øvre del av nål", + "needle-color-shadow-down": "Skyggefarge under (drop shadow)", + "highlights-settings": "Innstillinger for høydepunkter", + "highlights-width": "Bredde på høydepunkter", + "highlights": "Høydepunkter", + "highlight-from": "Fra", + "highlight-to": "Til", + "highlight-color": "Farge", + "no-highlights": "Ingen høydepunkter konfigurert", + "add-highlight": "Legg til høydepunkt", + "animation-settings": "Animasjonsinnstillinger", + "enable-animation": "Animasjon", + "animation-duration-rule": "Varighet og regel for animasjon", + "animation-duration": "Varighet på animasjon", + "animation-rule": "Animasjonsregel", + "animation-linear": "Lineær", + "animation-quad": "Kvadratisk", + "animation-quint": "Kvintisk", + "animation-cycle": "Syklus", + "animation-bounce": "Sprett", + "animation-elastic": "Elastisk", + "animation-dequad": "Avtagende kvadratisk", + "animation-dequint": "Avtagende kvintisk", + "animation-decycle": "Avtagende syklus", + "animation-debounce": "Avtagende sprett", + "animation-delastic": "Avtagende elastisk", + "linear-gauge-settings": "Innstillinger for lineærmåler", + "bar-stroke": "Strek for søyle", + "bar-stroke-width": "Strebredde for søyle", + "bar-stroke-color": "Strekfarge for søyle", + "bar-background-color": "Bakgrunnsfarge for søyle – start gradient", + "bar-background-color-end": "Bakgrunnsfarge for søyle – slutt gradient", + "progress-bar-color": "Farge på fremdriftsindikator", + "progress-bar": "Fremdriftsindikator", + "progress-bar-color-start": "Startfarge for fremdriftsindikator (gradient)", + "progress-bar-color-end": "Sluttfarge for fremdriftsindikator (gradient)", + "major-ticks-names": "Navn på hovedinndelinger", + "show-stroke-ticks": "Vis konturlinjer på inndelinger", + "major-ticks-font": "Skrift for hovedinndelinger", + "border-color": "Rammefarge", + "border-width": "Rammebredde", + "needle-circle": "Nålsirkel", + "needle-circle-color": "Farge på nålsirkel", + "animation-target": "Animasjonsmål", + "animation-target-needle": "Nål", + "animation-target-plate": "Plate", + "common-settings": "Vanlige innstillinger for måler", + "gauge-type": "Type måler", + "gauge-type-arc": "Bue", + "gauge-type-donut": "Smultring", + "gauge-type-horizontal-bar": "Horisontal søyle", + "gauge-type-vertical-bar": "Vertikal søyle", + "donut-start-angle": "Startvinkel (grader)", + "bar-settings": "Innstillinger for målersøyle", + "relative-bar-width": "Relativ søylebredde", + "neon-glow-brightness": "Lysstyrke for neon-glød effekt (0–100)", + "neon-glow-brightness-hint": "0 - deaktiver effekt", + "stripes-thickness": "Tykkelse på striper", + "stripes-thickness-hint": "0 - ingen striper", + "rounded-line-cap": "Avrundet linjeende", + "bar-color-settings": "Fargeinnstillinger for søyle", + "use-precise-level-color-values": "Bruk presise fargenivåer", + "bar-colors": "Søylefarger, fra lav til høy", + "color": "Farge", + "no-bar-colors": "Ingen søylefarger konfigurert", + "add-bar-color": "Legg til søylefarge", + "from": "Fra", + "to": "Til", + "fixed-level-colors": "Søylefarger ved hjelp av grenseverdier", + "gauge-title-settings": "Innstillinger for målertittel", + "show-gauge-title": "Vis målertittel", + "gauge-title": "Målertittel", + "gauge-title-font": "Skrift for målertittel", + "unit-title-and-timestamp-settings": "Innstillinger for enhetstittel og tidsstempel", + "show-timestamp": "Tidsstempel", + "timestamp-format": "Format for tidsstempel", + "label-font": "Skrift for etikett under verdi", + "value-settings": "Innstillinger for verdi", + "show-value": "Vis verditekst", + "min-max-settings": "Innstillinger for min/maks etiketter", + "show-min-max": "Vis minimum og maksimum verdier", + "min-max-font": "Skrift for minimum og maksimum etiketter", + "show-ticks": "Vis inndelinger", + "tick-width": "Bredde på inndelinger", + "tick-color": "Farge på inndelinger", + "tick-values": "Verdier for inndelinger", + "no-tick-values": "Ingen verdier for inndelinger konfigurert", + "add-tick-value": "Legg til inndelingsverdi", + "gauge-appearance": "Utseende på måler", + "units-title": "Tittel for enheter", + "value": "Verdi", + "ticks": "Inndelinger", + "arrow-and-scale-color": "Standardfarge for pil og skala", + "scale-settings": "Skalainnstillinger", + "scale": "Skala", + "scale-color": "Skalafarger", + "compass-appearance": "Utseende på kompass", + "label": "Etikett", + "labels": "Etiketter", + "label-style": "Stil på etikett", + "simple-gauge-type": "Type", + "gauge-bar-background": "Bakgrunn for målersøyle", + "bar-color": "Søylefarge", + "min-and-max-value": "Min og maks verdi", + "min-and-max-label": "Etikett for min og maks", + "font": "Skrift", + "tick-width-and-color": "Bredde og farge på inndelinger", + "min-max-validation-text": "Maksverdi må være større enn minverdi" + }, + "gpio": { + "pin": "Pin", + "label": "Etikett", + "row": "Rad", + "column": "Kolonne", + "color": "Farge", + "panel-settings": "Panelinnstillinger", + "background-color": "Bakgrunnsfarge", + "gpio-switches": "GPIO-brytere", + "no-gpio-switches": "Ingen GPIO-brytere konfigurert", + "add-gpio-switch": "Legg til GPIO-bryter", + "gpio-status-request": "Forespørsel om GPIO-status", + "method-name": "Metodenavn", + "method-body": "Metodeinnhold", + "gpio-status-change-request": "Forespørsel om endring i GPIO-status", + "parse-gpio-status-function": "Funksjon for parsing av GPIO-status", + "gpio-leds": "GPIO-lysdioder", + "no-gpio-leds": "Ingen GPIO-lysdioder konfigurert", + "add-gpio-led": "Legg til GPIO-lysdioder" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "Attributtparameter kan ikke brukes i denne widgeten", + "blocked-location": "Geolokasjon er blokkert i nettleseren din", + "claim-device": "Krev enhet", + "claim-failed": "Klarte ikke å kreve enheten!", + "claim-not-found": "Enhet ikke funnet!", + "claim-successful": "Enheten ble krevd med suksess!", + "date": "Dato", + "device-name": "Enhetsnavn", + "device-name-required": "Enhetsnavn er påkrevd", + "discard-changes": "Forkast endringer", + "entity-attribute-required": "Enhetens attributt er påkrevd", + "entity-coordinate-required": "Begge feltene, breddegrad og lengdegrad, er påkrevd", + "entity-timeseries-required": "Tidsserie for enhet er påkrevd", + "get-location": "Hent gjeldende posisjon", + "invalid-date": "Ugyldig dato", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "min-value-error": "Minimumsverdi er {{value}}", + "max-value-error": "Maksimumsverdi er {{value}}", + "not-allowed-entity": "Valgt enhet kan ikke ha delte attributter", + "no-attribute-selected": "Ingen attributt valgt", + "no-datakey-selected": "Ingen datanøkkel valgt", + "no-coordinate-specified": "Datanøkkel for bredde-/lengdegrad er ikke angitt", + "no-entity-selected": "Ingen enhet valgt", + "no-image": "Ingen bilde", + "no-support-geolocation": "Nettleseren din støtter ikke geolokasjon", + "no-support-web-camera": "Nettleseren din støtter ikke kameraer", + "enable-https-use-widget": "Vennligst aktiver HTTPS for å bruke denne widgeten", + "no-found-your-camera": "Finner ikke kameraet ditt", + "no-permission-camera": "Tillatelse ble avslått av brukeren / Dette nettstedet har ikke tillatelse til å bruke kameraet", + "no-timeseries-selected": "Ingen tidsserie valgt", + "secret-key": "Hemmelig nøkkel", + "secret-key-required": "Hemmelig nøkkel er påkrevd", + "switch-attribute-value": "Bytt enhetens attributtverdi", + "switch-camera": "Bytt kamera", + "switch-timeseries-value": "Bytt enhetens tidsserieverdi", + "take-photo": "Ta bilde", + "time": "Tid", + "timeseries-not-allowed": "Tidsserieparameter kan ikke brukes i denne widgeten", + "update-failed": "Oppdatering mislyktes", + "update-successful": "Oppdatering vellykket", + "update-attribute": "Oppdater attributt", + "update-timeseries": "Oppdater tidsserie", + "value": "Verdi", + "general-settings": "Generelle innstillinger", + "widget-title": "Widget-tittel", + "claim-button-label": "Etikett for kravknapp", + "show-secret-key-field": "Vis feltet 'Hemmelig nøkkel'", + "labels-settings": "Etikettinnstillinger", + "show-labels": "Vis etiketter", + "device-name-label": "Etikett for inputfelt for enhetsnavn", + "secret-key-label": "Etikett for inputfelt for hemmelig nøkkel", + "messages-settings": "Meldingsinnstillinger", + "claim-device-success-message": "Meldingstekst for vellykket krav av enhet", + "claim-device-not-found-message": "Meldingstekst når enhet ikke ble funnet", + "claim-device-failed-message": "Meldingstekst for mislykket krav av enhet", + "claim-device-name-required-message": "Feilmelding for 'Enhetsnavn påkrevd'", + "claim-device-secret-key-required-message": "Feilmelding for 'Hemmelig nøkkel påkrevd'", + "show-label": "Vis etikett", + "label": "Etikett", + "required": "Påkrevd", + "required-error-message": "Feilmelding for 'Påkrevd'", + "show-result-message": "Vis resultatmelding", + "integer-field-settings": "Innstillinger for heltallfelt", + "min-value": "Minimumsverdi", + "max-value": "Maksimumsverdi", + "double-field-settings": "Innstillinger for desimalfelt", + "text-field-settings": "Innstillinger for tekstfelt", + "min-length": "Minimumslengde", + "max-length": "Maksimumslengde", + "checkbox-settings": "Innstillinger for avkrysningsboks", + "true-label": "Etikett for avkrysset", + "false-label": "Etikett for ikke avkrysset", + "image-input-settings": "Innstillinger for bildefelt", + "display-preview": "Vis forhåndsvisning", + "display-clear-button": "Vis klareringsknapp", + "display-apply-button": "Vis bruk-knapp", + "display-discard-button": "Vis forkast-knapp", + "datetime-field-settings": "Innstillinger for dato/tid-felt", + "display-time-input": "Vis tidsinput", + "latitude-key-name": "Nøkkelnavn for breddegrad", + "longitude-key-name": "Nøkkelnavn for lengdegrad", + "show-get-location-button": "Vis knappen 'Hent gjeldende posisjon'", + "use-high-accuracy": "Bruk høy nøyaktighet", + "location-fields-settings": "Innstillinger for posisjonsfelt", + "latitude-label": "Etikett for breddegrad", + "longitude-label": "Etikett for lengdegrad", + "input-fields-alignment": "Justering av inputfelt", + "input-fields-alignment-column": "Kolonne (standard)", + "input-fields-alignment-row": "Rad", + "layout": "Oppsett", + "row-gap": "Avstand mellom rader i piksler", + "column-gap": "Avstand mellom kolonner i piksler", + "latitude-field-required": "Breddegradsfeltet er påkrevd", + "longitude-field-required": "Lengdegradsfeltet er påkrevd", + "attribute-settings": "Attributtinnstillinger", + "widget-mode": "Widget-modus", + "widget-mode-update-attribute": "Oppdater attributt", + "widget-mode-update-timeseries": "Oppdater tidsserie", + "attribute-scope": "Attributtomfang", + "attribute-scope-server": "Serverattributt", + "attribute-scope-shared": "Delt attributt", + "value-required": "Verdi påkrevd", + "image-settings": "Bildeinnstillinger", + "image-format": "Bildeformat", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Bildekvalitet som bruker tapskomprimering, som JPEG og WEBP", + "max-image-width": "Maksimal bildebredde", + "max-image-height": "Maksimal bildehøyde", + "action-buttons": "Handlingsknapper", + "show-action-buttons": "Vis handlingsknapper", + "update-all-values": "Oppdater alle verdier, ikke bare endrede", + "save-button-label": "Etikett for 'LAGRE'-knapp", + "reset-button-label": "Etikett for 'ANGRE'-knapp", + "group-settings": "Gruppeinnstillinger", + "show-group-title": "Vis tittel for gruppe av felt relatert til forskjellige enheter", + "group-title": "Gruppetittel", + "fields-alignment": "Feltsjustering", + "fields-alignment-row": "Rad (standard)", + "fields-alignment-column": "Kolonne", + "fields-in-row": "Antall felt i raden", + "option-value": "Verdi (skriv 'null' for å opprette tomt alternativ)", + "option-label": "Etikett", + "hide-input-field": "Skjul inputfelt", + "datakey-type": "Datanøkkeltype", + "datakey-type-server": "Serverattributt (standard)", + "datakey-type-shared": "Delt attributt", + "datakey-type-timeseries": "Tidsserie", + "datakey-value-type": "Datanøkkelverditype", + "datakey-value-type-string": "Streng", + "datakey-value-type-double": "Desimaltall", + "datakey-value-type-integer": "Heltall", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "Boolsk (avkrysningsboks)", + "datakey-value-type-boolean-switch": "Boolsk (bryter)", + "datakey-value-type-date-time": "Dato og tid", + "datakey-value-type-date": "Dato", + "datakey-value-type-time": "Tid", + "datakey-value-type-select": "Velg", + "datakey-value-type-radio": "Radioknapp", + "datakey-value-type-color": "Farge", + "value-is-required": "Verdi er påkrevd", + "ability-to-edit-attribute": "Mulighet til å redigere attributt", + "ability-to-edit-attribute-editable": "Redigerbar (standard)", + "ability-to-edit-attribute-disabled": "Deaktivert", + "ability-to-edit-attribute-readonly": "Skrivebeskyttet", + "disable-on-datakey-name": "Deaktiver ved falsk verdi fra annen datanøkkel (angi navn på datanøkkel)", + "field-appearance": "Feltutseende", + "appearance-fill": "Fyll", + "appearance-outline": "Kontur", + "subscript-sizing": "Størrelse på underskrift", + "subscript-sizing-fixed": "Fast", + "subscript-sizing-dynamic": "Dynamisk", + "slide-toggle-settings": "Innstillinger for bryter", + "slide-toggle-label-position": "Plassering av etikett for bryter", + "slide-toggle-label-position-after": "Etter", + "slide-toggle-label-position-before": "Før", + "select-options": "Velg alternativer", + "no-select-options": "Ingen valgalternativer konfigurert", + "add-select-option": "Legg til valgalternativ", + "numeric-field-settings": "Innstillinger for numerisk felt", + "step-interval": "Stegintervall mellom verdier", + "error-messages": "Feilmeldinger", + "min-value-error-message": "Feilmelding for 'Min verdi'", + "max-value-error-message": "Feilmelding for 'Maks verdi'", + "invalid-date-error-message": "Feilmelding for 'Ugyldig dato'", + "invalid-JSON-error-message": "Feilmelding for 'Ugyldig JSON'", + "icon-settings": "Ikoninnstillinger", + "dialog-editor-settings": "Innstillinger for dialogredigering", + "use-custom-icon": "Bruk tilpasset ikon", + "input-cell-icon": "Ikon som vises før inputfelt", + "value-conversion-settings": "Innstillinger for verdikonvertering", + "get-value-settings": "Hent verdi-innstillinger", + "use-get-value-function": "Bruk getValue-funksjon", + "get-value-function": "getValue-funksjon", + "set-value-settings": "Angi verdi-innstillinger", + "use-set-value-function": "Bruk setValue-funksjon", + "set-value-function": "setValue-funksjon", + "json-invalid": "JSON-verdien har et ugyldig format", + "title": "Tittel", + "cancel-button-label": "Etikett for 'Avbryt'-knapp", + "radio-button-settings": "Innstillinger for radioknapper", + "color": "Farge", + "columns": "Kolonner", + "radio-options": "Radioknappealternativer", + "no-radio-options": "Ingen radioknappealternativer konfigurert", + "add-radio-option": "Legg til radioknappealternativ", + "radio-label-position": "Etikettplassering", + "radio-label-position-before": "Før", + "radio-label-position-after": "Etter" + }, + "invalid-qr-code-text": "Ugyldig inputtekst for QR-kode. Input må være av typen streng", + "qr-code": { + "use-qr-code-text-function": "Bruk QR-kodetekstfunksjon", + "qr-code-text-pattern": "QR-kodetekstmønster (for eksempel '${entityName} | ${keyName} - noe tekst.')", + "qr-code-text-pattern-hint": "QR-kodetekstmønster bruker verdien til den første funnet nøkkelen i enhetene i enhetsaliaset.", + "qr-code-text-pattern-required": "QR-kodetekstmønster er påkrevd.", + "qr-code-text-function": "QR-kodetekstfunksjon" + }, + "label-widget": { + "label-pattern": "Mønster", + "label-pattern-hint": "Hint: for eksempel 'Tekst ${keyName} enheter.' eller ${#<key index>} enheter", + "label-pattern-required": "Mønster er påkrevd", + "label-position": "Posisjon (prosent i forhold til bakgrunn)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Bakgrunnsfarge", + "font-settings": "Skriftinnstillinger", + "background-image": "Bakgrunnsbilde", + "labels": "Etiketter", + "no-labels": "Ingen etiketter konfigurert", + "add-label": "Legg til etikett" + }, + "navigation": { + "title": "Tittel", + "navigation-path": "Navigasjonssti", + "filter-type": "Filtertype", + "filter-type-all": "Alle elementer", + "filter-type-include": "Inkluder elementer", + "filter-type-exclude": "Ekskluder elementer", + "items": "Elementer", + "enter-urls-to-filter": "Skriv inn URL-er for å filtrere..." + }, + "persistent-table": { + "rpc-id": "RPC-ID", + "message-type": "Meldingstype", + "method": "Metode", + "params": "Parametere", + "created-time": "Opprettelsestid", + "expiration-time": "Utløpstid", + "retries": "Forsøk", + "status": "Status", + "filter": "Filter", + "refresh": "Oppdater", + "add": "Legg til RPC-forespørsel", + "details": "Detaljer", + "delete": "Slett", + "delete-request-title": "Slett vedvarende RPC-forespørsel", + "delete-request-text": "Er du sikker på at du vil slette forespørselen?", + "details-title": "Detaljer RPC-ID: ", + "additional-info": "Tilleggsinformasjon", + "response": "Respons", + "any-status": "Enhver status", + "rpc-status-list": "RPC-statusliste", + "no-request-prompt": "Ingen forespørsler å vise", + "send-request": "Send forespørsel", + "add-title": "Opprett vedvarende RPC-forespørsel", + "method-error": "Metode er påkrevd.", + "timeout-error": "Minimum tidsavbrudd er 5000 (5 sekunder).", + "white-space-error": "Mellomrom er ikke tillatt.", + "rpc-status": { + "QUEUED": "I KØ", + "SENT": "SENDT", + "DELIVERED": "LEVERT", + "SUCCESSFUL": "VELLYKKET", + "TIMEOUT": "TIDSAVBRUDD", + "EXPIRED": "UTLØPT", + "FAILED": "FEILET" + }, + "rpc-search-status-all": "ALLE", + "message-types": { + "false": "To-veis", + "true": "En-veis" + }, + "general-settings": "Generelle innstillinger", + "enable-filter": "Aktiver filter", + "enable-sticky-header": "Vis overskrift ved rulling", + "enable-sticky-action": "Vis handlingskolonne ved rulling", + "display-request-details": "Vis forespørselsdetaljer", + "allow-send-request": "Tillat å sende RPC-forespørsel", + "allow-delete-request": "Tillat sletting av forespørsel", + "columns-settings": "Kolonneinnstillinger", + "display-columns": "Kolonner som skal vises", + "column": "Kolonne", + "no-columns-found": "Ingen kolonner funnet", + "no-columns-matching": "'{{column}}' ble ikke funnet." + }, + "range-chart": { + "chart": "Diagram", + "data-zoom": "Datazoom", + "range-chart-appearance": "Utseende for områdediagram", + "range-colors": "Områdefarger", + "out-of-range-color": "Farge utenfor område", + "show-range-thresholds": "Vis områdegrenser", + "range-thresholds-settings": "Innstillinger for områdegrenser", + "fill-area": "Fyll område", + "fill-area-opacity": "Opasitet for fylt område", + "range-chart-style": "Stil for områdediagram" + }, + "rpc": { + "value-settings": "Verdiinnstillinger", + "initial-value": "Startverdi", + "retrieve-value-settings": "Innhent på/av-verdiinnstillinger", + "retrieve-value-method": "Hent verdi ved hjelp av metode", + "retrieve-value-method-none": "Ikke hent", + "retrieve-value-method-rpc": "Kall RPC-metode for å hente verdi", + "retrieve-value-method-attribute": "Abonner på attributt", + "retrieve-value-method-timeseries": "Abonner på tidsserier", + "attribute-value-key": "Attributtnøkkel", + "timeseries-value-key": "Tidsserienøkkel", + "get-value-method": "RPC get-verdimetode", + "parse-value-function": "Parse-verdi-funksjon", + "update-value-settings": "Oppdater verdiinnstillinger", + "set-value-method": "RPC set-verdimetode", + "convert-value-function": "Konverter verdi-funksjon", + "rpc-settings": "RPC-innstillinger", + "request-timeout": "Tidsavbrudd for RPC-forespørsel (ms)", + "persistent-rpc-settings": "Vedvarende RPC-innstillinger", + "request-persistent": "Vedvarende RPC-forespørsel", + "persistent-polling-interval": "Polling-intervall (ms) for å hente respons på vedvarende RPC-kommando", + "common-settings": "Vanlige innstillinger", + "switch-title": "Tittel for bryter", + "show-on-off-labels": "Vis på/av-etiketter", + "slide-toggle-label": "Skyvebryteretikett", + "label-position": "Etikettposisjon", + "label-position-before": "Før", + "label-position-after": "Etter", + "slider-color": "Farge på skyvebryter", + "slider-color-primary": "Primær", + "slider-color-accent": "Fremhevet", + "slider-color-warn": "Advarsel", + "button-style": "Knappestil", + "button-raised": "Hevet knapp", + "button-primary": "Primærfarge", + "button-background-color": "Bakgrunnsfarge på knapp", + "button-text-color": "Tekstfarge på knapp", + "widget-title": "Widget-tittel", + "button-label": "Knappetikett", + "device-attribute-scope": "Omfang for enhetsattributt", + "server-attribute": "Serverattributt", + "shared-attribute": "Delt attributt", + "device-attribute-parameters": "Enhetsattributtparametere", + "is-one-way-command": "Er enveis kommando", + "rpc-method": "RPC-metode", + "rpc-method-params": "RPC-metodeparametere", + "show-rpc-error": "Vis feil ved RPC-kommandoutførelse", + "led-title": "LED-tittel", + "led-color": "LED-farge", + "check-status-settings": "Statuskontrollinnstillinger", + "perform-rpc-status-check": "Utfør RPC-enhetsstatuskontroll", + "retrieve-led-status-value-method": "Hent LED-statusverdi ved hjelp av metode", + "led-status-value-attribute": "Enhetsattributt som inneholder LED-statusverdi", + "led-status-value-timeseries": "Enhetstidsserie som inneholder LED-statusverdi", + "check-status-method": "RPC-metode for statuskontroll", + "parse-led-status-value-function": "Parse LED-statusverdi-funksjon", + "knob-title": "Knappetittel", + "min-value": "Minimumsverdi", + "max-value": "Maksimumsverdi" + }, + "maps": { + "map-type": { + "type": "Karttype", + "map": "Kart", + "image": "Bilde" + }, + "image": { + "image-source": "Bildekilde", + "image-source-image": "Bilde", + "image-source-entity-key": "Enhetsnøkkel", + "source-entity-alias": "Alias for kildeenhet", + "image-url-key": "URL-nøkkel for bilde", + "image-url-key-required": "URL-nøkkel for bilde er påkrevd" + }, + "control": { + "map-controls": "Kartkontroller", + "position": "Posisjon", + "position-topleft": "Øverst til venstre", + "position-topright": "Øverst til høyre", + "position-bottomleft": "Nederst til venstre", + "position-bottomright": "Nederst til høyre", + "zoom-actions": "Zoom-handlinger", + "zoom-scroll": "Rull", + "zoom-double-click": "Dobbeltklikk", + "zoom-control-buttons": "Kontrollknapper", + "scale": "Skala", + "scale-metric": "Metrisk", + "scale-imperial": "Imperial", + "switch-to-drag-mode-using-button": "Bytt til dra-modus ved hjelp av knapp" + }, + "timeline": { + "control-panel": "Tidslinjekontrollpanel", + "time-step": "Tidsintervall", + "speed-options": "Hastighetsalternativer", + "timestamp": "Tidsstempel", + "snap-to-real-location": "Fest til reell posisjon", + "location-snap-filter-function": "Filterfunksjon for posisjonsjustering", + "no-trips-data-available": "Ingen turdata tilgjengelig" + }, + "map-action": { + "map-action-buttons": "Kart-handlingsknapper", + "label": "Etikett", + "icon": "Ikon", + "color": "Farge", + "action": "Handling", + "add-button": "Legg til knapp", + "no-action-buttons-configured": "Ingen handlingsknapper konfigurert", + "remove-action-button": "Fjern handlingsknapp", + "map-action-button": "Kart-handlingsknapp", + "button-requires": "Knappen krever etikett eller ikon" + }, + "common": { + "common-map-settings": "Vanlige kartinnstillinger", + "fit-map-bounds": "Tilpass kartgrenser for å dekke alle markører", + "default-map-center-position": "Standard kart midtposisjon", + "default-map-zoom-level": "Standard zoomnivå for kart", + "entities-limit": "Grense for antall enheter som skal lastes" + }, + "layer": { + "label": "Etikett", + "layer": "Lag", + "layers": "Lag", + "map-layers": "Kartlag", + "add-layer": "Legg til lag", + "layer-settings": "Laginnstillinger", + "remove-layer": "Fjern lag", + "no-layers": "Ingen lag konfigurert", + "roadmap": "Veikart", + "satellite": "Satellitt", + "hybrid": "Hybrid", + "reference": { + "reference-layer": "Referanselag", + "no-layer": "Ingen lag", + "openstreetmap-hybrid": "OpenStreetMap Hybrid", + "world-edition-hybrid": "Verdensutgave Hybrid", + "enhanced-contrast-hybrid": "Forbedret Kontrast Hybrid" + }, + "provider": { + "provider": "Leverandør", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "Veikart", + "satellite": "Satellitt", + "hybrid": "Hybrid", + "terrain": "Terreng" + }, + "here": { + "title": "HERE", + "normal-day": "Normal dag", + "normal-night": "Normal natt", + "hybrid-day": "Hybrid dag", + "terrain-day": "Terreng dag" + }, + "tencent": { + "title": "Tencent", + "normal": "Normal", + "satellite": "Satellitt", + "terrain": "Terreng" + }, + "custom": { + "title": "Egendefinert", + "tile-url": "Flis-URL" + } + }, + "credentials": { + "credentials": "Legitimasjon", + "api-key": "API-nøkkel" + } + }, + "overlays": { + "overlays": "Overlegg", + "overlays-hint": "Konfigurer datakilder, utseende, atferd, redigeringsalternativer og gruppering for kartenheter", + "trips": "Turer", + "markers": "Markører", + "polygons": "Polygoner", + "circles": "Sirkler" + }, + "data-layer": { + "source": "Kilde", + "filter": "Filter", + "additional-data-keys": "Ekstra datanøkler", + "additional-datasources": "Ekstra datakilder", + "additional-datasources-hint": "Datakilde for tilgang til attributter eller telemetri fra enheter som ikke vises på kartet, kan brukes i kartoverleggfunksjoner.", + "more-datasources": "Flere datakilder", + "data-keys": "Datanøkler", + "add-datasource": "Legg til datakilde", + "no-datasources": "Ingen datakilder konfigurert", + "remove-datasource": "Fjern datakilde", + "behavior": "Oppførsel", + "on-click": "Ved klikk", + "on-click-hint": "Handling som utføres når brukeren klikker på elementet i kartet.", + "groups": "Grupper", + "groups-hint": "Liste over gruppenavn som er tilknyttet overlegget, brukes til å styre synlighet på kartet.", + "color": "Farge", + "color-settings": "Fargeinnstillinger", + "color-type-constant": "Konstant", + "color-type-range": "Område", + "color-type-function": "Funksjon", + "color-range-source-key": "Kildenøkkel for fargeområde", + "color-range-source-key-required": "Kildenøkkel for fargeområde er påkrevd", + "color-range": "Fargeområde", + "color-function": "Fargefunksjon", + "label": "Etikett", + "tooltip": "Verktøytips", + "pattern-type-pattern": "Mønster", + "pattern-type-function": "Funksjon", + "label-pattern": "Etikett (mønstereksempler: '${entityName}', '${entityName}: (Tekst ${keyName} enheter.)')", + "label-function": "Etikettfunksjon", + "tooltip-pattern": "Verktøytips (f.eks. 'Tekst ${keyName} enheter.' eller Lenketekst)", + "tooltip-function": "Verktøytipsfunksjon", + "tooltip-trigger": "Utløser for verktøytips", + "tooltip-trigger-click": "Vis verktøytips ved klikk", + "tooltip-trigger-hover": "Vis verktøytips ved hover", + "auto-close-tooltips": "Lukk verktøytips automatisk", + "tooltip-offset": "Forskyvning av verktøytips", + "tooltip-offset-horizontal": "Horisontal", + "tooltip-offset-vertical": "Vertikal", + "tooltip-tag-actions": "Tagg-handlinger", + "add-tooltip-tag-action": "Legg til tagg-handling", + "edit-tooltip-tag-action": "Rediger tagg-handling", + "remove-tooltip-tag-action": "Fjern tagg-handling", + "action-add": "Legg til", + "action-edit": "Rediger", + "action-move": "Flytt", + "action-remove": "Fjern", + "edit-instruments": "Verktøy", + "persist-location-attribute-scope": "Omfang for attributt til lagring av posisjon", + "enable-snapping": "Aktiver snapping til andre hjørner for presis tegning", + "enable-snapping-hint": "Justerer automatisk nye punkter med eksisterende former for enklere og mer nøyaktig tegning.", + "drag-drop-mode": "Dra-og-slipp-modus", + "trip": { + "no-trips": "Ingen turer konfigurert", + "add-trip": "Legg til tur", + "trip-configuration": "Turkonfigurasjon", + "remove-trip": "Fjern tur" + }, + "marker": { + "marker": "Markør", + "latitude-key": "Latitude-nøkkel", + "longitude-key": "Longitude-nøkkel", + "x-pos-key": "X-posisjonsnøkkel", + "y-pos-key": "Y-posisjonsnøkkel", + "latitude-key-required": "Latitude-nøkkel er påkrevd", + "longitude-key-required": "Longitude-nøkkel er påkrevd", + "x-pos-key-required": "X-posisjonsnøkkel er påkrevd", + "y-pos-key-required": "Y-posisjonsnøkkel er påkrevd", + "no-markers": "Ingen markører konfigurert", + "add-marker": "Legg til markør", + "marker-configuration": "Markørkonfigurasjon", + "remove-marker": "Fjern markør", + "marker-type": "Markørtype", + "marker-type-shape": "Form", + "marker-type-icon": "Ikon", + "marker-type-image": "Bilde", + "shape": "Form", + "icon": "Ikon", + "image": "Bilde", + "marker-shapes": "Markørformer", + "marker-icon": "Markørikon", + "marker-appearance": "Markørutseende", + "marker-image": "Markørbilde", + "marker-image-type-image": "Bilde", + "marker-image-type-function": "Funksjon", + "custom-marker-image-size": "Egendefinert markørbildestørrelse", + "marker-image-function": "Markørbildets funksjon", + "marker-images": "Markørbilder", + "marker-offset": "Markørforskyvning", + "offset-horizontal": "Horisontal", + "offset-vertical": "Vertikal", + "rotate-marker": "Roter markør", + "offset-angle": "Vinkelavvik", + "position-conversion": "Posisjonskonvertering", + "position-conversion-function": "Posisjonskonverteringsfunksjon, skal returnere x,y-koordinater som desimaler fra 0 til 1", + "clustering": { + "use-map-markers-clustering": "Bruk markørklynger", + "zoom-on-cluster-click": "Zoom inn ved klikking på klynge", + "max-zoom": "Maksimal zoomnivå når en markør kan være en del av en klynge (0 - 18)", + "max-radius": "Maks radius som en klynge dekker", + "zoom-animation": "Animér markører ved zooming", + "bounds-on-cluster-mouse-over": "Vis markørgrenser ved musepeker over klynge", + "spiderfy-max-zoom-level": "Utvid til enkeltmarkører ved maks zoomnivå", + "load-optimization": "Lastoptimalisering", + "chunked-load": "Bruk blokker for å legge til markører for å unngå at siden fryser", + "lazy-load": "Bruk treg lasting for markører", + "use-cluster-marker-color-function": "Bruk fargefunksjon for klyngemarkører", + "marker-color-function": "Markørfargefunksjon" + }, + "edit": "Rediger markør", + "remove-marker-for": "Fjern markør for '{{entityName}}'", + "place-marker": "Plasser markør", + "place-marker-hint": "Klikk for å plassere markør", + "place-marker-hint-with-entity": "Klikk for å plassere '{{entityName}}'-enhet" + }, + "path": { + "path": "Sti", + "path-decorator": "Stidekorator", + "decorator-symbol": "Dekorsymbol", + "decorator-symbol-arrow-head": "Pil", + "decorator-symbol-dash": "Strek", + "decorator-arrangement": "Dekorarrangement", + "decorator-offset": "Start", + "decorator-end-offset": "Slutt", + "decorator-repeat": "Gjenta" + }, + "points": { + "points": "Punkter", + "point-tooltip": "Punkt-verktøytips" + }, + "shape": { + "fill": "Fyll", + "fill-type-color": "Farge", + "fill-type-stripe": "Striper", + "fill-type-image": "Bilde", + "color": "Farge", + "stripe": "Stripe", + "image": "Bilde", + "stroke": "Strek", + "fill-image": "Fyllbilde", + "fill-image-type-image": "Bilde", + "fill-image-type-function": "Funksjon", + "preserve-aspect-ratio": "Bevar høyde-bredde-forhold", + "opacity": "Opasitet", + "angle": "Rotasjonsvinkel", + "scale": "Skala", + "fill-image-function": "Formfyllbilde-funksjon", + "fill-images": "Formfyllbilder", + "stripe-pattern": "Stripe-mønster", + "first-stripe": "Første stripe", + "second-stripe": "Andre stripe" + }, + "polygon": { + "polygon-key": "Polygon-nøkkel", + "polygon-key-required": "Polygon-nøkkel er påkrevd", + "no-polygons": "Ingen polygoner konfigurert", + "add-polygon": "Legg til polygon", + "polygon-configuration": "Polygonkonfigurasjon", + "remove-polygon": "Fjern polygon", + "edit": "Rediger polygon", + "remove-polygon-for": "Fjern polygon for '{{entityName}}'", + "cut": "Klipp polygonområde", + "rotate": "Roter polygon", + "draw-rectangle": "Tegn rektangel", + "draw-polygon": "Tegn polygon", + "polygon-place-first-point-cut-hint": "Klikk for å plassere første punkt", + "continue-polygon-cut-hint": "Klikk for å fortsette tegningen", + "finish-polygon-cut-hint": "Klikk på første punkt for å avslutte og lagre", + "polygon-place-first-point-hint": "Polygon: klikk for å plassere første punkt", + "polygon-place-first-point-hint-with-entity": "Polygon for '{{entityName}}': klikk for å plassere første punkt", + "continue-polygon-hint": "Polygon: klikk for å fortsette tegningen", + "continue-polygon-hint-with-entity": "Polygon for '{{entityName}}': klikk for å fortsette tegningen", + "finish-polygon-hint": "Polygon: klikk første punkt for å avslutte tegning", + "finish-polygon-hint-with-entity": "Polygon for '{{entityName}}': klikk første punkt for å avslutte og lagre", + "rectangle-place-first-point-hint": "Rektangel: klikk for å plassere første punkt", + "rectangle-place-first-point-hint-with-entity": "Rektangel for '{{entityName}}': klikk for å plassere første punkt", + "finish-rectangle-hint": "Rektangel: klikk for å avslutte tegningen", + "finish-rectangle-hint-with-entity": "Rektangel for '{{entityName}}': klikk for å avslutte og lagre" + }, + "circle": { + "circle-key": "Sirkel-nøkkel", + "circle-key-required": "Sirkel-nøkkel er påkrevd", + "no-circles": "Ingen sirkler konfigurert", + "add-circle": "Legg til sirkel", + "circle-configuration": "Sirkelkonfigurasjon", + "remove-circle": "Fjern sirkel", + "edit": "Rediger sirkel", + "remove-circle-for": "Fjern sirkel for '{{entityName}}'", + "draw-circle": "Tegn sirkel", + "place-circle-center-hint-with-entity": "Sirkel for '{{entityName}}': klikk for å plassere sentrum", + "place-circle-center-hint": "Sirkel: klikk for å plassere sentrum", + "finish-circle-hint-with-entity": "Sirkel for '{{entityName}}': klikk for å avslutte og lagre sirkel", + "finish-circle-hint": "Sirkel: klikk for å avslutte tegning" + }, + "select-entity": "Velg enhet", + "select-entity-hint": "Tips: etter valg, klikk på kartet for å angi posisjon" + }, + "select-entity": "Velg enhet", + "select-entity-hint": "Tips: etter valg, klikk på kartet for å angi posisjon", + "tooltips": { + "placeMarker": "Klikk for å plassere '{{entityName}}' enhet", + "firstVertex": "Polygon for '{{entityName}}': klikk for å plassere første punkt", + "firstVertex-cut": "Klikk for å plassere første punkt", + "continueLine": "Polygon for '{{entityName}}': klikk for å fortsette tegningen", + "continueLine-cut": "Klikk for å fortsette tegningen", + "finishLine": "Klikk på eksisterende markør for å avslutte", + "finishPoly": "Polygon for '{{entityName}}': klikk på første punkt for å avslutte og lagre", + "finishPoly-cut": "Klikk på første punkt for å avslutte og lagre", + "finishRect": "Polygon for '{{entityName}}': klikk for å avslutte og lagre", + "startCircle": "Sirkel for '{{entityName}}': klikk for å plassere sentrum", + "finishCircle": "Sirkel for '{{entityName}}': klikk for å avslutte sirkelen", + "placeCircleMarker": "Klikk for å plassere sirkelmarkør" + }, + "actions": { + "finish": "Fullfør", + "cancel": "Avbryt", + "removeLastVertex": "Fjern siste punkt" + }, + "buttonTitles": { + "drawMarkerButton": "Plasser enhet", + "drawPolyButton": "Lag polygon", + "drawLineButton": "Lag linje", + "drawCircleButton": "Lag sirkel", + "drawRectButton": "Lag rektangel", + "editButton": "Redigeringsmodus", + "dragButton": "Dra og slipp-modus", + "cutButton": "Klipp polygonområde", + "deleteButton": "Fjern", + "drawCircleMarkerButton": "Lag sirkelmarkør", + "rotateButton": "Roter polygon" + }, + "map-provider-settings": "Kartleverandør-innstillinger", + "map-provider": "Kartleverandør", + "map-provider-google": "Google Maps", + "map-provider-openstreet": "OpenStreet Maps", + "map-provider-here": "HERE Maps", + "map-provider-image": "Bildekart", + "map-provider-tencent": "Tencent Maps", + "openstreet-provider": "OpenStreet kartleverandør", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Standard)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Bruk tilpasset leverandør", + "custom-provider-tile-url": "Tilpasset leverandørens flis-URL", + "google-maps-api-key": "Google Maps API-nøkkel", + "default-map-type": "Standard karttype", + "google-map-type-roadmap": "Veikart", + "google-map-type-satelite": "Satellitt", + "google-map-type-hybrid": "Hybrid", + "google-map-type-terrain": "Terreng", + "map-layer": "Kartlag", + "here-map-normal-day": "HERE.normalDay (Standard)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Legitimasjon", + "here-app-id": "HERE app ID", + "here-app-code": "HERE app-kode", + "here-api-key": "HERE API-nøkkel", + "here-use-new-version-api-3": "Bruk API versjon 3", + "tencent-maps-api-key": "Tencent Maps API-nøkkel", + "tencent-map-type-roadmap": "Veikart", + "tencent-map-type-satelite": "Satellitt", + "tencent-map-type-hybrid": "Hybrid", + "image-map-background": "Bakgrunnsbilde for kart", + "image-map-background-from-entity-attribute": "Hent kartbakgrunn fra enhetens attributt", + "image-url-source-entity-alias": "Bilde-URL kilde enhetsalias", + "image-url-source-entity-attribute": "Bilde-URL kilde enhetsattributt", + "common-map-settings": "Felles kartinnstillinger", + "x-pos-key-name": "X-posisjonsnøkkelnavn", + "y-pos-key-name": "Y-posisjonsnøkkelnavn", + "latitude-key-name": "Breddegrad-nøkkelnavn", + "longitude-key-name": "Lengdegrad-nøkkelnavn", + "default-map-zoom-level": "Standard kart-zoomnivå (0 - 20)", + "default-map-center-position": "Standard kart-senterposisjon (0,0)", + "disable-scroll-zooming": "Deaktiver zooming med rulling", + "disable-double-click-zooming": "Deaktiver zooming med dobbeltklikk", + "disable-zoom-control-buttons": "Deaktiver zoomkontrollknapper", + "fit-map-bounds": "Tilpass kartgrenser for å dekke alle markører", + "use-default-map-center-position": "Bruk standard kart-senterposisjon", + "entities-limit": "Grense for antall enheter som skal lastes", + "markers-settings": "Innstillinger for markører", + "marker-offset-x": "X-forskyvning for markør relativt til posisjon multiplisert med markørens bredde", + "marker-offset-y": "Y-forskyvning for markør relativt til posisjon multiplisert med markørens høyde", + "position-function": "Posisjonskonverteringsfunksjon, skal returnere x,y-koordinater som desimaltall fra 0 til 1 hver", + "draggable-marker": "Flyttbar markør", + "label": "Etikett", + "show-label": "Vis etikett", + "use-label-function": "Bruk etikettfunksjon", + "label-pattern": "Etikett (eksempel på mønster: '${entityName}', '${entityName}: (Tekst ${keyName} enheter.)')", + "label-function": "Etikettfunksjon", + "tooltip": "Verktøytips", + "show-tooltip": "Vis verktøytips", + "show-tooltip-action": "Handling for å vise verktøytips", + "show-tooltip-action-click": "Vis verktøytips ved klikk (Standard)", + "show-tooltip-action-hover": "Vis verktøytips ved hover", + "auto-close-tooltips": "Lukk verktøytips automatisk", + "use-tooltip-function": "Bruk verktøytipsfunksjon", + "tooltip-pattern": "Verktøytips (f.eks. 'Tekst ${keyName} enheter.' eller Lenketekst)", + "tooltip-function": "Verktøytipsfunksjon", + "tooltip-offset-x": "X-forskyvning for verktøytips relativt til markørens anker multiplisert med markørens bredde", + "tooltip-offset-y": "Y-forskyvning for verktøytips relativt til markørens anker multiplisert med markørens høyde", + "color": "Farge", + "use-color-function": "Bruk fargefunksjon", + "color-function": "Fargefunksjon", + "marker-image": "Markørbilde", + "use-marker-image-function": "Bruk markørbildefunksjon", + "custom-marker-image": "Egendefinert markørbilde", + "custom-marker-image-size": "Egendefinert markørbildestørrelse (px)", + "marker-image-function": "Markørbildefunksjon", + "marker-images": "Markørbilder", + "polygon-settings": "Innstillinger for polygon", + "show-polygon": "Vis polygon", + "polygon-key-name": "Polygon-nøkkelnavn", + "enable-polygon-edit": "Aktiver redigering av polygon", + "polygon-label": "Polygonetikett", + "show-polygon-label": "Vis polygonetikett", + "use-polygon-label-function": "Bruk polygonetikettfunksjon", + "polygon-label-pattern": "Polygonetikett (eksempel på mønster: '${entityName}', '${entityName}: (Tekst ${keyName} enheter.)')", + "polygon-label-function": "Polygonetikettfunksjon", + "polygon-tooltip": "Polygonverktøytips", + "show-polygon-tooltip": "Vis polygonverktøytips", + "auto-close-polygon-tooltips": "Lukk polygonverktøytips automatisk", + "use-polygon-tooltip-function": "Bruk polygonverktøytipsfunksjon", + "polygon-tooltip-pattern": "Verktøytips (f.eks. 'Tekst ${keyName} enheter.' eller Lenketekst)", + "polygon-tooltip-function": "Polygonverktøytipsfunksjon", + "polygon-color": "Polygonfarge", + "polygon-opacity": "Polygonopasitet", + "use-polygon-color-function": "Bruk polygonfargefunksjon", + "polygon-color-function": "Polygonfargefunksjon", + "polygon-stroke": "Polygonomriss", + "stroke-color": "Omrissfarge", + "stroke-opacity": "Omrissopasitet", + "stroke-weight": "Omrissvekt", + "use-polygon-stroke-color-function": "Bruk polygon omrissfargefunksjon", + "polygon-stroke-color-function": "Polygon omrissfargefunksjon", + "circle-settings": "Innstillinger for sirkel", + "show-circle": "Vis sirkel", + "circle-key-name": "Sirkel-nøkkelnavn", + "enable-circle-edit": "Aktiver redigering av sirkel", + "circle-label": "Sirkeletikett", + "show-circle-label": "Vis sirkeletikett", + "use-circle-label-function": "Bruk sirkeletikettfunksjon", + "circle-label-pattern": "Sirkeletikett (eksempel på mønster: '${entityName}', '${entityName}: (Tekst ${keyName} enheter.)')", + "circle-label-function": "Sirkeletikettfunksjon", + "circle-tooltip": "Sirkelverktøytips", + "show-circle-tooltip": "Vis sirkelverktøytips", + "auto-close-circle-tooltips": "Lukk sirkelverktøytips automatisk", + "use-circle-tooltip-function": "Bruk sirkelverktøytipsfunksjon", + "circle-tooltip-pattern": "Verktøytips (f.eks. 'Tekst ${keyName} enheter.' eller Lenketekst)", + "circle-tooltip-function": "Sirkelverktøytipsfunksjon", + "circle-fill-color": "Sirkel fyllfarge", + "circle-fill-color-opacity": "Sirkel fyllfarge opasitet", + "use-circle-fill-color-function": "Bruk sirkel fyllfargefunksjon", + "circle-fill-color-function": "Sirkel fyllfargefunksjon", + "circle-stroke": "Sirkelomriss", + "use-circle-stroke-color-function": "Bruk sirkel omrissfargefunksjon", + "circle-stroke-color-function": "Sirkel omrissfargefunksjon", + "markers-clustering-settings": "Innstillinger for markørklynging", + "use-map-markers-clustering": "Bruk kartmarkørklynging", + "zoom-on-cluster-click": "Zoom inn ved klikk på en klynge", + "max-cluster-zoom": "Maksimalt zoomnivå hvor en markør kan være del av en klynge (0 - 18)", + "max-cluster-radius-pixels": "Maksimal radius som en klynge dekker i piksler", + "cluster-zoom-animation": "Vis animasjon på markører ved zooming", + "show-markers-bounds-on-cluster-mouse-over": "Vis markørgrenser ved hover over en klynge", + "spiderfy-max-zoom-level": "Spred markører på maksimal zoomnivå (for å vise alle markører i en klynge)", + "load-optimization": "Optimalisering av lasting", + "cluster-chunked-loading": "Bruk blokker for å legge til markører slik at siden ikke fryser", + "cluster-markers-lazy-load": "Bruk treg lasting for å legge til markører", + "editor-settings": "Innstillinger for redigering", + "enable-snapping": "Aktiver snapping til andre punkter for presis tegning", + "init-draggable-mode": "Start kart i dra-modus", + "hide-all-edit-buttons": "Skjul alle redigeringsknapper", + "hide-draw-buttons": "Skjul tegneknapper", + "hide-edit-buttons": "Skjul redigeringsknapper", + "hide-remove-button": "Skjul fjern-knapp", + "route-map-settings": "Innstillinger for rutekart", + "trip-animation-settings": "Innstillinger for reiseanimasjon", + "normalization-step": "Normaliseringssteg for data (ms)", + "tooltip-background-color": "Bakgrunnsfarge for verktøytips", + "tooltip-font-color": "Skriftfarge for verktøytips", + "tooltip-opacity": "Opasitet for verktøytips (0-1)", + "auto-close-tooltip": "Lukk verktøytips automatisk", + "rotation-angle": "Sett ekstra rotasjonsvinkel for markør (grader)", + "path-settings": "Innstillinger for sti", + "path-color": "Stifarge", + "use-path-color-function": "Bruk stifargefunksjon", + "path-color-function": "Stifargefunksjon", + "path-decorator": "Stidekoratør", + "use-path-decorator": "Bruk stidekoratør", + "decorator-symbol": "Dekoratørsymbol", + "decorator-symbol-arrow-head": "Pil", + "decorator-symbol-dash": "Strek", + "decorator-symbol-size": "Størrelse på dekoratørsymbol (px)", + "use-path-decorator-custom-color": "Bruk egendefinert farge for stidekoratør", + "decorator-custom-color": "Egendefinert farge for dekoratør", + "decorator-offset": "Dekoratørforskyvning", + "end-decorator-offset": "Sluttforskyvning for dekoratør", + "decorator-repeat": "Gjenta dekoratør", + "points-settings": "Innstillinger for punkter", + "show-points": "Vis punkter", + "point-color": "Punktfarge", + "point-size": "Punktstørrelse (px)", + "use-point-color-function": "Bruk punktfargefunksjon", + "point-color-function": "Punktfargefunksjon", + "use-point-as-anchor": "Bruk punkt som anker", + "point-as-anchor-function": "Punkt som anker-funksjon", + "independent-point-tooltip": "Uavhengig punktverktøytips", + "clustering-markers": "Markørklynging", + "use-icon-create-function": "Bruk markørfargefunksjon", + "marker-color-function": "Markørfargefunksjon" + }, + "markdown": { + "use-markdown-text-function": "Bruk markdown/HTML-verdifunksjon", + "markdown-text-function": "Markdown/HTML-verdifunksjon", + "markdown-text-pattern": "Markdown/HTML-mønster (markdown eller HTML med variabler, for eksempel '${entityName} eller ${keyName} - noe tekst.')", + "apply-default-markdown-style": "Bruk standard markdown-stil", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label": "Etikett", + "label-position": "Etikettposisjon", + "label-position-left": "Venstre", + "label-position-top": "Topp" + }, + "single-switch": { + "behavior": "Oppførsel", + "layout": "Oppsett", + "layout-right": "Høyre", + "layout-left": "Venstre", + "layout-centered": "Sentrert", + "auto-scale": "Automatisk skalering", + "label": "Etikett", + "icon": "Ikon", + "switch-color": "Farge på bryter", + "on": "På", + "off": "Av", + "disabled": "Deaktivert", + "tumbler-color": "Farge på bryterknapp", + "on-label": "På-etikett", + "off-label": "Av-etikett", + "switch": "Bryter" + }, + "slider": { + "behavior": "Oppførsel", + "initial-value": "Startverdi", + "initial-value-hint": "Handling for å hente startverdi for glidebryteren.", + "on-value-change": "Ved verdiendring", + "on-value-change-hint": "Handling som utløses når glidebryterens verdi endres.", + "layout": "Oppsett", + "layout-default": "Standard", + "layout-extended": "Utvidet", + "layout-simplified": "Forenklet", + "auto-scale": "Automatisk skalering", + "icon": "Ikon", + "value": "Verdi", + "range": "Område", + "min": "min", + "max": "maks", + "range-ticks": "Områdemarkeringer", + "tick-marks": "Merkestreker", + "colors": "Farger", + "main": "Hoved", + "background": "Bakgrunn", + "left-icon": "Venstre ikon", + "right-icon": "Høyre ikon", + "slider": "Glidebryter" + }, + "value-card": { + "layout": "Oppsett", + "layout-square": "Kvadratisk", + "layout-vertical": "Vertikalt", + "layout-centered": "Sentrert", + "layout-simplified": "Forenklet", + "layout-horizontal": "Horisontalt", + "layout-horizontal-reversed": "Horisontalt omvendt", + "label": "Etikett", + "icon": "Ikon", + "value": "Verdi", + "date": "Dato", + "value-card-style": "Stil for verdikort", + "auto-scale": "Automatisk skalering" + }, + "label-card": { + "auto-scale": "Automatisk skalering", + "label": "Etikett", + "icon": "Ikon", + "label-card-style": "Stil for etikettkort" + }, + "label-value-card": { + "value": "Verdi", + "label-value-card-style": "Stil for etikett og verdi-kort" + }, + "liquid-level-card": { + "layout-simple": "Enkel", + "layout-percentage": "Prosent", + "layout-absolute": "Absolutt", + "layout": "Oppsett", + "background-overlay": "Bakgrunnsoverlegg for verdi", + "total-volume": "Totalvolum", + "total-volume-units": "Enheter for totalvolum", + "tank": "Tank", + "shape": "Form", + "datasource-units": "Kildeenheter", + "widget-units": "Widget-enheter", + "decimals": "Desimaler", + "liquid": "Væske", + "liquid-color": "Væskefarge", + "value": "Verdi", + "value-font": "Skrift for verdi", + "level": "Nivå", + "last-update": "Siste oppdatering", + "shape-by-attribute": "Angi tankform via attributtnavn", + "tooltip-background": "Bakgrunnsfarge", + "background-blur": "Bakgrunnsuskarphet", + "tank-color": "Tankfarge", + "static": "Statisk", + "see-examples": "Se eksempler", + "attribute": "Attributt", + "shape-type": "Type", + "v-oval": "Vertikal oval", + "v-cylinder": "Vertikal sylinder", + "v-capsule": "Vertikal kapsel", + "rectangle": "Rektangel", + "h-oval": "Horisontal oval", + "h-ellipse": "Horisontal ellipse", + "h-dish-ends": "Horisontale bueender", + "h-cylinder": "Horisontal sylinder", + "h-capsule": "Horisontal kapsel", + "h-elliptical_2_1": "Horisontal 2:1 elliptisk", + "icon": "Kortikon", + "title": "Korttittel", + "units": "Enheter", + "color-and-font": "Farge og skrift", + "shape-attribute-name": "Attributtnavn", + "total-volume-required": "Totalvolum er påkrevd.", + "attribute-name-required": "Attributtnavn er påkrevd.", + "attribute-key-not-set": "Attributtnøkkel for '{{attributeName}}' er ikke satt", + "attribute-key-invalid": "Attributtnøkkel for '{{attributeName}}' er ugyldig" + }, + "aggregated-value-card": { + "subtitle": "Undertittel", + "chart": "Diagram", + "values": "Verdier", + "value-appearance": "Utseende for verdi", + "position": "Posisjon", + "position-center": "Senter", + "position-right-top": "Høyre øverst", + "position-right-bottom": "Høyre nederst", + "position-left-top": "Venstre øverst", + "position-left-bottom": "Venstre nederst", + "font": "Skrift", + "color": "Farge", + "arrow": "Pil", + "display-up-down-arrow": "Vis opp-/ned-pil", + "add-value": "Legg til verdi", + "remove-value": "Fjern verdi", + "no-values": "Ingen verdier konfigurert", + "aggregation": "Aggregering", + "aggregated-value-card-style": "Stil for aggregert verdikort", + "auto-scale": "Automatisk skalering" + }, + "value-chart-card": { + "layout": "Oppsett", + "layout-left": "Venstre", + "layout-right": "Høyre", + "auto-scale": "Automatisk skalering", + "icon": "Ikon", + "value": "Verdi", + "chart": "Diagram", + "value-chart-card-style": "Stil for verdidiagramkort" + }, + "progress-bar": { + "layout": "Oppsett", + "layout-default": "Standard", + "layout-simplified": "Forenklet", + "auto-scale": "Automatisk skalering", + "icon": "Ikon", + "value": "Verdi", + "range": "Område", + "min": "min", + "max": "maks", + "range-ticks": "Område-tikk", + "bar": "Stolpe", + "bar-color": "Stolpefarge", + "bar-background": "Stolpebakgrunn", + "progress-bar-card-style": "Stil for fremdriftsindikator" + }, + "notification": { + "max-notification-display": "Maksimalt antall varsler som vises", + "counter": "Teller", + "counter-hint": "Teller vises hvis «Widget-tittel» er aktivert", + "icon": "Ikon", + "counter-value": "Verdi", + "counter-color": "Farge", + "notification-button": "Varslingsknapper", + "button-view-all": "Vis alle", + "button-filter": "Filtrer", + "type-filter": "Typefilter", + "button-mark-read": "Merk alle som lest", + "notification-types": "Varslingstyper", + "notification-type": "Varslingstype", + "search-type": "Søk etter type", + "any-type": "Alle typer" + }, + "alarm-count": { + "alarm-count-card-style": "Stil for alarmantallkort" + }, + "entity-count": { + "entity-count-card-style": "Stil for enhetsantallkort" + }, + "count": { + "layout": "Oppsett", + "layout-column": "Kolonne", + "layout-row": "Rad", + "label": "Etikett", + "icon": "Ikon", + "icon-background": "Ikonbakgrunn", + "value": "Verdi", + "chevron": "Chevron", + "auto-scale": "Automatisk skalering" + }, + "table": { + "common-table-settings": "Vanlige tabellinnstillinger", + "enable-search": "Aktiver søk", + "enable-sticky-header": "Vis alltid topptekst", + "enable-sticky-action": "Vis alltid handlingskolonne", + "hidden-cell-button-display-mode": "Visningsmodus for skjulte celleknapper", + "show-empty-space-hidden-action": "Vis tomt område i stedet for skjult cellehandling", + "dont-reserve-space-hidden-action": "Ikke reserver plass til skjulte handlingsknapper", + "display-timestamp": "Tidsstempel", + "display-pagination": "Vis paginering", + "default-page-size": "Standard sidestørrelse", + "page-step-settings": "Sideinnstillinger", + "page-step-count": "Antall trinn", + "page-step-increment": "Trinnøkning", + "page-step-count-format-message": "Må være et heltall mellom 1 og 100.", + "page-step-increment-format-message": "Må være et heltall større enn eller lik 1.", + "use-entity-label-tab-name": "Bruk enhetens etikett i fanetittelen", + "hide-empty-lines": "Skjul tomme linjer", + "row-style": "Radstil", + "use-row-style-function": "Bruk radstilfunksjon", + "row-style-function": "Radstilfunksjon", + "cell-style": "Cellestil", + "use-cell-style-function": "Bruk cellestilfunksjon", + "cell-style-function": "Cellestilfunksjon", + "cell-content": "Celleinnhold", + "use-cell-content-function": "Bruk celleinnholdsfunksjon", + "cell-content-function": "Celleinnholdsfunksjon", + "show-latest-data-column": "Vis siste data-kolonne", + "latest-data-column-order": "Rekkefølge på siste data-kolonne", + "entities-table-title": "Tittel for enhetstabell", + "enable-select-column-display": "Aktiver valg av kolonner som skal vises", + "display-entity-name": "Vis kolonne for enhetsnavn", + "entity-name-column-title": "Tittel på enhetsnavn-kolonne", + "display-entity-label": "Vis kolonne for enhetsetikett", + "entity-label-column-title": "Tittel på enhetsetikett-kolonne", + "display-entity-type": "Vis kolonne for enhetstype", + "default-sort-order": "Standard sorteringsrekkefølge", + "custom-title": "Egendefinert overskrift", + "column-width": "Kolonnebredde (px eller %)", + "default-column-visibility": "Standard kolonne-synlighet", + "column-visibility-visible": "Synlig", + "column-visibility-hidden": "Skjult", + "column-visibility-hidden-mobile": "Skjult i mobilmodus", + "column-selection-to-display": "Kolonnevalg i «Kolonner som skal vises»", + "column-selection-to-display-enabled": "Aktivert", + "column-selection-to-display-disabled": "Deaktivert", + "alarms-table-title": "Tittel for alarmtabell", + "enable-alarms-selection": "Aktiver valg av alarmer", + "enable-alarms-search": "Aktiver alarmsøk", + "enable-alarm-filter": "Aktiver alarmfilter", + "display-alarm-details": "Vis alarmdetaljer", + "allow-alarms-ack": "Tillat bekreftelse av alarmer", + "allow-alarms-clear": "Tillat fjerning av alarmer", + "display-alarm-activity": "Vis alarmaktivitet", + "allow-alarms-assign": "Tillat tildeling av alarmer", + "columns": "Kolonner", + "column-settings": "Kolonneinnstillinger", + "remove-column": "Fjern kolonne", + "add-column": "Legg til kolonne", + "no-columns": "Ingen kolonner konfigurert", + "columns-to-display": "Kolonner som skal vises", + "table-header": "Tabelloverskrift", + "header-buttons": "Overskriftsknapper", + "table-buttons": "Tabellknapper", + "pagination": "Paginering", + "rows": "Rader", + "timeseries-column-error": "Minst én tidsseriekolonne må angis", + "alarm-column-error": "Minst én alarmkolonne må angis", + "table-tabs": "Tabellfaner", + "show-cell-actions-menu-mobile": "Vis rullegardinmeny for cellehandlinger i mobilmodus", + "disable-sorting": "Deaktiver sortering" + }, + "latest-chart": { + "total": "Totalt", + "auto-scale": "Auto-skala", + "clockwise-layout": "Med klokken", + "sort-series": "Sorter serier etter etikett", + "tooltip-value-type-absolute": "Absolutt", + "tooltip-value-type-percentage": "Prosent" + }, + "pie-chart": { + "pie-chart-appearance": "Kakediagramutseende", + "label": "Etikett", + "border": "Kantlinje", + "radius": "Radius", + "pie-chart-card-style": "Kakediagramkortstil" + }, + "radar-chart": { + "radar-appearance": "Radarutseende", + "shape": "Form", + "shape-polygon": "Polygon", + "shape-circle": "Sirkel", + "color": "Farge", + "line": "Linje", + "points": "Punkter", + "points-label": "Punktetikett", + "radar-axis": "Radarakse", + "axis-label": "Aksesetikett", + "ticks-label": "Tikkeetikett", + "radar-chart-style": "Radar-diagramstil" + }, + "time-series-chart": { + "chart": "Diagram", + "chart-style": "Diagramstil", + "data-zoom": "Datainnzooming", + "stack-mode": "Stablemodus", + "stack-mode-hint": "Stabler serier i diagrammet. Serier med samme enhet blir stablet oppå hverandre.", + "axes": "Akser", + "y-axes": "Y-akser", + "line-type": "Linjetype", + "line-width": "Linjebredde", + "type-line": "Linje", + "type-bar": "Søyle", + "type-point": "Punkt", + "no-aggregation-bar-width-strategy": "Strategi for søylebredde for ikke-aggregert data", + "no-aggregation-bar-width-strategy-group": "Grupper", + "no-aggregation-bar-width-strategy-separate": "Separate", + "bar-group-width": "Gruppebredde for søyler", + "bar-width": "Søylebredde", + "bar-width-relative": "Prosentandel av tidsvinduet", + "bar-width-absolute": "Absolutt (ms)", + "comparison": { + "comparison": "Sammenligning", + "comparison-hint": "Sammenligning fungerer kun med historiske data!", + "show": "Vis", + "settings": "Innstillinger for sammenligning", + "show-values-for-comparison": "Vis historiske data for sammenligning", + "comparison-values-label": "Etikett for sammenligningsnøkkel", + "comparison-values-label-auto": "Auto", + "comparison-data-color": "Farge for sammenligningsdata" + }, + "threshold": { + "thresholds": "Terskler", + "source": "Kilde", + "key-value": "Nøkkel / Verdi", + "no-thresholds": "Ingen terskler konfigurert", + "add-threshold": "Legg til terskel", + "type-constant": "Konstant", + "type-latest-key": "Nøkkel", + "type-entity": "Enhet", + "threshold-settings": "Terskelinnstillinger", + "remove-threshold": "Fjern terskel", + "threshold-value-required": "Terskelverdi er påkrevd.", + "key-required": "Nøkkel er påkrevd.", + "entity-key-required": "Enhetsnøkkel er påkrevd.", + "line-appearance": "Linjeutseende", + "line-color": "Linjefarge", + "start-symbol": "Startsymbol", + "end-symbol": "Sluttsymbol", + "symbol-size": "Størrelse", + "label": "Etikett", + "label-position-start": "Start", + "label-position-middle": "Midt", + "label-position-end": "Slutt", + "label-position-inside-start": "Innsiden start", + "label-position-inside-start-top": "Innsiden start topp", + "label-position-inside-start-bottom": "Innsiden start bunn", + "label-position-inside-middle": "Innsiden midt", + "label-position-inside-middle-top": "Innsiden midt topp", + "label-position-inside-middle-bottom": "Innsiden midt bunn", + "label-position-inside-end": "Innsiden slutt", + "label-position-inside-end-top": "Innsiden slutt topp", + "label-position-inside-end-bottom": "Innsiden slutt bunn", + "label-background": "Bakgrunn for etikett" + }, + "state": { + "states": "Tilstander", + "label": "Etikett", + "ticks-value": "Tikkverdier", + "source": "Kilde", + "value-range": "Verdi / Område", + "no-states": "Ingen tilstander konfigurert", + "add-state": "Legg til tilstand", + "type-constant": "Konstant", + "type-range": "Område", + "from": "Fra", + "to": "Til", + "remove-state": "Fjern tilstand" + }, + "grid": { + "grid": "Rutenett", + "background-color": "Bakgrunnsfarge", + "border": "Kantlinje" + }, + "axis": { + "axes": "Akser", + "x-axis": "X-akse", + "y-axis": "Y-akse", + "y-axis-settings": "Y-akseinnstillinger", + "comparison-x-axis-settings": "Sammenligningsinnstillinger for X-akse", + "remove-y-axis": "Fjern Y-akse", + "id": "ID", + "label": "Etikett", + "position": "Posisjon", + "position-left": "Venstre", + "position-right": "Høyre", + "position-top": "Topp", + "position-bottom": "Bunn", + "tick-labels": "Tikketiketter", + "ticks-formatter-function": "Formatfunksjon for tikker", + "ticks-generator-function": "Generatorfunksjon for tikker", + "show-ticks": "Vis tikker", + "show-line": "Vis linje", + "show-split-lines": "Vis delingslinjer", + "show-split-lines-x-axis-hint": "Hvis aktivert, vises vertikale linjer i diagrammet.", + "show-split-lines-y-axis-hint": "Hvis aktivert, vises horisontale linjer i diagrammet.", + "ticks-interval": "Tikkerintervall", + "ticks-interval-hint": "Tving angitt segmenteringsintervall for aksen.", + "split-number": "Antall inndelinger", + "split-number-hint": "Antall segmenter aksen deles i.", + "min": "Min", + "max": "Maks", + "show": "Vis", + "add-y-axis": "Legg til Y-akse" + }, + "series": { + "legend-settings": "Innstillinger for tegnforklaring", + "show-in-legend": "Vis i tegnforklaring", + "show-in-legend-hint": "Vis serienavn og data i tegnforklaringen.", + "hidden-by-default": "Skjult som standard", + "hidden-by-default-hint": "Gjør serien skjult i tegnforklaringen som standard.", + "series-type": "Serietype", + "type": "Type", + "type-line": "Linje", + "type-bar": "Søyle", + "line": { + "line": "Linje", + "show-line": "Vis linje", + "step-line": "Trappetrinn-linje", + "step-type-start": "Start", + "step-type-middle": "Midt", + "step-type-end": "Slutt", + "smooth-line": "Jevn linje" + }, + "point": { + "points": "Punkter", + "show-points": "Vis punkter", + "point-label": "Punktetikett", + "point-label-hint": "Vis etikett med verdi over punktet i serien.", + "point-label-background": "Bakgrunn for punktetikett", + "point-shape": "Punktform", + "point-size": "Punktstørrelse" + } + } + }, + "wind-speed-direction": { + "layout": "Oppsett", + "layout-default": "Standard", + "layout-advanced": "Avansert", + "layout-simplified": "Forenklet", + "values": "Verdier", + "wind-direction": "Vindretning", + "center-value": "Senterverdi", + "icon": "Ikon", + "arrow": "Pil", + "ticks": "Tikkmerker", + "labels-type": "Etikettype", + "directional-names": "Retningsnavn", + "degrees": "Grader", + "major-ticks": "Hovedtikkmerker", + "minor-ticks": "Småtikkmerker", + "wind-speed-direction-card-style": "Stil for kort for vindhastighet og retning", + "ticks-color": "Farge på tikkmerker", + "ticks-labels-type": "Tikkmerkeetikettstype", + "arrow-color": "Pilfarge" + }, + "value-source": { + "value-source": "Verdikilde", + "predefined-value": "Konstant", + "entity-attribute": "Enhetsattributt", + "value": "Verdi", + "value-required": "Verdi er påkrevd.", + "key-required": "Nøkkel er påkrevd.", + "entity-key-required": "Enhetsnøkkel er påkrevd.", + "source-entity-alias": "Alias for kildeenhet", + "source-entity-attribute": "Attributt for kildeenhet", + "type-constant": "Konstant", + "type-latest-key": "Nøkkel", + "type-entity": "Enhet" + }, + "rpc-state": { + "initial-state": "Initial tilstand", + "initial-state-hint": "Handling for å hente komponentens starttilstand (På/Av).", + "disabled-state": "Deaktivert tilstand", + "disabled-state-hint": "Konfigurer betingelse som deaktiverer komponenten.", + "turn-on": "Slå 'På'", + "turn-on-hint": "Handling utløst når bryteren slås til 'På'", + "turn-off": "Slå 'Av'", + "turn-off-hint": "Handling utløst når bryteren slås til 'Av'", + "on": "På", + "off": "Av", + "disabled": "Deaktivert" + }, + "value-action": { + "do-nothing": "Ikke gjør noe", + "execute-rpc": "Utfør RPC", + "get-attribute": "Hent attributt", + "set-attribute": "Angi attributt", + "get-time-series": "Hent tidsserie", + "get-alarm-status": "Hent alarmstatus", + "get-dashboard-state": "Hent dashboard state ID", + "get-dashboard-state-object": "Hent dashboard state-objekt", + "add-time-series": "Legg til tidsserie", + "execute-rpc-text": "Utfør RPC-metode '{{methodName}}'", + "get-time-series-text": "Bruk tidsserie '{{key}}'", + "get-attribute-text": "Bruk attributt '{{key}}'", + "get-alarm-status-text": "Bruk alarmstatus", + "get-dashboard-state-text": "Bruk dashboard state", + "get-dashboard-state-object-text": "Bruk dashboard state-objekt", + "when-dashboard-state-is-text": "Når dashboard state ID er '{{state}}'", + "when-dashboard-state-function-is-text": "Når f(dashboard state ID) er '{{state}}'", + "when-dashboard-state-object-function-is-text": "Når f(dashboard state-objekt) er '{{state}}'", + "set-attribute-to-value-text": "Angi attributt '{{key}}' til: {{value}}", + "add-time-series-value-text": "Legg til tidsserieverdi '{{key}}': {{value}}", + "set-attribute-text": "Angi attributt '{{key}}'", + "add-time-series-text": "Legg til tidsserie '{{key}}'", + "action": "Handling", + "value": "Verdi", + "init-value-hint": "Verdi som angis frem til enheten sender data.", + "method": "Metode", + "method-name-required": "Metodenavn er påkrevd.", + "request-timeout-ms": "Timeout for RPC-forespørsel (ms)", + "request-timeout-required": "Timeout for forespørsel er påkrevd.", + "min-request-timeout-error": "Timeout må være større enn eller lik 5000 ms (5 sekunder).", + "request-persistent": "Vedvarende RPC-forespørsel", + "persistent-polling-interval": "Pollingintervall for vedvarende RPC (ms)", + "persistent-polling-interval-hint": "Pollingintervall (ms) for å hente respons fra vedvarende RPC-kommando", + "persistent-polling-interval-required": "Pollingintervall er påkrevd.", + "min-persistent-polling-interval-error": "Pollingintervall må være større enn eller lik 1000 ms (1 sekund).", + "attribute-scope": "Attributtomfang", + "attribute-key": "Attributtnøkkel", + "attribute-key-required": "Attributtnøkkel er påkrevd.", + "time-series-key": "Tidsserienøkkel", + "time-series-key-required": "Tidsserienøkkel er påkrevd.", + "action-result-converter": "Omformer for handlingsresultat", + "converter-none": "Ingen", + "converter-function": "Funksjon", + "converter-constant": "Konstant", + "converter-value": "Verdi", + "parse-value-function": "Funksjon for å tolke verdi", + "state-when-result-is": "'{{state}}' når resultatet er", + "parameters": "Parametere", + "convert-value-function": "Funksjon for å konvertere verdi", + "error": { + "target-entity-is-not-set": "Målenhet er ikke angitt!", + "failed-to-perform-action": "Kunne ikke utføre {{ actionLabel }}-handlingen.", + "invalid-attribute-scope": "Omfanget '{{scope}}' støttes ikke av enhetstypen {{entityType}}." + } + }, + "widget-font": { + "font-settings": "Skriftinnstillinger", + "font-family": "Skriftfamilie", + "size": "Størrelse", + "relative-font-size": "Relativ skriftstørrelse (prosent)", + "font-style": "Stil", + "font-style-normal": "Normal", + "font-style-italic": "Kursiv", + "font-style-oblique": "Skrå", + "font-weight": "Tykkelse", + "font-weight-normal": "Normal", + "font-weight-bold": "Fet", + "font-weight-bolder": "Fetere", + "font-weight-lighter": "Tynnere", + "color": "Farge", + "shadow-color": "Skyggefarge", + "preview": "Forhåndsvisning", + "line-height": "Linjehøyde", + "auto": "Auto" + }, + "home": { + "no-data-available": "Ingen data tilgjengelig" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "Disk", + "cpu-warning-text": "Høyt CPU-forbruk. For å unngå systemfeil, optimaliser systemytelsen.", + "cpu-critical-text": "Kritisk høyt CPU-forbruk. For å unngå systemfeil, optimaliser systemytelsen.", + "ram-warning-text": "Lite tilgjengelig RAM. For å unngå systemfeil, optimaliser ytelsen eller øk RAM-størrelsen.", + "ram-critical-text": "Kritisk lite tilgjengelig RAM. For å unngå systemfeil, optimaliser ytelsen eller øk RAM-størrelsen.", + "disk-warning-text": "Lite tilgjengelig diskplass. For å unngå datatap, frigjør eller utvid diskplassen.", + "disk-critical-text": "Kritisk lite diskplass. For å unngå datatap, frigjør eller utvid diskplassen." + }, + "cluster-info": { + "service-id": "Tjeneste-ID", + "service-type": "Tjenestetype", + "no-data": "Ingen data" + }, + "transport-messages": { + "title": "Transportmeldinger", + "info": "Alle meldinger mottatt fra enheter" + }, + "activity": { + "title": "Aktivitet" + }, + "documentation": { + "title": "Dokumentasjon", + "add-link": "Legg til lenke", + "add-link-title": "Legg til dokumentasjonslenke", + "name": "Navn", + "name-required": "Navn er påkrevd.", + "link": "Lenke", + "link-required": "Lenke er påkrevd.", + "columns": "Kolonner" + }, + "quick-links": { + "title": "Hurtiglenker", + "add-link": "Legg til lenke", + "add-link-title": "Legg til hurtiglenke", + "quick-link": "Hurtiglenke", + "quick-link-required": "Hurtiglenke er påkrevd.", + "no-links-matching": "Ingen lenker samsvarer med '{{name}}'.", + "columns": "Kolonner" + }, + "recent-dashboards": { + "title": "Dashbord", + "last": "Sist vist", + "starred": "Favorittmerket", + "name": "Navn", + "last-viewed": "Sist vist", + "no-last-viewed-dashboards": "Ingen nylig viste dashbord" + }, + "configured-features": { + "title": "Konfigurerte funksjoner", + "info": "Status for funksjoner som krever konfigurasjon", + "email-feature": "E-post", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2FA", + "feature-configured": "Funksjon er konfigurert.\nKlikk for å sette opp", + "feature-not-configured": "Funksjon er ikke konfigurert.\nKlikk for å sette opp" + }, + "version-info": { + "title": "Versjon", + "contact-us": "Kontakt oss", + "current-version": "Gjeldende versjon", + "current": "Nåværende", + "available-version": "Tilgjengelig versjon", + "available": "Tilgjengelig", + "upgrade": "Oppgrader", + "version-is-up-to-date": "Versjonen er oppdatert" + }, + "usage-info": { + "title": "Bruk", + "entities": "Enheter", + "api-calls": "API-kall" + }, + "functions": { + "title": "Funksjoner", + "pe-feature-tooltip": "Kun i ThingsBoard\nProfessional Edition", + "switch-to-pe": "Bytt til PE", + "alarms": "Alarmer", + "dashboards": "Dashbord", + "entities-and-relations": "Enheter og relasjoner", + "profiles": "Profiler", + "advanced-features": "Avanserte funksjoner", + "notification-center": "Varslingssenter", + "api-usage": "API-bruk", + "customers": "Kunder", + "customers-hierarchy": "Kundehierarki", + "roles-and-permissions": "Roller og tillatelser", + "groups": "Grupper", + "integrations": "Integrasjoner", + "solution-templates": "Løsningsmaler", + "scheduler": "Planlegger", + "white-labeling": "White-labeling" + }, + "devices": { + "view-docs": "Vis dokumentasjon", + "inactive": "Inaktiv", + "active": "Aktiv", + "total": "Totalt" + }, + "alarms": { + "critical": "Kritisk", + "assigned-to-me": "Tildelt meg", + "total": "Totalt" + }, + "getting-started": { + "get-started": "Kom i gang", + "finish": "Fullfør", + "done-welcome-title": "Velkommen om bord", + "done-welcome-text": "Du klarte det flott!", + "sys-admin": { + "step1": { + "title": "Opprett leietaker og leietakeradministrator", + "content": "

    En leietaker er en person eller organisasjon som eier eller produserer enheter og eiendeler. Leietakeren kan ha flere leietakeradministratorer, kunder, enheter og eiendeler.

    Leietakeradministratoren kan opprette og administrere enheter, eiendeler, kunder og dashbord i leietakeren sin konto.

    Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-create-tenant": "Hvordan opprette leietaker og leietakeradministrator" + }, + "step2": { + "title": "Konfigurer funksjon: E-postserver", + "content": "

    Konfigurasjon av e-postserver er viktig for aktivering av brukere, gjenoppretting av passord og levering av alarmvarsler.

    Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-configure-mail-server": "Hvordan konfigurere e-postserver" + }, + "step3": { + "title": "Konfigurer funksjon: SMS-leverandør", + "content": "

    Konfigurer SMS-leverandører for å varsle kunder om alarmer via SMS.

    Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-configure-sms-provider": "Hvordan konfigurere SMS-leverandør" + }, + "step4": { + "title": "Konfigurer funksjon: White-labeling", + "content": "

    Tilpass logo og fargepalett for ditt firma eller produkt uten koding og uten å måtte restarte tjenesten.

    Følg dokumentasjonen for hvordan du gjør det:

    " + }, + "step5": { + "title": "Konfigurer funksjon: 2FA", + "content": "

    Forbedre sikkerheten på plattformens kontoer med tofaktorautentisering.

    Følg dokumentasjonen for hvordan du gjør det:

    " + }, + "step6": { + "title": "Konfigurer funksjon: OAuth 2", + "content": "

    Forenkle innloggingen for leietakere og kundebrukere med Single Sign-On via OAuth 2.0.

    Følg dokumentasjonen for hvordan du gjør det:

    " + } + }, + "tenant-admin": { + "step1": { + "title": "Opprett enhet", + "content": "

    La oss provisjonere din første enhet til plattformen via brukergrensesnittet. Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-create-device": "Hvordan opprette enhet" + }, + "step2": { + "title": "Koble til enhet", + "content-before": "

    For å koble til enheten må du hente legitimasjon. Vi anbefaler å bruke automatisk genererte legitimasjonsdata – tilgangstoken – i denne veiledningen.

    • Gå til enhetstabellen
    • Klikk på enheten for å åpne detaljene
    • Trykk på knappen «Kopier tilgangstoken»

    Bruk enkle kommandoer for å publisere data via HTTP. Husk å erstatte $ACCESS_TOKEN med tilgangstokenet for enheten:

    ", + "ubuntu": { + "install-curl": "Installer cURL for Ubuntu:" + }, + "macos": { + "install-curl": "Installer cURL for MacOS:" + }, + "windows": { + "install-curl": "Fra og med Windows 10 b17063 er cURL tilgjengelig som standard." + }, + "replace-access-token": "Erstatt $ACCESS_TOKEN med din enhets token:", + "content-after": "

    Du kan også bruke andre protokoller som MQTT, CoAP osv.

    Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-connect-device": "Hvordan koble til enhet" + }, + "step3": { + "title": "Opprett dashbord", + "content": "

    Opprett et dashbord for å visualisere data fra enheter, eiendeler osv.

    Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-create-dashboard": "Hvordan opprette dashbord" + }, + "step4": { + "title": "Konfigurer alarmregler", + "alarm-rules": "Alarmregler", + "content": "

    La oss utløse en alarm når temperaturen når 25°C. Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-configure-alarm-rules": "Hvordan konfigurere alarmregler" + }, + "step5": { + "title": "Opprett alarm", + "content-before": "

    For å utløse alarmen, send en ny telemetrverdi på 26°C eller høyere.

    ", + "replace-access-token": "Erstatt $ACCESS_TOKEN med din enhets token:", + "content-after": "

    Følg dokumentasjonen for hvordan du gjør det:

    ", + "how-to-create-alarm": "Hvordan opprette alarm" + }, + "step6": { + "title": "Opprett kunde og del dashbord", + "content": "

    Ved å opprette dashbord for sluttbruker, kan kundebrukeren bare se sine egne enheter og data. Data fra andre kunder vil være skjult.

    Følg dokumentasjonen for hvordan du gjør det:

    " + } + } + } + }, + "icon": { + "icon": "Ikon", + "icons": "Ikoner", + "select-icon": "Velg ikon", + "material-icons": "Materialikoner", + "show-all": "Vis alle ikoner", + "search-icon": "Søk ikon", + "no-icons-found": "Ingen ikoner funnet for '{{iconSearch}}'" + }, + "phone-input": { + "phone-input-label": "Telefonnummer", + "phone-input-required": "Telefonnummer er påkrevd", + "phone-input-validation": "Telefonnummeret er ugyldig eller ikke mulig", + "phone-input-pattern": "Ugyldig telefonnummer. Skal være i E.164-format, f.eks. {{phoneNumber}}", + "phone-input-hint": "Telefonnummer i E.164-format, f.eks. {{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "Handlingscelleknapp", + "row-click": "Ved radklikk", + "cell-click": "Ved celleklikk", + "polygon-click": "Ved polygonklikk", + "marker-click": "Ved markørklikk", + "circle-click": "Ved sirkelklikk", + "tooltip-tag-action": "Verktøytips tag-handling", + "node-selected": "Ved valg av node", + "element-click": "Ved HTML-elementklikk", + "pie-slice-click": "Ved sektorklikk", + "row-double-click": "Ved dobbeltklikk på rad", + "cell-double-click": "Ved dobbeltklikk på celle", + "card-click": "Ved kortklikk", + "click": "Ved klikk" + } + }, + "paginator": { + "items-per-page": "Elementer per side:", + "first-page-label": "Første side", + "last-page-label": "Siste side", + "next-page-label": "Neste side", + "previous-page-label": "Forrige side", + "items-per-page-separator": "av" + }, + "language": { + "language": "Språk" + } +} 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 80b65b83f9..6dd6ca467e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pl_PL.json +++ b/ui-ngx/src/assets/locale/locale.constant-pl_PL.json @@ -5148,7 +5148,6 @@ "add-limit": "Dodaj limit", "advanced-settings": "Zaawansowane ustawienia", "edit-limit": "Edytuj limit", - "but-less-than": "ale mniej niż", "edit-transport-tenant-msg-title": "Edytuj limity szybkości wiadomości tenanta transportu", "edit-transport-tenant-telemetry-msg-title": "Edytuj limity szybkości komunikatów telemetrycznych Tenanta transportu", "edit-transport-tenant-telemetry-data-points-title": "Edytuj limity szybkości punktów danych telemetrycznych Tenanta transportu", 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 770f5df355..024a836551 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -4675,7 +4675,6 @@ "add-limit": "添加限制", "advanced-settings": "高级设置", "edit-limit": "编辑限制", - "but-less-than": "但小于", "edit-transport-tenant-msg-title": "编辑传输租户消息速率限制", "edit-transport-tenant-telemetry-msg-title": "编辑传输租户遥测消息速率限制", "edit-transport-tenant-telemetry-data-points-title": "编辑传输租户遥测数据点速率限制", 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 9e2246b83d..7b0575f739 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json @@ -3048,7 +3048,6 @@ "add-limit": "增加限制", "advanced-settings": "進階設定", "edit-limit": "編輯限制", - "but-less-than": "但小於", "edit-transport-tenant-msg-title": "編輯傳輸租戶訊息速率限制", "edit-transport-tenant-telemetry-msg-title": "編輯傳輸租戶遙測訊息速率限制", "edit-transport-tenant-telemetry-data-points-title": "編輯傳輸租戶遙測資料端速率限制", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index e516810895..b88145331c 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -163,6 +163,9 @@ .tb-form-panel-title { font-weight: 500; font-size: 16px; + &.tb-normal { + font-weight: normal; + } &.tb-required::after { font-size: 13px; @@ -819,4 +822,41 @@ } } } + + .tb-form-panel.outlined { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mdc-outlined-text-field-container-shape: 6px; + --mat-form-field-trailing-icon-color: rgba(0, 0, 0, 0.56); + + box-shadow: none; + gap: 0; + padding-bottom: 0; + + &:not(.stroked) { + border-radius: 0; + } + + &:not(.mat-padding,.padding) { + padding: 0; + } + + & > .tb-form-panel-title { + margin-bottom: 16px; + } + + .tb-form-panel { + @media #{$mat-xs} { + gap: 16px; + } + } + + .tb-form-row { + height: 56px; + margin-bottom: 22px; + &.disabled { + border-color: var(--mdc-outlined-text-field-disabled-outline-color); + color: var(--mdc-outlined-text-field-disabled-input-text-color); + } + } + } } diff --git a/ui-ngx/src/scss/animations.scss b/ui-ngx/src/scss/animations.scss index b4f9d83d41..865e70fd35 100644 --- a/ui-ngx/src/scss/animations.scss +++ b/ui-ngx/src/scss/animations.scss @@ -16,15 +16,23 @@ @keyframes tbMoveFromTopFade { from { opacity: 0; - transform: translate(0, -100%); } + + to { + opacity: 1; + transform: translate(0, 0); + } } @keyframes tbMoveToTopFade { + from { + opacity: 1; + transform: translate(0, 0); + } + to { opacity: 0; - transform: translate(0, -100%); } } @@ -32,15 +40,23 @@ @keyframes tbMoveFromBottomFade { from { opacity: 0; - transform: translate(0, 100%); } + + to { + opacity: 1; + transform: translate(0, 0); + } } @keyframes tbMoveToBottomFade { + from { + opacity: 1; + transform: translate(0, 0); + } + to { opacity: 0; - transform: translate(0, 150%); } } diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 539a3b1960..d3cc76b687 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -1292,6 +1292,12 @@ pre.tb-highlight { background: rgba(0, 0, 0, 0.06); } } + + &.tox-fullscreen { + mat-sidenav.tb-site-sidenav { + z-index: 1; + } + } } /*************** diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 6e86f3c2b0..dc79929cc8 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -3740,6 +3740,14 @@ cacache@^18.0.0: tar "^6.1.11" unique-filename "^3.0.0" +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -4788,6 +4796,15 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.3" +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + earcut@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/earcut/-/earcut-3.0.1.tgz#f60b3f671c5657cca9d3e131c5527c5dde00ef38" @@ -4968,6 +4985,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -4985,6 +5007,13 @@ es-object-atoms@^1.0.0: dependencies: es-errors "^1.3.0" +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" @@ -4994,6 +5023,16 @@ es-set-tostringtag@^2.0.3: has-tostringtag "^1.0.2" hasown "^2.0.1" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es-shim-unscopables@^1.0.0, es-shim-unscopables@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" @@ -5508,13 +5547,15 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" -form-data@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== +form-data@4.0.0, form-data@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" + integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" formdata-polyfill@^4.0.10: @@ -5635,6 +5676,30 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.6: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-stream@^6.0.0, get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" @@ -5750,6 +5815,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -5807,6 +5877,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" @@ -6970,6 +7045,11 @@ marked@~12.0.2: resolved "https://registry.yarnpkg.com/marked/-/marked-12.0.2.tgz#b31578fe608b599944c69807b00f18edab84647e" integrity sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"