From b95dc476cbc59102a336639d84fe879ff6600519 Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Mon, 14 Oct 2019 18:09:18 +0300 Subject: [PATCH 1/6] tool for migrating from Postgres to hybrid mode --- tools/pom.xml | 32 +++ .../client/tools/migrator/MigratorTool.java | 93 ++++++++ .../tools/migrator/PgCaLatestMigrator.java | 179 ++++++++++++++ .../PostgresToCassandraTelemetryMigrator.java | 222 ++++++++++++++++++ .../client/tools/migrator/README.md | 62 +++++ .../client/tools/migrator/WriterBuilder.java | 84 +++++++ 6 files changed, 672 insertions(+) create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/PostgresToCassandraTelemetryMigrator.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/README.md create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java diff --git a/tools/pom.xml b/tools/pom.xml index 7402fe4708..16d511e6db 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -51,6 +51,38 @@ com.google.guava guava + + org.apache.cassandra + cassandra-all + 3.11.4 + compile + + + commons-io + commons-io + 2.6 + compile + + + + + + maven-assembly-plugin + + + + org.thingsboard.client.tools.migrator.MigratorTool + + + + jar-with-dependencies + + + + + + + diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java new file mode 100644 index 0000000000..fdc1917ca8 --- /dev/null +++ b/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; + } + +} diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java new file mode 100644 index 0000000000..667f5e6d0f --- /dev/null +++ b/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 raw = Arrays.stream(line.trim().split("\t")) + .map(String::trim) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toList()); + List 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 castToNumericIfPossible(List values) { + try { + if (values.get(6) != null && NumberUtils.isNumber(values.get(6).toString())) { + Double casted = NumberUtils.createDouble(values.get(6).toString()); + List 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 toValues(List 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 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("\\."); + } +} diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/PostgresToCassandraTelemetryMigrator.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/PostgresToCassandraTelemetryMigrator.java new file mode 100644 index 0000000000..fd8562ce3a --- /dev/null +++ b/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 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 raw = Arrays.stream(line.trim().split("\t")) + .map(String::trim) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toList()); + List 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 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 castToNumericIfPossible(List values) { + try { + if (values.get(6) != null && NumberUtils.isNumber(values.get(6).toString())) { + Double casted = NumberUtils.createDouble(values.get(6).toString()); + List 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 values) { + String key = values.get(0) + "|" + values.get(1) + "|" + values.get(2) + "|" + values.get(3); + partitions.add(key); + } + + private static List toValues(List 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 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("\\."); + } + +} diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md b/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md new file mode 100644 index 0000000000..da79f39701 --- /dev/null +++ b/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md @@ -0,0 +1,62 @@ +# Description: +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 + +# How to build Tool: +Switch to `tools` project in Command Line and execute +`mvn clean compile assembly:single` +It will generate single jar with dependencies. + +# Instruction: + +1. Dump Telemetry table from the source Postgres table. Do not use compression if possible because Tool can only work with uncompressed file +dump ts_kv table -> `pg_dump -h localhost -U postgres -d thingsboard -t ts_kv > ts_kv.dmp` + +2. Dump Latest Telemetry table from the source Postgres table. Do not use compression if possible because Tool can only work with uncompressed file +dump ts_kv_latest -> `pg_dump -h localhost -U postgres -d thingsboard -t ts_kv_latest > ts_kv_latest.dmp` + +3. [Optional] - move dumped files to the machine where cassandra will be hosted + +4. Prepare directory structure: +Tool will 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 + +5. 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 + + +## After tool finished +1. install Cassandra on the instance +2. 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 + +6. Switch Thignsboard to hybrid mode: +Modify Thingsboard properites file `thingsboard.yml` + - DATABASE_TS_TYPE = cassandra + - TS_KV_PARTITIONING = MONTHS + - [optional] - connection properties for cassandra + +7. Start Thingsboard \ No newline at end of file diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java new file mode 100644 index 0000000000..73be1a7fd1 --- /dev/null +++ b/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(); + } +} From 042db525ae3494573b39aa1e1f643ff4c3f613a0 Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Mon, 14 Oct 2019 18:10:20 +0300 Subject: [PATCH 2/6] fix dependencies --- tools/pom.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/tools/pom.xml b/tools/pom.xml index 16d511e6db..b980123fb2 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -55,13 +55,10 @@ org.apache.cassandra cassandra-all 3.11.4 - compile commons-io commons-io - 2.6 - compile From 09a164be0fde68183f24f3082753e4e504df3e51 Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Tue, 15 Oct 2019 10:57:47 +0300 Subject: [PATCH 3/6] Migrator tool: fix dependencies and update docs --- tools/pom.xml | 4 + .../tools/migrator/PgCaLatestMigrator.java | 2 +- .../client/tools/migrator/README.md | 73 +++++++++++++------ 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/tools/pom.xml b/tools/pom.xml index b980123fb2..0d044365a0 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -56,6 +56,10 @@ cassandra-all 3.11.4 + + com.datastax.cassandra + cassandra-driver-core + commons-io commons-io diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java index 667f5e6d0f..a109b0a5dd 100644 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java +++ b/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md b/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md index da79f39701..70c5dafcaf 100644 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md +++ b/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md @@ -1,5 +1,6 @@ # Description: -Tool used for migrating ThingsBoard into hybrid mode from Postgres. +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 @@ -7,56 +8,80 @@ But in general here are few benchmarks: 3. Generation SSTables from dump -> 100GB = 3 hours 4. 100GB Dump file will be converted into SSTable with size about 18GB -# How to build Tool: -Switch to `tools` project in Command Line and execute -`mvn clean compile assembly:single` -It will generate single jar with dependencies. +# Tool build Instruction: +Switch to `tools` module in Command Line and execute -# Instruction: + 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: -1. Dump Telemetry table from the source Postgres table. Do not use compression if possible because Tool can only work with uncompressed file -dump ts_kv table -> `pg_dump -h localhost -U postgres -d thingsboard -t ts_kv > ts_kv.dmp` + `pg_dump -h localhost -U postgres -d thingsboard -t ts_kv_latest > ts_kv_latest.dmp` -2. Dump Latest Telemetry table from the source Postgres table. Do not use compression if possible because Tool can only work with uncompressed file -dump ts_kv_latest -> `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 -3. [Optional] - move dumped files to the machine 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` -4. Prepare directory structure: -Tool will 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 -5. Run tool: -*Note: if you run this tool on remote instance - don't forget to execute this command in screen to avoid unexpected termination +#### 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 + -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 + -castEnable false +``` +Tool execution time depends on DB size, CPU resources and Disk throughput -## After tool finished -1. install Cassandra on the instance -2. Using `cqlsh` create `thingsboard` keyspace and requred tables from this file `schema-ts.cql` +## 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 +``` -6. Switch Thignsboard to hybrid mode: +## Switch Thignsboard into Hybrid Mode + Modify Thingsboard properites file `thingsboard.yml` + - DATABASE_TS_TYPE = cassandra - - TS_KV_PARTITIONING = MONTHS - - [optional] - connection properties for cassandra + - TS_KV_PARTITIONING = MONTHS -7. Start Thingsboard \ No newline at end of file +# Final steps +Start Thingsboard and verify migration \ No newline at end of file From 26fa1fd832972aef0a88a1caf6a0a6dacdf5cd0b Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 15 Oct 2019 13:23:43 +0300 Subject: [PATCH 4/6] Improve widget device claim --- .../json/system/widget_bundles/input_widgets.json | 14 +++++++------- ui/src/app/locale/locale.constant-en_US.json | 8 ++++++++ ui/src/app/locale/locale.constant-ru_RU.json | 10 +++++++++- ui/src/app/locale/locale.constant-uk_UA.json | 8 ++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/application/src/main/data/json/system/widget_bundles/input_widgets.json b/application/src/main/data/json/system/widget_bundles/input_widgets.json index c6df5a1fdb..47e25203e4 100644 --- a/application/src/main/data/json/system/widget_bundles/input_widgets.json +++ b/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": "
\n
\n \n \n \n
\n
Device name is required.
\n
\n
\n \n \n \n
\n
Device secret is required.
\n
\n
\n
\n Claim\n
", - "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": "
\n
\n \n \n \n
\n
{{requiredErrorDevice}}
\n
\n
\n \n \n \n
\n
{{requiredErrorSecretKey}}
\n
\n
\n
\n
\n {{labelClaimButon}}\n
\n
", + "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\":{}}" } } ] -} \ No newline at end of file +} diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 006db27e14..42a34cbb46 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -1699,7 +1699,13 @@ }, "input-widgets": { "attribute-not-allowed": "Attribute parameter cannot be used in this widget", + "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-timeseries-required": "Entity timeseries is required", @@ -1710,6 +1716,8 @@ "no-image": "No image", "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", diff --git a/ui/src/app/locale/locale.constant-ru_RU.json b/ui/src/app/locale/locale.constant-ru_RU.json index c7d090eefa..b79be83801 100644 --- a/ui/src/app/locale/locale.constant-ru_RU.json +++ b/ui/src/app/locale/locale.constant-ru_RU.json @@ -1615,13 +1615,21 @@ }, "input-widgets": { "attribute-not-allowed": "Атрибут не может быть выбран в этом виджете", + "claim-device": "Подтвердить устройство", + "claim-failed": "Не удалось подтвердить устройство!", + "claim-not-found": "Устройство не найдено!", + "claim-successful": "Устройство успешно подтверждено!", "discard-changes": "Отменить изменения", + "device-name": "Название устройства", + "device-name-required": "Необходимо указать название устройства", "entity-attribute-required": "Значение атрибута обязателено", "entity-timeseries-required": "Значение телеметрии обязательно", "not-allowed-entity": "Выбраный объект не имеет общих атрибутов", "no-attribute-selected": "Атрибут не выбран", "no-entity-selected": "Объект не выбран", "no-timeseries-selected": "Параметр телеметрии не выбран", + "secret-key": "Секретный ключ", + "secret-key-required": "Необходимо указать секретный ключ", "switch-attribute-value": "Изменить значение атрибута", "switch-timeseries-value": "Изменить значение телеметрии", "timeseries-not-allowed": "Телеметрия не может быть выбрана в этом виджете", @@ -1665,4 +1673,4 @@ "cs_CZ": "Чешский" } } -} \ No newline at end of file +} diff --git a/ui/src/app/locale/locale.constant-uk_UA.json b/ui/src/app/locale/locale.constant-uk_UA.json index ad8b5682fa..d0c15734bd 100644 --- a/ui/src/app/locale/locale.constant-uk_UA.json +++ b/ui/src/app/locale/locale.constant-uk_UA.json @@ -2182,13 +2182,21 @@ }, "input-widgets": { "attribute-not-allowed": "Атрибут не може бути вибраний в цьому віджеті", + "claim-device": "Підтвердити пристрій", + "claim-failed": "Не вдалося підтвердити пристрій!", + "claim-not-found": "Пристрій не знайдено!", + "claim-successful": "Пристрій успішно підтверджено!", "discard-changes": "Скасувати зміни", + "device-name": "Назва пристрою", + "device-name-required": "Необхідно вказати назву пристрою", "entity-attribute-required": "Значення атрибута обов'язкове", "entity-timeseries-required": "Значення телеметрії обов'язкове", "not-allowed-entity": "Обрана сутність не має спільних атрибутів", "no-attribute-selected": "Атрибут не вибрано", "no-entity-selected": "Сутність не вибрано", "no-timeseries-selected": "Параметр телеметрії не вибрано", + "secret-key": "Секретний ключ", + "secret-key-required": "Необхідно вказати секретний ключ", "switch-attribute-value": "Змінити значення атрибута", "switch-timeseries-value": "Змінити значення телеметрії", "timeseries-not-allowed": "Телеметрія не може бути вибрана в цьому віджеті", From 502300cedde4def83b3119ee4e69285e6e793434 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 15 Oct 2019 13:57:22 +0300 Subject: [PATCH 5/6] License Header Fix --- .../thingsboard/client/tools/migrator/PgCaLatestMigrator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java index a109b0a5dd..667f5e6d0f 100644 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java +++ b/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaLatestMigrator.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, From ddce20ac01ed40bc7f97f6095508812aa0dee5e1 Mon Sep 17 00:00:00 2001 From: Chantsova Ekaterina Date: Thu, 10 Oct 2019 11:45:01 +0300 Subject: [PATCH 6/6] Missed translations for RU & UA --- ui/src/app/locale/locale.constant-en_US.json | 12 +- ui/src/app/locale/locale.constant-ru_RU.json | 87 ++++-- ui/src/app/locale/locale.constant-uk_UA.json | 283 +++++++++++-------- 3 files changed, 246 insertions(+), 136 deletions(-) diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 42a34cbb46..4eb0d9b7aa 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/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}}'.
Entity View aliases must be unique whithin the dashboard.", + "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
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.", diff --git a/ui/src/app/locale/locale.constant-ru_RU.json b/ui/src/app/locale/locale.constant-ru_RU.json index b79be83801..9f1d582e77 100644 --- a/ui/src/app/locale/locale.constant-ru_RU.json +++ b/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}}'.
В рамках одного дашборда псевдонимы представлений объектов должны быть уникальными.", "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}}', т.к. он используется следующими виджетами:
{{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": "Импортировать тип виджета", @@ -1648,11 +1699,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": { diff --git a/ui/src/app/locale/locale.constant-uk_UA.json b/ui/src/app/locale/locale.constant-uk_UA.json index d0c15734bd..df720c6cf8 100644 --- a/ui/src/app/locale/locale.constant-uk_UA.json +++ b/ui/src/app/locale/locale.constant-uk_UA.json @@ -49,6 +49,8 @@ "import": "Імпортувати", "export": "Експортувати", "share-via": "Поділитися через {{provider}}", + "continue": "Продовжити", + "discard-changes": "Скасувати зміни", "move": "Перемістити", "select": "Вибрати" }, @@ -193,20 +195,20 @@ "filter-type": "Тип фільтра", "filter-type-required": "Необхідно вказати тип фільтра.", "entity-filter-no-entity-matched": "Не знайдено жодних сутностей, які відповідають вказаному фільтру.", - "no-entity-filter-specified": "No entity filter specified", - "root-state-entity": "Use dashboard state entity as root", - "group-state-entity": "Use dashboard state entity as entity group", - "root-entity": "Root entity", - "state-entity-parameter-name": "State entity parameter name", - "default-state-entity": "Default state entity", - "default-state-entity-group": "Default state entity group", - "default-entity-parameter-name": "By default", - "max-relation-level": "Max relation level", - "unlimited-level": "Unlimited level", - "state-entity": "Dashboard state entity", - "entities-of-group-state-entity": "Entities from dashboard state entity group", - "all-entities": "All entities", - "any-relation": "any" + "no-entity-filter-specified": "Фільтр обїектів не вказано", + "root-state-entity": "Використовувати сутінсть стану як кореневу", + "group-state-entity": "Використовувати групу сутностей стану як кореневу", + "root-entity": "Коренева сутність", + "state-entity-parameter-name": "Параметр сутності стану", + "default-state-entity": "Сутність стану за замовчуванням", + "default-state-entity-group": "Група сутностей стану за замовчуванням", + "default-entity-parameter-name": "За замовчуванням", + "max-relation-level": "Максимальна глибина відносин", + "unlimited-level": "Необмежена глибина", + "state-entity": "Сутність стану панелі пристроїв", + "entities-of-group-state-entity": "Сутності із групи сутностей стану", + "all-entities": "Всі сутності", + "any-relation": "не вказано" }, "asset": { "asset": "Актив", @@ -276,7 +278,9 @@ "remove-assets-from-group": "Ви впевнені, що хочете видалити { count, plural, 1 {1 актив} other {# актив} } з групи '{entityGroup}'?", "group": "Група активів", "list-of-groups": "{ count, plural, 1 {Одна група активів} other {Список # груп активів} }", - "group-name-starts-with": "Групи активів, чиї імена починаються з '{{prefix}}'" + "group-name-starts-with": "Групи активів, чиї імена починаються з '{{prefix}}'", + "import": "Імпортувати активи", + "asset-file": "Файл з активами" }, "attribute": { "attributes": "Атрибути", @@ -400,8 +404,8 @@ "encoder": "Кодер", "test-decoder-fuction": "Тестування функції декодера", "test-encoder-fuction": "Тестування функції кодера", - "decoder-input-params": "Параметри введення декодера", - "encoder-input-params": "Параметри введення кодера", + "decoder-input-params": "Вхідні параметри декодера", + "encoder-input-params": "Вхідні параметри кодера", "payload": "Вхідне повідомлення", "payload-content-type": "Тип контенту вхідного повідомлення", "payload-content": "Зміст вхідного повідомлення", @@ -411,8 +415,8 @@ "test": "Тест", "metadata": "Метадані", "metadata-required": "Записи метаданих не можуть бути порожніми.", - "integration-metadata": "Інтеграція метаданих", - "integration-metadata-required": "Записи інтеграції метаданих не можуть бути порожніми.", + "integration-metadata": "Метедані інтеграції", + "integration-metadata-required": "Параметри метаданих інтеграції не можуть бути порожніми.", "output": "Вихідні дані", "import": "Імпорт перетворювача даних", "export": "Експорт перетворювача даних", @@ -482,7 +486,7 @@ "group": "Група клієнтів", "list-of-groups": "{ count, plural, 1 {Одна група клієнтів} other {Список # груп клієнтів} }", "group-name-starts-with": "Групи клієнтів, імена яких починаються з '{{prefix}}'", - "select-default-customer": "Виберати клієнта за замовчуванням", + "select-default-customer": "Вибрати клієнта за замовчуванням", "default-customer": "Клієнт за замовчуванням", "default-customer-required": "Необхідно вказати клієнта за замовчуванням для налагодження панелі візуалізації на рівні замовника", "allow-white-labeling": "Дозволити брендування" @@ -650,7 +654,7 @@ "show-details": "Показати деталі", "hide-details": "Приховати деталі", "select-state": "Виберіть цільовий стан", - "state-controller": "Контроль стану" + "state-controller": "Контроллер стану" }, "datakey": { "settings": "Налаштування", @@ -783,6 +787,8 @@ "public": "Публічно", "device-public": "Пристрій є публічним", "select-device": "Виберіть пристрій", + "import": "Імпортувати пристрої", + "device-file": "Файл з пристроями", "selected-devices": "{ count, plural, 1 {1 пристрій} other {# пристрої} } вибрано", "search": "Шукати пристрої", "select-group-to-add": "Виберіть цільову групу, щоб додати вибраний пристрій", @@ -884,9 +890,9 @@ "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 {Список # правил ланцюжків} }", + "type-rulechain": "Ланцюжок правил", + "type-rulechains": "Ланцюжки правил", + "list-of-rulechains": "{ count, plural, 1 {Один ланцюжок правил} other {Список # ланцюжків правил} }", "rulechain-name-starts-with": "Правило ланцюжків, імена яких починаються '{{prefix}}'", "type-scheduler-event": "Scheduler event", "type-scheduler-events": "Scheduler events", @@ -904,6 +910,7 @@ "search": "Пошук сутностей", "selected-entities": "{ count, plural, 1 {1 сутність} other {# сутності} } вибрано", "entity-name": "Ім'я сутності", + "entity-label": "Мітка сутності", "details": "Подробиці сутності", "no-entities-prompt": "Сутності не знайдено", "no-data": "Немає даних для відображення", @@ -966,7 +973,7 @@ }, "column-type-required": "Необхідно вказати тип стовпця.", "entity-field": { - "created-time": "Створений час", + "created-time": "Час створення", "name": "Ім'я", "type": "Тип", "assigned_customer": "Призначений клієнт", @@ -986,7 +993,7 @@ "sort-order": { "asc": "У порядку зростання", "desc": "У порядку зменшення", - "none": "Не має" + "none": "Немає" }, "details-mode": { "on-row-click": "Клацніть на рядок", @@ -995,7 +1002,7 @@ }, "add-to-group": "Додати до групи", "move-to-group": "Перемістити до групи", - "select-entity-group": "Виберати групу сутностей", + "select-entity-group": "Виберіть групу сутностей", "no-entity-groups-matching": "Не знайдено жодних груп сутностей, що відповідають '{{entityGroup}}'.", "target-entity-group-required": "Необхідно вказати цільову групу сутності.", "remove-from-group": "Видалити з групи", @@ -1013,7 +1020,7 @@ "enable-assets-management": "Увімкнути керування активами", "enable-devices-management": "Увімкнути керування пристроями", "enable-dashboards-management": "Увімкнути керування панелями візуалізації", - "open-details-on": "Відкрити деталі сутності", + "open-details-on": "Відкрити деталі сутності по", "select-existing": "Виберіть існуючу групу сутностей", "create-new": "Створити нову групу сутностей", "new-entity-group-name": "Нове ім'я групи сутностей", @@ -1023,77 +1030,78 @@ "entity-group-name-filter-required": "Необхідно задати назву групи сутностей." }, "entity-view": { - "entity-view": "Перегляд сутності", - "entity-view-required": "Необхідно вказати перегляд сутності.", - "entity-views": "Перегляди сутностей", - "management": "Керування переглядом сутностей", - "view-entity-views": "Переглянути перегляд сутностей", - "entity-view-alias": "Псевдонім перегляду сутності", - "aliases": "Псевдоніми перегляду сутності", + "entity-view": "Представлення сутності", + "entity-view-required": "Необхідно вказати представлення сутності.", + "entity-views": "Представлення сутностей", + "management": "Керування представленням сутностей", + "view-entity-views": "Переглянути представлення сутностей", + "entity-view-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}}'.
Псевдоніми перегляду повинні бути унікальними на панелі візуалізації.", + "duplicate-alias-error": "Псевдонім з такою назвою вже існує '{{alias}}'.
Псевдоніми представлення повинні бути унікальними на панелі візуалізації.", "configure-alias": "Налаштувати псевдонім '{{alias}}'", "no-entity-views-matching": "Сутності, які відповідають '{{entity}}' не знайдені.", "alias": "Псевдонім", - "alias-required": "Необхідно вказати псевдонім перегляду сутності.", - "remove-alias": "Видалити псевдонім перегляду сутності", - "add-alias": "Додати псевдонім перегляду сутності", - "name-starts-with": "Ім'я перегляду сутності починається з", - "entity-view-list": "Список перегляду сутності", + "alias-required": "Необхідно вказати псевдонім представлення сутності.", + "remove-alias": "Видалити псевдонім представлення сутності", + "add-alias": "Додати псевдонім представлення сутності", + "name-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": "Додати представлення сутності", "assign-to-customer": "Призначити клієнту", - "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 перегляд сутності} other {# перегляди сутностей } } клієнту", - "delete-entity-views": "Видалити перегляди сутностей", - "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 перегляд сутності } other {# перегляди сутностей } }?", - "delete-entity-views-action-title": "Видалити { count, plural, 1 {1 перегляд сутності } other {# перегляди сутностей } }", - "delete-entity-views-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": "Введіть тип перегляду сутності", - "any-entity-view": "Будь-який перегляд сутності", - "no-entity-view-types-matching": "Не знайдено жодних типів перегляду сутності, що відповідають '{{entitySubtype}}'.", - "entity-view-type-list-empty": "Не вибрано тип перегляду сутності.", - "entity-view-types": "Типи перегляду сутності", + "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 представлення сутності} other {# представлення сутностей } } клієнту", + "delete-entity-views": "Видалити представлення сутностей", + "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 представлення сутності } other {# представлення сутностей } }?", + "delete-entity-views-action-title": "Видалити { count, plural, 1 {1 представлення сутності } other {# представлення сутностей } }", + "delete-entity-views-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": "Введіть тип представлення сутності", + "any-entity-view": "Будь-яке представлення сутності", + "no-entity-view-types-matching": "Не знайдено жодних типів представлення сутності, що відповідають '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Не вибрано тип представлення сутності.", + "entity-view-types": "Типи представлення сутності", "name": "Ім'я", "name-required": "Необхідно вказати ім'я.", "description": "Опис", "events": "Події", "details": "Деталі", - "copyId": "Скопіювати Id перегляду сутності", + "copyId": "Скопіювати Id представлення сутності", "assignedToCustomer": "Призначений клієнту", - "unable-entity-view-device-alias-title": "Неможливо видалити псевдонім перегляду сутності", + "unable-entity-view-device-alias-title": "Неможливо видалити псевдонім представлення сутності", "unable-entity-view-device-alias-text": "Не вдалося видалити псевдонім пристрою'{{entityViewAlias}}', так як він використовується наступним(и) віджетом(ами):
{{widgetsList}}", - "select-entity-view": "Виберати перегляд сутності", - "make-public": "Зробити перегляд сутності публічним", + "select-entity-view": "Вибрати представлення сутності", + "make-public": "Зробити представлення сутності публічним", + "make-private": "Зробити представлення сутності приватним", "start-date": "Дата початку", "start-ts": "Час початку", "end-date": "Дата закінчення", @@ -1109,9 +1117,13 @@ "timeseries-placeholder": "Телеметрія", "target-entity": "Цільова сутність", "attributes-propagation": "Поширення атрибутів", - "attributes-propagation-hint": "Перегляд сутностей автоматично копіюватиме вказані атрибути з цільової сутності кожного разу, коли ви зберігаєте або оновлюєте ці перегляди. В цілях продуктивності, атрибути цільової сутності не поширюються на представлення сутності на кожній зміні їх атрибутів. Можна ввімкнути автоматичне поширення, налаштувавши у вашому ланцюжку правило \"copy to view\" і пов'язуючи його з повідомленнями типу \"Post attributes\" і \"Attributes Updated\"..", + "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": { "events": "Події", @@ -1264,6 +1276,7 @@ "modbus-add-server": "Додати сервер/ведений пристрій", "modbus-add-server-prompt": "Будь ласка, додайте сервер/ведений пристрій", "modbus-transport": "Транспорт", + "modbus-tcp-reconnect": "Перепідключатися автоматично", "modbus-port-name": "Ім'я послідовного порту", "modbus-encoding": "Кодування", "modbus-parity": "Паритет", @@ -1321,7 +1334,8 @@ "add-item-text": "Додати новий елемент", "no-items-text": "Не знайдено жодного елемента", "item-details": "Деталі елемента", - "delete-item": "Видалити елементи", + "delete-item": "Видалити елемент", + "delete-items": "Видалити елементи", "scroll-to-top": "Перейти угору" }, "help": { @@ -1338,7 +1352,39 @@ "import": { "no-file": "Не вибрано жодного файлу", "drop-file": "Перетягніть JSON файл, або клацніть, щоб вибрати файл для завантаження.", - "drop-csv-file": "Перетягніть CSV файл, або клацніть, щоб вибрати файл для завантаження." + "drop-csv-file": "Перетягніть 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}} сутності(ей)." + } }, "integration": { "integration": "Інтеграція", @@ -1681,7 +1727,7 @@ "no-link-labels-found": "Не знайдено жодних міток посилання", "no-link-label-matching": "Мітка'{{label}}' не знайдена.", "create-new-link-label": "Створити нову!", - "type-filter": "Filter", + "type-filter": "Фільтр", "type-filter-details": "Фільтрувати вхідні повідомлення з заданими умовами", "type-enrichment": "Насичення", "type-enrichment-details": "Додати додаткову інформацію до метаданих повідомлень", @@ -1690,7 +1736,7 @@ "type-action": "Дія", "type-action-details": "Виконати задану дію", "type-analytics": "Аналітика", - "type-analytics-details": "Виконати аналіз потокових або збережених даних", + "type-analytics-details": "Виконує аналіз потокових або збережених даних", "type-external": "Зовнішній", "type-external-details": "Взаємодіє з зовнішньою системою", "type-rule-chain": "Ланцюг правил", @@ -1711,12 +1757,13 @@ "metadata-required": "Записи метаданих не можуть бути порожніми.", "output": "Вихід", "test": "Тест", - "help": "Допомога" + "help": "Допомога", + "reset-debug-mode": "Вимкнути режим налогодження у всіх правилах" }, "scheduler": { "scheduler": "Планувальник", - "scheduler-event": "Scheduler event Планування події. Запланувати подію. Подія планувальника", - "select-scheduler-event": "Виберати подію", + "scheduler-event": "Подія планувальника", + "select-scheduler-event": "Вибрати подію", "no-scheduler-events-matching": "Не знайдено жодних подій, які відповідають '{{entity}}'.", "scheduler-event-required": "Необхвдно вказати заплановану подію", "management": "Управління планувальником", @@ -1726,7 +1773,7 @@ "created-time": "Час створення", "name": "Ім'я", "type": "Тип", - "assigned_customer": "Призначений клієнт", + "created_customer": "Створено клієнтом", "edit-scheduler-event": "Редагувати подію", "delete-scheduler-event": "Видалити подію", "no-scheduler-events": "Не знайдено жодних запланованих подій", @@ -1748,8 +1795,9 @@ "daily": "Щодня", "weekly": "Щотижня", "repeats-required": "Потрібно вказати повторення.", - "repeat-on": "Повторити на", - "ends-on": "Завершити на", + "repeat-on": "Повторювати по", + "repeat-every": "Повторювати кожний(у)", + "ends-on": "Завершення", "sunday-label": "Нд", "monday-label": "Пн", "tuesday-label": "Вт", @@ -1784,7 +1832,7 @@ "navigate-next": "Перейти далі", "starting-from": "Починаючи з", "until": "до", - "on": "на", + "on": "в", "sunday": "Неділя", "monday": "Понеділок", "tuesday": "Вівторок", @@ -1792,17 +1840,22 @@ "thursday": "Четвер", "friday": "П'ятниця", "saturday": "Субота", - "originator": "Засновник", - "single-entity": "Самостійна сутність", + "originator": "Ініціатор", + "single-entity": "Єдина сутність", "group-of-entities": "Група сутностей", "single-device": "Один пристрій", "group-of-devices": "Група пристроїв", - "message-body": "Тіло повідомлення", + "message-body": "Текст повідомлення", "target": "Ціль", "rpc-method": "Метод", "rpc-method-required": "Необхідно вказати метод", - "rpc-params": "Парами. Парні.", - "select-dashboard-state": "Виберіть стан панелі візуалізації" + "rpc-params": "Параметри", + "select-dashboard-state": "Виберіть стан панелі візуалізації", + "hours": "Години", + "minutes": "Хвилини", + "seconds": "Секунди", + "time-interval-required": "Необхідно вказати часовий інтервал", + "time-unit-required": "Необхідно вказати одиниці часу" }, "report": { "report-config": "Конфігурація звіту", @@ -1828,8 +1881,8 @@ "bcc": "Bcc", "subject": "Тема", "subject-required": "Необхідно вказати тему.", - "body": "Тіло", - "body-required": "Необідно вказати тіло." + "body": "Текст", + "body-required": "Лист не може бути пустим." }, "blob-entity": { "blob-entity": "Blob сутності", @@ -1841,10 +1894,10 @@ "clear-search": "Очистити пошук", "no-blob-entities-prompt": "Файлів не знайдено", "report": "Звіт", - "created-time": "Створено час", + "created-time": "Час створення", "name": "Ім'я", "type": "Тип", - "assigned_customer": "Призначений клієнт", + "created_customer": "Створено клієнтом", "download-blob-entity": "Завантажити файл", "delete-blob-entity": "Видалити файл", "delete-blob-entity-title": "Ви впевнені, що хочете видалити файл '{{blobEntityName}}'?", @@ -1852,7 +1905,7 @@ }, "timezone": { "timezone": "Часовий пояс", - "select-timezone": "Виберати часовий пояс ", + "select-timezone": "Вибрати часовий пояс ", "no-timezones-matching": "Не знайдено жодних часових поясів, які відповідають '{{timezone}}'.", "timezone-required": "Необхідно вказати часовий пояс." }, @@ -1877,7 +1930,7 @@ "description": "Опис", "details": "Деталі", "events": "Події", - "copyId": "Крпіювати Id власника", + "copyId": "Копіювати Id власника", "idCopiedMessage": "Id власника скопійовано в буфер обміну", "select-tenant": "Вибрати власника", "no-tenants-matching": "Не знайдено жодних власників, які відповідають '{{entity}}'.", @@ -1908,7 +1961,8 @@ "edit": "Редагувати вікно часу", "date-range": "Проміжок часу", "last": "Останнє", - "time-period": "Період часу" + "time-period": "Період часу", + "hide": "Приховати" }, "user": { "user": "Користувач", @@ -2088,6 +2142,7 @@ "decimals": "Кількість цифр після коми", "timewindow": "Вікно часу", "use-dashboard-timewindow": "Використати вікно часу на панелі візуалізації", + "display-timewindow": "Показувати вікно часу", "display-legend": "Показати легенду", "datasources": "Джерела даних", "maximum-datasources": "Максимально { count, plural, 1 {1 дозволене джерело даних.} other {# дозволені джерела даних } }", @@ -2112,7 +2167,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": "Імпортувати тип віджета", @@ -2240,17 +2298,18 @@ "domain-name": "Доменне ім'я" }, "icon": { - "icon": "веб-іконка", - "select-icon": "Виберіть веб-іконку", - "material-icons": "Матеріал веб-іконки", - "show-all": "Показати всі веб-іконки" + "icon": "Іконка", + "select-icon": "Виберіть Іконку", + "material-icons": "Іконки в стилі Material", + "show-all": "Показати всі іконки" }, "custom": { "widget-action": { - "action-cell-button": "Кнопка дії клітинки", + "action-cell-button": "Кнопка дії в комірці таблиці", "row-click": "Клацніть на рядок", "marker-click": "Клацніть на маркер", - "tooltip-tag-action": "Дії при підказці" + "polygon-click": "Дія при натисканні на полігон", + "tooltip-tag-action": "Дія при натисканні на посилання в підказці" } }, "language": {