Browse Source

Merge branch 'master' into feature/input-widget-locations

pull/2074/head
Vladyslav 7 years ago
committed by GitHub
parent
commit
c5a3c445ba
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      application/src/main/data/json/system/widget_bundles/input_widgets.json
  2. 33
      tools/pom.xml
  3. 93
      tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java
  4. 179
      tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java
  5. 222
      tools/src/main/java/org/thingsboard/client/tools/migrator/PostgresToCassandraTelemetryMigrator.java
  6. 87
      tools/src/main/java/org/thingsboard/client/tools/migrator/README.md
  7. 84
      tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java
  8. 20
      ui/src/app/locale/locale.constant-en_US.json
  9. 95
      ui/src/app/locale/locale.constant-ru_RU.json
  10. 4613
      ui/src/app/locale/locale.constant-uk_UA.json

14
application/src/main/data/json/system/widget_bundles/input_widgets.json

@ -347,15 +347,15 @@
"descriptor": {
"type": "static",
"sizeX": 7.5,
"sizeY": 4,
"sizeY": 4.5,
"resources": [],
"templateHtml": "<form name=\"claimDeviceForm\" class=\"claim-form\" ng-submit=\"claim()\">\n <fieldset ng-disabled=\"$root.loading || loading\">\n <md-input-container class=\"md-block\">\n <label>Device name</label>\n <input ng-model=\"deviceObj.deviceName\" name=\"deviceName\" required>\n <div ng-messages=\"claimDeviceForm.deviceName.$error\">\n <div ng-message=\"required\">Device name is required.</div>\n </div>\n </md-input-container>\n <md-input-container ng-if=\"deviceSecretField\" class=\"md-block\">\n <label>Device secret</label>\n <input name=\"deviceSecret\" ng-model=\"deviceObj.deviceSecret\" required>\n <div ng-messages=\"claimDeviceForm.deviceSecret.$error\">\n <div ng-message=\"required\">Device secret is required.</div>\n </div>\n </md-input-container>\n </fieldset>\n <md-button type=\"submit\" ng-disabled=\"loading || claimDeviceForm.$invalid || !claimDeviceForm.$dirty\" class=\"md-raised md-primary\">Claim</md-button>\n</form>",
"templateCss": ".claim-form {\n margin: 16px;\n}",
"controllerScript": "let $scope;\n\nself.onInit = function() {\n $scope = self.ctx.$scope;\n let $injector = $scope.$injector;\n let $q = $injector.get('$q');\n let $http = $injector.get('$http');\n let toast = $scope.$injector.get('toast');\n $scope.deviceSecretField = self.ctx.settings.deviceSecret;\n $scope.deviceObj = {};\n \n $scope.claim = () => {\n $scope.loading = true;\n claimDevice($scope.deviceObj.deviceName, $scope.deviceObj.deviceSecret).then(\n (data) => {\n resetForm();\n $scope.claimDeviceForm.$setPristine();\n $scope.claimDeviceForm.$setUntouched();\n $scope.loading = false;\n if (data.response == \"SUCCESS\") {\n toast.showSuccess('Device was successfully claimed!', 2000, angular.element('.claim-form'), 'bottom left');\n }\n },\n () => {\n $scope.loading = false;\n }\n );\n }\n \n function claimDevice(deviceName, deviceSecret, config) {\n let deferred = $q.defer();\n let url = \"/api/customer/device/\" + deviceName + \"/claim\";\n let obj = deviceSecret ? { secretKey: deviceSecret } : {};\n $http.post(url, obj, config).then(\n (payload) => {\n deferred.resolve(payload.data); \n },\n () => {\n deferred.reject();\n }\n );\n return deferred.promise;\n }\n \n function resetForm() {\n $scope.deviceObj = {};\n }\n}\n\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"required\": [],\n \"properties\": {\n \"deviceSecret\": {\n \"title\": \"Add 'Device secret' field\",\n \"type\": \"boolean\",\n \"default\": false\n }\n }\n },\n \"form\": [\n \"deviceSecret\"\n ]\n}",
"templateHtml": "<form name=\"claimDeviceForm\" class=\"claim-form\" ng-submit=\"claim()\">\n <fieldset ng-disabled=\"$root.loading || loading\">\n <md-input-container ng-class=\"{'show-label': showLabel}\" class=\"md-block\">\n <label>{{deviceLabel}}</label>\n <input ng-model=\"deviceObj.deviceName\" \n name=\"deviceName\" \n required\n >\n <div ng-messages=\"claimDeviceForm.deviceName.$error\">\n <div ng-message=\"required\">{{requiredErrorDevice}}</div>\n </div>\n </md-input-container>\n <md-input-container ng-if=\"secretKeyField\" class=\"md-block\" ng-class=\"{'show-label': showLabel}\">\n <label>{{secretKeyLabel}}</label>\n <input name=\"deviceSecret\" ng-model=\"deviceObj.deviceSecret\" required>\n <div ng-messages=\"claimDeviceForm.deviceSecret.$error\">\n <div ng-message=\"required\">{{requiredErrorSecretKey}}</div>\n </div>\n </md-input-container>\n </fieldset>\n <div class=\"md-block\" layout=\"row\" layout-align=\"end center\">\n <md-button type=\"submit\" ng-disabled=\"loading || claimDeviceForm.$invalid || !claimDeviceForm.$dirty\" class=\"md-raised md-primary\">{{labelClaimButon}}</md-button>\n </div>\n</form>",
"templateCss": ".claim-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.show-label label {\n display: block;\n}\n\nlabel {\n display: none;\n}",
"controllerScript": "let $scope;\n\nself.onInit = function() {\n $scope = self.ctx.$scope;\n let $injector = $scope.$injector;\n let $q = $injector.get('$q');\n let $http = $injector.get('$http');\n let toast = $scope.$injector.get('toast');\n let utils = $scope.$injector.get('utils');\n let $translate = $scope.$injector.get('$translate');\n let $rootScope = $scope.$injector.get('$rootScope');\n let settings = self.ctx.settings || {};\n $scope.secretKeyField = settings.deviceSecret;\n $scope.showLabel = settings.showLabel;\n $scope.deviceObj = {};\n \n const config = {\n ignoreErrors: true \n };\n \n let titleTemplate = \"\";\n let successfulClaim = utils.customTranslation(settings.successfulClaimDevice, settings.successfulClaimDevice) || $translate.instant('widgets.input-widgets.claim-successful');\n let failedClaimDevice = utils.customTranslation(settings.failedClaimDevice, settings.failedClaimDevice) || $translate.instant('widgets.input-widgets.claim-failed');\n let deviceNotFound = utils.customTranslation(settings.deviceNotFound, settings.deviceNotFound) || $translate.instant('widgets.input-widgets.claim-not-found');\n \n if (settings.widgetTitle && settings.widgetTitle.length) {\n titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n titleTemplate = self.ctx.widgetConfig.title;\n }\n self.ctx.widgetTitle = titleTemplate;\n \n $scope.deviceLabel = utils.customTranslation(settings.deviceLabel, settings.deviceLabel) || $translate.instant('widgets.input-widgets.device-name');\n $scope.requiredErrorDevice= utils.customTranslation(settings.requiredErrorDevice, settings.requiredErrorDevice) || $translate.instant('widgets.input-widgets.device-name-required');\n \n $scope.secretKeyLabel = utils.customTranslation(settings.secretKeyLabel, settings.secretKeyLabel) || $translate.instant('widgets.input-widgets.secret-key');\n $scope.requiredErrorSecretKey= utils.customTranslation(settings.requiredErrorSecretKey, settings.requiredErrorSecretKey) || $translate.instant('widgets.input-widgets.secret-key-required');\n \n $scope.labelClaimButon = utils.customTranslation(settings.labelClaimButon, settings.labelClaimButon) || $translate.instant('widgets.input-widgets.claim-device');\n \n $scope.claim = () => {\n $scope.loading = true;\n claimDevice($scope.deviceObj.deviceName, $scope.deviceObj.deviceSecret, config).then(\n (data) => {\n successClaim();\n },\n (error) => {\n $scope.loading = false;\n if(error.status == 404) {\n toast.showError(deviceNotFound, angular.element('.claim-form'),'bottom left');\n } else if(error.status == 400) {\n toast.showError(failedClaimDevice, angular.element('.claim-form'),'bottom left');\n }\n }\n );\n }\n \n function claimDevice(deviceName, deviceSecret, config) {\n let deferred = $q.defer();\n let url = \"/api/customer/device/\" + deviceName + \"/claim\";\n let obj = deviceSecret ? { secretKey: deviceSecret } : {};\n $http.post(url, obj, config).then(\n (payload) => {\n deferred.resolve(payload.data); \n },\n (error) => {\n deferred.reject(error);\n }\n );\n return deferred.promise;\n }\n \n function updateAliasData() {\n var aliasIds = [];\n for (var id in self.ctx.aliasController.resolvedAliases) {\n aliasIds.push(id);\n }\n var tasks = [];\n aliasIds.forEach(function(aliasId) {\n self.ctx.aliasController.setAliasUnresolved(aliasId);\n tasks.push(self.ctx.aliasController.getAliasInfo(aliasId));\n });\n $q.all(tasks).then(function() {\n $rootScope.$broadcast('widgetForceReInit');\n });\n }\n \n function successClaim() {\n resetForm();\n $scope.claimDeviceForm.$setPristine();\n $scope.claimDeviceForm.$setUntouched();\n $scope.loading = false;\n toast.showSuccess(successfulClaim, 2000);\n updateAliasData();\n }\n \n function resetForm() {\n $scope.deviceObj = {};\n }\n}\n\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"widgetTitle\": {\n \"title\": \"Widget title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"deviceSecret\": {\n \"title\": \"Show 'Secret key' input field\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"deviceLabel\": {\n \"title\": \"Label for device name\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"requiredErrorDevice\": {\n \"title\": \"'Device name required' error message\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"secretKeyLabel\": {\n \"title\": \"Label for secret key\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"requiredErrorSecretKey\": {\n \"title\": \"'Secret key required' error message\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"labelClaimButon\": {\n \"title\": \"Label for claiming button\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"successfulClaimDevice\": {\n \"title\": \"Text message of successful device claiming\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"deviceNotFound\": {\n \"title\": \"Text message when device not found\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"failedClaimDevice\": {\n \"title\": \"Text message of failed device claiming\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n [\n \"widgetTitle\",\n \"labelClaimButon\",\n \"deviceSecret\",\n \"showLabel\",\n \"deviceLabel\",\n \"secretKeyLabel\"\n ],\n [\n \"deviceNotFound\",\n \"failedClaimDevice\",\n \"successfulClaimDevice\",\n \"requiredErrorDevice\",\n \"requiredErrorSecretKey\"\n ]\n ],\n \"groupInfoes\": [{\n \"formIndex\": 0,\n \"GroupTitle\": \"General settings\"\n }, {\n \"formIndex\": 1,\n \"GroupTitle\": \"Message settings\"\n }]\n}",
"dataKeySettingsSchema": "{}\n",
"defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"deviceSecret\":true},\"title\":\"Device claiming widget\",\"dropShadow\":true,\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"enableFullscreen\":false,\"enableDataExport\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}"
"defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"deviceSecret\":true,\"showLabel\":true},\"title\":\"Device claiming widget\",\"dropShadow\":true,\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"enableFullscreen\":false,\"enableDataExport\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}"
}
}
]
}
}

33
tools/pom.xml

@ -51,6 +51,39 @@
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-all</artifactId>
<version>3.11.4</version>
</dependency>
<dependency>
<groupId>com.datastax.cassandra</groupId>
<artifactId>cassandra-driver-core</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>org.thingsboard.client.tools.migrator.MigratorTool</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>

93
tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java

@ -0,0 +1,93 @@
/**
* Copyright © 2016-2019 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.client.tools.migrator;
import org.apache.commons.cli.BasicParser;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import java.io.File;
public class MigratorTool {
public static void main(String[] args) {
CommandLine cmd = parseArgs(args);
try {
File latestSource = new File(cmd.getOptionValue("latestTelemetryFrom"));
File latestSaveDir = new File(cmd.getOptionValue("latestTelemetryOut"));
File tsSource = new File(cmd.getOptionValue("telemetryFrom"));
File tsSaveDir = new File(cmd.getOptionValue("telemetryOut"));
File partitionsSaveDir = new File(cmd.getOptionValue("partitionsOut"));
boolean castEnable = Boolean.parseBoolean(cmd.getOptionValue("castEnable"));
PgCaLatestMigrator.migrateLatest(latestSource, latestSaveDir, castEnable);
PostgresToCassandraTelemetryMigrator.migrateTs(tsSource, tsSaveDir, partitionsSaveDir, castEnable);
} catch (Throwable th) {
th.printStackTrace();
throw new IllegalStateException("failed", th);
}
}
private static CommandLine parseArgs(String[] args) {
Options options = new Options();
Option latestTsOpt = new Option("latestFrom", "latestTelemetryFrom", true, "latest telemetry source file path");
latestTsOpt.setRequired(true);
options.addOption(latestTsOpt);
Option latestTsOutOpt = new Option("latestOut", "latestTelemetryOut", true, "latest telemetry save dir");
latestTsOutOpt.setRequired(true);
options.addOption(latestTsOutOpt);
Option tsOpt = new Option("tsFrom", "telemetryFrom", true, "telemetry source file path");
tsOpt.setRequired(true);
options.addOption(tsOpt);
Option tsOutOpt = new Option("tsOut", "telemetryOut", true, "sstable save dir");
tsOutOpt.setRequired(true);
options.addOption(tsOutOpt);
Option partitionOutOpt = new Option("partitionsOut", "partitionsOut", true, "partitions save dir");
partitionOutOpt.setRequired(true);
options.addOption(partitionOutOpt);
Option castOpt = new Option("castEnable", "castEnable", true, "cast String to Double if possible");
castOpt.setRequired(true);
options.addOption(castOpt);
HelpFormatter formatter = new HelpFormatter();
CommandLineParser parser = new BasicParser();
try {
return parser.parse(options, args);
} catch (ParseException e) {
System.out.println(e.getMessage());
formatter.printHelp("utility-name", options);
System.exit(1);
}
return null;
}
}

179
tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java

@ -0,0 +1,179 @@
/**
* Copyright © 2016-2019 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.client.tools.migrator;
import com.google.common.collect.Lists;
import org.apache.cassandra.io.sstable.CQLSSTableWriter;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.LineIterator;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
public class PgCaLatestMigrator {
private static final long LOG_BATCH = 1000000;
private static final long rowPerFile = 1000000;
private static long linesProcessed = 0;
private static long linesMigrated = 0;
private static long castErrors = 0;
private static long castedOk = 0;
private static long currentWriterCount = 1;
private static CQLSSTableWriter currentTsWriter = null;
public static void migrateLatest(File sourceFile, File outDir, boolean castStringsIfPossible) throws IOException {
long startTs = System.currentTimeMillis();
long stepLineTs = System.currentTimeMillis();
long stepOkLineTs = System.currentTimeMillis();
LineIterator iterator = FileUtils.lineIterator(sourceFile);
currentTsWriter = WriterBuilder.getTsWriter(outDir);
boolean isBlockStarted = false;
boolean isBlockFinished = false;
String line;
while (iterator.hasNext()) {
if (linesProcessed++ % LOG_BATCH == 0) {
System.out.println(new Date() + " linesProcessed = " + linesProcessed + " in " + (System.currentTimeMillis() - stepLineTs) + " castOk " + castedOk + " castErr " + castErrors);
stepLineTs = System.currentTimeMillis();
}
line = iterator.nextLine();
if (isBlockFinished) {
break;
}
if (!isBlockStarted) {
if (isBlockStarted(line)) {
System.out.println();
System.out.println();
System.out.println(line);
System.out.println();
System.out.println();
isBlockStarted = true;
}
continue;
}
if (isBlockFinished(line)) {
isBlockFinished = true;
} else {
try {
List<String> raw = Arrays.stream(line.trim().split("\t"))
.map(String::trim)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toList());
List<Object> values = toValues(raw);
if (currentWriterCount == 0) {
System.out.println(new Date() + " close writer " + new Date());
currentTsWriter.close();
currentTsWriter = WriterBuilder.getLatestWriter(outDir);
}
if (castStringsIfPossible) {
currentTsWriter.addRow(castToNumericIfPossible(values));
} else {
currentTsWriter.addRow(values);
}
currentWriterCount++;
if (currentWriterCount >= rowPerFile) {
currentWriterCount = 0;
}
if (linesMigrated++ % LOG_BATCH == 0) {
System.out.println(new Date() + " migrated = " + linesMigrated + " in " + (System.currentTimeMillis() - stepOkLineTs));
stepOkLineTs = System.currentTimeMillis();
}
} catch (Exception ex) {
System.out.println(ex.getMessage() + " -> " + line);
}
}
}
long endTs = System.currentTimeMillis();
System.out.println();
System.out.println(new Date() + " Migrated rows " + linesMigrated + " in " + (endTs - startTs));
currentTsWriter.close();
System.out.println();
System.out.println("Finished migrate Latest Telemetry");
}
private static List<Object> castToNumericIfPossible(List<Object> values) {
try {
if (values.get(6) != null && NumberUtils.isNumber(values.get(6).toString())) {
Double casted = NumberUtils.createDouble(values.get(6).toString());
List<Object> numeric = Lists.newArrayList();
numeric.addAll(values);
numeric.set(6, null);
numeric.set(8, casted);
castedOk++;
return numeric;
}
} catch (Throwable th) {
castErrors++;
}
return values;
}
private static List<Object> toValues(List<String> raw) {
//expected Table structure:
// COPY public.ts_kv_latest (entity_type, entity_id, key, ts, bool_v, str_v, long_v, dbl_v) FROM stdin;
List<Object> result = new ArrayList<>();
result.add(raw.get(0));
result.add(fromString(raw.get(1)));
result.add(raw.get(2));
long ts = Long.parseLong(raw.get(3));
result.add(ts);
result.add(raw.get(4).equals("\\N") ? null : raw.get(4).equals("t") ? Boolean.TRUE : Boolean.FALSE);
result.add(raw.get(5).equals("\\N") ? null : raw.get(5));
result.add(raw.get(6).equals("\\N") ? null : Long.parseLong(raw.get(6)));
result.add(raw.get(7).equals("\\N") ? null : Double.parseDouble(raw.get(7)));
return result;
}
public static UUID fromString(String src) {
return UUID.fromString(src.substring(7, 15) + "-" + src.substring(3, 7) + "-1"
+ src.substring(0, 3) + "-" + src.substring(15, 19) + "-" + src.substring(19));
}
private static boolean isBlockStarted(String line) {
return line.startsWith("COPY public.ts_kv_latest");
}
private static boolean isBlockFinished(String line) {
return StringUtils.isBlank(line) || line.equals("\\.");
}
}

222
tools/src/main/java/org/thingsboard/client/tools/migrator/PostgresToCassandraTelemetryMigrator.java

@ -0,0 +1,222 @@
/**
* Copyright © 2016-2019 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.client.tools.migrator;
import com.google.common.collect.Lists;
import org.apache.cassandra.io.sstable.CQLSSTableWriter;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.LineIterator;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import java.io.File;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
public class PostgresToCassandraTelemetryMigrator {
private static final long LOG_BATCH = 1000000;
private static final long rowPerFile = 1000000;
private static long linesProcessed = 0;
private static long linesMigrated = 0;
private static long castErrors = 0;
private static long castedOk = 0;
private static long currentWriterCount = 1;
private static CQLSSTableWriter currentTsWriter = null;
private static CQLSSTableWriter currentPartitionWriter = null;
private static Set<String> partitions = new HashSet<>();
public static void migrateTs(File sourceFile, File outTsDir, File outPartitionDir, boolean castStringsIfPossible) throws IOException {
long startTs = System.currentTimeMillis();
long stepLineTs = System.currentTimeMillis();
long stepOkLineTs = System.currentTimeMillis();
LineIterator iterator = FileUtils.lineIterator(sourceFile);
currentTsWriter = WriterBuilder.getTsWriter(outTsDir);
currentPartitionWriter = WriterBuilder.getPartitionWriter(outPartitionDir);
boolean isBlockStarted = false;
boolean isBlockFinished = false;
String line;
while (iterator.hasNext()) {
if (linesProcessed++ % LOG_BATCH == 0) {
System.out.println(new Date() + " linesProcessed = " + linesProcessed + " in " + (System.currentTimeMillis() - stepLineTs) + " castOk " + castedOk + " castErr " + castErrors);
stepLineTs = System.currentTimeMillis();
}
line = iterator.nextLine();
if (isBlockFinished) {
break;
}
if (!isBlockStarted) {
if (isBlockStarted(line)) {
System.out.println();
System.out.println();
System.out.println(line);
System.out.println();
System.out.println();
isBlockStarted = true;
}
continue;
}
if (isBlockFinished(line)) {
isBlockFinished = true;
} else {
try {
List<String> raw = Arrays.stream(line.trim().split("\t"))
.map(String::trim)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toList());
List<Object> values = toValues(raw);
if (currentWriterCount == 0) {
System.out.println(new Date() + " close writer " + new Date());
currentTsWriter.close();
currentTsWriter = WriterBuilder.getTsWriter(outTsDir);
}
if (castStringsIfPossible) {
currentTsWriter.addRow(castToNumericIfPossible(values));
} else {
currentTsWriter.addRow(values);
}
processPartitions(values);
currentWriterCount++;
if (currentWriterCount >= rowPerFile) {
currentWriterCount = 0;
}
if (linesMigrated++ % LOG_BATCH == 0) {
System.out.println(new Date() + " migrated = " + linesMigrated + " in " + (System.currentTimeMillis() - stepOkLineTs) + " partitions = " + partitions.size());
stepOkLineTs = System.currentTimeMillis();
}
} catch (Exception ex) {
System.out.println(ex.getMessage() + " -> " + line);
}
}
}
long endTs = System.currentTimeMillis();
System.out.println();
System.out.println(new Date() + " Migrated rows " + linesMigrated + " in " + (endTs - startTs));
System.out.println("Partitions collected " + partitions.size());
startTs = System.currentTimeMillis();
for (String partition : partitions) {
String[] split = partition.split("\\|");
List<Object> values = Lists.newArrayList();
values.add(split[0]);
values.add(UUID.fromString(split[1]));
values.add(split[2]);
values.add(Long.parseLong(split[3]));
currentPartitionWriter.addRow(values);
}
currentPartitionWriter.close();
endTs = System.currentTimeMillis();
System.out.println();
System.out.println();
System.out.println(new Date() + " Migrated partitions " + partitions.size() + " in " + (endTs - startTs));
currentTsWriter.close();
System.out.println();
System.out.println("Finished migrate Telemetry");
}
private static List<Object> castToNumericIfPossible(List<Object> values) {
try {
if (values.get(6) != null && NumberUtils.isNumber(values.get(6).toString())) {
Double casted = NumberUtils.createDouble(values.get(6).toString());
List<Object> numeric = Lists.newArrayList();
numeric.addAll(values);
numeric.set(6, null);
numeric.set(8, casted);
castedOk++;
return numeric;
}
} catch (Throwable th) {
castErrors++;
}
return values;
}
private static void processPartitions(List<Object> values) {
String key = values.get(0) + "|" + values.get(1) + "|" + values.get(2) + "|" + values.get(3);
partitions.add(key);
}
private static List<Object> toValues(List<String> raw) {
//expected Table structure:
// COPY public.ts_kv (entity_type, entity_id, key, ts, bool_v, str_v, long_v, dbl_v) FROM stdin;
List<Object> result = new ArrayList<>();
result.add(raw.get(0));
result.add(fromString(raw.get(1)));
result.add(raw.get(2));
long ts = Long.parseLong(raw.get(3));
long partition = toPartitionTs(ts);
result.add(partition);
result.add(ts);
result.add(raw.get(4).equals("\\N") ? null : raw.get(4).equals("t") ? Boolean.TRUE : Boolean.FALSE);
result.add(raw.get(5).equals("\\N") ? null : raw.get(5));
result.add(raw.get(6).equals("\\N") ? null : Long.parseLong(raw.get(6)));
result.add(raw.get(7).equals("\\N") ? null : Double.parseDouble(raw.get(7)));
return result;
}
public static UUID fromString(String src) {
return UUID.fromString(src.substring(7, 15) + "-" + src.substring(3, 7) + "-1"
+ src.substring(0, 3) + "-" + src.substring(15, 19) + "-" + src.substring(19));
}
private static long toPartitionTs(long ts) {
LocalDateTime time = LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneOffset.UTC);
return time.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1).toInstant(ZoneOffset.UTC).toEpochMilli();
// return TsPartitionDate.MONTHS.truncatedTo(time).toInstant(ZoneOffset.UTC).toEpochMilli();
}
private static boolean isBlockStarted(String line) {
return line.startsWith("COPY public.ts_kv");
}
private static boolean isBlockFinished(String line) {
return StringUtils.isBlank(line) || line.equals("\\.");
}
}

87
tools/src/main/java/org/thingsboard/client/tools/migrator/README.md

@ -0,0 +1,87 @@
# Description:
This tool used for migrating ThingsBoard into hybrid mode from Postgres.
Performance of this tool depends on disk type and instance type (mostly on CPU resources).
But in general here are few benchmarks:
1. Creating Dump of the postgres ts_kv table -> 100GB = 90 minutes
2. If postgres table has size 100GB then dump file will be about 30GB
3. Generation SSTables from dump -> 100GB = 3 hours
4. 100GB Dump file will be converted into SSTable with size about 18GB
# Tool build Instruction:
Switch to `tools` module in Command Line and execute
mvn clean compile assembly:single
It will generate single jar file with all required dependencies inside `target dir` -> `tools-2.4.1-SNAPSHOT-jar-with-dependencies.jar`.
# Prepare requred files and run Tool:
#### Dump data from the source Postgres Database
*Do not use compression if possible because Tool can only work with uncompressed file
1. Dump table `ts_kv` table:
`pg_dump -h localhost -U postgres -d thingsboard -t ts_kv > ts_kv.dmp`
2. Dump table `ts_kv_latest` table:
`pg_dump -h localhost -U postgres -d thingsboard -t ts_kv_latest > ts_kv_latest.dmp`
3. [Optional] move table dumps to the instance where cassandra will be hosted
#### Prepare directory structure for SSTables
Tool use 3 different directories for saving SSTables - `ts_kv_cf`, `ts_kv_latest_cf`, `ts_kv_partitions_cf`
Create 3 empty directories. For example:
/home/ubunut/migration/ts
/home/ubunut/migration/ts_latest
/home/ubunut/migration/ts_partition
#### Run tool
*Note: if you run this tool on remote instance - don't forget to execute this command in `screen` to avoid unexpected termination
```
java -jar ./tools-2.4.1-SNAPSHOT-jar-with-dependencies.jar
-latestFrom ./source/ts_kv_latest.dmp
-latestOut /home/ubunut/migration/ts_latest
-tsFrom ./source/ts_kv.dmp
-tsOut /home/ubunut/migration/ts
-partitionsOut /home/ubunut/migration/ts_partition
-castEnable false
```
Tool execution time depends on DB size, CPU resources and Disk throughput
## Adding SSTables into Cassandra
* Note that this this part works only for single node Cassandra Cluster. If you have more nodes - it is better to use `sstableloader` tool.
1. [Optional] install Cassandra on the instance
2. [Optional] Using `cqlsh` create `thingsboard` keyspace and requred tables from this file `schema-ts.cql`
3. Stop Cassandra
4. Copy generated SSTable files into cassandra data dir:
```
sudo find /home/ubunut/migration/ts -name '*.*' -exec mv {} /var/lib/cassandra/data/thingsboard/ts_kv_cf-0e9aaf00ee5511e9a5fa7d6f489ffd13/ \;
sudo find /home/ubunut/migration/ts_latest -name '*.*' -exec mv {} /var/lib/cassandra/data/thingsboard/ts_kv_latest_cf-161449d0ee5511e9a5fa7d6f489ffd13/ \;
sudo find /home/ubunut/migration/ts_partition -name '*.*' -exec mv {} /var/lib/cassandra/data/thingsboard/ts_kv_partitions_cf-12e8fa80ee5511e9a5fa7d6f489ffd13/ \;
```
5. Start Cassandra service and trigger compaction
```
trigger compactions: nodetool compact thingsboard
check compaction status: nodetool compactionstats
```
## Switch Thignsboard into Hybrid Mode
Modify Thingsboard properites file `thingsboard.yml`
- DATABASE_TS_TYPE = cassandra
- TS_KV_PARTITIONING = MONTHS
# Final steps
Start Thingsboard and verify migration

84
tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java

@ -0,0 +1,84 @@
/**
* Copyright © 2016-2019 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.client.tools.migrator;
import org.apache.cassandra.io.sstable.CQLSSTableWriter;
import java.io.File;
public class WriterBuilder {
private static final String tsSchema = "CREATE TABLE thingsboard.ts_kv_cf (\n" +
" entity_type text, // (DEVICE, CUSTOMER, TENANT)\n" +
" entity_id timeuuid,\n" +
" key text,\n" +
" partition bigint,\n" +
" ts bigint,\n" +
" bool_v boolean,\n" +
" str_v text,\n" +
" long_v bigint,\n" +
" dbl_v double,\n" +
" PRIMARY KEY (( entity_type, entity_id, key, partition ), ts)\n" +
");";
private static final String latestSchema = "CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_latest_cf (\n" +
" entity_type text, // (DEVICE, CUSTOMER, TENANT)\n" +
" entity_id timeuuid,\n" +
" key text,\n" +
" ts bigint,\n" +
" bool_v boolean,\n" +
" str_v text,\n" +
" long_v bigint,\n" +
" dbl_v double,\n" +
" PRIMARY KEY (( entity_type, entity_id ), key)\n" +
") WITH compaction = { 'class' : 'LeveledCompactionStrategy' };";
private static final String partitionSchema = "CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_partitions_cf (\n" +
" entity_type text, // (DEVICE, CUSTOMER, TENANT)\n" +
" entity_id timeuuid,\n" +
" key text,\n" +
" partition bigint,\n" +
" PRIMARY KEY (( entity_type, entity_id, key ), partition)\n" +
") WITH CLUSTERING ORDER BY ( partition ASC )\n" +
" AND compaction = { 'class' : 'LeveledCompactionStrategy' };";
public static CQLSSTableWriter getTsWriter(File dir) {
return CQLSSTableWriter.builder()
.inDirectory(dir)
.forTable(tsSchema)
.using("INSERT INTO thingsboard.ts_kv_cf (entity_type, entity_id, key, partition, ts, bool_v, str_v, long_v, dbl_v) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
.build();
}
public static CQLSSTableWriter getLatestWriter(File dir) {
return CQLSSTableWriter.builder()
.inDirectory(dir)
.forTable(latestSchema)
.using("INSERT INTO thingsboard.ts_kv_latest_cf (entity_type, entity_id, key, ts, bool_v, str_v, long_v, dbl_v) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.build();
}
public static CQLSSTableWriter getPartitionWriter(File dir) {
return CQLSSTableWriter.builder()
.inDirectory(dir)
.forTable(partitionSchema)
.using("INSERT INTO thingsboard.ts_kv_partitions_cf (entity_type, entity_id, key, partition) " +
"VALUES (?, ?, ?, ?)")
.build();
}
}

20
ui/src/app/locale/locale.constant-en_US.json

@ -822,7 +822,7 @@
"no-keys-found": "No keys found.",
"create-new-alias": "Create a new one!",
"create-new-key": "Create a new one!",
"duplicate-alias-error": "Duplicate alias found '{{alias}}'.<br>Entity View aliases must be unique whithin the dashboard.",
"duplicate-alias-error": "Duplicate alias found '{{alias}}'.<br>Entity View aliases must be unique within the dashboard.",
"configure-alias": "Configure '{{alias}}' alias",
"no-entity-views-matching": "No entity views matching '{{entity}}' were found.",
"alias": "Alias",
@ -845,21 +845,21 @@
"add-entity-view-text": "Add new entity view",
"delete": "Delete entity view",
"assign-entity-views": "Assign entity views",
"assign-entity-views-text": "Assign { count, plural, 1 {1 entityView} other {# entityViews} } to customer",
"assign-entity-views-text": "Assign { count, plural, 1 {1 entity view} other {# entity views} } to customer",
"delete-entity-views": "Delete entity views",
"unassign-from-customer": "Unassign from customer",
"unassign-entity-views": "Unassign entity views",
"unassign-entity-views-action-title": "Unassign { count, plural, 1 {1 entityView} other {# entityViews} } from customer",
"unassign-entity-views-action-title": "Unassign { count, plural, 1 {1 entity view} other {# entity views} } from customer",
"assign-new-entity-view": "Assign new entity view",
"delete-entity-view-title": "Are you sure you want to delete the entity view '{{entityViewName}}'?",
"delete-entity-view-text": "Be careful, after the confirmation the entity view and all related data will become unrecoverable.",
"delete-entity-views-title": "Are you sure you want to entity view { count, plural, 1 {1 entityView} other {# entityViews} }?",
"delete-entity-views-action-title": "Delete { count, plural, 1 {1 entityView} other {# entityViews} }",
"delete-entity-views-title": "Are you sure you want to delete { count, plural, 1 {1 entity view} other {# entity views} }?",
"delete-entity-views-action-title": "Delete { count, plural, 1 {1 entity view} other {# entity views} }",
"delete-entity-views-text": "Be careful, after the confirmation all selected entity views will be removed and all related data will become unrecoverable.",
"unassign-entity-view-title": "Are you sure you want to unassign the entity view '{{entityViewName}}'?",
"unassign-entity-view-text": "After the confirmation the entity view will be unassigned and won't be accessible by the customer.",
"unassign-entity-view": "Unassign entity view",
"unassign-entity-views-title": "Are you sure you want to unassign { count, plural, 1 {1 entityView} other {# entityViews} }?",
"unassign-entity-views-title": "Are you sure you want to unassign { count, plural, 1 {1 entity view} other {# entity views} }?",
"unassign-entity-views-text": "After the confirmation all selected entity views will be unassigned and won't be accessible by the customer.",
"entity-view-type": "Entity View type",
"entity-view-type-required": "Entity View type is required.",
@ -1700,7 +1700,13 @@
"input-widgets": {
"attribute-not-allowed": "Attribute parameter cannot be used in this widget",
"blocked-location": "Geolocation is blocked in your browser",
"claim-device": "Claim device",
"claim-failed": "Failed to claim the device!",
"claim-not-found": "Device not found!",
"claim-successful": "Device was successfully claimed!",
"date": "Date",
"device-name": "Device name",
"device-name-required": "Device name is required",
"discard-changes": "Discard changes",
"entity-attribute-required": "Entity attribute is required",
"entity-coordinate-required": "Both fields, latitude and longitude, are required",
@ -1717,6 +1723,8 @@
"no-support-geolocation": "Your browser doesn't support geolocation",
"no-support-web-camera": "No supported web camera",
"no-timeseries-selected": "No timeseries selected",
"secret-key": "Secret key",
"secret-key-required": "Secret key is required",
"switch-attribute-value": "Switch entity attribute value",
"switch-camera": "Switch camera",
"switch-timeseries-value": "Switch entity timeseries value",

95
ui/src/app/locale/locale.constant-ru_RU.json

@ -48,7 +48,9 @@
"paste-reference": "Вставить ссылку",
"import": "Импортировать",
"export": "Экспортировать",
"share-via": "Поделиться в {{provider}}"
"share-via": "Поделиться в {{provider}}",
"continue": "Продолжить",
"discard-changes": "Отменить изменения"
},
"aggregation": {
"aggregation": "Агрегация",
@ -150,8 +152,8 @@
"filter-type-single-entity": "Отдельный объект",
"filter-type-entity-list": "Список объектов",
"filter-type-entity-name": "Название объекта",
"filter-type-state-entity": "Объект, полученный из дашборда",
"filter-type-state-entity-description": "Объект, полученный из параметров дашборда",
"filter-type-state-entity": "Объект из состояния дашборда",
"filter-type-state-entity-description": "Объект, полученный из параметров состояния дашборда",
"filter-type-asset-type": "Тип актива",
"filter-type-asset-type-description": "Активы типа '{{assetType}}'",
"filter-type-asset-type-and-name-description": "Активы типа '{{assetType}}' и названием, начинающимся с '{{prefix}}'",
@ -246,7 +248,9 @@
"select-asset": "Выбрать активы",
"no-assets-matching": "Активы, соответствующие '{{entity}}', не найдены.",
"asset-required": "Актив обязателен",
"name-starts-with": "Название актива, начинающееся с"
"name-starts-with": "Название актива, начинающееся с",
"import": "Импортировать активы",
"asset-file": "Файл с активами"
},
"attribute": {
"attributes": "Атрибуты",
@ -665,7 +669,9 @@
"is-gateway": "Гейтвей",
"public": "Общедоступный",
"device-public": "Устройство общедоступно",
"select-device": "Выбрать устройство"
"select-device": "Выбрать устройство",
"import": "Импортировать устройства",
"device-file": "Файл с устройствами"
},
"dialog": {
"close": "Закрыть диалог"
@ -771,6 +777,7 @@
"search": "Поиск объектов",
"selected-entities": "Выбран(ы) { count, plural, 1 {1 объект} few {# объекта} other {# объектов} }",
"entity-name": "Название объекта",
"entity-label": "Метка объекта",
"details": "Подробности об объекте",
"no-entities-prompt": "Объекты не найдены",
"no-data": "Нет данных для отображения",
@ -793,7 +800,7 @@
"duplicate-alias-error": "Найден дубликат псевдонима '{{alias}}'.<br>В рамках одного дашборда псевдонимы представлений объектов должны быть уникальными.",
"configure-alias": "Настроить псевдоним '{{alias}}'",
"no-entity-views-matching": "Объекты, соответствующие '{{entity}}', не найдены.",
"alias": "Псевдонимы",
"alias": "Псевдоним",
"alias-required": "Псевдоним представления объекта обязателен.",
"remove-alias": "Убрать псевдоним представления объекта",
"add-alias": "Добавить псевдоним представления объекта",
@ -843,11 +850,12 @@
"events": "События",
"details": "Подробности",
"copyId": "Копировать ИД представление объекта",
"assignedToCustomer": "Назначенные клиенту",
"assignedToCustomer": "Назначено клиенту",
"unable-entity-view-device-alias-title": "Не удалось удалить псевдоним представления объекта",
"unable-entity-view-device-alias-text": "Не удалось удалить псевдоним устройства '{{entityViewAlias}}', т.к. он используется следующими виджетами:<br/>{{widgetsList}}",
"select-entity-view": "Выбрать представление объекта",
"make-public": "Открыть общий доступ к представлению объекта",
"make-private": "Закрыть общий доступ к представлению объекта",
"start-date": "Дата начала",
"start-ts": "Время начала",
"end-date": "Дата окончания",
@ -865,7 +873,11 @@
"attributes-propagation": "Пробросить атрибуты",
"attributes-propagation-hint": "Представление объекта автоматически копирует указанные атрибуты с Целевого Объекта каждый раз, когда вы сохраняете или обновляете это представление. В целях производительности атрибуты целевого объекта не пробрасываются в представление объекта на каждом их изменении. Вы можете включить автоматический проброс, настроив в вашей цепочке правило \"copy to view\" и соединив его с сообщениями типа \"Post attributes\" и \"Attributes Updated\".",
"timeseries-data": "Данные телеметрии",
"timeseries-data-hint": "Настроить ключи данных телеметрии целевого объекта, которые будут доступны представлению объекта. Эти данные только для чтения."
"timeseries-data-hint": "Настроить ключи данных телеметрии целевого объекта, которые будут доступны представлению объекта. Эти данные только для чтения.",
"make-public-entity-view-title": "Вы уверенны, что хотите открыть общий доступ к представленю объекта '{{entityViewName}}'?",
"make-public-entity-view-text": "После подтверждения представление объекта и все связанные с ним данные станут публичными и доступными для других пользователей.",
"make-private-entity-view-title": "Вы уверенны, что хотите закрыть общий доступ к представлению объекта '{{entityViewName}}'?",
"make-private-entity-view-text": "После подтверждения представление объекта и все звязанные с ним данные станут приватными и не будут доступны для других пользователей."
},
"event": {
"event-type": "Тип события",
@ -1012,6 +1024,7 @@
"modbus-add-server": "Добавить сервер/ведомое устройство",
"modbus-add-server-prompt": "Пожалуйста, добавить сервер/ведомое устройство",
"modbus-transport": "Транспорт",
"modbus-tcp-reconnect": "Переподключатсься автоматически",
"modbus-port-name": "Название последовательного порта",
"modbus-encoding": "Кодирование символов",
"modbus-parity": "Паритет",
@ -1086,7 +1099,40 @@
},
"import": {
"no-file": "Файл не выбран",
"drop-file": "Перетащите JSON файл или кликните для выбора файла."
"drop-file": "Перетащите JSON файл или кликните для выбора файла.",
"drop-file-csv": "Перетащите 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-type": {
"name": "Название",
"type": "Тип",
"column-type": "Тип колонки",
"client-attribute": "Клиентский атрибут",
"shared-attribute": "Общий атрибут",
"server-attribute": "Серверный атрибут",
"timeseries": "Телеметрия",
"entity-field": "Entity field",
"access-token": "Токен"
},
"stepper-text": {
"select-file": "Выберите файл",
"configuration": "Конфигурация импорта",
"column-type": "Выберите тип колонок",
"creat-entities": "Создание новых объектов",
"done": "Завершено"
},
"message": {
"create-entities": "{{count}} новый(х) объект(ов) было успешно создано.",
"update-entities": "{{count}} объект(ов) успешно обновлено.",
"error-entities": "Возникла ошибка при создании {{count}} объекта(ов)."
}
},
"item": {
"selected": "Выбранные"
@ -1347,7 +1393,8 @@
"edit": "Изменить временное окно",
"date-range": "Диапазон дат",
"last": "Последние",
"time-period": "Период времени"
"time-period": "Период времени",
"hide": "Скрыть"
},
"user": {
"user": "Пользователь",
@ -1419,12 +1466,12 @@
"edit": "Редактировать виджет",
"remove-widget-title": "Вы точно хотите удалить виджет '{{widgetTitle}}'?",
"remove-widget-text": "Внимание, после подтверждения виджет и все связанные с ним данные будут безвозвратно утеряны.",
"timeseries": "Выборка по времени",
"search-data": "Search data",
"no-data-found": "No data found",
"timeseries": "Телеметрия",
"search-data": "Поиск данных",
"no-data-found": "Данные не найдено",
"latest-values": "Последние значения",
"rpc": "Управляющий виджет",
"alarm": "Alarm widget",
"alarm": "Виджет оповещений",
"static": "Статический виджет",
"select-widget-type": "Выберите тип виджета",
"missing-widget-title-error": "Укажите название виджета!",
@ -1521,6 +1568,7 @@
"decimals": "Количество цифр после запятой",
"timewindow": "Временное окно",
"use-dashboard-timewindow": "Использовать временное окно дашборда",
"display-timewindow": "Показывать временное окно",
"display-legend": "Показать легенду",
"datasources": "Источники данных",
"maximum-datasources": "Максимальной количество источников данных равно {{count}}",
@ -1545,7 +1593,10 @@
"edit-action": "Редактировать действие",
"delete-action": "Удалить действие",
"delete-action-title": "Удалить действие виджета",
"delete-action-text": "Вы точно хотите удалить действие виджета '{{actionName}}'?"
"delete-action-text": "Вы точно хотите удалить действие виджета '{{actionName}}'?",
"display-icon": "Показывать иконку в названии",
"icon-color": "Цвет иконки",
"icon-size": "Размер иконки"
},
"widget-type": {
"import": "Импортировать тип виджета",
@ -1616,7 +1667,13 @@
"input-widgets": {
"attribute-not-allowed": "Атрибут не может быть выбран в этом виджете",
"blocked-location": "Геолокация заблокирована в вашем браузере",
"claim-device": "Подтвердить устройство",
"claim-failed": "Не удалось подтвердить устройство!",
"claim-not-found": "Устройство не найдено!",
"claim-successful": "Устройство успешно подтверждено!",
"discard-changes": "Отменить изменения",
"device-name": "Название устройства",
"device-name-required": "Необходимо указать название устройства",
"entity-attribute-required": "Значение атрибута обязателено",
"entity-coordinate-required": "Необходимо указать широту и долготу",
"entity-timeseries-required": "Значение телеметрии обязательно",
@ -1629,6 +1686,8 @@
"no-coordinate-specified": "Ключ для широты/долготы не указан",
"no-support-geolocation": "Ваш браузер не поддерживает геолокацию",
"no-timeseries-selected": "Параметр телеметрии не выбран",
"secret-key": "Секретный ключ",
"secret-key-required": "Необходимо указать секретный ключ",
"switch-attribute-value": "Изменить значение атрибута",
"switch-timeseries-value": "Изменить значение телеметрии",
"timeseries-not-allowed": "Телеметрия не может быть выбрана в этом виджете",
@ -1647,11 +1706,11 @@
},
"custom": {
"widget-action": {
"action-cell-button": "Кнопка действия ячейки",
"action-cell-button": "Кнопка действия в ячейке таблицы",
"row-click": "Действий при щелчке на строку",
"marker-click": "Действия при щелчке на указателе",
"marker-click": "Действия при щелчке на маркер",
"polygon-click": "Действия при щелчке на полигон",
"tooltip-tag-action": "Действие при подсказке"
"tooltip-tag-action": "Действие при нажатии на ссылку в подсказке"
}
},
"language": {

4613
ui/src/app/locale/locale.constant-uk_UA.json

File diff suppressed because it is too large
Loading…
Cancel
Save