From b7ed7ea039e96e65218eba375a14d36d2091e7f0 Mon Sep 17 00:00:00 2001 From: hagaic Date: Tue, 21 Aug 2018 00:58:45 +0300 Subject: [PATCH 001/307] hybrid db initial commit --- .../install/ThingsboardInstallService.java | 2 +- .../CassandraDatabaseSchemaService.java | 4 ++-- .../CassandraDatabaseUpgradeService.java | 4 ++-- .../src/main/resources/thingsboard.yml | 7 +++++- .../dao/cassandra/CassandraCluster.java | 4 ++-- .../cassandra/CassandraInstallCluster.java | 4 ++-- .../dao/cassandra/CassandraQueryOptions.java | 4 ++-- .../dao/cassandra/CassandraSocketOptions.java | 4 ++-- .../dao/sql/timeseries/JpaTimeseriesDao.java | 3 ++- .../CassandraBaseTimeseriesDao.java | 3 ++- .../server/dao/util/BufferedRateLimiter.java | 2 +- .../server/dao/util/NoSqlAnyDao.java | 22 +++++++++++++++++++ .../server/dao/util/NoSqlTsDao.java | 22 +++++++++++++++++++ .../thingsboard/server/dao/util/SqlTsDao.java | 22 +++++++++++++++++++ 14 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index f863d0bb2a..d0add1aa89 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -116,7 +116,7 @@ public class ThingsboardInstallService { log.info("Installing DataBase schema..."); - databaseSchemaService.createDatabaseSchema(); + databaseSchemaService.createDatabaseSchema();//TODO log.info("Loading system data..."); diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java index dd76b21844..6eeeb0a620 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java @@ -20,7 +20,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import org.thingsboard.server.service.install.cql.CQLStatementsParser; import java.nio.file.Path; @@ -28,7 +28,7 @@ import java.nio.file.Paths; import java.util.List; @Service -@NoSqlDao +@NoSqlAnyDao @Profile("install") @Slf4j public class CassandraDatabaseSchemaService implements DatabaseSchemaService { diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index f4ff92cf23..2cbd167242 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -23,7 +23,7 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.dao.cassandra.CassandraCluster; import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; import org.thingsboard.server.dao.dashboard.DashboardService; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import org.thingsboard.server.service.install.cql.CQLStatementsParser; import org.thingsboard.server.service.install.cql.CassandraDbHelper; @@ -45,7 +45,7 @@ import static org.thingsboard.server.service.install.DatabaseHelper.TENANT_ID; import static org.thingsboard.server.service.install.DatabaseHelper.TITLE; @Service -@NoSqlDao +@NoSqlAnyDao @Profile("install") @Slf4j public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 743a8607b5..5573c7538b 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -151,6 +151,7 @@ quota: # Enable Host API Limits enabled: "${QUOTA_TENANT_ENABLED:false}" # Array of whitelist tenants + # Array of whitelist tenants whitelist: "${QUOTA_TENANT_WHITELIST:}" # Array of blacklist tenants blacklist: "${QUOTA_HOST_BLACKLIST:}" @@ -160,6 +161,10 @@ quota: database: type: "${DATABASE_TYPE:sql}" # cassandra OR sql + entities: + type: "${DATABASE_TYPE:sql}" # cassandra OR sql + ts: + type: "${DATABASE_TYPE:cassandra}" # cassandra OR sql # Cassandra driver configuration parameters cassandra: @@ -206,7 +211,7 @@ cassandra: write_consistency_level: "${CASSANDRA_WRITE_CONSISTENCY_LEVEL:ONE}" default_fetch_size: "${CASSANDRA_DEFAULT_FETCH_SIZE:2000}" # Specify partitioning size for timestamp key-value storage. Example MINUTES, HOURS, DAYS, MONTHS,INDEFINITE - ts_key_value_partitioning: "${TS_KV_PARTITIONING:MONTHS}" + ts_key_value_partitioning: "${TS_KV_PARTITIONING:INDEFINITE}" ts_key_value_ttl: "${TS_KV_TTL:0}" buffer_size: "${CASSANDRA_QUERY_BUFFER_SIZE:200000}" concurrent_limit: "${CASSANDRA_QUERY_CONCURRENT_LIMIT:1000}" diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java index 567523228a..19409ad146 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java @@ -17,12 +17,12 @@ package org.thingsboard.server.dao.cassandra; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component -@NoSqlDao +@NoSqlAnyDao public class CassandraCluster extends AbstractCassandraCluster { @Value("${cassandra.keyspace_name}") diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java index 02968078f1..247a204ee5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java @@ -17,12 +17,12 @@ package org.thingsboard.server.dao.cassandra; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component -@NoSqlDao +@NoSqlAnyDao @Profile("install") public class CassandraInstallCluster extends AbstractCassandraCluster { diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java index 474cad7c6a..1f09342ca1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java @@ -21,14 +21,14 @@ import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component @Configuration @Data -@NoSqlDao +@NoSqlAnyDao public class CassandraQueryOptions { @Value("${cassandra.query.default_fetch_size}") diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java index 8171ccc07f..15263c8b34 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java @@ -20,14 +20,14 @@ import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component @Configuration @Data -@NoSqlDao +@NoSqlAnyDao public class CassandraSocketOptions { @Value("${cassandra.socket.connect_timeout}") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java index e5f145f0fd..df72943249 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java @@ -42,6 +42,7 @@ import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; import org.thingsboard.server.dao.timeseries.TimeseriesDao; import org.thingsboard.server.dao.timeseries.TsInsertExecutorType; import org.thingsboard.server.dao.util.SqlDao; +import org.thingsboard.server.dao.util.SqlTsDao; import javax.annotation.Nullable; import javax.annotation.PostConstruct; @@ -58,7 +59,7 @@ import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; @Component @Slf4j -@SqlDao +@SqlTsDao public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService implements TimeseriesDao { @Value("${sql.ts_inserts_executor_type}") diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index c025e64a31..be36d3b156 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -47,6 +47,7 @@ import org.thingsboard.server.common.data.kv.TsKvQuery; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.nosql.CassandraAbstractAsyncDao; import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlTsDao; import javax.annotation.Nullable; import javax.annotation.PostConstruct; @@ -68,7 +69,7 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; */ @Component @Slf4j -@NoSqlDao +@NoSqlTsDao public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implements TimeseriesDao { private static final int MIN_AGGREGATION_STEP_MS = 1000; diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java index 817845b54a..eab05b2fdb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java @@ -34,7 +34,7 @@ import java.util.concurrent.atomic.AtomicInteger; @Component @Slf4j -@NoSqlDao +@NoSqlAnyDao public class BufferedRateLimiter implements AsyncRateLimiter { private final ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java new file mode 100644 index 0000000000..7e7aefa3c3 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +@ConditionalOnExpression("'${database.type}'=='cassandra' || '${database.ts.type}'=='cassandra'") +public @interface NoSqlAnyDao { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java new file mode 100644 index 0000000000..748365fe41 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +@ConditionalOnProperty(prefix = "database.ts", value = "type", havingValue = "cassandra") +public @interface NoSqlTsDao { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java new file mode 100644 index 0000000000..0470486408 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +@ConditionalOnProperty(prefix = "database.ts", value = "type", havingValue = "sql") +public @interface SqlTsDao { +} From c9fc57b3fd88137ac48c2fa9d47cf12a7703993a Mon Sep 17 00:00:00 2001 From: hagaic Date: Tue, 21 Aug 2018 02:05:45 +0300 Subject: [PATCH 002/307] make hybrid db optional --- .../thingsboard/server/install/ThingsboardInstallService.java | 2 +- application/src/main/resources/thingsboard.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index d0add1aa89..884afc06c9 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -116,7 +116,7 @@ public class ThingsboardInstallService { log.info("Installing DataBase schema..."); - databaseSchemaService.createDatabaseSchema();//TODO + databaseSchemaService.createDatabaseSchema();//TODO issue 1005 - create both SQL and C* schemas in hybrid mode log.info("Loading system data..."); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 5573c7538b..a379e472aa 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -164,7 +164,8 @@ database: entities: type: "${DATABASE_TYPE:sql}" # cassandra OR sql ts: - type: "${DATABASE_TYPE:cassandra}" # cassandra OR sql + type: "${DATABASE_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) + # Cassandra driver configuration parameters cassandra: From 0ef899d4692fe1a7cc551690abb2db8b74f84f0b Mon Sep 17 00:00:00 2001 From: zhsh Date: Tue, 21 Aug 2018 21:12:13 +0800 Subject: [PATCH 003/307] Fix #1022 --- .../action/manage-widget-actions.directive.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ui/src/app/components/widget/action/manage-widget-actions.directive.js b/ui/src/app/components/widget/action/manage-widget-actions.directive.js index 88a37a876e..81103227ba 100644 --- a/ui/src/app/components/widget/action/manage-widget-actions.directive.js +++ b/ui/src/app/components/widget/action/manage-widget-actions.directive.js @@ -244,13 +244,18 @@ function ManageWidgetActionsController($rootScope, $scope, $document, $mdDialog, vm.widgetActions[actionSourceId] = targetActions; } if (prevActionId) { - var index = getActionIndex(prevActionId, vm.allActions); - if (index > -1) { - vm.allActions[index] = action; + const indexInTarget = getActionIndex(prevActionId, targetActions); + const indexInAllActions = getActionIndex(prevActionId, vm.allActions); + if (indexInTarget > -1) { + targetActions[indexInTarget] = widgetAction; + } else if (indexInAllActions > -1) { + const prevActionSourceId = vm.allActions[indexInAllActions].actionSourceId; + const index = getActionIndex(prevActionId,vm.widgetActions[prevActionSourceId]); + vm.widgetActions[prevActionSourceId].splice(index,1); + targetActions.push(widgetAction); } - index = getActionIndex(prevActionId, targetActions); - if (index > -1) { - targetActions[index] = widgetAction; + if (indexInAllActions > -1) { + vm.allActions[indexInAllActions] = action; } } else { vm.allActions.push(action); From 4976a5326f5953a11a8edb80261aa21ffc9add10 Mon Sep 17 00:00:00 2001 From: hagaic Date: Wed, 22 Aug 2018 04:14:19 +0300 Subject: [PATCH 004/307] install hybrid db schema --- .../install/ThingsboardInstallService.java | 2 +- ...assandraAbstractDatabaseSchemaService.java | 56 ++ .../CassandraDatabaseSchemaService.java | 28 +- .../CassandraEntityDatabaseSchemaService.java | 25 + .../CassandraTsDatabaseSchemaService.java | 25 + .../install/HybridDatabaseSchemaService.java | 44 ++ .../SqlAbstractDatabaseSchemaService.java | 65 ++ .../install/SqlDatabaseSchemaService.java | 34 +- .../SqlEntityDatabaseSchemaService.java | 25 + .../install/SqlTsDatabaseSchemaService.java | 25 + .../server/dao/util/HybridDao.java | 22 + .../resources/cassandra/schema-entities.cql | 605 ++++++++++++++++++ .../main/resources/cassandra/schema-ts.cql | 55 ++ .../main/resources/sql/schema-entities.sql | 229 +++++++ dao/src/main/resources/sql/schema-ts.sql | 39 ++ 15 files changed, 1229 insertions(+), 50 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java create mode 100644 dao/src/main/resources/cassandra/schema-entities.cql create mode 100644 dao/src/main/resources/cassandra/schema-ts.cql create mode 100644 dao/src/main/resources/sql/schema-entities.sql create mode 100644 dao/src/main/resources/sql/schema-ts.sql diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 884afc06c9..f863d0bb2a 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -116,7 +116,7 @@ public class ThingsboardInstallService { log.info("Installing DataBase schema..."); - databaseSchemaService.createDatabaseSchema();//TODO issue 1005 - create both SQL and C* schemas in hybrid mode + databaseSchemaService.createDatabaseSchema(); log.info("Loading system data..."); diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java new file mode 100644 index 0000000000..d8d47444e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; +import org.thingsboard.server.service.install.cql.CQLStatementsParser; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +@Slf4j +public abstract class CassandraAbstractDatabaseSchemaService /*implements DatabaseSchemaService*/ { + + private static final String CASSANDRA_DIR = "cassandra"; + + @Autowired + private CassandraInstallCluster cluster; + + @Autowired + private InstallScripts installScripts; + + private final String schemaCql; + + protected CassandraAbstractDatabaseSchemaService(String schemaCql) { + this.schemaCql = schemaCql; + } + + //@Override + public void createDatabaseSchema() throws Exception { + log.info("Installing Cassandra DataBase schema part: " + schemaCql); + Path schemaFile = Paths.get(installScripts.getDataDir(), CASSANDRA_DIR, schemaCql); + loadCql(schemaFile); + + } + + private void loadCql(Path cql) throws Exception { + List statements = new CQLStatementsParser(cql).getStatements(); + statements.forEach(statement -> cluster.getSession().execute(statement)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java index 6eeeb0a620..0735a59fca 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java @@ -19,39 +19,25 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; -import org.thingsboard.server.dao.util.NoSqlAnyDao; -import org.thingsboard.server.service.install.cql.CQLStatementsParser; - -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; +import org.thingsboard.server.dao.util.NoSqlDao; @Service -@NoSqlAnyDao +@NoSqlDao @Profile("install") @Slf4j public class CassandraDatabaseSchemaService implements DatabaseSchemaService { - private static final String CASSANDRA_DIR = "cassandra"; - private static final String SCHEMA_CQL = "schema.cql"; - @Autowired - private CassandraInstallCluster cluster; + private CassandraEntityDatabaseSchemaService cassandraEntityDatabaseSchemaService; @Autowired - private InstallScripts installScripts; + private CassandraTsDatabaseSchemaService cassandraTsDatabaseSchemaService; + @Override public void createDatabaseSchema() throws Exception { log.info("Installing Cassandra DataBase schema..."); - Path schemaFile = Paths.get(installScripts.getDataDir(), CASSANDRA_DIR, SCHEMA_CQL); - loadCql(schemaFile); - - } - - private void loadCql(Path cql) throws Exception { - List statements = new CQLStatementsParser(cql).getStatements(); - statements.forEach(statement -> cluster.getSession().execute(statement)); + cassandraEntityDatabaseSchemaService.createDatabaseSchema(); + cassandraTsDatabaseSchemaService.createDatabaseSchema(); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java new file mode 100644 index 0000000000..9e4afb1a2a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.stereotype.Service; + +@Service +public class CassandraEntityDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService { + public CassandraEntityDatabaseSchemaService() { + super("schema-entities.cql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java new file mode 100644 index 0000000000..addc180a8a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.stereotype.Service; + +@Service +public class CassandraTsDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService { + public CassandraTsDatabaseSchemaService() { + super("schema-ts.cql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java new file mode 100644 index 0000000000..e689c70109 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.HybridDao; + +@Service +@Profile("install") +@Slf4j +@HybridDao +public class HybridDatabaseSchemaService implements DatabaseSchemaService { + + @Autowired + private SqlEntityDatabaseSchemaService sqlEntityDatabaseSchemaService; + + @Autowired + private CassandraTsDatabaseSchemaService cassandraTsDatabaseSchemaService; + + + @Override + public void createDatabaseSchema() throws Exception { + log.info("Installing Hybrid SQL/Cassandra DataBase schema..."); + sqlEntityDatabaseSchemaService.createDatabaseSchema(); + cassandraTsDatabaseSchemaService.createDatabaseSchema(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java new file mode 100644 index 0000000000..f468a6e475 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; + +@Slf4j +public abstract class SqlAbstractDatabaseSchemaService /*implements DatabaseSchemaService*/ { + + private static final String SQL_DIR = "sql"; + + @Value("${spring.datasource.url}") + private String dbUrl; + + @Value("${spring.datasource.username}") + private String dbUserName; + + @Value("${spring.datasource.password}") + private String dbPassword; + + @Autowired + private InstallScripts installScripts; + + private final String schemaSql; + + protected SqlAbstractDatabaseSchemaService(String schemaSql) { + this.schemaSql = schemaSql; + } + + //@Override + public void createDatabaseSchema() throws Exception { + + log.info("Installing SQL DataBase schema part: " + schemaSql); + + Path schemaFile = Paths.get(installScripts.getDataDir(), SQL_DIR, schemaSql); + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + String sql = new String(Files.readAllBytes(schemaFile), Charset.forName("UTF-8")); + conn.createStatement().execute(sql); //NOSONAR, ignoring because method used to load initial thingsboard database schema + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java index 1daf66086a..8544b8bbf2 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java @@ -17,50 +17,28 @@ package org.thingsboard.server.service.install; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.util.SqlDao; -import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.sql.Connection; -import java.sql.DriverManager; - @Service @Profile("install") @Slf4j @SqlDao public class SqlDatabaseSchemaService implements DatabaseSchemaService { - private static final String SQL_DIR = "sql"; - private static final String SCHEMA_SQL = "schema.sql"; - - @Value("${spring.datasource.url}") - private String dbUrl; - - @Value("${spring.datasource.username}") - private String dbUserName; - - @Value("${spring.datasource.password}") - private String dbPassword; + @Autowired + private SqlEntityDatabaseSchemaService sqlEntityDatabaseSchemaService; @Autowired - private InstallScripts installScripts; + private SqlTsDatabaseSchemaService sqlTsDatabaseSchemaService; + @Override public void createDatabaseSchema() throws Exception { - log.info("Installing SQL DataBase schema..."); - - Path schemaFile = Paths.get(installScripts.getDataDir(), SQL_DIR, SCHEMA_SQL); - try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { - String sql = new String(Files.readAllBytes(schemaFile), Charset.forName("UTF-8")); - conn.createStatement().execute(sql); //NOSONAR, ignoring because method used to load initial thingsboard database schema - } - + sqlEntityDatabaseSchemaService.createDatabaseSchema(); + sqlTsDatabaseSchemaService.createDatabaseSchema(); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java new file mode 100644 index 0000000000..0826099133 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.stereotype.Service; + +@Service +public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaService { + public SqlEntityDatabaseSchemaService() { + super("schema-entities.sql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java new file mode 100644 index 0000000000..6c8f8b5054 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.stereotype.Service; + +@Service +public class SqlTsDatabaseSchemaService extends SqlAbstractDatabaseSchemaService { + public SqlTsDatabaseSchemaService() { + super("schema-ts.sql"); + } +} \ No newline at end of file diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java new file mode 100644 index 0000000000..2caf8ccace --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +@ConditionalOnExpression("'${database.entity.type}'=='sql' && '${database.ts.type}'=='cassandra'") +public @interface HybridDao { +} diff --git a/dao/src/main/resources/cassandra/schema-entities.cql b/dao/src/main/resources/cassandra/schema-entities.cql new file mode 100644 index 0000000000..e1a21ebc1a --- /dev/null +++ b/dao/src/main/resources/cassandra/schema-entities.cql @@ -0,0 +1,605 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +CREATE KEYSPACE IF NOT EXISTS thingsboard +WITH replication = { + 'class' : 'SimpleStrategy', + 'replication_factor' : 1 +}; + +CREATE TABLE IF NOT EXISTS thingsboard.user ( + id timeuuid, + tenant_id timeuuid, + customer_id timeuuid, + email text, + search_text text, + authority text, + first_name text, + last_name text, + additional_info text, + PRIMARY KEY (id, tenant_id, customer_id, authority) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_by_email AS + SELECT * + from thingsboard.user + WHERE email IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND id IS NOT NULL AND authority IS NOT + NULL + PRIMARY KEY ( email, tenant_id, customer_id, id, authority ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_by_tenant_and_search_text AS + SELECT * + from thingsboard.user + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND authority IS NOT NULL AND search_text IS NOT NULL AND id + IS NOT NULL + PRIMARY KEY ( tenant_id, customer_id, authority, search_text, id ) + WITH CLUSTERING ORDER BY ( customer_id DESC, authority DESC, search_text ASC, id DESC ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_by_customer_and_search_text AS + SELECT * + from thingsboard.user + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND authority IS NOT NULL AND search_text IS NOT NULL AND id + IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, authority, search_text, id ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, authority DESC, search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.user_credentials ( + id timeuuid PRIMARY KEY, + user_id timeuuid, + enabled boolean, + password text, + activate_token text, + reset_token text +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_credentials_by_user AS + SELECT * + from thingsboard.user_credentials + WHERE user_id IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( user_id, id ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_credentials_by_activate_token AS + SELECT * + from thingsboard.user_credentials + WHERE activate_token IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( activate_token, id ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_credentials_by_reset_token AS + SELECT * + from thingsboard.user_credentials + WHERE reset_token IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( reset_token, id ); + +CREATE TABLE IF NOT EXISTS thingsboard.admin_settings ( + id timeuuid PRIMARY KEY, + key text, + json_value text +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.admin_settings_by_key AS + SELECT * + from thingsboard.admin_settings + WHERE key IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( key, id ) + WITH CLUSTERING ORDER BY ( id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.tenant ( + id timeuuid, + title text, + search_text text, + region text, + country text, + state text, + city text, + address text, + address2 text, + zip text, + phone text, + email text, + additional_info text, + PRIMARY KEY (id, region) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.tenant_by_region_and_search_text AS + SELECT * + from thingsboard.tenant + WHERE region IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( region, search_text, id ) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.customer ( + id timeuuid, + tenant_id timeuuid, + title text, + search_text text, + country text, + state text, + city text, + address text, + address2 text, + zip text, + phone text, + email text, + additional_info text, + PRIMARY KEY (id, tenant_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.customer_by_tenant_and_title AS + SELECT * + from thingsboard.customer + WHERE tenant_id IS NOT NULL AND title IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, title, id ) + WITH CLUSTERING ORDER BY ( title ASC, id DESC ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.customer_by_tenant_and_search_text AS + SELECT * + from thingsboard.customer + WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id ) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.device ( + id timeuuid, + tenant_id timeuuid, + customer_id timeuuid, + name text, + type text, + search_text text, + additional_info text, + PRIMARY KEY (id, tenant_id, customer_id, type) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_name AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, name, id, customer_id, type) + WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) + WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_by_type_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.device_credentials ( + id timeuuid PRIMARY KEY, + device_id timeuuid, + credentials_type text, + credentials_id text, + credentials_value text +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_credentials_by_device AS + SELECT * + from thingsboard.device_credentials + WHERE device_id IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( device_id, id ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_credentials_by_credentials_id AS + SELECT * + from thingsboard.device_credentials + WHERE credentials_id IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( credentials_id, id ); + +CREATE TABLE IF NOT EXISTS thingsboard.asset ( + id timeuuid, + tenant_id timeuuid, + customer_id timeuuid, + name text, + type text, + search_text text, + additional_info text, + PRIMARY KEY (id, tenant_id, customer_id, type) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_and_name AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, name, id, customer_id, type) + WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) + WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_customer_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_customer_by_type_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.entity_subtype ( + tenant_id timeuuid, + entity_type text, // (DEVICE, ASSET) + type text, + PRIMARY KEY (tenant_id, entity_type, type) +); + +CREATE TABLE IF NOT EXISTS thingsboard.alarm ( + id timeuuid, + tenant_id timeuuid, + type text, + originator_id timeuuid, + originator_type text, + severity text, + status text, + start_ts bigint, + end_ts bigint, + ack_ts bigint, + clear_ts bigint, + details text, + propagate boolean, + PRIMARY KEY ((tenant_id, originator_id, originator_type), type, id) +) WITH CLUSTERING ORDER BY ( type ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.alarm_by_id AS + SELECT * + from thingsboard.alarm + WHERE tenant_id IS NOT NULL AND originator_id IS NOT NULL AND originator_type IS NOT NULL AND type IS NOT NULL + AND type IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (id, tenant_id, originator_id, originator_type, type) + WITH CLUSTERING ORDER BY ( tenant_id ASC, originator_id ASC, originator_type ASC, type ASC); + +CREATE TABLE IF NOT EXISTS thingsboard.relation ( + from_id timeuuid, + from_type text, + to_id timeuuid, + to_type text, + relation_type_group text, + relation_type text, + additional_info text, + PRIMARY KEY ((from_id, from_type), relation_type_group, relation_type, to_id, to_type) +) WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, to_id ASC, to_type ASC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.relation_by_type_and_child_type AS + SELECT * + from thingsboard.relation + WHERE from_id IS NOT NULL AND from_type IS NOT NULL AND relation_type_group IS NOT NULL AND relation_type IS NOT NULL AND to_id IS NOT NULL AND to_type IS NOT NULL + PRIMARY KEY ((from_id, from_type), relation_type_group, relation_type, to_type, to_id) + WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, to_type ASC, to_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.reverse_relation AS + SELECT * + from thingsboard.relation + WHERE from_id IS NOT NULL AND from_type IS NOT NULL AND relation_type_group IS NOT NULL AND relation_type IS NOT NULL AND to_id IS NOT NULL AND to_type IS NOT NULL + PRIMARY KEY ((to_id, to_type), relation_type_group, relation_type, from_id, from_type) + WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, from_id ASC, from_type ASC); + +CREATE TABLE IF NOT EXISTS thingsboard.widgets_bundle ( + id timeuuid, + tenant_id timeuuid, + alias text, + title text, + search_text text, + image blob, + PRIMARY KEY (id, tenant_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.widgets_bundle_by_tenant_and_search_text AS + SELECT * + from thingsboard.widgets_bundle + WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id ) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.widgets_bundle_by_tenant_and_alias AS + SELECT * + from thingsboard.widgets_bundle + WHERE tenant_id IS NOT NULL AND alias IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, alias, id ) + WITH CLUSTERING ORDER BY ( alias ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.widget_type ( + id timeuuid, + tenant_id timeuuid, + bundle_alias text, + alias text, + name text, + descriptor text, + PRIMARY KEY (id, tenant_id, bundle_alias) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.widget_type_by_tenant_and_aliases AS + SELECT * + from thingsboard.widget_type + WHERE tenant_id IS NOT NULL AND bundle_alias IS NOT NULL AND alias IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, bundle_alias, alias, id ) + WITH CLUSTERING ORDER BY ( bundle_alias ASC, alias ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.dashboard ( + id timeuuid, + tenant_id timeuuid, + title text, + search_text text, + assigned_customers text, + configuration text, + PRIMARY KEY (id, tenant_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.dashboard_by_tenant_and_search_text AS + SELECT * + from thingsboard.dashboard + WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id ) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.attributes_kv_cf ( + entity_type text, // (DEVICE, CUSTOMER, TENANT) + entity_id timeuuid, + attribute_type text, // (CLIENT_SIDE, SHARED, SERVER_SIDE) + attribute_key text, + bool_v boolean, + str_v text, + long_v bigint, + dbl_v double, + last_update_ts bigint, + PRIMARY KEY ((entity_type, entity_id, attribute_type), attribute_key) +) WITH compaction = { 'class' : 'LeveledCompactionStrategy' }; + +CREATE TABLE IF NOT EXISTS thingsboard.component_descriptor ( + id timeuuid, + type text, + scope text, + name text, + search_text text, + clazz text, + configuration_descriptor text, + actions text, + PRIMARY KEY (clazz, id, type, scope) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.component_desc_by_type_search_text AS + SELECT * + from thingsboard.component_descriptor + WHERE type IS NOT NULL AND scope IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL AND clazz IS NOT NULL + PRIMARY KEY ( type, search_text, id, clazz, scope) + WITH CLUSTERING ORDER BY ( search_text DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.component_desc_by_scope_type_search_text AS + SELECT * + from thingsboard.component_descriptor + WHERE type IS NOT NULL AND scope IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL AND clazz IS NOT NULL + PRIMARY KEY ( (scope, type), search_text, id, clazz) + WITH CLUSTERING ORDER BY ( search_text DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.component_desc_by_id AS + SELECT * + from thingsboard.component_descriptor + WHERE type IS NOT NULL AND scope IS NOT NULL AND id IS NOT NULL AND clazz IS NOT NULL + PRIMARY KEY ( id, clazz, scope, type ) + WITH CLUSTERING ORDER BY ( clazz ASC, scope ASC, type DESC); + +CREATE TABLE IF NOT EXISTS thingsboard.event ( + tenant_id timeuuid, // tenant or system + id timeuuid, + event_type text, + event_uid text, + entity_type text, + entity_id timeuuid, + body text, + PRIMARY KEY ((tenant_id, entity_type, entity_id), event_type, event_uid) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.event_by_type_and_id AS + SELECT * + FROM thingsboard.event + WHERE tenant_id IS NOT NULL AND entity_type IS NOT NULL AND entity_id IS NOT NULL AND id IS NOT NULL + AND event_type IS NOT NULL AND event_uid IS NOT NULL + PRIMARY KEY ((tenant_id, entity_type, entity_id), event_type, id, event_uid) + WITH CLUSTERING ORDER BY (event_type ASC, id ASC, event_uid ASC); + + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.event_by_id AS + SELECT * + FROM thingsboard.event + WHERE tenant_id IS NOT NULL AND entity_type IS NOT NULL AND entity_id IS NOT NULL AND id IS NOT NULL + AND event_type IS NOT NULL AND event_uid IS NOT NULL + PRIMARY KEY ((tenant_id, entity_type, entity_id), id, event_type, event_uid) + WITH CLUSTERING ORDER BY (id ASC, event_type ASC, event_uid ASC); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_entity_id ( + tenant_id timeuuid, + id timeuuid, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, entity_id, entity_type), id) +); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_customer_id ( + tenant_id timeuuid, + id timeuuid, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, customer_id), id) +); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_user_id ( + tenant_id timeuuid, + id timeuuid, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, user_id), id) +); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_tenant_id ( + tenant_id timeuuid, + id timeuuid, + partition bigint, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, partition), id) +); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_tenant_id_partitions ( + tenant_id timeuuid, + partition bigint, + PRIMARY KEY (( tenant_id ), partition) +) WITH CLUSTERING ORDER BY ( partition ASC ) +AND compaction = { 'class' : 'LeveledCompactionStrategy' }; + +CREATE TABLE IF NOT EXISTS thingsboard.msg_queue ( + node_id timeuuid, + cluster_partition bigint, + ts_partition bigint, + ts bigint, + msg blob, + PRIMARY KEY ((node_id, cluster_partition, ts_partition), ts)) +WITH CLUSTERING ORDER BY (ts DESC) +AND compaction = { + 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', + 'min_threshold': '5', + 'base_time_seconds': '43200', + 'max_window_size_seconds': '43200', + 'tombstone_threshold': '0.9', + 'unchecked_tombstone_compaction': 'true' +}; + +CREATE TABLE IF NOT EXISTS thingsboard.msg_ack_queue ( + node_id timeuuid, + cluster_partition bigint, + ts_partition bigint, + msg_id timeuuid, + PRIMARY KEY ((node_id, cluster_partition, ts_partition), msg_id)) +WITH CLUSTERING ORDER BY (msg_id DESC) +AND compaction = { + 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', + 'min_threshold': '5', + 'base_time_seconds': '43200', + 'max_window_size_seconds': '43200', + 'tombstone_threshold': '0.9', + 'unchecked_tombstone_compaction': 'true' +}; + +CREATE TABLE IF NOT EXISTS thingsboard.processed_msg_partitions ( + node_id timeuuid, + cluster_partition bigint, + ts_partition bigint, + PRIMARY KEY ((node_id, cluster_partition), ts_partition)) +WITH CLUSTERING ORDER BY (ts_partition DESC) +AND compaction = { + 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', + 'min_threshold': '5', + 'base_time_seconds': '43200', + 'max_window_size_seconds': '43200', + 'tombstone_threshold': '0.9', + 'unchecked_tombstone_compaction': 'true' +}; + +CREATE TABLE IF NOT EXISTS thingsboard.rule_chain ( + id uuid, + tenant_id uuid, + name text, + search_text text, + first_rule_node_id uuid, + root boolean, + debug_mode boolean, + configuration text, + additional_info text, + PRIMARY KEY (id, tenant_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.rule_chain_by_tenant_and_search_text AS + SELECT * + from thingsboard.rule_chain + WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id ) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.rule_node ( + id uuid, + rule_chain_id uuid, + type text, + name text, + debug_mode boolean, + search_text text, + configuration text, + additional_info text, + PRIMARY KEY (id) +); diff --git a/dao/src/main/resources/cassandra/schema-ts.cql b/dao/src/main/resources/cassandra/schema-ts.cql new file mode 100644 index 0000000000..a5c5ec2ff1 --- /dev/null +++ b/dao/src/main/resources/cassandra/schema-ts.cql @@ -0,0 +1,55 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +CREATE KEYSPACE IF NOT EXISTS thingsboard +WITH replication = { + 'class' : 'SimpleStrategy', + 'replication_factor' : 1 +}; + +CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_cf ( + entity_type text, // (DEVICE, CUSTOMER, TENANT) + entity_id timeuuid, + key text, + partition bigint, + ts bigint, + bool_v boolean, + str_v text, + long_v bigint, + dbl_v double, + PRIMARY KEY (( entity_type, entity_id, key, partition ), ts) +); + +CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_partitions_cf ( + entity_type text, // (DEVICE, CUSTOMER, TENANT) + entity_id timeuuid, + key text, + partition bigint, + PRIMARY KEY (( entity_type, entity_id, key ), partition) +) WITH CLUSTERING ORDER BY ( partition ASC ) + AND compaction = { 'class' : 'LeveledCompactionStrategy' }; + +CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_latest_cf ( + entity_type text, // (DEVICE, CUSTOMER, TENANT) + entity_id timeuuid, + key text, + ts bigint, + bool_v boolean, + str_v text, + long_v bigint, + dbl_v double, + PRIMARY KEY (( entity_type, entity_id ), key) +) WITH compaction = { 'class' : 'LeveledCompactionStrategy' }; diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql new file mode 100644 index 0000000000..20efec8cd6 --- /dev/null +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -0,0 +1,229 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + + +CREATE TABLE IF NOT EXISTS admin_settings ( + id varchar(31) NOT NULL CONSTRAINT admin_settings_pkey PRIMARY KEY, + json_value varchar, + key varchar(255) +); + +CREATE TABLE IF NOT EXISTS alarm ( + id varchar(31) NOT NULL CONSTRAINT alarm_pkey PRIMARY KEY, + ack_ts bigint, + clear_ts bigint, + additional_info varchar, + end_ts bigint, + originator_id varchar(31), + originator_type integer, + propagate boolean, + severity varchar(255), + start_ts bigint, + status varchar(255), + tenant_id varchar(31), + type varchar(255) +); + +CREATE TABLE IF NOT EXISTS asset ( + id varchar(31) NOT NULL CONSTRAINT asset_pkey PRIMARY KEY, + additional_info varchar, + customer_id varchar(31), + name varchar(255), + search_text varchar(255), + tenant_id varchar(31), + type varchar(255) +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id varchar(31) NOT NULL CONSTRAINT audit_log_pkey PRIMARY KEY, + tenant_id varchar(31), + customer_id varchar(31), + entity_id varchar(31), + entity_type varchar(255), + entity_name varchar(255), + user_id varchar(31), + user_name varchar(255), + action_type varchar(255), + action_data varchar(1000000), + action_status varchar(255), + action_failure_details varchar(1000000) +); + +CREATE TABLE IF NOT EXISTS attribute_kv ( + entity_type varchar(255), + entity_id varchar(31), + attribute_type varchar(255), + attribute_key varchar(255), + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + last_update_ts bigint, + CONSTRAINT attribute_kv_unq_key UNIQUE (entity_type, entity_id, attribute_type, attribute_key) +); + +CREATE TABLE IF NOT EXISTS component_descriptor ( + id varchar(31) NOT NULL CONSTRAINT component_descriptor_pkey PRIMARY KEY, + actions varchar(255), + clazz varchar, + configuration_descriptor varchar, + name varchar(255), + scope varchar(255), + search_text varchar(255), + type varchar(255) +); + +CREATE TABLE IF NOT EXISTS customer ( + id varchar(31) NOT NULL CONSTRAINT customer_pkey PRIMARY KEY, + additional_info varchar, + address varchar, + address2 varchar, + city varchar(255), + country varchar(255), + email varchar(255), + phone varchar(255), + search_text varchar(255), + state varchar(255), + tenant_id varchar(31), + title varchar(255), + zip varchar(255) +); + +CREATE TABLE IF NOT EXISTS dashboard ( + id varchar(31) NOT NULL CONSTRAINT dashboard_pkey PRIMARY KEY, + configuration varchar(10000000), + assigned_customers varchar(1000000), + search_text varchar(255), + tenant_id varchar(31), + title varchar(255) +); + +CREATE TABLE IF NOT EXISTS device ( + id varchar(31) NOT NULL CONSTRAINT device_pkey PRIMARY KEY, + additional_info varchar, + customer_id varchar(31), + type varchar(255), + name varchar(255), + search_text varchar(255), + tenant_id varchar(31) +); + +CREATE TABLE IF NOT EXISTS device_credentials ( + id varchar(31) NOT NULL CONSTRAINT device_credentials_pkey PRIMARY KEY, + credentials_id varchar, + credentials_type varchar(255), + credentials_value varchar, + device_id varchar(31) +); + +CREATE TABLE IF NOT EXISTS event ( + id varchar(31) NOT NULL CONSTRAINT event_pkey PRIMARY KEY, + body varchar, + entity_id varchar(31), + entity_type varchar(255), + event_type varchar(255), + event_uid varchar(255), + tenant_id varchar(31), + CONSTRAINT event_unq_key UNIQUE (tenant_id, entity_type, entity_id, event_type, event_uid) +); + +CREATE TABLE IF NOT EXISTS relation ( + from_id varchar(31), + from_type varchar(255), + to_id varchar(31), + to_type varchar(255), + relation_type_group varchar(255), + relation_type varchar(255), + additional_info varchar, + CONSTRAINT relation_unq_key UNIQUE (from_id, from_type, relation_type_group, relation_type, to_id, to_type) +); + +CREATE TABLE IF NOT EXISTS tb_user ( + id varchar(31) NOT NULL CONSTRAINT tb_user_pkey PRIMARY KEY, + additional_info varchar, + authority varchar(255), + customer_id varchar(31), + email varchar(255) UNIQUE, + first_name varchar(255), + last_name varchar(255), + search_text varchar(255), + tenant_id varchar(31) +); + +CREATE TABLE IF NOT EXISTS tenant ( + id varchar(31) NOT NULL CONSTRAINT tenant_pkey PRIMARY KEY, + additional_info varchar, + address varchar, + address2 varchar, + city varchar(255), + country varchar(255), + email varchar(255), + phone varchar(255), + region varchar(255), + search_text varchar(255), + state varchar(255), + title varchar(255), + zip varchar(255) +); + +CREATE TABLE IF NOT EXISTS user_credentials ( + id varchar(31) NOT NULL CONSTRAINT user_credentials_pkey PRIMARY KEY, + activate_token varchar(255) UNIQUE, + enabled boolean, + password varchar(255), + reset_token varchar(255) UNIQUE, + user_id varchar(31) UNIQUE +); + +CREATE TABLE IF NOT EXISTS widget_type ( + id varchar(31) NOT NULL CONSTRAINT widget_type_pkey PRIMARY KEY, + alias varchar(255), + bundle_alias varchar(255), + descriptor varchar(1000000), + name varchar(255), + tenant_id varchar(31) +); + +CREATE TABLE IF NOT EXISTS widgets_bundle ( + id varchar(31) NOT NULL CONSTRAINT widgets_bundle_pkey PRIMARY KEY, + alias varchar(255), + search_text varchar(255), + tenant_id varchar(31), + title varchar(255) +); + +CREATE TABLE IF NOT EXISTS rule_chain ( + id varchar(31) NOT NULL CONSTRAINT rule_chain_pkey PRIMARY KEY, + additional_info varchar, + configuration varchar(10000000), + name varchar(255), + first_rule_node_id varchar(31), + root boolean, + debug_mode boolean, + search_text varchar(255), + tenant_id varchar(31) +); + +CREATE TABLE IF NOT EXISTS rule_node ( + id varchar(31) NOT NULL CONSTRAINT rule_node_pkey PRIMARY KEY, + rule_chain_id varchar(31), + additional_info varchar, + configuration varchar(10000000), + type varchar(255), + name varchar(255), + debug_mode boolean, + search_text varchar(255) +); diff --git a/dao/src/main/resources/sql/schema-ts.sql b/dao/src/main/resources/sql/schema-ts.sql new file mode 100644 index 0000000000..53bc15acc3 --- /dev/null +++ b/dao/src/main/resources/sql/schema-ts.sql @@ -0,0 +1,39 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +CREATE TABLE IF NOT EXISTS ts_kv ( + entity_type varchar(255) NOT NULL, + entity_id varchar(31) NOT NULL, + key varchar(255) NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + CONSTRAINT ts_kv_unq_key UNIQUE (entity_type, entity_id, key, ts) +); + +CREATE TABLE IF NOT EXISTS ts_kv_latest ( + entity_type varchar(255) NOT NULL, + entity_id varchar(31) NOT NULL, + key varchar(255) NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + CONSTRAINT ts_kv_latest_unq_key UNIQUE (entity_type, entity_id, key) +); From 90e0d8965bc9b2d8886a6a9dbc87269dae4b02a0 Mon Sep 17 00:00:00 2001 From: hagaic Date: Wed, 22 Aug 2018 04:27:12 +0300 Subject: [PATCH 005/307] install hybrid db schema (simplified) --- .../install/ThingsboardInstallService.java | 16 +++++-- ...assandraAbstractDatabaseSchemaService.java | 4 +- .../CassandraDatabaseSchemaService.java | 43 ------------------ .../CassandraEntityDatabaseSchemaService.java | 5 ++- .../CassandraTsDatabaseSchemaService.java | 5 ++- .../install/EntityDatabaseSchemaService.java | 4 ++ .../install/HybridDatabaseSchemaService.java | 44 ------------------- .../SqlAbstractDatabaseSchemaService.java | 4 +- .../install/SqlDatabaseSchemaService.java | 44 ------------------- .../SqlEntityDatabaseSchemaService.java | 5 ++- .../install/SqlTsDatabaseSchemaService.java | 5 ++- .../install/TsDatabaseSchemaService.java | 4 ++ 12 files changed, 40 insertions(+), 143 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java delete mode 100644 application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java delete mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index f863d0bb2a..2a55abff65 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -24,9 +24,10 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.install.DataUpdateService; -import org.thingsboard.server.service.install.DatabaseSchemaService; import org.thingsboard.server.service.install.DatabaseUpgradeService; +import org.thingsboard.server.service.install.EntityDatabaseSchemaService; import org.thingsboard.server.service.install.SystemDataLoaderService; +import org.thingsboard.server.service.install.TsDatabaseSchemaService; @Service @Profile("install") @@ -43,7 +44,10 @@ public class ThingsboardInstallService { private Boolean loadDemo; @Autowired - private DatabaseSchemaService databaseSchemaService; + private EntityDatabaseSchemaService entityDatabaseSchemaService; + + @Autowired + private TsDatabaseSchemaService tsDatabaseSchemaService; @Autowired private DatabaseUpgradeService databaseUpgradeService; @@ -114,9 +118,13 @@ public class ThingsboardInstallService { log.info("Starting ThingsBoard Installation..."); - log.info("Installing DataBase schema..."); + log.info("Installing DataBase schema for entities..."); + + entityDatabaseSchemaService.createDatabaseSchema(); + + log.info("Installing DataBase schema for timeseries..."); - databaseSchemaService.createDatabaseSchema(); + tsDatabaseSchemaService.createDatabaseSchema(); log.info("Loading system data..."); diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java index d8d47444e5..10559ba2d2 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java @@ -25,7 +25,7 @@ import java.nio.file.Paths; import java.util.List; @Slf4j -public abstract class CassandraAbstractDatabaseSchemaService /*implements DatabaseSchemaService*/ { +public abstract class CassandraAbstractDatabaseSchemaService implements DatabaseSchemaService { private static final String CASSANDRA_DIR = "cassandra"; @@ -41,7 +41,7 @@ public abstract class CassandraAbstractDatabaseSchemaService /*implements Databa this.schemaCql = schemaCql; } - //@Override + @Override public void createDatabaseSchema() throws Exception { log.info("Installing Cassandra DataBase schema part: " + schemaCql); Path schemaFile = Paths.get(installScripts.getDataDir(), CASSANDRA_DIR, schemaCql); diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java deleted file mode 100644 index 0735a59fca..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.install; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; -import org.thingsboard.server.dao.util.NoSqlDao; - -@Service -@NoSqlDao -@Profile("install") -@Slf4j -public class CassandraDatabaseSchemaService implements DatabaseSchemaService { - - @Autowired - private CassandraEntityDatabaseSchemaService cassandraEntityDatabaseSchemaService; - - @Autowired - private CassandraTsDatabaseSchemaService cassandraTsDatabaseSchemaService; - - - @Override - public void createDatabaseSchema() throws Exception { - log.info("Installing Cassandra DataBase schema..."); - cassandraEntityDatabaseSchemaService.createDatabaseSchema(); - cassandraTsDatabaseSchemaService.createDatabaseSchema(); - } -} diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java index 9e4afb1a2a..7937ef23be 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java @@ -16,9 +16,12 @@ package org.thingsboard.server.service.install; import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlDao; @Service -public class CassandraEntityDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService { +@NoSqlDao +public class CassandraEntityDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService + implements EntityDatabaseSchemaService { public CassandraEntityDatabaseSchemaService() { super("schema-entities.cql"); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java index addc180a8a..ba18b57ff1 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java @@ -16,9 +16,12 @@ package org.thingsboard.server.service.install; import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlTsDao; @Service -public class CassandraTsDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService { +@NoSqlTsDao +public class CassandraTsDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService + implements TsDatabaseSchemaService { public CassandraTsDatabaseSchemaService() { super("schema-ts.cql"); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java new file mode 100644 index 0000000000..1d0ddc34a4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java @@ -0,0 +1,4 @@ +package org.thingsboard.server.service.install; + +public interface EntityDatabaseSchemaService extends DatabaseSchemaService { +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java deleted file mode 100644 index e689c70109..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/install/HybridDatabaseSchemaService.java +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.install; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; -import org.thingsboard.server.dao.util.HybridDao; - -@Service -@Profile("install") -@Slf4j -@HybridDao -public class HybridDatabaseSchemaService implements DatabaseSchemaService { - - @Autowired - private SqlEntityDatabaseSchemaService sqlEntityDatabaseSchemaService; - - @Autowired - private CassandraTsDatabaseSchemaService cassandraTsDatabaseSchemaService; - - - @Override - public void createDatabaseSchema() throws Exception { - log.info("Installing Hybrid SQL/Cassandra DataBase schema..."); - sqlEntityDatabaseSchemaService.createDatabaseSchema(); - cassandraTsDatabaseSchemaService.createDatabaseSchema(); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java index f468a6e475..4a39b8f60b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java @@ -27,7 +27,7 @@ import java.sql.Connection; import java.sql.DriverManager; @Slf4j -public abstract class SqlAbstractDatabaseSchemaService /*implements DatabaseSchemaService*/ { +public abstract class SqlAbstractDatabaseSchemaService implements DatabaseSchemaService { private static final String SQL_DIR = "sql"; @@ -49,7 +49,7 @@ public abstract class SqlAbstractDatabaseSchemaService /*implements DatabaseSche this.schemaSql = schemaSql; } - //@Override + @Override public void createDatabaseSchema() throws Exception { log.info("Installing SQL DataBase schema part: " + schemaSql); diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java deleted file mode 100644 index 8544b8bbf2..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.install; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; -import org.thingsboard.server.dao.util.SqlDao; - -@Service -@Profile("install") -@Slf4j -@SqlDao -public class SqlDatabaseSchemaService implements DatabaseSchemaService { - - @Autowired - private SqlEntityDatabaseSchemaService sqlEntityDatabaseSchemaService; - - @Autowired - private SqlTsDatabaseSchemaService sqlTsDatabaseSchemaService; - - - @Override - public void createDatabaseSchema() throws Exception { - log.info("Installing SQL DataBase schema..."); - sqlEntityDatabaseSchemaService.createDatabaseSchema(); - sqlTsDatabaseSchemaService.createDatabaseSchema(); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java index 0826099133..16453c6ebb 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java @@ -16,9 +16,12 @@ package org.thingsboard.server.service.install; import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.SqlDao; @Service -public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaService { +@SqlDao +public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaService + implements EntityDatabaseSchemaService { public SqlEntityDatabaseSchemaService() { super("schema-entities.sql"); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java index 6c8f8b5054..82daf904df 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java @@ -16,9 +16,12 @@ package org.thingsboard.server.service.install; import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.SqlTsDao; @Service -public class SqlTsDatabaseSchemaService extends SqlAbstractDatabaseSchemaService { +@SqlTsDao +public class SqlTsDatabaseSchemaService extends SqlAbstractDatabaseSchemaService + implements TsDatabaseSchemaService { public SqlTsDatabaseSchemaService() { super("schema-ts.sql"); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java new file mode 100644 index 0000000000..d95166feb8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java @@ -0,0 +1,4 @@ +package org.thingsboard.server.service.install; + +public interface TsDatabaseSchemaService extends DatabaseSchemaService { +} From 5165160b3b618001c4eb3d7a9a1900f84e761c6f Mon Sep 17 00:00:00 2001 From: hagaic Date: Wed, 22 Aug 2018 06:55:29 +0300 Subject: [PATCH 006/307] revert upgrade C* in hybrid mode, since no upgrade of TS tables --- .../service/install/CassandraDatabaseUpgradeService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index 2cbd167242..f4ff92cf23 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -23,7 +23,7 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.dao.cassandra.CassandraCluster; import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; import org.thingsboard.server.dao.dashboard.DashboardService; -import org.thingsboard.server.dao.util.NoSqlAnyDao; +import org.thingsboard.server.dao.util.NoSqlDao; import org.thingsboard.server.service.install.cql.CQLStatementsParser; import org.thingsboard.server.service.install.cql.CassandraDbHelper; @@ -45,7 +45,7 @@ import static org.thingsboard.server.service.install.DatabaseHelper.TENANT_ID; import static org.thingsboard.server.service.install.DatabaseHelper.TITLE; @Service -@NoSqlAnyDao +@NoSqlDao @Profile("install") @Slf4j public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { From 8f614846eb0f456efdd0f915c4388db0acb8a35c Mon Sep 17 00:00:00 2001 From: hagaic Date: Wed, 22 Aug 2018 09:16:59 +0300 Subject: [PATCH 007/307] add license headers --- .../install/EntityDatabaseSchemaService.java | 15 +++++++++++++++ .../service/install/TsDatabaseSchemaService.java | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java index 1d0ddc34a4..c215ea0d8f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.install; public interface EntityDatabaseSchemaService extends DatabaseSchemaService { diff --git a/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java index d95166feb8..0a2ba7586c 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.install; public interface TsDatabaseSchemaService extends DatabaseSchemaService { From 2b0ac1f1d031c578b1b0de036dacc0d2588166cb Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 27 Aug 2018 15:16:46 +0300 Subject: [PATCH 008/307] Create EntityViewId class --- .../server/common/data/id/EntityViewId.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java new file mode 100644 index 0000000000..2d6a0fbf28 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java @@ -0,0 +1,27 @@ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.thingsboard.server.common.data.EntityType; + +import java.util.UUID; + +public class EntityViewId extends UUIDBased implements EntityId { + + + private static final long serialVersionUID = 1L; + + @JsonCreator + public EntityViewId(@JsonProperty("id") UUID id) { + super(id); + } + + public static EntityViewId fromString(String entityViewID) { + return new EntityViewId(UUID.fromString(entityViewID)); + } + + @Override + public EntityType getEntityType() { + return EntityType.ENTITY_VIEW; + } +} From 30e1c480ed6f84c48cc391042d3014ad614e7d5c Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 27 Aug 2018 16:05:53 +0300 Subject: [PATCH 009/307] Was created EntityView class --- .../server/common/data/EntityType.java | 2 +- .../server/common/data/EntityView.java | 127 ++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index fe9c018718..ef4994adda 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -19,5 +19,5 @@ package org.thingsboard.server.common.data; * @author Andrew Shvayka */ public enum EntityType { - TENANT, CUSTOMER, USER, DASHBOARD, ASSET, DEVICE, ALARM, RULE_CHAIN, RULE_NODE; + TENANT, CUSTOMER, USER, DASHBOARD, ASSET, DEVICE, ALARM, RULE_CHAIN, RULE_NODE, ENTITY_VIEW } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java new file mode 100644 index 0000000000..b160b888f5 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -0,0 +1,127 @@ +package org.thingsboard.server.common.data; + +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +public class EntityView extends SearchTextBasedWithAdditionalInfo + implements HasName, HasTenantId, HasCustomerId { + + private static final long serialVersionUID = 5582010124562018986L; + + private EntityId entityId; + private TenantId tenantId; + private CustomerId customerId; + private String name; + private List keys; + private Long tsStart; + private Long tsEnd; + + public EntityView() { + } + + public EntityView(EntityViewId id) { + super(id); + } + + public EntityView(EntityId entityId, + TenantId tenantId, + CustomerId customerId, + String name, + List keys, + Long tsStart, + Long tsEnd) { + + this.entityId = entityId; + this.tenantId = tenantId; + this.customerId = customerId; + this.name = name; + this.keys = keys; + this.tsStart = tsStart; + this.tsEnd = tsEnd; + } + + public EntityView(EntityView entityView) { + super(entityView); + } + + public EntityId getEntityId() { + return entityId; + } + + public void setEntityId(EntityId entityId) { + this.entityId = entityId; + } + + public void setTenantId(TenantId tenantId) { + this.tenantId = tenantId; + } + + public void setCustomerId(CustomerId customerId) { + this.customerId = customerId; + } + + public void setName(String name) { + this.name = name; + } + + public List getKeys() { + return keys; + } + + public void setKeys(List keys) { + this.keys = keys; + } + + public Long getTsStart() { + return tsStart; + } + + public void setTsStart(Long tsStart) { + this.tsStart = tsStart; + } + + public Long getTsEnd() { + return tsEnd; + } + + public void setTsEnd(Long tsEnd) { + this.tsEnd = tsEnd; + } + + @Override + public String getSearchText() { + return getName() /*What the ...*/; + } + + @Override + public CustomerId getCustomerId() { + return customerId; + } + + @Override + public String getName() { + return name; + } + + @Override + public TenantId getTenantId() { + return tenantId; + } + + @Override + public String toString() { + return "EntityView{entityId=" + entityId.getId() + + ", tenantId=" + tenantId + + ", customerId=" + customerId + + ", name='" + name + "\'" + + ", keys=" + String.join(",", keys) + + ", tsStart=" + tsStart + + ", tsEnd=" + tsEnd + "}"; + } +} From a22bef2e182f3ac21ecd410202d77895328f073c Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 27 Aug 2018 17:01:58 +0300 Subject: [PATCH 010/307] Was created first test to EntityViewController --- .../controller/BaseDeviceControllerTest.java | 4 +- .../BaseEntityViewControllerTest.java | 81 +++++++++++++++++++ .../nosql/EntityViewControllerNoSqlTest.java | 24 ++++++ .../sql/EntityViewControllerSqlTest.java | 26 ++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java index 2b86934185..55e18fbeb5 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java @@ -41,7 +41,9 @@ import org.junit.Before; import org.junit.Test; import com.fasterxml.jackson.core.type.TypeReference; - +/** + * Created by Victor Basanets on 8/27/2017. + */ public abstract class BaseDeviceControllerTest extends AbstractControllerTest { private IdComparator idComparator = new IdComparator<>(); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java new file mode 100644 index 0000000000..45b068be19 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -0,0 +1,81 @@ +package org.thingsboard.server.controller; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.security.Authority; + +import java.util.Arrays; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +public abstract class BaseEntityViewControllerTest extends AbstractControllerTest { + + private Tenant savedTenant; + private User tenantAdmin; + private Device testDevice; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Device device = new Device(); + device.setName("Test device"); + device.setType("default"); + testDevice = doPost("/api/device", device, Device.class); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveEntityViewWithIdOfDevice() throws Exception { + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setName("Test entity view"); + view.setKeys(Arrays.asList("key1", "key2", "key3")); + EntityView savedView = doPost("/api/entity-view", view, EntityView.class); + + Assert.assertNotNull(savedView); + Assert.assertNotNull(savedView.getId()); + Assert.assertTrue(savedView.getCreatedTime() > 0); + Assert.assertEquals(savedTenant.getId(), savedView.getTenantId()); + Assert.assertNotNull(savedView.getCustomerId()); + Assert.assertEquals(NULL_UUID, savedView.getCustomerId().getId()); + Assert.assertEquals(savedView.getName(), savedView.getName()); + + savedView.setName("New test entity view"); + doPost("/api/entity-view", savedView, EntityView.class); + + EntityView foundEntityView = doGet("/api/device/" + + savedView.getId().getId().toString(), EntityView.class); + + Assert.assertEquals(foundEntityView.getName(), savedView.getName()); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java new file mode 100644 index 0000000000..bc461a3119 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller.nosql; + +import org.thingsboard.server.controller.BaseEntityViewControllerTest; +/** + * Created by Victor Basanets on 8/27/2017. + */ +public class EntityViewControllerNoSqlTest + extends BaseEntityViewControllerTest { +} diff --git a/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java new file mode 100644 index 0000000000..f481ee718e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller.sql; + +import org.thingsboard.server.controller.BaseEntityViewControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; +/** + * Created by Victor Basanets on 8/27/2017. + */ +@DaoSqlTest +public class EntityViewControllerSqlTest + extends BaseEntityViewControllerTest { +} From 076bddb32a3224350fa9929e8b6abc5b6c174bd8 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 27 Aug 2018 17:15:01 +0300 Subject: [PATCH 011/307] Was modified thingsboard.yml --- .../src/main/resources/thingsboard.yml | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 743a8607b5..3e05ff7e39 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -343,36 +343,36 @@ spring.resources.chain: enabled: "true" # HSQLDB DAO Configuration -spring: - data: - jpa: - repositories: - enabled: "true" - jpa: - hibernate: - ddl-auto: "validate" - database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.HSQLDialect}" - datasource: - driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.hsqldb.jdbc.JDBCDriver}" - url: "${SPRING_DATASOURCE_URL:jdbc:hsqldb:file:${SQL_DATA_FOLDER:/tmp}/thingsboardDb;sql.enforce_size=false;hsqldb.log_size=5}" - username: "${SPRING_DATASOURCE_USERNAME:sa}" - password: "${SPRING_DATASOURCE_PASSWORD:}" +# spring: +# data: +# jpa: +# repositories: +# enabled: "true" +# jpa: +# hibernate: +# ddl-auto: "validate" +# database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.HSQLDialect}" +# datasource: +# driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.hsqldb.jdbc.JDBCDriver}" +# url: "${SPRING_DATASOURCE_URL:jdbc:hsqldb:file:${SQL_DATA_FOLDER:/tmp}/thingsboardDb;sql.enforce_size=false;hsqldb.log_size=5}" +# username: "${SPRING_DATASOURCE_USERNAME:sa}" +# password: "${SPRING_DATASOURCE_PASSWORD:}" # PostgreSQL DAO Configuration -#spring: -# data: -# sql: -# repositories: -# enabled: "true" -# sql: -# hibernate: -# ddl-auto: "validate" -# database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.PostgreSQLDialect}" -# datasource: -# driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.postgresql.Driver}" -# url: "${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/thingsboard}" -# username: "${SPRING_DATASOURCE_USERNAME:postgres}" -# password: "${SPRING_DATASOURCE_PASSWORD:postgres}" +spring: + data: + sql: + repositories: + enabled: "true" + sql: + hibernate: + ddl-auto: "validate" + database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.PostgreSQLDialect}" + datasource: + driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.postgresql.Driver}" + url: "${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/thingsboard}" + username: "${SPRING_DATASOURCE_USERNAME:postgres}" + password: "${SPRING_DATASOURCE_PASSWORD:postgres}" # Audit log parameters audit_log: From 09a13ba82d5a86c318582e12e867d3d7ee052175 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 27 Aug 2018 17:30:49 +0300 Subject: [PATCH 012/307] Was added descriptive headings titles to source code files --- .../BaseEntityViewControllerTest.java | 18 ++++++++++++++++++ .../nosql/EntityViewControllerNoSqlTest.java | 1 + .../sql/EntityViewControllerSqlTest.java | 1 + .../server/common/data/EntityView.java | 18 ++++++++++++++++++ .../server/common/data/id/EntityViewId.java | 19 ++++++++++++++++++- 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 45b068be19..e87ea6b4ea 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.controller; import org.junit.After; @@ -15,6 +30,9 @@ import java.util.Arrays; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; +/** + * Created by Victor Basanets on 8/27/2017. + */ public abstract class BaseEntityViewControllerTest extends AbstractControllerTest { private Tenant savedTenant; diff --git a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java index bc461a3119..095edd19f8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller.nosql; import org.thingsboard.server.controller.BaseEntityViewControllerTest; + /** * Created by Victor Basanets on 8/27/2017. */ diff --git a/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java index f481ee718e..84e621fcf6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller.sql; import org.thingsboard.server.controller.BaseEntityViewControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; + /** * Created by Victor Basanets on 8/27/2017. */ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index b160b888f5..06debfb001 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.data; import lombok.EqualsAndHashCode; @@ -8,6 +23,9 @@ import org.thingsboard.server.common.data.id.TenantId; import java.util.List; +/** + * Created by Victor Basanets on 8/27/2017. + */ @EqualsAndHashCode(callSuper = true) public class EntityView extends SearchTextBasedWithAdditionalInfo implements HasName, HasTenantId, HasCustomerId { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java index 2d6a0fbf28..459dd990a4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; @@ -6,9 +21,11 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +/** + * Created by Victor Basanets on 8/27/2017. + */ public class EntityViewId extends UUIDBased implements EntityId { - private static final long serialVersionUID = 1L; @JsonCreator From 1120f0a4c5881c9d74530ea02b26a18ed3240ff3 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 27 Aug 2018 18:39:22 +0300 Subject: [PATCH 013/307] Was added EntityViewSevice interface --- .../dao/entityview/EntityViewService.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java new file mode 100644 index 0000000000..f4e1700bb3 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -0,0 +1,31 @@ +package org.thingsboard.server.dao.entityview; + +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; + +public interface EntityViewService { + EntityView findEntityViewById(EntityViewId entityViewId); + + + EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name); + + EntityView saveEntityView(EntityView entityView); + + EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId); + + void deleteEntityView(EntityViewId entityViewId); + + TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); + + TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, String type, TextPageLink pageLink); + + void deleteEntityViewByTenantId(TenantId tenantId); + + TextPageData findEntityViewByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink); + + TextPageData findEntityViewByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, String type, TextPageLink pageLink); +} From 7e00615a0d017e77695e5b0385d4dcab0345bba5 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 27 Aug 2018 19:03:39 +0300 Subject: [PATCH 014/307] Was modified BaseController abstract class: added EntityViewService --- .../server/controller/BaseController.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index de73fe0796..af62e04cff 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -56,6 +56,7 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.model.ModelConstants; @@ -139,6 +140,9 @@ public abstract class BaseController { @Autowired protected DeviceStateService deviceStateService; + @Autowired + protected EntityViewService entityViewService; + @ExceptionHandler(ThingsboardException.class) public void handleThingsboardException(ThingsboardException ex, HttpServletResponse response) { errorResponseHandler.handle(ex, response); @@ -313,6 +317,9 @@ public abstract class BaseController { case USER: checkUserId(new UserId(entityId.getId())); return; + case ENTITY_VIEW: + checkEntityView(entityViewService.findEntityViewById(new EntityViewId(entityId.getId()))); + return; default: throw new IllegalArgumentException("Unsupported entity type: " + entityId.getEntityType()); } @@ -340,6 +347,25 @@ public abstract class BaseController { } } + protected EntityView checkEntityViewId(EntityViewId entityViewId) throws ThingsboardException { + try { + validateId(entityViewId, "Incorrect entityViewId " + entityViewId); + EntityView entityView = entityViewService.findEntityViewById(entityViewId); + checkEntityView(entityView); + return entityView; + } catch (Exception e) { + throw handleException(e, false); + } + } + + protected void checkEntityView(EntityView entityView) throws ThingsboardException { + checkNotNull(entityView); + checkTenantId(entityView.getTenantId()); + if (entityView.getCustomerId() != null && !entityView.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + checkCustomerId(entityView.getCustomerId()); + } + } + Asset checkAssetId(AssetId assetId) throws ThingsboardException { try { validateId(assetId, "Incorrect assetId " + assetId); From 9bcfec00ba95bd481a88fa92ee5fee9e8e159576 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 28 Aug 2018 13:57:42 +0300 Subject: [PATCH 015/307] Was created controller EntityViewController --- .../controller/EntityViewController.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 application/src/main/java/org/thingsboard/server/controller/EntityViewController.java diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java new file mode 100644 index 0000000000..acb583b396 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityViewId; + +/** + * Created by Victor Basanets on 8/28/2017. + */ +@RestController +@RequestMapping("/api") +public class EntityViewController extends BaseController { + + public static final String ENTITY_VIEW_ID = "entityViewId"; + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.GET) + @ResponseBody + public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) + throws ThingsboardException { + + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + return checkEntityViewId(entityViewId); + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entity-view", method = RequestMethod.POST) + @ResponseBody + public EntityView saveEntityView(@RequestBody EntityView entityView) + throws ThingsboardException { + + try { + entityView.setTenantId(getCurrentUser().getTenantId()); + EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); + + logEntityAction(savedEntityView.getId(), savedEntityView, null, + entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); + + return savedEntityView; + + } catch (Exception e) { + + logEntityAction(emptyId(EntityType.ENTITY_VIEW), entityView, null, + entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, e); + + throw handleException(e); + } + } +} From 14f658c92a166e9747942f0eb199dd123cf781c4 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 10:48:11 +0300 Subject: [PATCH 016/307] Was created EntityViewService interface --- .../dao/entityview/EntityViewService.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index f4e1700bb3..9077a29a1c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.dao.entityview; import org.thingsboard.server.common.data.EntityView; @@ -7,9 +22,12 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; +/** + * Created by Victor Basanets on 8/27/2017. + */ public interface EntityViewService { - EntityView findEntityViewById(EntityViewId entityViewId); + EntityView findEntityViewById(EntityViewId entityViewId); EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name); From e55a0d3fd6384845fdd81f52e554045293d6f05a Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 10:57:00 +0300 Subject: [PATCH 017/307] Was modified constructor of EntityView class --- .../main/java/org/thingsboard/server/common/data/EntityView.java | 1 + 1 file changed, 1 insertion(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 06debfb001..6b4dcd1a0c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -41,6 +41,7 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo private Long tsEnd; public EntityView() { + super(); } public EntityView(EntityViewId id) { From f5200b3b15d8b636c8eb1fc53d943c7b4041ac19 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 12:25:42 +0300 Subject: [PATCH 018/307] Was added the constant 'ENTITY_VIEW_CACHE' --- .../java/org/thingsboard/server/common/data/CacheConstants.java | 1 + 1 file changed, 1 insertion(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 21de402721..698a69ef6b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -20,4 +20,5 @@ public class CacheConstants { public static final String RELATIONS_CACHE = "relations"; public static final String DEVICE_CACHE = "devices"; public static final String ASSET_CACHE = "assets"; + public static final String ENTITY_VIEW_CACHE = "entityViews"; } From e6580ca1af296a644195f5a7133a0e9c491550b1 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 12:31:37 +0300 Subject: [PATCH 019/307] Was added new abstract methods --- .../server/dao/entityview/EntityViewService.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 9077a29a1c..8fcaf30dff 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -35,6 +35,8 @@ public interface EntityViewService { EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId); + EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId); + void deleteEntityView(EntityViewId entityViewId); TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); @@ -43,7 +45,11 @@ public interface EntityViewService { void deleteEntityViewByTenantId(TenantId tenantId); - TextPageData findEntityViewByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink); + TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, + TextPageLink pageLink); + + TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, + String type, TextPageLink pageLink); - TextPageData findEntityViewByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, String type, TextPageLink pageLink); + void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); } From 7c5d595dbe795e910ea1edf87e66dc0f2c39420c Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 12:33:17 +0300 Subject: [PATCH 020/307] Was created implementation of EntityViewService interface --- .../dao/entityview/EntityViewServiceImpl.java | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java new file mode 100644 index 0000000000..952b1fd46b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -0,0 +1,281 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.entityview; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.customer.CustomerDao; +import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.service.PaginatedRemover; +import org.thingsboard.server.dao.tenant.TenantDao; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; +import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validatePageLink; +import static org.thingsboard.server.dao.service.Validator.validateString; + +/** + * Created by Victor Basanets on 8/28/2017. + */ +@Service +@Slf4j +public class EntityViewServiceImpl extends AbstractEntityService + implements EntityViewService { + + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; + public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; + public static final String INCORRECT_ENTITY_VIEW_ID = "Incorrect entityViewId "; + + @Autowired + private EntityViewDao entityViewDao; + + @Autowired + private TenantDao tenantDao; + + @Autowired + private CustomerDao customerDao; + + @Autowired + private CacheManager cacheManager; + + @Override + public EntityView findEntityViewById(EntityViewId entityViewId) { + log.trace("Executing findEntityViewById [{}]", entityViewId); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + return entityViewDao.findById(entityViewId.getId()); + } + + @Override + public EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name) { + log.trace("Executing findEntityViewByTenantIdAndName [{}][{}]", tenantId, name); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + return entityViewDao.findEntityViewByTenantIdAndName(tenantId.getId(), name) + .orElse(null); + } + + @Override + public EntityView saveEntityView(EntityView entityView) { + log.trace("Executing save entity view [{}]", entityView); + entityViewValidator.validate(entityView); + return entityViewDao.save(entityView); + } + + @Override + public EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId) { + EntityView entityView = findEntityViewById(entityViewId); + entityView.setCustomerId(customerId); + return saveEntityView(entityView); + } + + @Override + public EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId) { + EntityView entityView = findEntityViewById(entityViewId); + entityView.setCustomerId(null); + return saveEntityView(entityView); + } + + @Override + public void deleteEntityView(EntityViewId entityViewId) { + log.trace("Executing deleteEntityView [{}]", entityViewId); + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + deleteEntityRelations(entityViewId); + EntityView entityView = entityViewDao.findById(entityViewId.getId()); + List list = new ArrayList<>(); + list.add(entityView.getTenantId()); + list.add(entityView.getName()); + cache.evict(list); + entityViewDao.removeById(entityViewId.getId()); + } + + @Override + public TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink) { + log.trace("Executing findEntityViewByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + List entityViews = entityViewDao.findEntityViewByTenantId(tenantId.getId(), pageLink); + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, String type, + TextPageLink pageLink) { + + log.trace("Executing findEntityViewByTenantIdAndType, tenantId [{}], type [{}], pageLink [{}]", + tenantId, type, pageLink); + + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateString(type, "Incorrect type " + type); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + List entityViews = entityViewDao.findEntityViewByTenantIdAndType(tenantId.getId(), + type, pageLink); + + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public void deleteEntityViewByTenantId(TenantId tenantId) { + log.trace("Executing deleteEntityViewByTenantId, tenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + tenantEntityViewRemover.removeEntities(tenantId); + } + + @Override + public TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, + TextPageLink pageLink) { + + log.trace("Executing findEntityViewByTenantIdAndCustomerId, tenantId [{}], customerId [{}], pageLink [{}]", + tenantId, customerId, pageLink); + + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(), + customerId.getId(), pageLink); + + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, + String type, TextPageLink pageLink) { + + log.trace("Executing findEntityViewsByTenantIdAndCustomerIdAndType, tenantId [{}], customerId [{}], type [{}]," + + " pageLink [{}]", tenantId, customerId, type, pageLink); + + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + validateString(type, "Incorrect type " + type); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId.getId(), + customerId.getId(), type, pageLink); + + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId) { + log.trace("Executing unassignCustomerEntityViews, tenantId [{}], customerId [{}]", tenantId, customerId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + new CustomerEntityViewsUnAssigner(tenantId).removeEntities(customerId); + } + + private DataValidator entityViewValidator = + new DataValidator() { + + @Override + protected void validateCreate(EntityView entityView) { + entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) + .ifPresent( e -> { + throw new DataValidationException("Entity view with such name already exists!"); + }); + } + + @Override + protected void validateUpdate(EntityView entityView) { + entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) + .ifPresent( e -> { + if (!e.getUuidId().equals(entityView.getUuidId())) { + throw new DataValidationException("Entity view with such name already exists!"); + } + }); + } + + @Override + protected void validateDataImpl(EntityView entityView) { + if (StringUtils.isEmpty(String.join("", entityView.getKeys()))) { + throw new DataValidationException("Entity view type should be specified!"); + } + if (StringUtils.isEmpty(entityView.getName())) { + throw new DataValidationException("Entity view name should be specified!"); + } + if (entityView.getTenantId() == null) { + throw new DataValidationException("Entity view should be assigned to tenant!"); + } else { + Tenant tenant = tenantDao.findById(entityView.getTenantId().getId()); + if (tenant == null) { + throw new DataValidationException("Entity view is referencing to non-existent tenant!"); + } + } + if (entityView.getCustomerId() == null) { + entityView.setCustomerId(new CustomerId(NULL_UUID)); + } else if (!entityView.getCustomerId().getId().equals(NULL_UUID)) { + Customer customer = customerDao.findById(entityView.getCustomerId().getId()); + if (customer == null) { + throw new DataValidationException("Can't assign entity view to non-existent customer!"); + } + if (!customer.getTenantId().getId().equals(entityView.getTenantId().getId())) { + throw new DataValidationException("Can't assign entity view to customer from different tenant!"); + } + } + } + }; + + private PaginatedRemover tenantEntityViewRemover = + new PaginatedRemover() { + + @Override + protected List findEntities(TenantId id, TextPageLink pageLink) { + return entityViewDao.findEntityViewByTenantId(id.getId(), pageLink); + } + + @Override + protected void removeEntity(EntityView entity) { + deleteEntityView(new EntityViewId(entity.getUuidId())); + } + }; + + private class CustomerEntityViewsUnAssigner extends PaginatedRemover { + + private TenantId tenantId; + + CustomerEntityViewsUnAssigner(TenantId tenantId) { + this.tenantId = tenantId; + } + + @Override + protected List findEntities(CustomerId id, TextPageLink pageLink) { + return entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(), id.getId(), pageLink); + } + + @Override + protected void removeEntity(EntityView entity) { + unassignEntityViewFromCustomer(new EntityViewId(entity.getUuidId())); + } + + } +} From 17866a979868d1252052916ed1c934fcc4735ee6 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 12:34:00 +0300 Subject: [PATCH 021/307] Was created dao interface --- .../server/dao/entityview/EntityViewDao.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java new file mode 100644 index 0000000000..fbd1957db8 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -0,0 +1,81 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.entityview; + +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.Dao; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Created by Victor Basanets on 8/28/2017. + */ +public interface EntityViewDao extends Dao { + + /** + * Find entity views by tenantId and page link. + * + * @param tenantId the tenantId + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink); + + /** + * Find entity views by tenantId and entity view name. + * + * @param tenantId the tenantId + * @param name the entity view name + * @return the optional entity view object + */ + Optional findEntityViewByTenantIdAndName(UUID tenantId, String name); + + /** + * Find entity views by tenantId, type and page link. + * + * @param tenantId the tenantId + * @param type the type + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink); + + /** + * Find entity views by tenantId, customerId and page link. + * + * @param tenantId the tenantId + * @param customerId the customerId + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, TextPageLink pageLink); + + /** + * Find entity views by tenantId, customerId, type and page link. + * + * @param tenantId the tenantId + * @param customerId the customerId + * @param type the type + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, UUID customerId, String type, + TextPageLink pageLink); + +} From f880fc2e429113a080eeab1bc54dea609f102739 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 13:26:00 +0300 Subject: [PATCH 022/307] Was added method for delete of entity view --- .../controller/EntityViewController.java | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index acb583b396..fd3ccf2fe1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -15,18 +15,21 @@ */ package org.thingsboard.server.controller; +import com.google.common.util.concurrent.ListenableFuture; +import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.ArrayList; +import java.util.List; /** * Created by Victor Basanets on 8/28/2017. @@ -55,24 +58,41 @@ public class EntityViewController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/entity-view", method = RequestMethod.POST) @ResponseBody - public EntityView saveEntityView(@RequestBody EntityView entityView) - throws ThingsboardException { - + public EntityView saveEntityView(@RequestBody EntityView entityView) throws ThingsboardException { try { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); - logEntityAction(savedEntityView.getId(), savedEntityView, null, entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); return savedEntityView; } catch (Exception e) { - logEntityAction(emptyId(EntityType.ENTITY_VIEW), entityView, null, entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, e); throw handleException(e); } } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteEntityView(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + EntityView entityView = checkEntityViewId(entityViewId); + entityViewService.deleteEntityView(entityViewId); + + logEntityAction(entityViewId, entityView, entityView.getCustomerId(), + ActionType.DELETED,null, strEntityViewId); + } catch (Exception e) { + logEntityAction(emptyId(EntityType.ENTITY_VIEW), + null, + null, + ActionType.DELETED, e, strEntityViewId); + throw handleException(e); + } + } } From c8733b695c999102acd707bf46e98f2a97b515b5 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 13:48:26 +0300 Subject: [PATCH 023/307] Was added tests of find entity by id and delete entity methods --- .../BaseEntityViewControllerTest.java | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index e87ea6b4ea..d0598586d7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -72,11 +72,24 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes .andExpect(status().isOk()); } + @Test + public void testFindEntityViewById() throws Exception { + EntityView view = new EntityView(); + view.setName("Test entity view"); + view.setEntityId(testDevice.getId()); + view.setKeys(Arrays.asList("key1", "key2", "key3")); + EntityView savedView = doPost("/api/entity-view", view, EntityView.class); + EntityView foundView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + Assert.assertNotNull(foundView); + Assert.assertEquals(savedView, foundView); + } + @Test public void testSaveEntityViewWithIdOfDevice() throws Exception { EntityView view = new EntityView(); view.setEntityId(testDevice.getId()); view.setName("Test entity view"); + view.setTenantId(savedTenant.getId()); view.setKeys(Arrays.asList("key1", "key2", "key3")); EntityView savedView = doPost("/api/entity-view", view, EntityView.class); @@ -91,9 +104,24 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes savedView.setName("New test entity view"); doPost("/api/entity-view", savedView, EntityView.class); - EntityView foundEntityView = doGet("/api/device/" + EntityView foundEntityView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(foundEntityView.getName(), savedView.getName()); } + + @Test + public void testDeleteEntityView() throws Exception { + EntityView view = new EntityView(); + view.setName("Test entity view"); + view.setEntityId(testDevice.getId()); + view.setKeys(Arrays.asList("key1", "key2", "key3")); + EntityView savedView = doPost("/api/entity-view", view, EntityView.class); + + doDelete("/api/entity-view/" + savedView.getId().getId().toString()) + .andExpect(status().isOk()); + + doGet("/api/entity-view/" + savedView.getId().getId().toString()) + .andExpect(status().isNotFound()); + } } From 6e5bda589f05b5bbe59203423df7bbbe25602510 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 15:28:48 +0300 Subject: [PATCH 024/307] Was returned to the original state --- .../src/main/resources/thingsboard.yml | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 3e05ff7e39..be5149dcb8 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -343,36 +343,36 @@ spring.resources.chain: enabled: "true" # HSQLDB DAO Configuration -# spring: -# data: -# jpa: -# repositories: -# enabled: "true" -# jpa: -# hibernate: -# ddl-auto: "validate" -# database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.HSQLDialect}" -# datasource: -# driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.hsqldb.jdbc.JDBCDriver}" -# url: "${SPRING_DATASOURCE_URL:jdbc:hsqldb:file:${SQL_DATA_FOLDER:/tmp}/thingsboardDb;sql.enforce_size=false;hsqldb.log_size=5}" -# username: "${SPRING_DATASOURCE_USERNAME:sa}" -# password: "${SPRING_DATASOURCE_PASSWORD:}" +spring: + data: + jpa: + repositories: + enabled: "true" + jpa: + hibernate: + ddl-auto: "validate" + database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.HSQLDialect}" + datasource: + driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.hsqldb.jdbc.JDBCDriver}" + url: "${SPRING_DATASOURCE_URL:jdbc:hsqldb:file:${SQL_DATA_FOLDER:/tmp}/thingsboardDb;sql.enforce_size=false;hsqldb.log_size=5}" + username: "${SPRING_DATASOURCE_USERNAME:sa}" + password: "${SPRING_DATASOURCE_PASSWORD:}" # PostgreSQL DAO Configuration -spring: - data: - sql: - repositories: - enabled: "true" - sql: - hibernate: - ddl-auto: "validate" - database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.PostgreSQLDialect}" - datasource: - driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.postgresql.Driver}" - url: "${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/thingsboard}" - username: "${SPRING_DATASOURCE_USERNAME:postgres}" - password: "${SPRING_DATASOURCE_PASSWORD:postgres}" +# spring: +# data: +# sql: +# repositories: +# enabled: "true" +# sql: +# hibernate: +# ddl-auto: "validate" +# database-platform: "${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.PostgreSQLDialect}" +# datasource: +# driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.postgresql.Driver}" +# url: "${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/thingsboard}" +# username: "${SPRING_DATASOURCE_USERNAME:postgres}" +# password: "${SPRING_DATASOURCE_PASSWORD:postgres}" # Audit log parameters audit_log: From d4cc05deba317714d8481afdd1a789414199d215 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 15:35:10 +0300 Subject: [PATCH 025/307] Was corrected header --- .../server/controller/BaseDeviceControllerTest.java | 4 +--- .../server/controller/BaseEntityViewControllerTest.java | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java index 55e18fbeb5..2b86934185 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java @@ -41,9 +41,7 @@ import org.junit.Before; import org.junit.Test; import com.fasterxml.jackson.core.type.TypeReference; -/** - * Created by Victor Basanets on 8/27/2017. - */ + public abstract class BaseDeviceControllerTest extends AbstractControllerTest { private IdComparator idComparator = new IdComparator<>(); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index d0598586d7..7d9bae1901 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -30,9 +30,6 @@ import java.util.Arrays; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; -/** - * Created by Victor Basanets on 8/27/2017. - */ public abstract class BaseEntityViewControllerTest extends AbstractControllerTest { private Tenant savedTenant; From a11592641c0db367230e451305171930175478dc Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 15:38:02 +0300 Subject: [PATCH 026/307] Were replace constructor, gets & sets by annotations --- .../server/common/data/EntityView.java | 77 ++----------------- 1 file changed, 5 insertions(+), 72 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 6b4dcd1a0c..b12e38e6b8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -15,17 +15,22 @@ */ package org.thingsboard.server.common.data; +import lombok.AllArgsConstructor; +import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import java.beans.ConstructorProperties; import java.util.List; /** * Created by Victor Basanets on 8/27/2017. */ +@Data +@AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class EntityView extends SearchTextBasedWithAdditionalInfo implements HasName, HasTenantId, HasCustomerId { @@ -48,71 +53,10 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo super(id); } - public EntityView(EntityId entityId, - TenantId tenantId, - CustomerId customerId, - String name, - List keys, - Long tsStart, - Long tsEnd) { - - this.entityId = entityId; - this.tenantId = tenantId; - this.customerId = customerId; - this.name = name; - this.keys = keys; - this.tsStart = tsStart; - this.tsEnd = tsEnd; - } - public EntityView(EntityView entityView) { super(entityView); } - public EntityId getEntityId() { - return entityId; - } - - public void setEntityId(EntityId entityId) { - this.entityId = entityId; - } - - public void setTenantId(TenantId tenantId) { - this.tenantId = tenantId; - } - - public void setCustomerId(CustomerId customerId) { - this.customerId = customerId; - } - - public void setName(String name) { - this.name = name; - } - - public List getKeys() { - return keys; - } - - public void setKeys(List keys) { - this.keys = keys; - } - - public Long getTsStart() { - return tsStart; - } - - public void setTsStart(Long tsStart) { - this.tsStart = tsStart; - } - - public Long getTsEnd() { - return tsEnd; - } - - public void setTsEnd(Long tsEnd) { - this.tsEnd = tsEnd; - } - @Override public String getSearchText() { return getName() /*What the ...*/; @@ -132,15 +76,4 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo public TenantId getTenantId() { return tenantId; } - - @Override - public String toString() { - return "EntityView{entityId=" + entityId.getId() + - ", tenantId=" + tenantId + - ", customerId=" + customerId + - ", name='" + name + "\'" + - ", keys=" + String.join(",", keys) + - ", tsStart=" + tsStart + - ", tsEnd=" + tsEnd + "}"; - } } From a1aa321831fe1ae9a6fa4470602e0e6027902298 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 29 Aug 2018 15:39:13 +0300 Subject: [PATCH 027/307] Was deleted Cache --- .../server/dao/entityview/EntityViewServiceImpl.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 952b1fd46b..d7e9e9a192 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -38,9 +38,7 @@ import org.thingsboard.server.dao.tenant.TenantDao; import java.util.ArrayList; import java.util.List; -import java.util.Optional; -import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -68,9 +66,6 @@ public class EntityViewServiceImpl extends AbstractEntityService @Autowired private CustomerDao customerDao; - @Autowired - private CacheManager cacheManager; - @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); @@ -110,14 +105,12 @@ public class EntityViewServiceImpl extends AbstractEntityService @Override public void deleteEntityView(EntityViewId entityViewId) { log.trace("Executing deleteEntityView [{}]", entityViewId); - Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); deleteEntityRelations(entityViewId); EntityView entityView = entityViewDao.findById(entityViewId.getId()); List list = new ArrayList<>(); list.add(entityView.getTenantId()); list.add(entityView.getName()); - cache.evict(list); entityViewDao.removeById(entityViewId.getId()); } From 1f31bd7d187e7347f204530bfbbbf0c7591aa016 Mon Sep 17 00:00:00 2001 From: Mitia Shvayka Date: Thu, 30 Aug 2018 09:46:41 +0300 Subject: [PATCH 028/307] refactoring mqtt --- .../server/transport/mqtt/MqttTransportHandler.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 185b7a82c3..091b45d3ec 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -51,6 +51,8 @@ import javax.security.cert.X509Certificate; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.*; import static io.netty.handler.codec.mqtt.MqttMessageType.*; @@ -79,6 +81,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private volatile InetSocketAddress address; private volatile GatewaySessionCtx gatewaySessionCtx; + private Map mqttQoSMap = new ConcurrentHashMap<>(); + public MqttTransportHandler(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, MqttTransportAdaptor adaptor, SslHandler sslHandler, QuotaService quotaService) { this.processor = processor; @@ -228,6 +232,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement String topicName = subscription.topicName(); //TODO: handle this qos level. MqttQoS reqQoS = subscription.qualityOfService(); + mqttQoSMap.put(topicName, reqQoS); try { if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); @@ -244,6 +249,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement grantedQoSList.add(getMinSupportedQos(reqQoS)); } else if (topicName.equals(GATEWAY_ATTRIBUTES_TOPIC)) { grantedQoSList.add(getMinSupportedQos(reqQoS)); + }else if (topicName.equals(GATEWAY_RPC_TOPIC)) { + grantedQoSList.add(getMinSupportedQos(reqQoS)); } else { log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topicName, reqQoS); grantedQoSList.add(FAILURE.value()); @@ -262,6 +269,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); for (String topicName : mqttMsg.payload().topics()) { + mqttQoSMap.remove(topicName); try { if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); From 29ed792a00ea151df21648eafbce04f688da5f7e Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 30 Aug 2018 10:34:09 +0300 Subject: [PATCH 029/307] Mqtt Sub QoS improvement --- .../session/DeviceAwareSessionContext.java | 2 + .../transport/mqtt/MqttTransportHandler.java | 122 +++++++++++------- .../mqtt/adaptors/JsonMqttAdaptor.java | 2 +- .../mqtt/session/DeviceSessionCtx.java | 8 +- .../mqtt/session/GatewayDeviceSessionCtx.java | 10 +- .../mqtt/session/GatewaySessionCtx.java | 5 +- .../MqttDeviceAwareSessionContext.java | 57 ++++++++ 7 files changed, 150 insertions(+), 56 deletions(-) create mode 100644 transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index f0fb51e6e8..2e7b412c31 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -73,4 +73,6 @@ public abstract class DeviceAwareSessionContext implements SessionContext { public Device getDevice() { return device; } + + } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 091b45d3ec..5effd2b497 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -53,6 +53,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.*; import static io.netty.handler.codec.mqtt.MqttMessageType.*; @@ -77,12 +78,12 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private final RelationService relationService; private final QuotaService quotaService; private final SslHandler sslHandler; + private final ConcurrentMap mqttQoSMap; + private volatile boolean connected; private volatile InetSocketAddress address; private volatile GatewaySessionCtx gatewaySessionCtx; - private Map mqttQoSMap = new ConcurrentHashMap<>(); - public MqttTransportHandler(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, MqttTransportAdaptor adaptor, SslHandler sslHandler, QuotaService quotaService) { this.processor = processor; @@ -90,7 +91,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement this.relationService = relationService; this.authService = authService; this.adaptor = adaptor; - this.deviceSessionCtx = new DeviceSessionCtx(processor, authService, adaptor); + this.mqttQoSMap = new ConcurrentHashMap<>(); + this.deviceSessionCtx = new DeviceSessionCtx(processor, authService, adaptor, mqttQoSMap); this.sessionId = deviceSessionCtx.getSessionId().toUidStr(); this.sslHandler = sslHandler; this.quotaService = quotaService; @@ -170,18 +172,25 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void handleMqttPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { try { - if (topicName.equals(GATEWAY_TELEMETRY_TOPIC)) { - gatewaySessionCtx.onDeviceTelemetry(mqttMsg); - } else if (topicName.equals(GATEWAY_ATTRIBUTES_TOPIC)) { - gatewaySessionCtx.onDeviceAttributes(mqttMsg); - } else if (topicName.equals(GATEWAY_ATTRIBUTES_REQUEST_TOPIC)) { - gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); - } else if (topicName.equals(GATEWAY_RPC_TOPIC)) { - gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); - } else if (topicName.equals(GATEWAY_CONNECT_TOPIC)) { - gatewaySessionCtx.onDeviceConnect(mqttMsg); - } else if (topicName.equals(GATEWAY_DISCONNECT_TOPIC)) { - gatewaySessionCtx.onDeviceDisconnect(mqttMsg); + switch (topicName) { + case GATEWAY_TELEMETRY_TOPIC: + gatewaySessionCtx.onDeviceTelemetry(mqttMsg); + break; + case GATEWAY_ATTRIBUTES_TOPIC: + gatewaySessionCtx.onDeviceAttributes(mqttMsg); + break; + case GATEWAY_ATTRIBUTES_REQUEST_TOPIC: + gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); + break; + case GATEWAY_RPC_TOPIC: + gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); + break; + case GATEWAY_CONNECT_TOPIC: + gatewaySessionCtx.onDeviceConnect(mqttMsg); + break; + case GATEWAY_DISCONNECT_TOPIC: + gatewaySessionCtx.onDeviceDisconnect(mqttMsg); + break; } } catch (RuntimeException | AdaptorException e) { log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); @@ -229,40 +238,53 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); List grantedQoSList = new ArrayList<>(); for (MqttTopicSubscription subscription : mqttMsg.payload().topicSubscriptions()) { - String topicName = subscription.topicName(); - //TODO: handle this qos level. + String topic = subscription.topicName(); MqttQoS reqQoS = subscription.qualityOfService(); - mqttQoSMap.put(topicName, reqQoS); try { - if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(DEVICE_RPC_REQUESTS_SUB_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(DEVICE_RPC_RESPONSE_SUB_TOPIC)) { - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(DEVICE_ATTRIBUTES_RESPONSES_TOPIC)) { - deviceSessionCtx.setAllowAttributeResponses(); - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(GATEWAY_ATTRIBUTES_TOPIC)) { - grantedQoSList.add(getMinSupportedQos(reqQoS)); - }else if (topicName.equals(GATEWAY_RPC_TOPIC)) { - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else { - log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topicName, reqQoS); - grantedQoSList.add(FAILURE.value()); + switch (topic) { + case DEVICE_ATTRIBUTES_TOPIC: { + AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); + processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + } + case DEVICE_RPC_REQUESTS_SUB_TOPIC: { + AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); + processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + } + case DEVICE_RPC_RESPONSE_SUB_TOPIC: + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: + deviceSessionCtx.setAllowAttributeResponses(); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + case GATEWAY_ATTRIBUTES_TOPIC: + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + case GATEWAY_RPC_TOPIC: + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + default: + log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); + grantedQoSList.add(FAILURE.value()); + break; } } catch (AdaptorException e) { - log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topicName, reqQoS); + log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); grantedQoSList.add(FAILURE.value()); } } ctx.writeAndFlush(createSubAckMessage(mqttMsg.variableHeader().messageId(), grantedQoSList)); } + private void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { + grantedQoSList.add(getMinSupportedQos(reqQoS)); + mqttQoSMap.put(topic, getMinSupportedQos(reqQoS)); + } + private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { if (!checkConnected(ctx)) { return; @@ -271,14 +293,20 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement for (String topicName : mqttMsg.payload().topics()) { mqttQoSMap.remove(topicName); try { - if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - } else if (topicName.equals(DEVICE_RPC_REQUESTS_SUB_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - } else if (topicName.equals(DEVICE_ATTRIBUTES_RESPONSES_TOPIC)) { - deviceSessionCtx.setDisallowAttributeResponses(); + switch (topicName) { + case DEVICE_ATTRIBUTES_TOPIC: { + AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); + processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); + break; + } + case DEVICE_RPC_REQUESTS_SUB_TOPIC: { + AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); + processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); + break; + } + case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: + deviceSessionCtx.setDisallowAttributeResponses(); + break; } } catch (AdaptorException e) { log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index f0b29cbd17..c8baaf7e53 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -170,7 +170,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, JsonElement json) { MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(MqttMessageType.PUBLISH, false, MqttQoS.AT_LEAST_ONCE, false, 0); + new MqttFixedHeader(MqttMessageType.PUBLISH, false, ctx.getQoSForTopic(topic), false, 0); MqttPublishVariableHeader header = new MqttPublishVariableHeader(topic, ctx.nextMsgId()); ByteBuf payload = ALLOCATOR.buffer(); payload.writeBytes(GSON.toJson(json).getBytes(UTF8)); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index 9367a04407..3dbb3efe0e 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -30,13 +30,15 @@ import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; +import java.util.Map; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; /** * @author Andrew Shvayka */ @Slf4j -public class DeviceSessionCtx extends DeviceAwareSessionContext { +public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { private final MqttTransportAdaptor adaptor; private final MqttSessionId sessionId; @@ -44,8 +46,8 @@ public class DeviceSessionCtx extends DeviceAwareSessionContext { private volatile boolean allowAttributeResponses; private AtomicInteger msgIdSeq = new AtomicInteger(0); - public DeviceSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, MqttTransportAdaptor adaptor) { - super(processor, authService); + public DeviceSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, MqttTransportAdaptor adaptor, ConcurrentMap mqttQoSMap) { + super(processor, authService, mqttQoSMap); this.adaptor = adaptor; this.sessionId = new MqttSessionId(); } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java index dd9c9215a6..e5be5c71a1 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -38,13 +38,15 @@ import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import java.nio.charset.Charset; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; /** * Created by ashvayka on 19.01.17. */ -public class GatewayDeviceSessionCtx extends DeviceAwareSessionContext { +public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext { private static final Gson GSON = new Gson(); private static final Charset UTF8 = Charset.forName("UTF-8"); @@ -56,8 +58,8 @@ public class GatewayDeviceSessionCtx extends DeviceAwareSessionContext { private volatile boolean closed; private AtomicInteger msgIdSeq = new AtomicInteger(0); - public GatewayDeviceSessionCtx(GatewaySessionCtx parent, Device device) { - super(parent.getProcessor(), parent.getAuthService(), device); + public GatewayDeviceSessionCtx(GatewaySessionCtx parent, Device device, ConcurrentMap mqttQoSMap) { + super(parent.getProcessor(), parent.getAuthService(), device, mqttQoSMap); this.parent = parent; this.sessionId = new MqttSessionId(); } @@ -195,7 +197,7 @@ public class GatewayDeviceSessionCtx extends DeviceAwareSessionContext { private MqttPublishMessage createMqttPublishMsg(String topic, JsonElement json) { MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(MqttMessageType.PUBLISH, false, MqttQoS.AT_LEAST_ONCE, false, 0); + new MqttFixedHeader(MqttMessageType.PUBLISH, false, getQoSForTopic(topic), false, 0); MqttPublishVariableHeader header = new MqttPublishVariableHeader(topic, msgIdSeq.incrementAndGet()); ByteBuf payload = ALLOCATOR.buffer(); payload.writeBytes(GSON.toJson(json).getBytes(UTF8)); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java index 69ed17f3b0..98ad6d2c2c 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -43,6 +43,7 @@ import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; import java.util.*; +import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; import static org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor.validateJsonPayload; @@ -63,6 +64,7 @@ public class GatewaySessionCtx { private final DeviceAuthService authService; private final RelationService relationService; private final Map devices; + private final ConcurrentMap mqttQoSMap; private ChannelHandlerContext channel; public GatewaySessionCtx(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, DeviceSessionCtx gatewaySessionCtx) { @@ -73,6 +75,7 @@ public class GatewaySessionCtx { this.gateway = gatewaySessionCtx.getDevice(); this.gatewaySessionId = gatewaySessionCtx.getSessionId(); this.devices = new HashMap<>(); + this.mqttQoSMap = gatewaySessionCtx.getMqttQoSMap(); } public void onDeviceConnect(MqttPublishMessage msg) throws AdaptorException { @@ -96,7 +99,7 @@ public class GatewaySessionCtx { relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); processor.onDeviceAdded(device); } - GatewayDeviceSessionCtx ctx = new GatewayDeviceSessionCtx(this, device); + GatewayDeviceSessionCtx ctx = new GatewayDeviceSessionCtx(this, device, mqttQoSMap); devices.put(deviceName, ctx); log.debug("[{}] Added device [{}] to the gateway session", gatewaySessionId, deviceName); processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new AttributesSubscribeMsg()))); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java new file mode 100644 index 0000000000..f085064016 --- /dev/null +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.session; + +import io.netty.handler.codec.mqtt.MqttQoS; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.transport.SessionMsgProcessor; +import org.thingsboard.server.common.transport.auth.DeviceAuthService; +import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; + +import java.util.Map; +import java.util.concurrent.ConcurrentMap; + +/** + * Created by ashvayka on 30.08.18. + */ +public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionContext { + + private final ConcurrentMap mqttQoSMap; + + public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, ConcurrentMap mqttQoSMap) { + super(processor, authService); + this.mqttQoSMap = mqttQoSMap; + } + + public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, Device device, ConcurrentMap mqttQoSMap) { + super(processor, authService, device); + this.mqttQoSMap = mqttQoSMap; + } + + public ConcurrentMap getMqttQoSMap() { + return mqttQoSMap; + } + + public MqttQoS getQoSForTopic(String topic) { + Integer qos = mqttQoSMap.get(topic); + if (qos != null) { + return MqttQoS.valueOf(qos); + } else { + return MqttQoS.AT_LEAST_ONCE; + } + } + +} From 20e63b94889c27c534e2d717b3fd59ecf0d374a3 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 30 Aug 2018 19:04:35 +0300 Subject: [PATCH 030/307] Was added the model EntityViewEntity constants --- .../server/dao/model/ModelConstants.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 3a934eb5d2..90ccb66ca0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -45,6 +45,7 @@ public class ModelConstants { public static final String SEARCH_TEXT_PROPERTY = "search_text"; public static final String ADDITIONAL_INFO_PROPERTY = "additional_info"; public static final String ENTITY_TYPE_PROPERTY = "entity_type"; + /*public static final String ENTITY_VIEW_ID_PROPERTY = "entity_view_id";*/ public static final String ENTITY_TYPE_COLUMN = ENTITY_TYPE_PROPERTY; public static final String ENTITY_ID_COLUMN = "entity_id"; @@ -143,6 +144,19 @@ public class ModelConstants { public static final String DEVICE_BY_TENANT_AND_NAME_VIEW_NAME = "device_by_tenant_and_name"; public static final String DEVICE_TYPES_BY_TENANT_VIEW_NAME = "device_types_by_tenant"; + /** + * Cassandra entityView constants. + */ + public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_view"; + public static final String ENTITY_VIEW_ENTITY_ID_PROPERTY = ENTITY_ID_COLUMN; + public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; + public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; + public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; + public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; + public static final String ENTITY_VIEW_TS_BEGIN_PROPERTY = "ts_begin"; + public static final String ENTITY_VIEW_TS_END_PROPERTY = "ts_end"; + public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; + /** * Cassandra audit log constants. */ From ab2a5cceba8969f73a216818045a2374556483cb Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 30 Aug 2018 19:07:03 +0300 Subject: [PATCH 031/307] Was created new model --- .../dao/model/sql/EntityViewEntity.java | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java new file mode 100644 index 0000000000..1444e9745e --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -0,0 +1,138 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.sql; + +import com.datastax.driver.core.utils.UUIDs; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.model.SearchTextEntity; +import org.thingsboard.server.dao.util.mapping.JsonStringType; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@TypeDef(name = "json", typeClass = JsonStringType.class) +@Table(name = ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME) +public class EntityViewEntity extends BaseSqlEntity implements SearchTextEntity { + + @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) + private String entityId; + + @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY) + private String tenantId; + + @Column(name = ModelConstants.ENTITY_VIEW_CUSTOMER_ID_PROPERTY) + private String customerId; + + @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) + private String name; + + @Type(type = "json") + @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) + private JsonNode keys; + + @Column(name = ModelConstants.ENTITY_VIEW_TS_BEGIN_PROPERTY) + private String tsBegin; + + @Column(name = ModelConstants.ENTITY_VIEW_TS_END_PROPERTY) + private String tsEnd; + + @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY) + private String searchText; + + @Type(type = "json") + @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY) + private JsonNode additionalInfo; + + public EntityViewEntity() { + super(); + } + + public EntityViewEntity(EntityView entityView) { + if (entityView.getId() != null) { + this.setId(entityView.getId().getId()); + } + if (entityView.getEntityId() != null) { + this.entityId = toString(entityView.getEntityId().getId()); + } + if (entityView.getTenantId() != null) { + this.tenantId = toString(entityView.getTenantId().getId()); + } + if (entityView.getCustomerId() != null) { + this.customerId = toString(entityView.getCustomerId().getId()); + } + this.name = entityView.getName(); + try { + this.keys = new ObjectMapper().readTree("{\"" + entityView.getName() + "\" : [" + + entityView.getKeys().stream() + .map(k -> "\"" + k + "\"") + .collect(Collectors.joining(", ")) + "]}"); + } catch (IOException e) { + e.printStackTrace(); + } + this.tsBegin = String.valueOf(entityView.getTsBegin()); + this.tsEnd = String.valueOf(entityView.getTsEnd()); + this.additionalInfo = entityView.getAdditionalInfo(); + } + + @Override + public String getSearchTextSource() { + return name; + } + + @Override + public void setSearchText(String searchText) { + this.searchText = searchText; + } + + @Override + public EntityView toData() { + EntityView entityView = new EntityView(new EntityViewId(getId())); + entityView.setCreatedTime(UUIDs.unixTimestamp(getId())); + + /*Ned to refactor and replace DeviceId to Class<> instance*/ + entityView.setEntityId(entityId != null ? new DeviceId(toUUID(entityId)) : null); + entityView.setTenantId(tenantId != null ? new TenantId(toUUID(tenantId)) : null); + entityView.setCustomerId(customerId != null ? new CustomerId(toUUID(customerId)) : null); + entityView.setName(name); + try { + entityView.setKeys(new ObjectMapper().readValue(keys.toString(), new TypeReference>(){})); + } catch (IOException e) { + e.printStackTrace(); + } + entityView.setAdditionalInfo(additionalInfo); + return entityView; + } +} From 14fa80a26846de3e68c180c08400eb1cd01a35a1 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 31 Aug 2018 12:37:22 +0300 Subject: [PATCH 032/307] Was refactored --- .../org/thingsboard/server/common/data/EntityView.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index b12e38e6b8..102f6eebec 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -15,9 +15,7 @@ */ package org.thingsboard.server.common.data; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.EqualsAndHashCode; +import lombok.*; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; @@ -29,6 +27,7 @@ import java.util.List; /** * Created by Victor Basanets on 8/27/2017. */ + @Data @AllArgsConstructor @EqualsAndHashCode(callSuper = true) @@ -42,7 +41,7 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo private CustomerId customerId; private String name; private List keys; - private Long tsStart; + private Long tsBegin; private Long tsEnd; public EntityView() { From 76efea555c2a558a588bb818a26663abd978a7c7 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 31 Aug 2018 12:39:13 +0300 Subject: [PATCH 033/307] Was created sql & nosql entities --- .../dao/model/nosql/EntityViewEntity.java | 151 ++++++++++++++++++ .../dao/model/sql/EntityViewEntity.java | 20 ++- 2 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java new file mode 100644 index 0000000000..c3227e9605 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -0,0 +1,151 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.nosql; + +import com.datastax.driver.core.utils.UUIDs; +import com.datastax.driver.mapping.annotations.PartitionKey; +import com.datastax.driver.mapping.annotations.Table; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.hibernate.annotations.Type; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.model.SearchTextEntity; + +import javax.persistence.Column; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ID_PROPERTY; + +/** + * Created by Victor Basanets on 8/31/2017. + */ +@Data +@Table(name = ENTITY_VIEW_TABLE_FAMILY_NAME) +@EqualsAndHashCode +@ToString +public class EntityViewEntity implements SearchTextEntity { + + @PartitionKey(value = 0) + @Column(name = ID_PROPERTY) + private UUID id; + + @PartitionKey(value = 1) + @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) + private UUID entityId; + + @PartitionKey(value = 2) + @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY) + private UUID tenantId; + + @PartitionKey(value = 3) + @Column(name = ModelConstants.ENTITY_VIEW_CUSTOMER_ID_PROPERTY) + private UUID customerId; + + @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) + private String name; + + @Type(type = "json") + @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) + private JsonNode keys; + + @Column(name = ModelConstants.ENTITY_VIEW_TS_BEGIN_PROPERTY) + private String tsBegin; + + @Column(name = ModelConstants.ENTITY_VIEW_TS_END_PROPERTY) + private String tsEnd; + + @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY) + private String searchText; + + @Type(type = "json") + @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY) + private JsonNode additionalInfo; + + public EntityViewEntity() { + super(); + } + + public EntityViewEntity(EntityView entityView) { + if (entityView.getId() != null) { + this.id = entityView.getId().getId(); + } + if (entityView.getEntityId() != null) { + this.entityId = entityView.getEntityId().getId(); + } + if (entityView.getTenantId() != null) { + this.tenantId = entityView.getTenantId().getId(); + } + if (entityView.getCustomerId() != null) { + this.customerId = entityView.getCustomerId().getId(); + } + this.name = entityView.getName(); + try { + this.keys = new ObjectMapper().readTree("{\"" + entityView.getName() + "\" : [" + + entityView.getKeys().stream() + .map(k -> "\"" + k + "\"") + .collect(Collectors.joining(", ")) + "]}"); + } catch (IOException e) { + e.printStackTrace(); + } + this.tsBegin = String.valueOf(entityView.getTsBegin()); + this.tsEnd = String.valueOf(entityView.getTsEnd()); + this.additionalInfo = entityView.getAdditionalInfo(); + } + + @Override + public String getSearchTextSource() { + return name; + } + + @Override + public EntityView toData() { + EntityView entityView = new EntityView(new EntityViewId(id)); + entityView.setCreatedTime(UUIDs.unixTimestamp(id)); + if (entityId != null) { + entityView.setEntityId(new DeviceId(entityId)); + } + if (tenantId != null) { + entityView.setTenantId(new TenantId(tenantId)); + } + if (customerId != null) { + entityView.setCustomerId(new CustomerId(customerId)); + } + entityView.setName(name); + try { + entityView.setKeys(new ObjectMapper().readValue(keys.toString(), new TypeReference>(){})); + } catch (IOException e) { + e.printStackTrace(); + } + entityView.setTsBegin(Long.parseLong(tsBegin)); + entityView.setTsEnd(Long.parseLong(tsEnd)); + entityView.setAdditionalInfo(additionalInfo); + return entityView; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index 1444e9745e..dba075ce01 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -23,6 +23,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.hibernate.annotations.Type; import org.hibernate.annotations.TypeDef; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; @@ -38,8 +39,13 @@ import javax.persistence.Entity; import javax.persistence.Table; import java.io.IOException; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; +/** + * Created by Victor Basanets on 8/30/2017. + */ + @Data @EqualsAndHashCode(callSuper = true) @Entity @@ -123,15 +129,23 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc entityView.setCreatedTime(UUIDs.unixTimestamp(getId())); /*Ned to refactor and replace DeviceId to Class<> instance*/ - entityView.setEntityId(entityId != null ? new DeviceId(toUUID(entityId)) : null); - entityView.setTenantId(tenantId != null ? new TenantId(toUUID(tenantId)) : null); - entityView.setCustomerId(customerId != null ? new CustomerId(toUUID(customerId)) : null); + if (entityId != null) { + entityView.setEntityId(new DeviceId(toUUID(entityId))); + } + if (tenantId != null) { + entityView.setTenantId(new TenantId(toUUID(tenantId))); + } + if (customerId != null) { + entityView.setCustomerId(new CustomerId(toUUID(customerId))); + } entityView.setName(name); try { entityView.setKeys(new ObjectMapper().readValue(keys.toString(), new TypeReference>(){})); } catch (IOException e) { e.printStackTrace(); } + entityView.setTsBegin(Long.parseLong(tsBegin)); + entityView.setTsEnd(Long.parseLong(tsEnd)); entityView.setAdditionalInfo(additionalInfo); return entityView; } From bc1e228dc117477c5840773858728c2fe3045131 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 31 Aug 2018 14:20:50 +0300 Subject: [PATCH 034/307] Was modified, method find(.*)Type renamed to find(.*)EntityId --- .../server/dao/entityview/EntityViewDao.java | 23 ++++++++----- .../dao/entityview/EntityViewService.java | 11 +++++-- .../dao/entityview/EntityViewServiceImpl.java | 33 ++++++++++--------- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index fbd1957db8..781cb3a68d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.entityview; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.Dao; @@ -47,14 +48,16 @@ public interface EntityViewDao extends Dao { Optional findEntityViewByTenantIdAndName(UUID tenantId, String name); /** - * Find entity views by tenantId, type and page link. + * Find entity views by tenantId, entityId and page link. * * @param tenantId the tenantId - * @param type the type + * @param entityId the entityId * @param pageLink the page link * @return the list of entity view objects */ - List findEntityViewByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink); + List findEntityViewByTenantIdAndEntityId(UUID tenantId, + EntityId entityId, + TextPageLink pageLink); /** * Find entity views by tenantId, customerId and page link. @@ -64,18 +67,22 @@ public interface EntityViewDao extends Dao { * @param pageLink the page link * @return the list of entity view objects */ - List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, TextPageLink pageLink); + List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, + UUID customerId, + TextPageLink pageLink); /** - * Find entity views by tenantId, customerId, type and page link. + * Find entity views by tenantId, customerId, entityId and page link. * * @param tenantId the tenantId * @param customerId the customerId - * @param type the type + * @param entityId the entityId * @param pageLink the page link * @return the list of entity view objects */ - List findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, UUID customerId, String type, - TextPageLink pageLink); + List findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, + UUID customerId, + EntityId entityId, + TextPageLink pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 8fcaf30dff..943d35e9b4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.dao.entityview; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; @@ -41,15 +43,18 @@ public interface EntityViewService { TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); - TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, String type, TextPageLink pageLink); + TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, + TextPageLink pageLink); void deleteEntityViewByTenantId(TenantId tenantId); TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink); - TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, - String type, TextPageLink pageLink); + TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, + CustomerId customerId, + EntityId entityId, + TextPageLink pageLink); void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index d7e9e9a192..e44cea552f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; @@ -124,17 +125,17 @@ public class EntityViewServiceImpl extends AbstractEntityService } @Override - public TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, String type, + public TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TextPageLink pageLink) { - log.trace("Executing findEntityViewByTenantIdAndType, tenantId [{}], type [{}], pageLink [{}]", - tenantId, type, pageLink); + log.trace("Executing findEntityViewByTenantIdAndType, tenantId [{}], entityId [{}], pageLink [{}]", + tenantId, entityId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - validateString(type, "Incorrect type " + type); + validateString(entityId.toString(), "Incorrect entityId " + entityId.toString()); validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); - List entityViews = entityViewDao.findEntityViewByTenantIdAndType(tenantId.getId(), - type, pageLink); + List entityViews = entityViewDao.findEntityViewByTenantIdAndEntityId(tenantId.getId(), + entityId, pageLink); return new TextPageData<>(entityViews, pageLink); } @@ -150,8 +151,8 @@ public class EntityViewServiceImpl extends AbstractEntityService public TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink) { - log.trace("Executing findEntityViewByTenantIdAndCustomerId, tenantId [{}], customerId [{}], pageLink [{}]", - tenantId, customerId, pageLink); + log.trace("Executing findEntityViewByTenantIdAndCustomerId, tenantId [{}], customerId [{}]," + + " pageLink [{}]", tenantId, customerId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); @@ -163,18 +164,20 @@ public class EntityViewServiceImpl extends AbstractEntityService } @Override - public TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, - String type, TextPageLink pageLink) { + public TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, + CustomerId customerId, + EntityId entityId, + TextPageLink pageLink) { - log.trace("Executing findEntityViewsByTenantIdAndCustomerIdAndType, tenantId [{}], customerId [{}], type [{}]," + - " pageLink [{}]", tenantId, customerId, type, pageLink); + log.trace("Executing findEntityViewsByTenantIdAndCustomerIdAndType, tenantId [{}], customerId [{}]," + + " entityId [{}], pageLink [{}]", tenantId, customerId, entityId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); - validateString(type, "Incorrect type " + type); + validateString(entityId.toString(), "Incorrect entityId " + entityId.toString()); validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); - List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId.getId(), - customerId.getId(), type, pageLink); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndEntityId( + tenantId.getId(), customerId.getId(), entityId, pageLink); return new TextPageData<>(entityViews, pageLink); } From 7009cbd8acaba462105fb05f59881a0b4b5d55c9 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 31 Aug 2018 14:26:38 +0300 Subject: [PATCH 035/307] Was added asigning value to of 'searchText' field into constructors --- .../thingsboard/server/dao/model/nosql/EntityViewEntity.java | 1 + .../org/thingsboard/server/dao/model/sql/EntityViewEntity.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index c3227e9605..a6d57829c7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -116,6 +116,7 @@ public class EntityViewEntity implements SearchTextEntity { } this.tsBegin = String.valueOf(entityView.getTsBegin()); this.tsEnd = String.valueOf(entityView.getTsEnd()); + this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index dba075ce01..b12cf07976 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -39,7 +39,6 @@ import javax.persistence.Entity; import javax.persistence.Table; import java.io.IOException; import java.util.List; -import java.util.UUID; import java.util.stream.Collectors; /** @@ -110,6 +109,7 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } this.tsBegin = String.valueOf(entityView.getTsBegin()); this.tsEnd = String.valueOf(entityView.getTsEnd()); + this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } From de21d977a183bdadf8acfbb777b5ef5ac06c3157 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 31 Aug 2018 15:40:03 +0300 Subject: [PATCH 036/307] Was final changing --- .../org/thingsboard/server/dao/entityview/EntityViewDao.java | 4 ++-- .../server/dao/entityview/EntityViewServiceImpl.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index 781cb3a68d..25404db834 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -56,7 +56,7 @@ public interface EntityViewDao extends Dao { * @return the list of entity view objects */ List findEntityViewByTenantIdAndEntityId(UUID tenantId, - EntityId entityId, + UUID entityId, TextPageLink pageLink); /** @@ -82,7 +82,7 @@ public interface EntityViewDao extends Dao { */ List findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, UUID customerId, - EntityId entityId, + UUID entityId, TextPageLink pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index e44cea552f..4a42577044 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -135,7 +135,7 @@ public class EntityViewServiceImpl extends AbstractEntityService validateString(entityId.toString(), "Incorrect entityId " + entityId.toString()); validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); List entityViews = entityViewDao.findEntityViewByTenantIdAndEntityId(tenantId.getId(), - entityId, pageLink); + entityId.getId(), pageLink); return new TextPageData<>(entityViews, pageLink); } @@ -177,7 +177,7 @@ public class EntityViewServiceImpl extends AbstractEntityService validateString(entityId.toString(), "Incorrect entityId " + entityId.toString()); validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndEntityId( - tenantId.getId(), customerId.getId(), entityId, pageLink); + tenantId.getId(), customerId.getId(), entityId.getId(), pageLink); return new TextPageData<>(entityViews, pageLink); } From 4fa083c7488ec14b3ee7b5c622a38e94e1b766ad Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 31 Aug 2018 16:01:10 +0300 Subject: [PATCH 037/307] Added dao impl --- .../sql/entityview/EntityViewRepository.java | 81 ++++++++++++++ .../dao/sql/entityview/JpaEntityViewDao.java | 100 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java new file mode 100644 index 0000000000..27bd8395ac --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -0,0 +1,81 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.entityview; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.dao.model.sql.EntityViewEntity; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.List; + +/** + * Created by Victor Basanets on 8/31/2017. + */ +@SqlDao +public interface EntityViewRepository extends CrudRepository { + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:textSearch, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantId(@Param("tenantId") String tenantId, + @Param("textSearch") String textSearch, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.entityId = :entityId " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:textSearch, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndEntityId(@Param("tenantId") String tenantId, + @Param("entityId") String entityId, + @Param("textSearch") String textSearch, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.customerId = :customerId " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:searchText, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndCustomerId(@Param("tenantId") String tenantId, + @Param("customerId") String customerId, + @Param("searchText") String searchText, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.customerId = :customerId " + + "AND e.entityId = :entityId " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:textSearch, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndCustomerIdAndEntityId(@Param("tenantId") String tenantId, + @Param("customerId") String customerId, + @Param("entityId") String entityId, + @Param("textSearch") String textSearch, + @Param("idOffset") String idOffset, + Pageable pageable); + + EntityViewEntity findByTenantIdAndName(String tenantId, String name); + + List findAllByTenantIdAndCustomerIdAndIdIn(String tenantId, + String customerId, + List entityViewsIds); + + List findAllByTenantIdAndIdIn(String tenantId, List entityViewsIds); +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java new file mode 100644 index 0000000000..82ac3e08c6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -0,0 +1,100 @@ +package org.thingsboard.server.dao.sql.entityview; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.entityview.EntityViewDao; +import org.thingsboard.server.dao.model.sql.EntityViewEntity; +import org.thingsboard.server.dao.sql.JpaAbstractSearchTextDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID_STR; + +@Component +@SqlDao +public class JpaEntityViewDao extends JpaAbstractSearchTextDao + implements EntityViewDao { + + @Autowired + EntityViewRepository entityViewRepository; + + @Override + protected Class getEntityClass() { + return EntityViewEntity.class; + } + + @Override + protected CrudRepository getCrudRepository() { + return entityViewRepository; + } + + @Override + public List findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantId( + fromTimeUUID(tenantId), + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()))); + } + + @Override + public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { + return Optional.ofNullable( + DaoUtil.getData(entityViewRepository.findByTenantIdAndName(fromTimeUUID(tenantId), name))); + } + + @Override + public List findEntityViewByTenantIdAndEntityId(UUID tenantId, + UUID entityId, + TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndEntityId( + fromTimeUUID(tenantId), + fromTimeUUID(entityId), + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()))); + } + + @Override + public List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, + UUID customerId, + TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndCustomerId( + fromTimeUUID(tenantId), + fromTimeUUID(customerId), + Objects.toString(pageLink, ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()) + )); + } + + @Override + public List findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, + UUID customerId, + UUID entityId, + TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndCustomerIdAndEntityId( + fromTimeUUID(tenantId), + fromTimeUUID(customerId), + fromTimeUUID(entityId), + Objects.toString(pageLink, ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()) + )); + } +} From 65908b001c987df9f9bea616c49961806a98e21f Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 3 Sep 2018 19:23:38 +0300 Subject: [PATCH 038/307] Was modified an entity view constant --- .../java/org/thingsboard/server/dao/model/ModelConstants.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 90ccb66ca0..e084593324 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -147,7 +147,7 @@ public class ModelConstants { /** * Cassandra entityView constants. */ - public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_view"; + public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_views"; public static final String ENTITY_VIEW_ENTITY_ID_PROPERTY = ENTITY_ID_COLUMN; public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; From 1f60a19dac20535a949a88aed6753a6d1e85232d Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 3 Sep 2018 19:26:42 +0300 Subject: [PATCH 039/307] Was added script to create new dbs for EntityView model --- dao/src/main/resources/cassandra/schema.cql | 42 +++++++++++++++++++++ dao/src/main/resources/sql/schema.sql | 13 +++++++ 2 files changed, 55 insertions(+) diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql index f03122ab6b..bdd413d6ad 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema.cql @@ -638,3 +638,45 @@ CREATE TABLE IF NOT EXISTS thingsboard.rule_node ( additional_info text, PRIMARY KEY (id) ); + +CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( + id timeuuid, + entity_id timeuuid, + tenant_id timeuuid, + customer_id timeuuid, + name text, + keys text, + ts_begin bigint, + ts_end bigint, + search_text text, + additional_info text, + PRIMARY KEY (id, entity_id, tenant_id, customer_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) + WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_entity AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, id, customer_id, name) + WITH CLUSTERING ORDER BY (entity_id ASC, customer_id ASC, id DESC, name DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, id, entity_id, name) + WITH CLUSTERING ORDER BY (customer_id ASC, id DESC, entity_id DESC, name DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, entity_id, id, name) + WITH CLUSTERING ORDER BY (customer_id ASC, entity_id DESC, id DESC, name DESC); diff --git a/dao/src/main/resources/sql/schema.sql b/dao/src/main/resources/sql/schema.sql index 91e77da503..8e21e72e9d 100644 --- a/dao/src/main/resources/sql/schema.sql +++ b/dao/src/main/resources/sql/schema.sql @@ -251,3 +251,16 @@ CREATE TABLE IF NOT EXISTS rule_node ( debug_mode boolean, search_text varchar(255) ); + +CREATE TABLE IF NOT EXISTS entity_views ( + id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, + additional_info varchar, + customer_id varchar(31), + keys varchar(255), + ts_begin varchar(255), + ts_end varchar(255), + name varchar(255), + search_text varchar(255), + entity_id varchar(31), + tenant_id varchar(31) +); From 8fdfa909dbc211ec5078e5bcdaa710ff37b92c7d Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 3 Sep 2018 19:30:48 +0300 Subject: [PATCH 040/307] Was modified classes to config and create DB for EntityView model --- .../server/install/ThingsboardInstallService.java | 7 +++++++ .../server/service/install/DefaultDataUpdateService.java | 4 ++++ .../service/install/SqlDatabaseUpgradeService.java | 9 +++++++++ 3 files changed, 20 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index f863d0bb2a..347b05409e 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -88,6 +88,13 @@ public class ThingsboardInstallService { dataUpdateService.updateData("1.4.0"); + case "2.0.0": + log.info("Upgrading ThingsBoard from version 2.0.0 to 2.1.1 ..."); + + databaseUpgradeService.upgradeDatabase("2.0.0"); + + dataUpdateService.updateData("2.0.0"); + log.info("Updating system data..."); systemDataLoaderService.deleteSystemWidgetBundle("charts"); diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java index 5daebcc3c5..0194a5dfd7 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java @@ -49,6 +49,10 @@ public class DefaultDataUpdateService implements DataUpdateService { log.info("Updating data from version 1.4.0 to 2.0.0 ..."); tenantsDefaultRuleChainUpdater.updateEntities(null); break; + case "2.0.0": + log.info("Updating data from version 2.0.0 to 2.1.1 ..."); + tenantsDefaultRuleChainUpdater.updateEntities(null); + break; default: throw new RuntimeException("Unable to update data, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 3a4a837217..7d701b7a53 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -107,6 +107,15 @@ public class SqlDatabaseUpgradeService implements DatabaseUpgradeService { log.info("Schema updated."); } break; + case "2.0.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.1", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + } + break; + default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); } From 64620a89ceebe54731c9fb349098492dd51287cc Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 3 Sep 2018 19:32:29 +0300 Subject: [PATCH 041/307] Was add couple test methods --- .../sql/EntityViewControllerSqlTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java index 84e621fcf6..e10610ac6e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java @@ -15,13 +15,30 @@ */ package org.thingsboard.server.controller.sql; +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.controller.BaseEntityViewControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.Arrays; + /** * Created by Victor Basanets on 8/27/2017. */ @DaoSqlTest public class EntityViewControllerSqlTest extends BaseEntityViewControllerTest { + + @Test + public void testSaveEntityViewWithIdOfDevice() throws Exception { + super.testSaveEntityViewWithIdOfDevice(); + } + + @Test + public void testFindEntityViewById() throws Exception { + super.testFindEntityViewById(); + } + + } From 2cc562ed58cfc34675f5cced8fc853b90ef95b91 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 3 Sep 2018 19:48:01 +0300 Subject: [PATCH 042/307] Was added header --- .../dao/sql/entityview/JpaEntityViewDao.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 82ac3e08c6..57b0afae8b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.dao.sql.entityview; import org.springframework.beans.factory.annotation.Autowired; @@ -21,6 +36,9 @@ import java.util.UUID; import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID_STR; +/** + * Created by Victor Basanets on 8/31/2017. + */ @Component @SqlDao public class JpaEntityViewDao extends JpaAbstractSearchTextDao From d24391a27632ed1fc638a93ffba55618a3400820 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 4 Sep 2018 10:45:39 +0300 Subject: [PATCH 043/307] Was added update for sql and cql --- .../main/data/upgrade/2.1.1/schema_update.cql | 91 +++++++++++++++++++ .../main/data/upgrade/2.1.1/schema_update.sql | 30 ++++++ 2 files changed, 121 insertions(+) create mode 100644 application/src/main/data/upgrade/2.1.1/schema_update.cql create mode 100644 application/src/main/data/upgrade/2.1.1/schema_update.sql diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql new file mode 100644 index 0000000000..c6fdf5a3b8 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -0,0 +1,91 @@ +/*-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +--*/ +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_name; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_entity; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_customer; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity; + +DROP TABLE IF EXISTS thingsboard.entity_views; + +CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( + id timeuuid, + entity_id timeuuid, + tenant_id timeuuid, + customer_id timeuuid, + name text, + keys text, + ts_begin bigint, + ts_end bigint, + search_text text, + additional_info text, + PRIMARY KEY (id, entity_id, tenant_id, customer_id) + ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL + AND tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND keys IS NOT NULL + AND ts_begin IS NOT NULL + AND ts_end IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) + WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_entity AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL + AND tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND keys IS NOT NULL + AND ts_begin IS NOT NULL + AND ts_end IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, id, customer_id, name) + WITH CLUSTERING ORDER BY (entity_id ASC, customer_id ASC, id DESC, name DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL + AND tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND keys IS NOT NULL + AND ts_begin IS NOT NULL + AND ts_end IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, id, entity_id, name) + WITH CLUSTERING ORDER BY (customer_id ASC, id DESC, entity_id DESC, name DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL + AND tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND keys IS NOT NULL + AND ts_begin IS NOT NULL + AND ts_end IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, entity_id, id, name) + WITH CLUSTERING ORDER BY (customer_id ASC, entity_id DESC, id DESC, name DESC); diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.sql b/application/src/main/data/upgrade/2.1.1/schema_update.sql new file mode 100644 index 0000000000..f4754e99c9 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.1/schema_update.sql @@ -0,0 +1,30 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DROP TABLE IF EXISTS entity_views; + +CREATE TABLE IF NOT EXISTS entity_views ( + id varchar(31) NOT NULL CONSTRAINT entity_views_pkey PRIMARY KEY, + additional_info varchar, + customer_id varchar(31), + keys varchar(255), + ts_begin varchar(255), + ts_end varchar(255), + name varchar(255), + search_text varchar(255), + entity_id varchar(31), + tenant_id varchar(31) +); From 0cb7f455f070902082e924a800b148f84c2b04d3 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 4 Sep 2018 11:05:52 +0300 Subject: [PATCH 044/307] Was modified header --- application/src/main/data/upgrade/2.1.1/schema_update.cql | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index c6fdf5a3b8..d9ba517df7 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -1,4 +1,4 @@ -/*-- +-- -- Copyright © 2016-2018 The Thingsboard Authors -- -- Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,7 +12,8 @@ -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. ---*/ +-- + DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_name; DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_entity; DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_customer; From 17bbb71e687fe18a2cf425d2562b26f82687ea18 Mon Sep 17 00:00:00 2001 From: Mitia Shvayka Date: Tue, 4 Sep 2018 11:11:18 +0300 Subject: [PATCH 045/307] refactoring MqttSqlTestSuite --- .../test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java index 5ddbb67c97..435a7670c8 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java @@ -15,11 +15,9 @@ */ package org.thingsboard.server.mqtt; -import org.cassandraunit.dataset.cql.ClassPathCQLDataSet; import org.junit.ClassRule; import org.junit.extensions.cpsuite.ClasspathSuite; import org.junit.runner.RunWith; -import org.thingsboard.server.dao.CustomCassandraCQLUnit; import org.thingsboard.server.dao.CustomSqlUnit; import java.util.Arrays; From d28bd62b83da735a1a441eed5da5eb47c033fb89 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 5 Sep 2018 18:06:58 +0300 Subject: [PATCH 046/307] Was created classes for telemetry --- .../data/objects/AttributesEntityView.java | 47 +++++++++++++++++++ .../data/objects/TelemetryEntityView.java | 43 +++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java new file mode 100644 index 0000000000..1c32579b72 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.objects; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * Created by Victor Basanets on 9/05/2017. + */ +@Data +@NoArgsConstructor +public class AttributesEntityView { + + private List cs = new ArrayList<>(); + private List ss = new ArrayList<>(); + private List sh = new ArrayList<>(); + + public AttributesEntityView(List cs, + List ss, + List sh) { + + this.cs = new ArrayList<>(cs); + this.ss = new ArrayList<>(ss); + this.sh = new ArrayList<>(sh); + } + + public AttributesEntityView(AttributesEntityView obj) { + this(obj.getCs(), obj.getSs(), obj.getSh()); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java new file mode 100644 index 0000000000..c899c65590 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.objects; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * Created by Victor Basanets on 9/05/2017. + */ +@Data +@NoArgsConstructor +public class TelemetryEntityView { + + private List timeseries; + private AttributesEntityView attributes; + + public TelemetryEntityView(List timeseries, AttributesEntityView attributes) { + + this.timeseries = new ArrayList<>(timeseries); + this.attributes = attributes; + } + + public TelemetryEntityView(TelemetryEntityView obj) { + this(obj.getTimeseries(), obj.getAttributes()); + } +} From 48ff2b2723bedffad829c9a9e756d05f62d9cf9b Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 5 Sep 2018 18:10:39 +0300 Subject: [PATCH 047/307] Was modified --- application/src/main/data/upgrade/2.1.1/schema_update.sql | 1 + .../java/org/thingsboard/server/common/data/EntityView.java | 6 ++---- .../thingsboard/server/common/data/id/EntityIdFactory.java | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.sql b/application/src/main/data/upgrade/2.1.1/schema_update.sql index f4754e99c9..2fc953516f 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.sql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.sql @@ -26,5 +26,6 @@ CREATE TABLE IF NOT EXISTS entity_views ( name varchar(255), search_text varchar(255), entity_id varchar(31), + entity_type varchar(255), tenant_id varchar(31) ); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 102f6eebec..03fce40cea 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -20,9 +20,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; - -import java.beans.ConstructorProperties; -import java.util.List; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; /** * Created by Victor Basanets on 8/27/2017. @@ -40,7 +38,7 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo private TenantId tenantId; private CustomerId customerId; private String name; - private List keys; + private TelemetryEntityView keys; //To Do: Changed from all code private Long tsBegin; private Long tsEnd; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index ed4cf2f784..4e35c0b2df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -57,6 +57,8 @@ public class EntityIdFactory { return new RuleChainId(uuid); case RULE_NODE: return new RuleNodeId(uuid); + case ENTITY_VIEW: + return new EntityViewId(uuid); } throw new IllegalArgumentException("EntityType " + type + " is not supported!"); } From 481d2f4770d376ae9363fe3c560d02e12549501a Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 5 Sep 2018 18:12:09 +0300 Subject: [PATCH 048/307] commit --- .../BaseEntityViewControllerTest.java | 26 +++++++++++++------ .../controller/ControllerSqlTestSuite.java | 2 +- .../nosql/EntityViewControllerNoSqlTest.java | 3 +-- .../sql/EntityViewControllerSqlTest.java | 15 +---------- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 7d9bae1901..c7ac98d413 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -23,6 +23,8 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.objects.AttributesEntityView; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.data.security.Authority; import java.util.Arrays; @@ -35,16 +37,12 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes private Tenant savedTenant; private User tenantAdmin; private Device testDevice; + private TelemetryEntityView obj; @Before public void beforeTest() throws Exception { loginSysAdmin(); - Device device = new Device(); - device.setName("Test device"); - device.setType("default"); - testDevice = doPost("/api/device", device, Device.class); - Tenant tenant = new Tenant(); tenant.setTitle("My tenant"); savedTenant = doPost("/api/tenant", tenant, Tenant.class); @@ -59,6 +57,18 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes tenantAdmin.setLastName("Downs"); tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + + Device device = new Device(); + device.setName("Test device"); + device.setType("default"); + testDevice = doPost("/api/device", device, Device.class); + + obj = new TelemetryEntityView( + Arrays.asList("109L", "209L"), + new AttributesEntityView( + Arrays.asList("caKey1", "caKey2", "caKey3"), + Arrays.asList("saKey1", "saKey2", "saKey3", "saKey4"), + Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4", "shKey5"))); } @After @@ -74,7 +84,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes EntityView view = new EntityView(); view.setName("Test entity view"); view.setEntityId(testDevice.getId()); - view.setKeys(Arrays.asList("key1", "key2", "key3")); + view.setKeys(new TelemetryEntityView(obj)); EntityView savedView = doPost("/api/entity-view", view, EntityView.class); EntityView foundView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertNotNull(foundView); @@ -87,7 +97,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setEntityId(testDevice.getId()); view.setName("Test entity view"); view.setTenantId(savedTenant.getId()); - view.setKeys(Arrays.asList("key1", "key2", "key3")); + view.setKeys(new TelemetryEntityView(obj)); EntityView savedView = doPost("/api/entity-view", view, EntityView.class); Assert.assertNotNull(savedView); @@ -112,7 +122,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes EntityView view = new EntityView(); view.setName("Test entity view"); view.setEntityId(testDevice.getId()); - view.setKeys(Arrays.asList("key1", "key2", "key3")); + view.setKeys(new TelemetryEntityView((TelemetryEntityView) obj)); EntityView savedView = doPost("/api/entity-view", view, EntityView.class); doDelete("/api/entity-view/" + savedView.getId().getId().toString()) diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index f316051212..a9e94e9184 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.controller.sql.*SqlTest", + "org.thingsboard.server.controller.sql.EntityViewControllerSqlTest", }) public class ControllerSqlTestSuite { diff --git a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java index 095edd19f8..ad066fc7ab 100644 --- a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java @@ -20,6 +20,5 @@ import org.thingsboard.server.controller.BaseEntityViewControllerTest; /** * Created by Victor Basanets on 8/27/2017. */ -public class EntityViewControllerNoSqlTest - extends BaseEntityViewControllerTest { +public class EntityViewControllerNoSqlTest extends BaseEntityViewControllerTest { } diff --git a/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java index e10610ac6e..76d99250c1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java @@ -27,18 +27,5 @@ import java.util.Arrays; * Created by Victor Basanets on 8/27/2017. */ @DaoSqlTest -public class EntityViewControllerSqlTest - extends BaseEntityViewControllerTest { - - @Test - public void testSaveEntityViewWithIdOfDevice() throws Exception { - super.testSaveEntityViewWithIdOfDevice(); - } - - @Test - public void testFindEntityViewById() throws Exception { - super.testFindEntityViewById(); - } - - +public class EntityViewControllerSqlTest extends BaseEntityViewControllerTest { } From 70ee36352816639472463caf089675411ba84005 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 5 Sep 2018 18:14:45 +0300 Subject: [PATCH 049/307] commit2 --- .../server/dao/model/ModelConstants.java | 1 + .../dao/model/nosql/EntityViewEntity.java | 32 ++++++--------- .../dao/model/sql/EntityViewEntity.java | 40 +++++++++---------- dao/src/main/resources/sql/schema.sql | 1 + 4 files changed, 34 insertions(+), 40 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index e084593324..d06ddbd8e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -152,6 +152,7 @@ public class ModelConstants { public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; + public static final String ENTITY_VIEW_TYPE_PROPERTY = "type_entity"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_TS_BEGIN_PROPERTY = "ts_begin"; public static final String ENTITY_VIEW_TS_END_PROPERTY = "ts_end"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index a6d57829c7..65914dd566 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -18,9 +18,7 @@ package org.thingsboard.server.dao.model.nosql; import com.datastax.driver.core.utils.UUIDs; import com.datastax.driver.mapping.annotations.PartitionKey; import com.datastax.driver.mapping.annotations.Table; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; @@ -30,15 +28,14 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.SearchTextEntity; import javax.persistence.Column; import java.io.IOException; -import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; import static org.thingsboard.server.dao.model.ModelConstants.ID_PROPERTY; @@ -106,16 +103,13 @@ public class EntityViewEntity implements SearchTextEntity { this.customerId = entityView.getCustomerId().getId(); } this.name = entityView.getName(); - try { - this.keys = new ObjectMapper().readTree("{\"" + entityView.getName() + "\" : [" + - entityView.getKeys().stream() - .map(k -> "\"" + k + "\"") - .collect(Collectors.joining(", ")) + "]}"); - } catch (IOException e) { - e.printStackTrace(); - } - this.tsBegin = String.valueOf(entityView.getTsBegin()); - this.tsEnd = String.valueOf(entityView.getTsEnd()); +// try { +// this.keys = entityView.getKeys(); +// } catch (IOException e) { +// e.printStackTrace(); +// } + this.tsBegin = entityView.getTsBegin() != null ? String.valueOf(entityView.getTsBegin()) : "0"; + this.tsEnd = entityView.getTsEnd() != null ? String.valueOf(entityView.getTsEnd()) : "0"; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -139,11 +133,11 @@ public class EntityViewEntity implements SearchTextEntity { entityView.setCustomerId(new CustomerId(customerId)); } entityView.setName(name); - try { - entityView.setKeys(new ObjectMapper().readValue(keys.toString(), new TypeReference>(){})); - } catch (IOException e) { - e.printStackTrace(); - } +// try { +// entityView.setKeys((TelemetryEntityView) entityView.getKeys().toObject(keys)); +// } catch (IOException e) { +// e.printStackTrace(); +// } entityView.setTsBegin(Long.parseLong(tsBegin)); entityView.setTsEnd(Long.parseLong(tsEnd)); entityView.setAdditionalInfo(additionalInfo); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index b12cf07976..62e9eb3e78 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -16,7 +16,6 @@ package org.thingsboard.server.dao.model.sql; import com.datastax.driver.core.utils.UUIDs; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; @@ -25,21 +24,18 @@ import org.hibernate.annotations.Type; import org.hibernate.annotations.TypeDef; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityViewId; -import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.*; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.SearchTextEntity; import org.thingsboard.server.dao.util.mapping.JsonStringType; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Table; +import javax.persistence.*; import java.io.IOException; -import java.util.List; -import java.util.stream.Collectors; + +import static org.thingsboard.server.dao.model.ModelConstants.AUDIT_LOG_ENTITY_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPERTY; /** * Created by Victor Basanets on 8/30/2017. @@ -64,9 +60,12 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) private String name; - @Type(type = "json") + @Enumerated(EnumType.STRING) + @Column(name = ENTITY_TYPE_PROPERTY) + private EntityType entityType; + @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) - private JsonNode keys; + private String keys; @Column(name = ModelConstants.ENTITY_VIEW_TS_BEGIN_PROPERTY) private String tsBegin; @@ -81,6 +80,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY) private JsonNode additionalInfo; + private static final ObjectMapper mapper = new ObjectMapper(); + public EntityViewEntity() { super(); } @@ -91,6 +92,7 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } if (entityView.getEntityId() != null) { this.entityId = toString(entityView.getEntityId().getId()); + this.entityType = entityView.getEntityId().getEntityType(); } if (entityView.getTenantId() != null) { this.tenantId = toString(entityView.getTenantId().getId()); @@ -100,15 +102,12 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } this.name = entityView.getName(); try { - this.keys = new ObjectMapper().readTree("{\"" + entityView.getName() + "\" : [" + - entityView.getKeys().stream() - .map(k -> "\"" + k + "\"") - .collect(Collectors.joining(", ")) + "]}"); + this.keys = mapper.writeValueAsString(entityView.getKeys()); } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = String.valueOf(entityView.getTsBegin()); - this.tsEnd = String.valueOf(entityView.getTsEnd()); + this.tsBegin = entityView.getTsBegin() != null ? String.valueOf(entityView.getTsBegin()) : ""; + this.tsEnd = entityView.getTsEnd() != null ? String.valueOf(entityView.getTsEnd()) : ""; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -128,9 +127,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc EntityView entityView = new EntityView(new EntityViewId(getId())); entityView.setCreatedTime(UUIDs.unixTimestamp(getId())); - /*Ned to refactor and replace DeviceId to Class<> instance*/ if (entityId != null) { - entityView.setEntityId(new DeviceId(toUUID(entityId))); + entityView.setEntityId(EntityIdFactory.getByTypeAndId(entityType.name(), toUUID(entityId).toString())); } if (tenantId != null) { entityView.setTenantId(new TenantId(toUUID(tenantId))); @@ -140,7 +138,7 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } entityView.setName(name); try { - entityView.setKeys(new ObjectMapper().readValue(keys.toString(), new TypeReference>(){})); + entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); } catch (IOException e) { e.printStackTrace(); } diff --git a/dao/src/main/resources/sql/schema.sql b/dao/src/main/resources/sql/schema.sql index 8e21e72e9d..b79b61f3af 100644 --- a/dao/src/main/resources/sql/schema.sql +++ b/dao/src/main/resources/sql/schema.sql @@ -260,6 +260,7 @@ CREATE TABLE IF NOT EXISTS entity_views ( ts_begin varchar(255), ts_end varchar(255), name varchar(255), + entity_type varchar(255), search_text varchar(255), entity_id varchar(31), tenant_id varchar(31) From d731bf614e93c5e87b092bfb2f3bba2456ec8409 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 5 Sep 2018 19:29:42 +0300 Subject: [PATCH 050/307] commit3 --- .../src/main/data/upgrade/2.1.1/schema_update.sql | 12 ++++++------ .../thingsboard/server/common/data/EntityView.java | 2 +- .../common/data/objects/AttributesEntityView.java | 5 +++++ .../common/data/objects/TelemetryEntityView.java | 5 +++++ .../server/dao/entityview/EntityViewServiceImpl.java | 3 +-- .../server/dao/model/sql/EntityViewEntity.java | 8 ++++---- dao/src/main/resources/sql/schema.sql | 10 +++++----- 7 files changed, 27 insertions(+), 18 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.sql b/application/src/main/data/upgrade/2.1.1/schema_update.sql index 2fc953516f..3670aad035 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.sql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.sql @@ -17,15 +17,15 @@ DROP TABLE IF EXISTS entity_views; CREATE TABLE IF NOT EXISTS entity_views ( - id varchar(31) NOT NULL CONSTRAINT entity_views_pkey PRIMARY KEY, - additional_info varchar, + id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, + entity_id varchar(31), + entity_type varchar(255), + tenant_id varchar(31), customer_id varchar(31), + name varchar(255), keys varchar(255), ts_begin varchar(255), ts_end varchar(255), - name varchar(255), search_text varchar(255), - entity_id varchar(31), - entity_type varchar(255), - tenant_id varchar(31) + additional_info varchar ); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 03fce40cea..813a9acd08 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -38,7 +38,7 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo private TenantId tenantId; private CustomerId customerId; private String name; - private TelemetryEntityView keys; //To Do: Changed from all code + private TelemetryEntityView keys; private Long tsBegin; private Long tsEnd; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java index 1c32579b72..b1d270c934 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java @@ -44,4 +44,9 @@ public class AttributesEntityView { public AttributesEntityView(AttributesEntityView obj) { this(obj.getCs(), obj.getSs(), obj.getSh()); } + + @Override + public String toString() { + return "{cs=" + cs + ", ss=" + ss + ", sh=" + sh + '}'; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java index c899c65590..e7398e6500 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java @@ -40,4 +40,9 @@ public class TelemetryEntityView { public TelemetryEntityView(TelemetryEntityView obj) { this(obj.getTimeseries(), obj.getAttributes()); } + + @Override + public String toString() { + return "{timeseries=" + timeseries + ", attributes=" + attributes + '}'; + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 4a42577044..6554083fe4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -213,7 +213,7 @@ public class EntityViewServiceImpl extends AbstractEntityService @Override protected void validateDataImpl(EntityView entityView) { - if (StringUtils.isEmpty(String.join("", entityView.getKeys()))) { + if (StringUtils.isEmpty(entityView.getKeys().toString())) { throw new DataValidationException("Entity view type should be specified!"); } if (StringUtils.isEmpty(entityView.getName())) { @@ -272,6 +272,5 @@ public class EntityViewServiceImpl extends AbstractEntityService protected void removeEntity(EntityView entity) { unassignEntityViewFromCustomer(new EntityViewId(entity.getUuidId())); } - } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index 62e9eb3e78..1175fac864 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -51,6 +51,10 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) private String entityId; + @Enumerated(EnumType.STRING) + @Column(name = ENTITY_TYPE_PROPERTY) + private EntityType entityType; + @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY) private String tenantId; @@ -60,10 +64,6 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) private String name; - @Enumerated(EnumType.STRING) - @Column(name = ENTITY_TYPE_PROPERTY) - private EntityType entityType; - @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) private String keys; diff --git a/dao/src/main/resources/sql/schema.sql b/dao/src/main/resources/sql/schema.sql index b79b61f3af..ed3583cef3 100644 --- a/dao/src/main/resources/sql/schema.sql +++ b/dao/src/main/resources/sql/schema.sql @@ -254,14 +254,14 @@ CREATE TABLE IF NOT EXISTS rule_node ( CREATE TABLE IF NOT EXISTS entity_views ( id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, - additional_info varchar, + entity_id varchar(31), + entity_type varchar(255), + tenant_id varchar(31), customer_id varchar(31), + name varchar(255), keys varchar(255), ts_begin varchar(255), ts_end varchar(255), - name varchar(255), - entity_type varchar(255), search_text varchar(255), - entity_id varchar(31), - tenant_id varchar(31) + additional_info varchar ); From 8ecd9628ca0d983df07b18882e19c12a03f1130a Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 6 Sep 2018 10:08:39 +0300 Subject: [PATCH 051/307] The final modifications for passing preliminary tests --- .../thingsboard/server/dao/model/sql/EntityViewEntity.java | 4 ++-- dao/src/test/resources/sql/drop-all-tables.sql | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index 1175fac864..9136717fe3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -106,8 +106,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = entityView.getTsBegin() != null ? String.valueOf(entityView.getTsBegin()) : ""; - this.tsEnd = entityView.getTsEnd() != null ? String.valueOf(entityView.getTsEnd()) : ""; + this.tsBegin = entityView.getTsBegin() != null ? String.valueOf(entityView.getTsBegin()) : "0"; + this.tsEnd = entityView.getTsEnd() != null ? String.valueOf(entityView.getTsEnd()) : "0"; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } diff --git a/dao/src/test/resources/sql/drop-all-tables.sql b/dao/src/test/resources/sql/drop-all-tables.sql index 23b6a56a47..ebc04b3938 100644 --- a/dao/src/test/resources/sql/drop-all-tables.sql +++ b/dao/src/test/resources/sql/drop-all-tables.sql @@ -18,4 +18,5 @@ DROP TABLE IF EXISTS user_credentials; DROP TABLE IF EXISTS widget_type; DROP TABLE IF EXISTS widgets_bundle; DROP TABLE IF EXISTS rule_node; -DROP TABLE IF EXISTS rule_chain; \ No newline at end of file +DROP TABLE IF EXISTS rule_chain; +DROP TABLE IF EXISTS entity_views; \ No newline at end of file From 40017c46dfc8ec59e093cc91bc83f0eed6a914c8 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 6 Sep 2018 10:23:58 +0300 Subject: [PATCH 052/307] Entity View feature - UI --- ui/src/app/api/entity-view.service.js | 237 +++++++++ ui/src/app/app.js | 2 + ui/src/app/common/types.constant.js | 3 +- .../app/entity-view/add-entity-view.tpl.html | 45 ++ ...add-entity-views-to-customer.controller.js | 123 +++++ .../add-entity-views-to-customer.tpl.html | 77 +++ .../assign-to-customer.controller.js | 123 +++++ .../entity-view/assign-to-customer.tpl.html | 76 +++ .../app/entity-view/entity-view-card.tpl.html | 22 + .../entity-view/entity-view-fieldset.tpl.html | 64 +++ .../app/entity-view/entity-view.controller.js | 483 ++++++++++++++++++ .../app/entity-view/entity-view.directive.js | 67 +++ ui/src/app/entity-view/entity-view.routes.js | 72 +++ ui/src/app/entity-view/entity-views.tpl.html | 83 +++ ui/src/app/entity-view/index.js | 41 ++ ui/src/app/locale/locale.constant-en_US.json | 73 +++ 16 files changed, 1590 insertions(+), 1 deletion(-) create mode 100644 ui/src/app/api/entity-view.service.js create mode 100644 ui/src/app/entity-view/add-entity-view.tpl.html create mode 100644 ui/src/app/entity-view/add-entity-views-to-customer.controller.js create mode 100644 ui/src/app/entity-view/add-entity-views-to-customer.tpl.html create mode 100644 ui/src/app/entity-view/assign-to-customer.controller.js create mode 100644 ui/src/app/entity-view/assign-to-customer.tpl.html create mode 100644 ui/src/app/entity-view/entity-view-card.tpl.html create mode 100644 ui/src/app/entity-view/entity-view-fieldset.tpl.html create mode 100644 ui/src/app/entity-view/entity-view.controller.js create mode 100644 ui/src/app/entity-view/entity-view.directive.js create mode 100644 ui/src/app/entity-view/entity-view.routes.js create mode 100644 ui/src/app/entity-view/entity-views.tpl.html create mode 100644 ui/src/app/entity-view/index.js diff --git a/ui/src/app/api/entity-view.service.js b/ui/src/app/api/entity-view.service.js new file mode 100644 index 0000000000..9bd8f8db08 --- /dev/null +++ b/ui/src/app/api/entity-view.service.js @@ -0,0 +1,237 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import thingsboardTypes from '../common/types.constant'; + +export default angular.module('thingsboard.api.entityView', [thingsboardTypes]) + .factory('entityViewService', EntityViewService) + .name; + +/*@ngInject*/ +function EntityViewService($http, $q, $window, userService, attributeService, customerService, types) { + + var service = { + assignEntityViewToCustomer: assignEntityViewToCustomer, + deleteEntityView: deleteEntityView, + getCustomerEntityViews: getCustomerEntityViews, + getEntityView: getEntityView, + getEntityViews: getEntityViews, + getTenantEntityViews: getTenantEntityViews, + saveEntityView: saveEntityView, + unassignEntityViewFromCustomer: unassignEntityViewFromCustomer, + getEntityViewAttributes: getEntityViewAttributes, + subscribeForEntityViewAttributes: subscribeForEntityViewAttributes, + unsubscribeForEntityViewAttributes: unsubscribeForEntityViewAttributes, + findByQuery: findByQuery, + getEntityViewTypes: getEntityViewTypes + } + + return service; + + function getTenantEntityViews(pageLink, applyCustomersInfo, config, type) { + var deferred = $q.defer(); + var url = '/api/tenant/entityViews?limit=' + pageLink.limit; + if (angular.isDefined(pageLink.textSearch)) { + url += '&textSearch=' + pageLink.textSearch; + } + if (angular.isDefined(pageLink.idOffset)) { + url += '&idOffset=' + pageLink.idOffset; + } + if (angular.isDefined(pageLink.textOffset)) { + url += '&textOffset=' + pageLink.textOffset; + } + if (angular.isDefined(type) && type.length) { + url += '&type=' + type; + } + $http.get(url, config).then(function success(response) { + if (applyCustomersInfo) { + customerService.applyAssignedCustomersInfo(response.data.data).then( + function success(data) { + response.data.data = data; + deferred.resolve(response.data); + }, + function fail() { + deferred.reject(); + } + ); + } else { + deferred.resolve(response.data); + } + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function getCustomerEntityViews(customerId, pageLink, applyCustomersInfo, config, type) { + var deferred = $q.defer(); + var url = '/api/customer/' + customerId + '/entityViews?limit=' + pageLink.limit; + if (angular.isDefined(pageLink.textSearch)) { + url += '&textSearch=' + pageLink.textSearch; + } + if (angular.isDefined(pageLink.idOffset)) { + url += '&idOffset=' + pageLink.idOffset; + } + if (angular.isDefined(pageLink.textOffset)) { + url += '&textOffset=' + pageLink.textOffset; + } + if (angular.isDefined(type) && type.length) { + url += '&type=' + type; + } + $http.get(url, config).then(function success(response) { + if (applyCustomersInfo) { + customerService.applyAssignedCustomerInfo(response.data.data, customerId).then( + function success(data) { + response.data.data = data; + deferred.resolve(response.data); + }, + function fail() { + deferred.reject(); + } + ); + } else { + deferred.resolve(response.data); + } + }, function fail() { + deferred.reject(); + }); + + return deferred.promise; + } + + function getEntityView(entityViewId, ignoreErrors, config) { + var deferred = $q.defer(); + var url = '/api/entityView/' + entityViewId; + if (!config) { + config = {}; + } + config = Object.assign(config, { ignoreErrors: ignoreErrors }); + $http.get(url, config).then(function success(response) { + deferred.resolve(response.data); + }, function fail(response) { + deferred.reject(response.data); + }); + return deferred.promise; + } + + function getEntityViews(entityViewIds, config) { + var deferred = $q.defer(); + var ids = ''; + for (var i=0;i0) { + ids += ','; + } + ids += entityViewIds[i]; + } + var url = '/api/entityViews?entityViewIds=' + ids; + $http.get(url, config).then(function success(response) { + var entityViews = response.data; + entityViews.sort(function (entityView1, entityView2) { + var id1 = entityView1.id.id; + var id2 = entityView2.id.id; + var index1 = entityViewIds.indexOf(id1); + var index2 = entityViewIds.indexOf(id2); + return index1 - index2; + }); + deferred.resolve(entityViews); + }, function fail(response) { + deferred.reject(response.data); + }); + return deferred.promise; + } + + function saveEntityView(entityView) { + var deferred = $q.defer(); + var url = '/api/entityView'; + $http.post(url, entityView).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function deleteEntityView(entityViewId) { + var deferred = $q.defer(); + var url = '/api/entityView/' + entityViewId; + $http.delete(url).then(function success() { + deferred.resolve(); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function assignEntityViewToCustomer(customerId, entityViewId) { + var deferred = $q.defer(); + var url = '/api/customer/' + customerId + '/entityView/' + entityViewId; + $http.post(url, null).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function unassignEntityViewFromCustomer(entityViewId) { + var deferred = $q.defer(); + var url = '/api/customer/entityView/' + entityViewId; + $http.delete(url).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function getEntityViewAttributes(entityViewId, attributeScope, query, successCallback, config) { + return attributeService.getEntityAttributes(types.entityType.entityView, entityViewId, attributeScope, query, successCallback, config); + } + + function subscribeForEntityViewAttributes(entityViewId, attributeScope) { + return attributeService.subscribeForEntityAttributes(types.entityType.entityView, entityViewId, attributeScope); + } + + function unsubscribeForEntityViewAttributes(subscriptionId) { + attributeService.unsubscribeForEntityAttributes(subscriptionId); + } + + function findByQuery(query, ignoreErrors, config) { + var deferred = $q.defer(); + var url = '/api/entityViews'; + if (!config) { + config = {}; + } + config = Object.assign(config, { ignoreErrors: ignoreErrors }); + $http.post(url, query, config).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function getEntityViewTypes(config) { + var deferred = $q.defer(); + var url = '/api/entityView/types'; + $http.get(url, config).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + +} diff --git a/ui/src/app/app.js b/ui/src/app/app.js index c8cdeb02b0..a3a179eb12 100644 --- a/ui/src/app/app.js +++ b/ui/src/app/app.js @@ -67,6 +67,7 @@ import thingsboardClipboard from './services/clipboard.service'; import thingsboardHome from './layout'; import thingsboardApiLogin from './api/login.service'; import thingsboardApiDevice from './api/device.service'; +import thingsboardApiEntityView from './api/entity-view.service'; import thingsboardApiUser from './api/user.service'; import thingsboardApiEntityRelation from './api/entity-relation.service'; import thingsboardApiAsset from './api/asset.service'; @@ -133,6 +134,7 @@ angular.module('thingsboard', [ thingsboardHome, thingsboardApiLogin, thingsboardApiDevice, + thingsboardApiEntityView, thingsboardApiUser, thingsboardApiEntityRelation, thingsboardApiAsset, diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 1e34577119..1d1b7171c3 100644 --- a/ui/src/app/common/types.constant.js +++ b/ui/src/app/common/types.constant.js @@ -327,7 +327,8 @@ export default angular.module('thingsboard.types', []) dashboard: "DASHBOARD", alarm: "ALARM", rulechain: "RULE_CHAIN", - rulenode: "RULE_NODE" + rulenode: "RULE_NODE", + entityview: "ENTITY_VIEW" }, aliasEntityType: { current_customer: "CURRENT_CUSTOMER" diff --git a/ui/src/app/entity-view/add-entity-view.tpl.html b/ui/src/app/entity-view/add-entity-view.tpl.html new file mode 100644 index 0000000000..48a1788ed0 --- /dev/null +++ b/ui/src/app/entity-view/add-entity-view.tpl.html @@ -0,0 +1,45 @@ + + +
+ +
+

entity-view.add

+ +
+ + + +
+
+ + + +
+ +
+
+ + + + {{ 'action.add' | translate }} + + {{ 'action.cancel' | translate }} + +
+
\ No newline at end of file diff --git a/ui/src/app/entity-view/add-entity-views-to-customer.controller.js b/ui/src/app/entity-view/add-entity-views-to-customer.controller.js new file mode 100644 index 0000000000..8e39546ffd --- /dev/null +++ b/ui/src/app/entity-view/add-entity-views-to-customer.controller.js @@ -0,0 +1,123 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/*@ngInject*/ +export default function AddEntityViewsToCustomerController(entityViewService, $mdDialog, $q, customerId, entityViews) { + + var vm = this; + + vm.entityViews = entityViews; + vm.searchText = ''; + + vm.assign = assign; + vm.cancel = cancel; + vm.hasData = hasData; + vm.noData = noData; + vm.searchEntityViewTextUpdated = searchEntityViewTextUpdated; + vm.toggleEntityViewSelection = toggleEntityViewSelection; + + vm.theEntityViews = { + getItemAtIndex: function (index) { + if (index > vm.entityViews.data.length) { + vm.theEntityViews.fetchMoreItems_(index); + return null; + } + var item = vm.entityViews.data[index]; + if (item) { + item.indexNumber = index + 1; + } + return item; + }, + + getLength: function () { + if (vm.entityViews.hasNext) { + return vm.entityViews.data.length + vm.entityViews.nextPageLink.limit; + } else { + return vm.entityViews.data.length; + } + }, + + fetchMoreItems_: function () { + if (vm.entityViews.hasNext && !vm.entityViews.pending) { + vm.entityViews.pending = true; + entityViewService.getTenantEntityViews(vm.entityViews.nextPageLink, false).then( + function success(entityViews) { + vm.entityViews.data = vm.entityViews.data.concat(entityViews.data); + vm.entityViews.nextPageLink = entityViews.nextPageLink; + vm.entityViews.hasNext = entityViews.hasNext; + if (vm.entityViews.hasNext) { + vm.entityViews.nextPageLink.limit = vm.entityViews.pageSize; + } + vm.entityViews.pending = false; + }, + function fail() { + vm.entityViews.hasNext = false; + vm.entityViews.pending = false; + }); + } + } + }; + + function cancel () { + $mdDialog.cancel(); + } + + function assign() { + var tasks = []; + for (var entityViewId in vm.entityViews.selections) { + tasks.push(entityViewService.assignEntityViewToCustomer(customerId, entityViewId)); + } + $q.all(tasks).then(function () { + $mdDialog.hide(); + }); + } + + function noData() { + return vm.entityViews.data.length == 0 && !vm.entityViews.hasNext; + } + + function hasData() { + return vm.entityViews.data.length > 0; + } + + function toggleEntityViewSelection($event, entityView) { + $event.stopPropagation(); + var selected = angular.isDefined(entityView.selected) && entityView.selected; + entityView.selected = !selected; + if (entityView.selected) { + vm.entityViews.selections[entityView.id.id] = true; + vm.entityViews.selectedCount++; + } else { + delete vm.entityViews.selections[entityView.id.id]; + vm.entityViews.selectedCount--; + } + } + + function searchEntityViewTextUpdated() { + vm.entityViews = { + pageSize: vm.entityViews.pageSize, + data: [], + nextPageLink: { + limit: vm.entityViews.pageSize, + textSearch: vm.searchText + }, + selections: {}, + selectedCount: 0, + hasNext: true, + pending: false + }; + } + +} diff --git a/ui/src/app/entity-view/add-entity-views-to-customer.tpl.html b/ui/src/app/entity-view/add-entity-views-to-customer.tpl.html new file mode 100644 index 0000000000..1149a1d8be --- /dev/null +++ b/ui/src/app/entity-view/add-entity-views-to-customer.tpl.html @@ -0,0 +1,77 @@ + + +
+ +
+

entity-view.assign-entity-view-to-customer

+ + + + +
+
+ + + +
+
+ entity-view.assign-entity-view-to-customer-text + + + + search + + + +
+ entity-view.no-entity-views-text + + + + + {{ entityView.name }} + + + +
+
+
+
+ + + + {{ 'action.assign' | translate }} + + {{ 'action.cancel' | + translate }} + + +
+
\ No newline at end of file diff --git a/ui/src/app/entity-view/assign-to-customer.controller.js b/ui/src/app/entity-view/assign-to-customer.controller.js new file mode 100644 index 0000000000..3e09ae6e4f --- /dev/null +++ b/ui/src/app/entity-view/assign-to-customer.controller.js @@ -0,0 +1,123 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/*@ngInject*/ +export default function AssignEntityViewToCustomerController(customerService, entityViewService, $mdDialog, $q, entityViewIds, customers) { + + var vm = this; + + vm.customers = customers; + vm.searchText = ''; + + vm.assign = assign; + vm.cancel = cancel; + vm.isCustomerSelected = isCustomerSelected; + vm.hasData = hasData; + vm.noData = noData; + vm.searchCustomerTextUpdated = searchCustomerTextUpdated; + vm.toggleCustomerSelection = toggleCustomerSelection; + + vm.theCustomers = { + getItemAtIndex: function (index) { + if (index > vm.customers.data.length) { + vm.theCustomers.fetchMoreItems_(index); + return null; + } + var item = vm.customers.data[index]; + if (item) { + item.indexNumber = index + 1; + } + return item; + }, + + getLength: function () { + if (vm.customers.hasNext) { + return vm.customers.data.length + vm.customers.nextPageLink.limit; + } else { + return vm.customers.data.length; + } + }, + + fetchMoreItems_: function () { + if (vm.customers.hasNext && !vm.customers.pending) { + vm.customers.pending = true; + customerService.getCustomers(vm.customers.nextPageLink).then( + function success(customers) { + vm.customers.data = vm.customers.data.concat(customers.data); + vm.customers.nextPageLink = customers.nextPageLink; + vm.customers.hasNext = customers.hasNext; + if (vm.customers.hasNext) { + vm.customers.nextPageLink.limit = vm.customers.pageSize; + } + vm.customers.pending = false; + }, + function fail() { + vm.customers.hasNext = false; + vm.customers.pending = false; + }); + } + } + }; + + function cancel() { + $mdDialog.cancel(); + } + + function assign() { + var tasks = []; + for (var i=0; i < entityViewIds.length;i++) { + tasks.push(entityViewService.assignEntityViewToCustomer(vm.customers.selection.id.id, entityViewIds[i])); + } + $q.all(tasks).then(function () { + $mdDialog.hide(); + }); + } + + function noData() { + return vm.customers.data.length == 0 && !vm.customers.hasNext; + } + + function hasData() { + return vm.customers.data.length > 0; + } + + function toggleCustomerSelection($event, customer) { + $event.stopPropagation(); + if (vm.isCustomerSelected(customer)) { + vm.customers.selection = null; + } else { + vm.customers.selection = customer; + } + } + + function isCustomerSelected(customer) { + return vm.customers.selection != null && customer && + customer.id.id === vm.customers.selection.id.id; + } + + function searchCustomerTextUpdated() { + vm.customers = { + pageSize: vm.customers.pageSize, + data: [], + nextPageLink: { + limit: vm.customers.pageSize, + textSearch: vm.searchText + }, + selection: null, + hasNext: true, + pending: false + }; + } +} diff --git a/ui/src/app/entity-view/assign-to-customer.tpl.html b/ui/src/app/entity-view/assign-to-customer.tpl.html new file mode 100644 index 0000000000..7c1fa2540e --- /dev/null +++ b/ui/src/app/entity-view/assign-to-customer.tpl.html @@ -0,0 +1,76 @@ + + +
+ +
+

entity-view.assign-entity-view-to-customer

+ + + + +
+
+ + + +
+
+ entity-view.assign-to-customer-text + + + + search + + + +
+ customer.no-customers-text + + + + + {{ customer.title }} + + + +
+
+
+
+ + + + {{ 'action.assign' | translate }} + + {{ 'action.cancel' | + translate }} + + +
+
\ No newline at end of file diff --git a/ui/src/app/entity-view/entity-view-card.tpl.html b/ui/src/app/entity-view/entity-view-card.tpl.html new file mode 100644 index 0000000000..1e90928540 --- /dev/null +++ b/ui/src/app/entity-view/entity-view-card.tpl.html @@ -0,0 +1,22 @@ + +
+
{{vm.item.type}}
+
{{vm.item.additionalInfo.description}}
+
{{'entity-view.assignedToCustomer' | translate}} '{{vm.item.assignedCustomer.title}}'
+
diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html new file mode 100644 index 0000000000..e0000c6d9d --- /dev/null +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -0,0 +1,64 @@ + +{{ 'entity-view.assign-to-customer' | translate }} +{{'entity-view.unassign-from-customer' | translate }} +{{ 'entity-view.delete' | translate }} + +
+ + + entity-view.copyId + +
+ + + + + + +
+ + + +
+
entity-view.name-required
+
+
+ + + + + + +
+
diff --git a/ui/src/app/entity-view/entity-view.controller.js b/ui/src/app/entity-view/entity-view.controller.js new file mode 100644 index 0000000000..fd3b5a836a --- /dev/null +++ b/ui/src/app/entity-view/entity-view.controller.js @@ -0,0 +1,483 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable import/no-unresolved, import/default */ + +import addEntityViewTemplate from './add-entity-view.tpl.html'; +import entityViewCard from './entity-view-card.tpl.html'; +import assignToCustomerTemplate from './assign-to-customer.tpl.html'; +import addEntityViewsToCustomerTemplate from './add-entity-views-to-customer.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +/*@ngInject*/ +export function EntityViewCardController(types) { + + var vm = this; + + vm.types = types; + + vm.isAssignedToCustomer = function() { + if (vm.item && vm.item.customerId && vm.parentCtl.entityViewsScope === 'tenant' && + vm.item.customerId.id != vm.types.id.nullUid && !vm.item.assignedCustomer.isPublic) { + return true; + } + return false; + } + + vm.isPublic = function() { + if (vm.item && vm.item.assignedCustomer && vm.parentCtl.entityViewsScope === 'tenant' && vm.item.assignedCustomer.isPublic) { + return true; + } + return false; + } +} + + +/*@ngInject*/ +export function EntityViewController($rootScope, userService, entityViewService, customerService, $state, $stateParams, + $document, $mdDialog, $q, $translate, types) { + + var customerId = $stateParams.customerId; + + var entityViewActionsList = []; + + var entityViewGroupActionsList = []; + + var vm = this; + + vm.types = types; + + vm.entityViewGridConfig = { + deleteItemTitleFunc: deleteEntityViewTitle, + deleteItemContentFunc: deleteEntityViewText, + deleteItemsTitleFunc: deleteEntityViewsTitle, + deleteItemsActionTitleFunc: deleteEntityViewsActionTitle, + deleteItemsContentFunc: deleteEntityViewsText, + + saveItemFunc: saveEntityView, + + getItemTitleFunc: getEntityViewTitle, + + itemCardController: 'EntityViewCardController', + itemCardTemplateUrl: entityViewCard, + parentCtl: vm, + + actionsList: entityViewActionsList, + groupActionsList: entityViewGroupActionsList, + + onGridInited: gridInited, + + addItemTemplateUrl: addEntityViewTemplate, + + addItemText: function() { return $translate.instant('entity-view.add-entity-view-text') }, + noItemsText: function() { return $translate.instant('entity-view.no-entity-views-text') }, + itemDetailsText: function() { return $translate.instant('entity-view.entity-view-details') }, + isDetailsReadOnly: isCustomerUser, + isSelectionEnabled: function () { + return !isCustomerUser(); + } + }; + + if (angular.isDefined($stateParams.items) && $stateParams.items !== null) { + vm.entityViewGridConfig.items = $stateParams.items; + } + + if (angular.isDefined($stateParams.topIndex) && $stateParams.topIndex > 0) { + vm.entityViewGridConfig.topIndex = $stateParams.topIndex; + } + + vm.entityViewsScope = $state.$current.data.entityViewsType; + + vm.assignToCustomer = assignToCustomer; + vm.makePublic = makePublic; + vm.unassignFromCustomer = unassignFromCustomer; + + initController(); + + function initController() { + var fetchEntityViewsFunction = null; + var deleteEntityViewFunction = null; + var refreshEntityViewsParamsFunction = null; + + var user = userService.getCurrentUser(); + + if (user.authority === 'CUSTOMER_USER') { + vm.entityViewsScope = 'customer_user'; + customerId = user.customerId; + } + if (customerId) { + vm.customerEntityViewsTitle = $translate.instant('customer.entity-views'); + customerService.getShortCustomerInfo(customerId).then( + function success(info) { + if (info.isPublic) { + vm.customerEntityViewsTitle = $translate.instant('customer.public-entity-views'); + } + } + ); + } + + if (vm.entityViewsScope === 'tenant') { + fetchEntityViewsFunction = function (pageLink, entityViewType) { + return entityViewService.getTenantEntityViews(pageLink, true, null, entityViewType); + }; + deleteEntityViewFunction = function (entityViewId) { + return entityViewService.deleteEntityView(entityViewId); + }; + refreshEntityViewsParamsFunction = function() { + return {"topIndex": vm.topIndex}; + }; + + entityViewActionsList.push( + { + onAction: function ($event, item) { + assignToCustomer($event, [ item.id.id ]); + }, + name: function() { return $translate.instant('action.assign') }, + details: function() { return $translate.instant('entity-view.assign-to-customer') }, + icon: "assignment_ind", + isEnabled: function(entityView) { + return entityView && (!entityView.customerId || entityView.customerId.id === types.id.nullUid); + } + } + ); + + entityViewActionsList.push( + { + onAction: function ($event, item) { + unassignFromCustomer($event, item, false); + }, + name: function() { return $translate.instant('action.unassign') }, + details: function() { return $translate.instant('entity-view.unassign-from-customer') }, + icon: "assignment_return", + isEnabled: function(entityView) { + return entityView && entityView.customerId && entityView.customerId.id !== types.id.nullUid && !entityView.assignedCustomer.isPublic; + } + } + ); + + entityViewActionsList.push({ + onAction: function ($event, item) { + unassignFromCustomer($event, item, true); + }, + name: function() { return $translate.instant('action.make-private') }, + details: function() { return $translate.instant('entity-view.make-private') }, + icon: "reply", + isEnabled: function(entityView) { + return entityView && entityView.customerId && entityView.customerId.id !== types.id.nullUid && entityView.assignedCustomer.isPublic; + } + }); + + entityViewActionsList.push( + { + onAction: function ($event, item) { + vm.grid.deleteItem($event, item); + }, + name: function() { return $translate.instant('action.delete') }, + details: function() { return $translate.instant('entity-view.delete') }, + icon: "delete" + } + ); + + entityViewGroupActionsList.push( + { + onAction: function ($event, items) { + assignEntiyViewsToCustomer($event, items); + }, + name: function() { return $translate.instant('entity-view.assign-entity-views') }, + details: function(selectedCount) { + return $translate.instant('entity-view.assign-entity-views-text', {count: selectedCount}, "messageformat"); + }, + icon: "assignment_ind" + } + ); + + entityViewGroupActionsList.push( + { + onAction: function ($event) { + vm.grid.deleteItems($event); + }, + name: function() { return $translate.instant('entity-view.delete-entity-views') }, + details: deleteEntityViewsActionTitle, + icon: "delete" + } + ); + + + + } else if (vm.entityViewsScope === 'customer' || vm.entityViewsScope === 'customer_user') { + fetchEntityViewsFunction = function (pageLink, entityViewType) { + return entityViewService.getCustomerEntityViews(customerId, pageLink, true, null, entityViewType); + }; + deleteentityViewFunction = function (entityViewId) { + return entityViewService.unassignEntityViewFromCustomer(entityViewId); + }; + refreshentityViewsParamsFunction = function () { + return {"customerId": customerId, "topIndex": vm.topIndex}; + }; + + if (vm.entityViewsScope === 'customer') { + entityViewActionsList.push( + { + onAction: function ($event, item) { + unassignFromCustomer($event, item, false); + }, + name: function() { return $translate.instant('action.unassign') }, + details: function() { return $translate.instant('entity-view.unassign-from-customer') }, + icon: "assignment_return", + isEnabled: function(entityView) { + return entityView && !entityView.assignedCustomer.isPublic; + } + } + ); + + entityViewGroupActionsList.push( + { + onAction: function ($event, items) { + unassignEntityViewsFromCustomer($event, items); + }, + name: function() { return $translate.instant('entity-view.unassign-entity-views') }, + details: function(selectedCount) { + return $translate.instant('entity-view.unassign-entity-views-action-title', {count: selectedCount}, "messageformat"); + }, + icon: "assignment_return" + } + ); + + vm.entityViewGridConfig.addItemAction = { + onAction: function ($event) { + addEntityViewsToCustomer($event); + }, + name: function() { return $translate.instant('entity-view.assign-entity-views') }, + details: function() { return $translate.instant('entity-view.assign-new-entity-view') }, + icon: "add" + }; + + + } else if (vm.entityViewsScope === 'customer_user') { + vm.entityViewGridConfig.addItemAction = {}; + } + } + + vm.entityViewGridConfig.refreshParamsFunc = refreshentityViewsParamsFunction; + vm.entityViewGridConfig.fetchItemsFunc = fetchentityViewsFunction; + vm.entityViewGridConfig.deleteItemFunc = deleteentityViewFunction; + + } + + function deleteEntityViewTitle(entityView) { + return $translate.instant('entity-view.delete-entity-view-title', {entityViewName: entityView.name}); + } + + function deleteEntityViewText() { + return $translate.instant('entity-view.delete-entity-view-text'); + } + + function deleteEntityViewsTitle(selectedCount) { + return $translate.instant('entity-view.delete-entity-views-title', {count: selectedCount}, 'messageformat'); + } + + function deleteEntityViewsActionTitle(selectedCount) { + return $translate.instant('entity-view.delete-entity-views-action-title', {count: selectedCount}, 'messageformat'); + } + + function deleteEntityViewsText () { + return $translate.instant('entity-view.delete-entity-views-text'); + } + + function gridInited(grid) { + vm.grid = grid; + } + + function getEntityViewTitle(entityView) { + return entityView ? entityView.name : ''; + } + + function saveEntityView(entityView) { + var deferred = $q.defer(); + entityViewService.saveEntityView(entityView).then( + function success(savedEntityView) { + $rootScope.$broadcast('entityViewSaved'); + var entityViews = [ savedEntityView ]; + customerService.applyAssignedCustomersInfo(entityViews).then( + function success(items) { + if (items && items.length == 1) { + deferred.resolve(items[0]); + } else { + deferred.reject(); + } + }, + function fail() { + deferred.reject(); + } + ); + }, + function fail() { + deferred.reject(); + } + ); + return deferred.promise; + } + + function isCustomerUser() { + return vm.entityViewsScope === 'customer_user'; + } + + function assignToCustomer($event, entityViewIds) { + if ($event) { + $event.stopPropagation(); + } + var pageSize = 10; + customerService.getCustomers({limit: pageSize, textSearch: ''}).then( + function success(_customers) { + var customers = { + pageSize: pageSize, + data: _customers.data, + nextPageLink: _customers.nextPageLink, + selection: null, + hasNext: _customers.hasNext, + pending: false + }; + if (customers.hasNext) { + customers.nextPageLink.limit = pageSize; + } + $mdDialog.show({ + controller: 'AssignEntityViewToCustomerController', + controllerAs: 'vm', + templateUrl: assignToCustomerTemplate, + locals: {entityViewIds: entityViewIds, customers: customers}, + parent: angular.element($document[0].body), + fullscreen: true, + targetEvent: $event + }).then(function () { + vm.grid.refreshList(); + }, function () { + }); + }, + function fail() { + }); + } + + function addEntityViewsToCustomer($event) { + if ($event) { + $event.stopPropagation(); + } + var pageSize = 10; + entityViewService.getTenantEntityViews({limit: pageSize, textSearch: ''}, false).then( + function success(_entityViews) { + var entityViews = { + pageSize: pageSize, + data: _entityViews.data, + nextPageLink: _entityViews.nextPageLink, + selections: {}, + selectedCount: 0, + hasNext: _entityViews.hasNext, + pending: false + }; + if (entityViews.hasNext) { + entityViews.nextPageLink.limit = pageSize; + } + $mdDialog.show({ + controller: 'AddEntityViewsToCustomerController', + controllerAs: 'vm', + templateUrl: addEntityViewsToCustomerTemplate, + locals: {customerId: customerId, entityViews: entityViews}, + parent: angular.element($document[0].body), + fullscreen: true, + targetEvent: $event + }).then(function () { + vm.grid.refreshList(); + }, function () { + }); + }, + function fail() { + }); + } + + function assignEntityViewsToCustomer($event, items) { + var entityViewIds = []; + for (var id in items.selections) { + entityViewIds.push(id); + } + assignToCustomer($event, entityViewIds); + } + + function unassignFromCustomer($event, entityView, isPublic) { + if ($event) { + $event.stopPropagation(); + } + var title; + var content; + var label; + if (isPublic) { + title = $translate.instant('entity-view.make-private-entity-view-title', {entityViewName: entityView.name}); + content = $translate.instant('entity-view.make-private-entity-view-text'); + label = $translate.instant('entity-view.make-private'); + } else { + title = $translate.instant('entity-view.unassign-entity-view-title', {entityViewName: entityView.name}); + content = $translate.instant('entity-view.unassign-entity-view-text'); + label = $translate.instant('entity-view.unassign-entity-view'); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title(title) + .htmlContent(content) + .ariaLabel(label) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + entityViewService.unassignEntityViewFromCustomer(entityView.id.id).then(function success() { + vm.grid.refreshList(); + }); + }); + } + + function unassignEntityViewsFromCustomer($event, items) { + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('entity-view.unassign-entity-views-title', {count: items.selectedCount}, 'messageformat')) + .htmlContent($translate.instant('entity-view.unassign-entity-views-text')) + .ariaLabel($translate.instant('entity-view.unassign-entity-view')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + var tasks = []; + for (var id in items.selections) { + tasks.push(entityViewService.unassignEntityViewFromCustomer(id)); + } + $q.all(tasks).then(function () { + vm.grid.refreshList(); + }); + }); + } + + function makePublic($event, entityView) { + if ($event) { + $event.stopPropagation(); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('entity-view.make-public-entity-view-title', {entityViewName: entityView.name})) + .htmlContent($translate.instant('entity-view.make-public-entity-view-text')) + .ariaLabel($translate.instant('entity-view.make-public')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + entityViewService.makeEntityViewPublic(entityView.id.id).then(function success() { + vm.grid.refreshList(); + }); + }); + } +} diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js new file mode 100644 index 0000000000..f98027062e --- /dev/null +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -0,0 +1,67 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable import/no-unresolved, import/default */ + +import entityViewFieldsetTemplate from './entity-view-fieldset.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +/*@ngInject*/ +export default function EntityViewDirective($compile, $templateCache, toast, $translate, types, clipboardService, entityViewService, customerService) { + var linker = function (scope, element) { + var template = $templateCache.get(entityViewFieldsetTemplate); + element.html(template); + + scope.types = types; + scope.isAssignedToCustomer = false; + scope.assignedCustomer = null; + + scope.$watch('entityView', function(newVal) { + if (newVal) { + if (scope.entityView.customerId && scope.entityView.customerId.id !== types.id.nullUid) { + scope.isAssignedToCustomer = true; + customerService.getShortCustomerInfo(scope.entityView.customerId.id).then( + function success(customer) { + scope.assignedCustomer = customer; + } + ); + } else { + scope.isAssignedToCustomer = false; + scope.assignedCustomer = null; + } + } + }); + + scope.onEntityViewIdCopied = function() { + toast.showSuccess($translate.instant('entity-view.idCopiedMessage'), 750, angular.element(element).parent().parent(), 'bottom left'); + }; + + $compile(element.contents())(scope); + } + return { + restrict: "E", + link: linker, + scope: { + entityView: '=', + isEdit: '=', + entityViewScope: '=', + theForm: '=', + onAssignToCustomer: '&', + onUnassignFromCustomer: '&', + onDeleteEntityView: '&' + } + }; +} diff --git a/ui/src/app/entity-view/entity-view.routes.js b/ui/src/app/entity-view/entity-view.routes.js new file mode 100644 index 0000000000..14f5079d41 --- /dev/null +++ b/ui/src/app/entity-view/entity-view.routes.js @@ -0,0 +1,72 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable import/no-unresolved, import/default */ + +import entityViewsTemplate from './entity-views.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +/*@ngInject*/ +export default function EntityViewRoutes($stateProvider, types) { + $stateProvider + .state('home.entityViews', { + url: '/entityViews', + params: {'topIndex': 0}, + module: 'private', + auth: ['TENANT_ADMIN', 'CUSTOMER_USER'], + views: { + "content@home": { + templateUrl: entityViewsTemplate, + controller: 'EntityViewController', + controllerAs: 'vm' + } + }, + data: { + entityViewsTypes: 'tenant', + searchEnabled: true, + searchByEntitySubtype: true, + searchEntityType: types.entityType.entityview, + pageTitle: 'entity-views.entity-views' + }, + ncyBreadcrumb: { + label: '{"icon": "devices_other", "label": "entity-view.entity-views"}' + } + }) + .state('home.customers.entityViews', { + url: '/:customerId/entityViews', + params: {'topIndex': 0}, + module: 'private', + auth: ['TENANT_ADMIN'], + views: { + "content@home": { + templateUrl: entityViewsTemplate, + controllerAs: 'vm', + controller: 'EntityViewController' + } + }, + data: { + entityViewsTypes: 'customer', + searchEnabled: true, + searchByEntitySubtype: true, + searchEntityType: types.entityType.entityview, + pageTitle: 'customer.entity-views' + }, + ncyBreadcrumb: { + label: '{"icon": "devices_other", "label": "{{ vm.customerEntityViewsTitle }}", "translate": "false"}' + } + }); + +} diff --git a/ui/src/app/entity-view/entity-views.tpl.html b/ui/src/app/entity-view/entity-views.tpl.html new file mode 100644 index 0000000000..5398449273 --- /dev/null +++ b/ui/src/app/entity-view/entity-views.tpl.html @@ -0,0 +1,83 @@ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/ui/src/app/entity-view/index.js b/ui/src/app/entity-view/index.js new file mode 100644 index 0000000000..ebdd3ea282 --- /dev/null +++ b/ui/src/app/entity-view/index.js @@ -0,0 +1,41 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import uiRouter from 'angular-ui-router'; +import thingsboardGrid from '../components/grid.directive'; +import thingsboardApiUser from '../api/user.service'; +import thingsboardApiEntityView from '../api/entity-view.service'; +import thingsboardApiCustomer from '../api/customer.service'; + +import EntityViewRoutes from './entity-view.routes'; +import EntityViewCardController from './entity-view.controller'; +import AssignEntityViewToCustomerController from './assign-to-customer.controller'; +import AddEntityViewsToCustomerController from './add-entity-views-to-customer.controller'; +import EntityViewDirective from './entity-view.directive'; + +export default angular.module('thingsboard.entityView', [ + uiRouter, + thingsboardGrid, + thingsboardApiUser, + thingsboardApiEntityView, + thingsboardApiCustomer +]) + .config(EntityViewRoutes) + .controller('EntityViewController', EntityViewCardController) + .controller('EntityViewCardController', EntityViewCardController) + .controller('AssignEntityViewToCustomerController', AssignEntityViewToCustomerController) + .controller('AddEntityViewsToCustomerController', AddEntityViewsToCustomerController) + .directive('tbEntityView', EntityViewDirective) + .name; diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 6321d63f46..5e04a52a00 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -338,10 +338,12 @@ "dashboard": "Customer Dashboard", "dashboards": "Customer Dashboards", "devices": "Customer Devices", + "entity-views": "Customer Entity Views", "assets": "Customer Assets", "public-dashboards": "Public Dashboards", "public-devices": "Public Devices", "public-assets": "Public Assets", + "public-entity-views": "Public Entity Views", "add": "Add Customer", "delete": "Delete customer", "manage-customer-users": "Manage customer users", @@ -750,6 +752,77 @@ "no-entities-prompt": "No entities found", "no-data": "No data to display" }, + "entity-view": { + "entity-view": "Entity View", + "entity-views": "Entity Views", + "management": "Entity View management", + "view-entity-views": "View Entity Views", + "entity-view-alias": "Entity View alias", + "aliases": "Entity View aliases", + "no-alias-matching": "'{{alias}}' not found.", + "no-aliases-found": "No aliases found.", + "no-key-matching": "'{{key}}' not found.", + "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.", + "configure-alias": "Configure '{{alias}}' alias", + "no-entity-views-matching": "No entity views matching '{{entity}}' were found.", + "alias": "Alias", + "alias-required": "Entity View alias is required.", + "remove-alias": "Remove entity view alias", + "add-alias": "Add entity view alias", + "name-starts-with": "Entity View name starts with", + "entity-view-list": "Entity View list", + "use-entity-view-name-filter": "Use filter", + "entity-view-list-empty": "No entity views selected.", + "entity-view-name-filter-required": "Entity view name filter is required.", + "entity-view-name-filter-no-entity-view-matched": "No entity views starting with '{{entityView}}' were found.", + "add": "Add Entity View", + "assign-to-customer": "Assign to customer", + "assign-entity-view-to-customer": "Assign Entity View(s) To Customer", + "assign-entity-view-to-customer-text": "Please select the entity views to assign to the customer", + "no-entity-views-text": "No entity views found", + "assign-to-customer-text": "Please select the customer to assign the entity view(s)", + "entity-view-details": "Entity view details", + "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", + "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", + "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-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-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.", + "select-entity-view-type": "Select entity view type", + "enter-entity-view-type": "Enter entity view type", + "any-entity-view": "Any entity view", + "no-entity-view-types-matching": "No entity view types matching '{{entitySubtype}}' were found.", + "entity-view-type-list-empty": "No entity view types selected.", + "entity-view-types": "Entity View types", + "name": "Name", + "name-required": "Name is required.", + "description": "Description", + "events": "Events", + "details": "Details", + "copyId": "Copy entity view Id", + "assignedToCustomer": "Assigned to customer", + "unable-entity-view-device-alias-title": "Unable to delete entity view alias", + "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", + "select-entity-view": "Select entity view" + }, "event": { "event-type": "Event type", "type-error": "Error", From c5827e1b9e0f08db4756ca28f40bde4180837f6b Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 6 Sep 2018 13:07:31 +0300 Subject: [PATCH 053/307] was added entity_type field to entity_views table --- application/src/main/data/upgrade/2.1.1/schema_update.cql | 3 ++- dao/src/main/resources/cassandra/schema.cql | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index d9ba517df7..1329a0b546 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -24,6 +24,7 @@ DROP TABLE IF EXISTS thingsboard.entity_views; CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( id timeuuid, entity_id timeuuid, + entity_type text, tenant_id timeuuid, customer_id timeuuid, name text, @@ -33,7 +34,7 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( search_text text, additional_info text, PRIMARY KEY (id, entity_id, tenant_id, customer_id) - ); +); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS SELECT * diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql index bdd413d6ad..68f196f005 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema.cql @@ -642,6 +642,7 @@ CREATE TABLE IF NOT EXISTS thingsboard.rule_node ( CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( id timeuuid, entity_id timeuuid, + entity_type text, tenant_id timeuuid, customer_id timeuuid, name text, From e132d702a2ec05e688eddacad5d5188ec8ca3450 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 6 Sep 2018 13:10:48 +0300 Subject: [PATCH 054/307] Was changed path to all sql and nosql tests --- .../thingsboard/server/controller/ControllerSqlTestSuite.java | 2 +- .../server/controller/nosql/EntityViewControllerNoSqlTest.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index a9e94e9184..c8a5da8151 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.controller.sql.EntityViewControllerSqlTest", + "org.thingsboard.server.controller.sql.*Test", }) public class ControllerSqlTestSuite { diff --git a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java index ad066fc7ab..404e4e253f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java @@ -16,9 +16,11 @@ package org.thingsboard.server.controller.nosql; import org.thingsboard.server.controller.BaseEntityViewControllerTest; +import org.thingsboard.server.dao.service.DaoNoSqlTest; /** * Created by Victor Basanets on 8/27/2017. */ +@DaoNoSqlTest public class EntityViewControllerNoSqlTest extends BaseEntityViewControllerTest { } From 3a3f6636e11f6fac0152b8621bb6afb3bb668001 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 6 Sep 2018 13:13:44 +0300 Subject: [PATCH 055/307] Was added new field to entity and method save to dao interface --- .../entityview/CassandraEntityViewDao.java | 90 +++++++++++++++++++ .../server/dao/entityview/EntityViewDao.java | 8 ++ .../dao/model/nosql/EntityViewEntity.java | 42 +++++---- 3 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java new file mode 100644 index 0000000000..e9282e23b0 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -0,0 +1,90 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.entityview; + +import com.datastax.driver.core.Statement; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.model.EntitySubtypeEntity; +import org.thingsboard.server.dao.model.nosql.EntityViewEntity; +import org.thingsboard.server.dao.nosql.CassandraAbstractSearchTextDao; +import org.thingsboard.server.dao.util.NoSqlDao; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; + +/** + * Created by Victor Basanets on 9/06/2017. + */ +@Component +@Slf4j +@NoSqlDao +public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao implements EntityViewDao { + + @Override + protected Class getColumnFamilyClass() { + return EntityViewEntity.class; + } + + @Override + protected String getColumnFamilyName() { + return ENTITY_VIEW_TABLE_FAMILY_NAME; + } + + @Override + public EntityView save(EntityView domain) { + EntityView savedEntityView = super.save(domain); + EntitySubtype entitySubtype = new EntitySubtype(savedEntityView.getTenantId(), EntityType.ENTITY_VIEW, + savedEntityView.getId().getEntityType().toString()); + EntitySubtypeEntity entitySubtypeEntity = new EntitySubtypeEntity(entitySubtype); + Statement saveStatement = cluster.getMapper(EntitySubtypeEntity.class).saveQuery(entitySubtypeEntity); + executeWrite(saveStatement); + return savedEntityView; + } + + /*Wasn't done!!!*/ + @Override + public List findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink) { + + } + + @Override + public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { + return Optional.empty(); + } + + @Override + public List findEntityViewByTenantIdAndEntityId(UUID tenantId, UUID entityId, TextPageLink pageLink) { + return null; + } + + @Override + public List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, TextPageLink pageLink) { + return null; + } + + @Override + public List findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, UUID customerId, UUID entityId, TextPageLink pageLink) { + return null; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index 25404db834..5cbbef834f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -29,6 +29,14 @@ import java.util.UUID; */ public interface EntityViewDao extends Dao { + /** + * Save or update device object + * + * @param entityView the entity-view object + * @return saved entity-view object + */ + EntityView save(EntityView entityView); + /** * Find entity views by tenantId and page link. * diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index 65914dd566..075a2c5854 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -19,24 +19,26 @@ import com.datastax.driver.core.utils.UUIDs; import com.datastax.driver.mapping.annotations.PartitionKey; import com.datastax.driver.mapping.annotations.Table; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.hibernate.annotations.Type; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityViewId; -import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.*; import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.SearchTextEntity; import javax.persistence.Column; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; import java.io.IOException; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; import static org.thingsboard.server.dao.model.ModelConstants.ID_PROPERTY; @@ -57,6 +59,10 @@ public class EntityViewEntity implements SearchTextEntity { @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) private UUID entityId; + @Enumerated(EnumType.STRING) + @Column(name = ENTITY_TYPE_PROPERTY) + private EntityType entityType; + @PartitionKey(value = 2) @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY) private UUID tenantId; @@ -68,9 +74,8 @@ public class EntityViewEntity implements SearchTextEntity { @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) private String name; - @Type(type = "json") @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) - private JsonNode keys; + private String keys; @Column(name = ModelConstants.ENTITY_VIEW_TS_BEGIN_PROPERTY) private String tsBegin; @@ -85,6 +90,8 @@ public class EntityViewEntity implements SearchTextEntity { @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY) private JsonNode additionalInfo; + private static final ObjectMapper mapper = new ObjectMapper(); + public EntityViewEntity() { super(); } @@ -95,6 +102,7 @@ public class EntityViewEntity implements SearchTextEntity { } if (entityView.getEntityId() != null) { this.entityId = entityView.getEntityId().getId(); + this.entityType = entityView.getEntityId().getEntityType(); } if (entityView.getTenantId() != null) { this.tenantId = entityView.getTenantId().getId(); @@ -103,11 +111,11 @@ public class EntityViewEntity implements SearchTextEntity { this.customerId = entityView.getCustomerId().getId(); } this.name = entityView.getName(); -// try { -// this.keys = entityView.getKeys(); -// } catch (IOException e) { -// e.printStackTrace(); -// } + try { + this.keys = mapper.writeValueAsString(entityView.getKeys()); + } catch (IOException e) { + e.printStackTrace(); + } this.tsBegin = entityView.getTsBegin() != null ? String.valueOf(entityView.getTsBegin()) : "0"; this.tsEnd = entityView.getTsEnd() != null ? String.valueOf(entityView.getTsEnd()) : "0"; this.searchText = entityView.getSearchText(); @@ -124,7 +132,7 @@ public class EntityViewEntity implements SearchTextEntity { EntityView entityView = new EntityView(new EntityViewId(id)); entityView.setCreatedTime(UUIDs.unixTimestamp(id)); if (entityId != null) { - entityView.setEntityId(new DeviceId(entityId)); + entityView.setEntityId(EntityIdFactory.getByTypeAndId(entityType.name(), entityId.toString())); } if (tenantId != null) { entityView.setTenantId(new TenantId(tenantId)); @@ -133,11 +141,11 @@ public class EntityViewEntity implements SearchTextEntity { entityView.setCustomerId(new CustomerId(customerId)); } entityView.setName(name); -// try { -// entityView.setKeys((TelemetryEntityView) entityView.getKeys().toObject(keys)); -// } catch (IOException e) { -// e.printStackTrace(); -// } + try { + entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); + } catch (IOException e) { + e.printStackTrace(); + } entityView.setTsBegin(Long.parseLong(tsBegin)); entityView.setTsEnd(Long.parseLong(tsEnd)); entityView.setAdditionalInfo(additionalInfo); From 80c2721d1d46cd77e4cf391a543dc72ff1cd6f89 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 6 Sep 2018 14:18:06 +0300 Subject: [PATCH 056/307] Was added the findEntityViewByIdAsync method --- .../server/dao/entityview/EntityViewService.java | 8 ++++---- .../server/dao/entityview/EntityViewServiceImpl.java | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 943d35e9b4..6de86be60c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -15,12 +15,10 @@ */ package org.thingsboard.server.dao.entityview; +import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.EntityViewId; -import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.*; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; @@ -57,4 +55,6 @@ public interface EntityViewService { TextPageLink pageLink); void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); + + ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 6554083fe4..6de1043794 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.entityview; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -190,6 +191,13 @@ public class EntityViewServiceImpl extends AbstractEntityService new CustomerEntityViewsUnAssigner(tenantId).removeEntities(customerId); } + @Override + public ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId) { + log.trace("Executing findDeviceById [{}]", entityViewId); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + return entityViewDao.findByIdAsync(entityViewId.getId()); + } + private DataValidator entityViewValidator = new DataValidator() { From 61da5d5d6e17685c3a57cacc07b693ca1a4adcb8 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 6 Sep 2018 14:19:35 +0300 Subject: [PATCH 057/307] Was addet to validate instance of entity-view --- .../service/security/AccessValidator.java | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java index 600820ec73..7f4f23a0ef 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java @@ -26,17 +26,11 @@ import org.springframework.stereotype.Component; import org.springframework.web.context.request.async.DeferredResult; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.AssetId; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.EntityIdFactory; -import org.thingsboard.server.common.data.id.RuleChainId; -import org.thingsboard.server.common.data.id.RuleNodeId; -import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.*; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.controller.HttpValidationCallback; @@ -44,6 +38,7 @@ import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.user.UserService; @@ -66,6 +61,7 @@ public class AccessValidator { public static final String CUSTOMER_USER_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION = "Customer user is not allowed to perform this operation!"; public static final String SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION = "System administrator is not allowed to perform this operation!"; public static final String DEVICE_WITH_REQUESTED_ID_NOT_FOUND = "Device with requested id wasn't found!"; + public static final String ENTITY_VIEW_WITH_REQUESTED_ID_NOT_FOUND = "Entity-view with requested id wasn't found!"; @Autowired protected TenantService tenantService; @@ -88,6 +84,9 @@ public class AccessValidator { @Autowired protected RuleChainService ruleChainService; + @Autowired + protected EntityViewService entityViewService; + private ExecutorService executor; @PostConstruct @@ -158,6 +157,9 @@ public class AccessValidator { case TENANT: validateTenant(currentUser, entityId, callback); return; + case ENTITY_VIEW: + validateEntityView(currentUser, entityId, callback); + return; default: //TODO: add support of other entities throw new IllegalStateException("Not Implemented!"); @@ -293,6 +295,27 @@ public class AccessValidator { } } + private void validateEntityView(final SecurityUser currentUser, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture entityViewFuture = entityViewService.findEntityViewByIdAsync(new EntityViewId(entityId.getId())); + Futures.addCallback(entityViewFuture, getCallback(callback, entityView -> { + if (entityView == null) { + return ValidationResult.entityNotFound(ENTITY_VIEW_WITH_REQUESTED_ID_NOT_FOUND); + } else { + if (!entityView.getTenantId().equals(currentUser.getTenantId())) { + return ValidationResult.accessDenied("Entity-view doesn't belong to the current Tenant!"); + } else if (currentUser.isCustomerUser() && !entityView.getCustomerId().equals(currentUser.getCustomerId())) { + return ValidationResult.accessDenied("Entity-view doesn't belong to the current Customer!"); + } else { + return ValidationResult.ok(entityView); + } + } + }), executor); + } + } + private FutureCallback getCallback(FutureCallback callback, Function> transformer) { return new FutureCallback() { @Override From ae647024a5e53dab6962cb571914b32cb74b6890 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 7 Sep 2018 17:34:44 +0300 Subject: [PATCH 058/307] Was refactored --- .../java/org/thingsboard/server/common/data/EntityView.java | 4 ++-- .../server/common/data/objects/AttributesEntityView.java | 5 ----- .../server/common/data/objects/TelemetryEntityView.java | 5 ----- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 813a9acd08..49dd20959f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -39,8 +39,8 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo private CustomerId customerId; private String name; private TelemetryEntityView keys; - private Long tsBegin; - private Long tsEnd; + private long startTs; + private long endTs; public EntityView() { super(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java index b1d270c934..1c32579b72 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java @@ -44,9 +44,4 @@ public class AttributesEntityView { public AttributesEntityView(AttributesEntityView obj) { this(obj.getCs(), obj.getSs(), obj.getSh()); } - - @Override - public String toString() { - return "{cs=" + cs + ", ss=" + ss + ", sh=" + sh + '}'; - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java index e7398e6500..c899c65590 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java @@ -40,9 +40,4 @@ public class TelemetryEntityView { public TelemetryEntityView(TelemetryEntityView obj) { this(obj.getTimeseries(), obj.getAttributes()); } - - @Override - public String toString() { - return "{timeseries=" + timeseries + ", attributes=" + attributes + '}'; - } } From 842240671d4dbb9d77dd83f07c78280ff1522f18 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 7 Sep 2018 17:36:52 +0300 Subject: [PATCH 059/307] Was added async method --- .../server/dao/entityview/EntityViewServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 6de1043794..49e1618c73 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -193,7 +193,7 @@ public class EntityViewServiceImpl extends AbstractEntityService @Override public ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId) { - log.trace("Executing findDeviceById [{}]", entityViewId); + log.trace("Executing findEntityViewById [{}]", entityViewId); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); return entityViewDao.findByIdAsync(entityViewId.getId()); } From ad48ecabd84f961ebaeefe385d94966077d9a1c4 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 7 Sep 2018 17:38:54 +0300 Subject: [PATCH 060/307] Was added some of the entity-view constants and refactored code --- .../org/thingsboard/server/dao/model/ModelConstants.java | 4 ++-- .../server/dao/model/nosql/EntityViewEntity.java | 8 ++++---- .../server/dao/model/sql/EntityViewEntity.java | 9 ++++----- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index d06ddbd8e5..6932bea1a3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -136,7 +136,6 @@ public class ModelConstants { public static final String DEVICE_NAME_PROPERTY = "name"; public static final String DEVICE_TYPE_PROPERTY = "type"; public static final String DEVICE_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; - public static final String DEVICE_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_tenant_and_search_text"; public static final String DEVICE_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_tenant_by_type_and_search_text"; public static final String DEVICE_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_customer_and_search_text"; @@ -152,11 +151,12 @@ public class ModelConstants { public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; - public static final String ENTITY_VIEW_TYPE_PROPERTY = "type_entity"; + public static final String ENTITY_VIEW_TENANT_AND_NAME_VIEW_NAME = "entity_view_by_tenant_and_name"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_TS_BEGIN_PROPERTY = "ts_begin"; public static final String ENTITY_VIEW_TS_END_PROPERTY = "ts_end"; public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; + public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_tenant_and_search_text"; /** * Cassandra audit log constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index 075a2c5854..bb5abdd87f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -116,8 +116,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = entityView.getTsBegin() != null ? String.valueOf(entityView.getTsBegin()) : "0"; - this.tsEnd = entityView.getTsEnd() != null ? String.valueOf(entityView.getTsEnd()) : "0"; + this.tsBegin = entityView.getStartTs() != null ? String.valueOf(entityView.getStartTs()) : "0"; + this.tsEnd = entityView.getEndTs() != null ? String.valueOf(entityView.getEndTs()) : "0"; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -146,8 +146,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - entityView.setTsBegin(Long.parseLong(tsBegin)); - entityView.setTsEnd(Long.parseLong(tsEnd)); + entityView.setStartTs(Long.parseLong(tsBegin)); + entityView.setEndTs(Long.parseLong(tsEnd)); entityView.setAdditionalInfo(additionalInfo); return entityView; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index 9136717fe3..c81e8f6027 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -34,7 +34,6 @@ import org.thingsboard.server.dao.util.mapping.JsonStringType; import javax.persistence.*; import java.io.IOException; -import static org.thingsboard.server.dao.model.ModelConstants.AUDIT_LOG_ENTITY_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPERTY; /** @@ -106,8 +105,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = entityView.getTsBegin() != null ? String.valueOf(entityView.getTsBegin()) : "0"; - this.tsEnd = entityView.getTsEnd() != null ? String.valueOf(entityView.getTsEnd()) : "0"; + this.tsBegin = entityView.getStartTs() != null ? String.valueOf(entityView.getStartTs()) : "0"; + this.tsEnd = entityView.getEndTs() != null ? String.valueOf(entityView.getEndTs()) : "0"; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -142,8 +141,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - entityView.setTsBegin(Long.parseLong(tsBegin)); - entityView.setTsEnd(Long.parseLong(tsEnd)); + entityView.setStartTs(Long.parseLong(tsBegin)); + entityView.setEndTs(Long.parseLong(tsEnd)); entityView.setAdditionalInfo(additionalInfo); return entityView; } From 66b330280ebe114d8ebe75f61aaa4a9da4f7e622 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 7 Sep 2018 17:46:56 +0300 Subject: [PATCH 061/307] Was added checks on entity-views obj fiew method for fetch update queries startTs & entdTs and other --- .../dao/timeseries/BaseTimeseriesService.java | 86 +++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index c981378939..634d7f222e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -21,12 +21,18 @@ import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.kv.BaseTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvQuery; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.Validator; +import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -44,10 +50,17 @@ public class BaseTimeseriesService implements TimeseriesService { @Autowired private TimeseriesDao timeseriesDao; + @Autowired + private EntityViewService entityViewService; + @Override public ListenableFuture> findAll(EntityId entityId, List queries) { validate(entityId); queries.forEach(query -> validate(query)); + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); + return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, queries)); + } return timeseriesDao.findAllAsync(entityId, queries); } @@ -56,7 +69,13 @@ public class BaseTimeseriesService implements TimeseriesService { validate(entityId); List> futures = Lists.newArrayListWithExpectedSize(keys.size()); keys.forEach(key -> Validator.validateString(key, "Incorrect key " + key)); - keys.forEach(key -> futures.add(timeseriesDao.findLatest(entityId, key))); + if (false/*entityId.getEntityType().equals(EntityType.ENTITY_VIEW)*/) { + EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); + Collection newKeys = chooseKeysForEntityView(entityView, keys); + newKeys.forEach(newKey -> futures.add(timeseriesDao.findLatest(entityView.getEntityId(), newKey))); + } else { + keys.forEach(key -> futures.add(timeseriesDao.findLatest(entityId, key))); + } return Futures.allAsList(futures); } @@ -69,6 +88,11 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> save(EntityId entityId, TsKvEntry tsKvEntry) { validate(entityId); + try { + checkForNonEntityView(entityId); + } catch (Exception e) { + e.printStackTrace(); + } if (tsKvEntry == null) { throw new IncorrectParameterException("Key value entry can't be null"); } @@ -79,6 +103,11 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> save(EntityId entityId, List tsKvEntries, long ttl) { + try { + checkForNonEntityView(entityId); + } catch (Exception e) { + e.printStackTrace(); + } List> futures = Lists.newArrayListWithExpectedSize(tsKvEntries.size() * INSERTS_PER_ENTRY); for (TsKvEntry tsKvEntry : tsKvEntries) { if (tsKvEntry == null) { @@ -90,11 +119,47 @@ public class BaseTimeseriesService implements TimeseriesService { } private void saveAndRegisterFutures(List> futures, EntityId entityId, TsKvEntry tsKvEntry, long ttl) { + try { + checkForNonEntityView(entityId); + } catch (Exception e) { + e.printStackTrace(); + } futures.add(timeseriesDao.savePartition(entityId, tsKvEntry.getTs(), tsKvEntry.getKey(), ttl)); futures.add(timeseriesDao.saveLatest(entityId, tsKvEntry)); futures.add(timeseriesDao.save(entityId, tsKvEntry, ttl)); } + private List updateQueriesForEntityView(EntityView entityView, List queries) { + List newQueries = new ArrayList<>(); + entityView.getKeys().getTimeseries() + .forEach(viewKey -> queries + .forEach(query -> { + if (query.getKey().equals(viewKey)) { + if (entityView.getStartTs() == 0 && entityView.getEndTs() == 0) { + newQueries.add(updateQuery(query.getStartTs(), query.getEndTs(), viewKey, query)); + } else if (entityView.getStartTs() == 0 && entityView.getEndTs() != 0) { + newQueries.add(updateQuery(query.getStartTs(), entityView.getEndTs(), viewKey, query)); + } else if (entityView.getStartTs() != 0 && entityView.getEndTs() == 0) { + newQueries.add(updateQuery(entityView.getStartTs(), query.getEndTs(), viewKey, query)); + } else { + newQueries.add(updateQuery(entityView.getStartTs(), entityView.getEndTs(), viewKey, query)); + } + }})); + return newQueries; + } + + @Deprecated /*Will be a modified*/ + private Collection chooseKeysForEntityView(EntityView entityView, Collection keys) { + Collection newKeys = new ArrayList<>(); + entityView.getKeys().getTimeseries() + .forEach(viewKey -> keys + .forEach(key -> { + if (key.equals(viewKey)) { + newKeys.add(key); + }})); + return newKeys; + } + private static void validate(EntityId entityId) { Validator.validateEntityId(entityId, "Incorrect entityId " + entityId); } @@ -108,4 +173,15 @@ public class BaseTimeseriesService implements TimeseriesService { throw new IncorrectParameterException("Incorrect TsKvQuery. Aggregation can't be empty"); } } + + private static TsKvQuery updateQuery(Long startTs, Long endTs, String viewKey, TsKvQuery query) { + return startTs <= query.getStartTs() && endTs >= query.getEndTs() ? query : + new BaseTsKvQuery(viewKey, startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); + } + + private static void checkForNonEntityView(EntityId entityId) throws Exception { + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + throw new Exception("Entity-views were read only"); + } + } } From 457b5d1250d2b71f49cb07ad49a3c2f13d369457 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 7 Sep 2018 17:58:09 +0300 Subject: [PATCH 062/307] Created entity-view dao for nosql db --- .../entityview/CassandraEntityViewDao.java | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index e9282e23b0..5716460d20 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -16,22 +16,24 @@ package org.thingsboard.server.dao.entityview; import com.datastax.driver.core.Statement; +import com.datastax.driver.core.querybuilder.Select; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.EntitySubtypeEntity; import org.thingsboard.server.dao.model.nosql.EntityViewEntity; import org.thingsboard.server.dao.nosql.CassandraAbstractSearchTextDao; import org.thingsboard.server.dao.util.NoSqlDao; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import java.util.*; -import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; +import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; +import static com.datastax.driver.core.querybuilder.QueryBuilder.select; +import static org.thingsboard.server.dao.model.ModelConstants.*; /** * Created by Victor Basanets on 9/06/2017. @@ -62,20 +64,36 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink) { + log.debug("Try to find entity-views by tenantId [{}] and pageLink [{}]", tenantId, pageLink); + List entityViewEntities = + findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME, + Collections.singletonList(eq(ENTITY_VIEW_TENANT_ID_PROPERTY, tenantId)), pageLink); + log.trace("Found entity-views [{}] by tenantId [{}] and pageLink [{}]", entityViewEntities, tenantId, pageLink); + return DaoUtil.convertDataList(entityViewEntities); } @Override - public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { - return Optional.empty(); + public Optional findEntityViewByTenantIdAndName(UUID tenantId, String entityViewName) { + return Optional.ofNullable(DaoUtil.getData( + findOneByStatement(select().from(ENTITY_VIEW_TENANT_AND_NAME_VIEW_NAME).where() + .and(eq(ENTITY_VIEW_TENANT_ID_PROPERTY, tenantId)) + .and(eq(ENTITY_VIEW_NAME_PROPERTY, entityViewName)))) + ); } @Override public List findEntityViewByTenantIdAndEntityId(UUID tenantId, UUID entityId, TextPageLink pageLink) { - return null; + log.debug("Try to find entity-views by tenantId [{}], entityId[{}] and pageLink [{}]", tenantId, entityId, pageLink); + List entityViewEntities = findPageWithTextSearch(DEVICE_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME, + Arrays.asList(eq(DEVICE_CUSTOMER_ID_PROPERTY, entityId), + eq(DEVICE_TENANT_ID_PROPERTY, tenantId)), + pageLink); + + log.trace("Found entity-views [{}] by tenantId [{}], entityId [{}] and pageLink [{}]", entityViewEntities, tenantId, entityId, pageLink); + return DaoUtil.convertDataList(entityViewEntities); } @Override From e0c2c5a994ae43b2f9ec94c788df3db8cb09790e Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 7 Sep 2018 18:38:25 +0300 Subject: [PATCH 063/307] Was added caching --- .../dao/entityview/EntityViewServiceImpl.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 49e1618c73..31d3b30518 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -21,6 +21,8 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityView; @@ -41,6 +43,8 @@ import org.thingsboard.server.dao.tenant.TenantDao; import java.util.ArrayList; import java.util.List; +import static org.thingsboard.server.common.data.CacheConstants.DEVICE_CACHE; +import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -68,6 +72,10 @@ public class EntityViewServiceImpl extends AbstractEntityService @Autowired private CustomerDao customerDao; + @Autowired + private CacheManager cacheManager; + + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); @@ -83,6 +91,7 @@ public class EntityViewServiceImpl extends AbstractEntityService .orElse(null); } + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}") @Override public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); @@ -107,12 +116,14 @@ public class EntityViewServiceImpl extends AbstractEntityService @Override public void deleteEntityView(EntityViewId entityViewId) { log.trace("Executing deleteEntityView [{}]", entityViewId); + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); deleteEntityRelations(entityViewId); EntityView entityView = entityViewDao.findById(entityViewId.getId()); List list = new ArrayList<>(); list.add(entityView.getTenantId()); list.add(entityView.getName()); + cache.evict(list); entityViewDao.removeById(entityViewId.getId()); } @@ -125,6 +136,7 @@ public class EntityViewServiceImpl extends AbstractEntityService return new TextPageData<>(entityViews, pageLink); } + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #entityId, #pageLink}") @Override public TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TextPageLink pageLink) { @@ -164,6 +176,7 @@ public class EntityViewServiceImpl extends AbstractEntityService return new TextPageData<>(entityViews, pageLink); } + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") @Override public TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, CustomerId customerId, From 20f4782cae1ae9f64fb4cd063e3aff1cf08cda9d Mon Sep 17 00:00:00 2001 From: Viktor Basanets Date: Mon, 10 Sep 2018 00:15:14 +0300 Subject: [PATCH 064/307] Was replaced header --- .../server/dao/timeseries/BaseTimeseriesService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 634d7f222e..9b837b24a5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. From ae8d8f01c713bf2db3a98e38709bef1805055009 Mon Sep 17 00:00:00 2001 From: Viktor Basanets Date: Mon, 10 Sep 2018 00:16:31 +0300 Subject: [PATCH 065/307] Was replaced null to 0L --- .../thingsboard/server/dao/model/nosql/EntityViewEntity.java | 4 ++-- .../thingsboard/server/dao/model/sql/EntityViewEntity.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index bb5abdd87f..990ff704c3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -116,8 +116,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = entityView.getStartTs() != null ? String.valueOf(entityView.getStartTs()) : "0"; - this.tsEnd = entityView.getEndTs() != null ? String.valueOf(entityView.getEndTs()) : "0"; + this.tsBegin = entityView.getStartTs() != 0L ? String.valueOf(entityView.getStartTs()) : "0"; + this.tsEnd = entityView.getEndTs() != 0L ? String.valueOf(entityView.getEndTs()) : "0"; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index c81e8f6027..a89d3dec84 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -105,8 +105,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = entityView.getStartTs() != null ? String.valueOf(entityView.getStartTs()) : "0"; - this.tsEnd = entityView.getEndTs() != null ? String.valueOf(entityView.getEndTs()) : "0"; + this.tsBegin = entityView.getStartTs() != 0L ? String.valueOf(entityView.getStartTs()) : "0"; + this.tsEnd = entityView.getEndTs() != 0L ? String.valueOf(entityView.getEndTs()) : "0"; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } From 5bf9528ee8878f80dbe5dadf6787a9086208779c Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 10 Sep 2018 11:11:07 +0300 Subject: [PATCH 066/307] Fixes for entity view --- .../controller/EntityViewController.java | 52 +++++++++++++++++-- .../entityview/EntityViewSearchQuery.java | 42 +++++++++++++++ .../server/dao/entityview/EntityViewDao.java | 2 - .../dao/entityview/EntityViewService.java | 8 +++ .../dao/entityview/EntityViewServiceImpl.java | 43 ++++++++++++--- .../server/dao/model/ModelConstants.java | 1 + .../dao/model/nosql/EntityViewEntity.java | 14 +++-- .../dao/sql/entityview/JpaEntityViewDao.java | 6 +++ dao/src/main/resources/cassandra/schema.cql | 2 +- ui/src/app/api/entity-view.service.js | 4 ++ ui/src/app/common/types.constant.js | 2 +- .../entity-view/entity-view-fieldset.tpl.html | 10 ++-- .../app/entity-view/entity-view.controller.js | 12 ++--- .../app/entity-view/entity-view.directive.js | 4 ++ ui/src/app/entity-view/entity-view.routes.js | 10 ++-- ui/src/app/entity-view/entity-views.tpl.html | 20 +++---- ui/src/app/entity-view/index.js | 4 +- ui/src/app/layout/index.js | 2 + ui/src/app/locale/locale.constant-en_US.json | 3 +- ui/src/app/services/menu.service.js | 52 +++++++++++++++---- 20 files changed, 226 insertions(+), 67 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index fd3ccf2fe1..227bed01f3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -19,17 +19,21 @@ import com.google.common.util.concurrent.ListenableFuture; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.service.security.model.SecurityUser; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; /** * Created by Victor Basanets on 8/28/2017. @@ -41,7 +45,7 @@ public class EntityViewController extends BaseController { public static final String ENTITY_VIEW_ID = "entityViewId"; @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.GET) + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.GET) @ResponseBody public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -56,7 +60,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entity-view", method = RequestMethod.POST) + @RequestMapping(value = "/entityView", method = RequestMethod.POST) @ResponseBody public EntityView saveEntityView(@RequestBody EntityView entityView) throws ThingsboardException { try { @@ -76,7 +80,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.DELETE) + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.DELETE) @ResponseStatus(value = HttpStatus.OK) public void deleteEntityView(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(ENTITY_VIEW_ID, strEntityViewId); @@ -95,4 +99,44 @@ public class EntityViewController extends BaseController { throw handleException(e); } } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/entityViews", params = {"limit"}, method = RequestMethod.GET) + @ResponseBody + public TextPageData getTenantEntityViews( + @RequestParam int limit, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String idOffset, + @RequestParam(required = false) String textOffset) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + TextPageLink pageLink = createPageLink(limit, textSearch, idOffset, textOffset); + return checkNotNull(entityViewService.findEntityViewByTenantId(tenantId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityViews", method = RequestMethod.POST) + @ResponseBody + public List findByQuery(@RequestBody EntityViewSearchQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkEntityId(query.getParameters().getEntityId()); + try { + List entityViews = checkNotNull(entityViewService.findEntityViewsByQuery(query).get()); + entityViews = entityViews.stream().filter(entityView -> { + try { + checkEntityView(entityView); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + return entityViews; + } catch (Exception e) { + throw handleException(e); + } + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java new file mode 100644 index 0000000000..752eafc6ad --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.entityview; + +import lombok.Data; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationsQuery; +import org.thingsboard.server.common.data.relation.EntityTypeFilter; +import org.thingsboard.server.common.data.relation.RelationsSearchParameters; + +import java.util.Collections; +import java.util.List; + +@Data +public class EntityViewSearchQuery { + + private RelationsSearchParameters parameters; + private String relationType; + + public EntityRelationsQuery toEntitySearchQuery() { + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(parameters); + query.setFilters( + Collections.singletonList(new EntityTypeFilter(relationType == null ? EntityRelation.CONTAINS_TYPE : relationType, + Collections.singletonList(EntityType.ENTITY_VIEW)))); + return query; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index 5cbbef834f..56a688ae47 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -16,7 +16,6 @@ package org.thingsboard.server.dao.entityview; import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.Dao; @@ -92,5 +91,4 @@ public interface EntityViewDao extends Dao { UUID customerId, UUID entityId, TextPageLink pageLink); - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 6de86be60c..a60376e501 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -16,12 +16,18 @@ package org.thingsboard.server.dao.entityview; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.device.DeviceSearchQuery; +import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.*; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; +import java.util.List; + /** * Created by Victor Basanets on 8/27/2017. */ @@ -57,4 +63,6 @@ public interface EntityViewService { void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId); + + ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 31d3b30518..2551183dcf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.entityview; +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -25,14 +27,22 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.device.DeviceSearchQuery; +import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.exception.DataValidationException; @@ -40,8 +50,12 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.tenant.TenantDao; +import javax.annotation.Nullable; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.DEVICE_CACHE; import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; @@ -55,8 +69,7 @@ import static org.thingsboard.server.dao.service.Validator.validateString; */ @Service @Slf4j -public class EntityViewServiceImpl extends AbstractEntityService - implements EntityViewService { +public class EntityViewServiceImpl extends AbstractEntityService implements EntityViewService { public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; @@ -75,7 +88,7 @@ public class EntityViewServiceImpl extends AbstractEntityService @Autowired private CacheManager cacheManager; - @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") +// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); @@ -91,7 +104,7 @@ public class EntityViewServiceImpl extends AbstractEntityService .orElse(null); } - @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}") +// @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}") @Override public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); @@ -136,7 +149,7 @@ public class EntityViewServiceImpl extends AbstractEntityService return new TextPageData<>(entityViews, pageLink); } - @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #entityId, #pageLink}") +// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #entityId, #pageLink}") @Override public TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TextPageLink pageLink) { @@ -176,7 +189,7 @@ public class EntityViewServiceImpl extends AbstractEntityService return new TextPageData<>(entityViews, pageLink); } - @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") +// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") @Override public TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, CustomerId customerId, @@ -211,6 +224,24 @@ public class EntityViewServiceImpl extends AbstractEntityService return entityViewDao.findByIdAsync(entityViewId.getId()); } + @Override + public ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query) { + ListenableFuture> relations = relationService.findByQuery(query.toEntitySearchQuery()); + ListenableFuture> entityViews = Futures.transformAsync(relations, r -> { + EntitySearchDirection direction = query.toEntitySearchQuery().getParameters().getDirection(); + List> futures = new ArrayList<>(); + for (EntityRelation relation : r) { + EntityId entityId = direction == EntitySearchDirection.FROM ? relation.getTo() : relation.getFrom(); + if (entityId.getEntityType() == EntityType.ENTITY_VIEW) { + futures.add(findEntityViewByIdAsync(new EntityViewId(entityId.getId()))); + } + } + return Futures.successfulAsList(futures); + }); + + return entityViews; + } + private DataValidator entityViewValidator = new DataValidator() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 6932bea1a3..a96f258043 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -151,6 +151,7 @@ public class ModelConstants { public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; + public static final String ENTITY_VIEW_TYPE_PROPERTY = DEVICE_TYPE_PROPERTY; public static final String ENTITY_VIEW_TENANT_AND_NAME_VIEW_NAME = "entity_view_by_tenant_and_name"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_TS_BEGIN_PROPERTY = "ts_begin"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index 990ff704c3..3f7571363a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.model.nosql; import com.datastax.driver.core.utils.UUIDs; +import com.datastax.driver.mapping.annotations.Column; import com.datastax.driver.mapping.annotations.PartitionKey; import com.datastax.driver.mapping.annotations.Table; import com.fasterxml.jackson.databind.JsonNode; @@ -31,10 +32,8 @@ import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.SearchTextEntity; -import javax.persistence.Column; import javax.persistence.EnumType; import javax.persistence.Enumerated; - import java.io.IOException; import java.util.UUID; @@ -55,22 +54,21 @@ public class EntityViewEntity implements SearchTextEntity { @Column(name = ID_PROPERTY) private UUID id; - @PartitionKey(value = 1) - @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) - private UUID entityId; - @Enumerated(EnumType.STRING) @Column(name = ENTITY_TYPE_PROPERTY) private EntityType entityType; - @PartitionKey(value = 2) + @PartitionKey(value = 1) @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY) private UUID tenantId; - @PartitionKey(value = 3) + @PartitionKey(value = 2) @Column(name = ModelConstants.ENTITY_VIEW_CUSTOMER_ID_PROPERTY) private UUID customerId; + @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) + private UUID entityId; + @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) private String name; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 57b0afae8b..6ef0acba7e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -15,12 +15,16 @@ */ package org.thingsboard.server.dao.sql.entityview; +import com.google.common.util.concurrent.ListenableFuture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.entityview.EntityViewDao; @@ -28,6 +32,8 @@ import org.thingsboard.server.dao.model.sql.EntityViewEntity; import org.thingsboard.server.dao.sql.JpaAbstractSearchTextDao; import org.thingsboard.server.dao.util.SqlDao; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql index 68f196f005..89df266d75 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema.cql @@ -651,7 +651,7 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( ts_end bigint, search_text text, additional_info text, - PRIMARY KEY (id, entity_id, tenant_id, customer_id) + PRIMARY KEY (id, tenant_id, customer_id) ); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS diff --git a/ui/src/app/api/entity-view.service.js b/ui/src/app/api/entity-view.service.js index 9bd8f8db08..f2d190863e 100644 --- a/ui/src/app/api/entity-view.service.js +++ b/ui/src/app/api/entity-view.service.js @@ -155,6 +155,10 @@ function EntityViewService($http, $q, $window, userService, attributeService, cu function saveEntityView(entityView) { var deferred = $q.defer(); var url = '/api/entityView'; + + entityView.keys = {}; + entityView.keys.timeseries = ['a', 'b']; + $http.post(url, entityView).then(function success(response) { deferred.resolve(response.data); }, function fail() { diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 1d1b7171c3..060b557b5d 100644 --- a/ui/src/app/common/types.constant.js +++ b/ui/src/app/common/types.constant.js @@ -328,7 +328,7 @@ export default angular.module('thingsboard.types', []) alarm: "ALARM", rulechain: "RULE_CHAIN", rulenode: "RULE_NODE", - entityview: "ENTITY_VIEW" + entityView: "ENTITY_VIEW" }, aliasEntityType: { current_customer: "CURRENT_CUSTOMER" diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index e0000c6d9d..cb46f3ddf7 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -15,6 +15,9 @@ limitations under the License. --> +{{ 'entity-view.make-public' | translate }} {{ 'entity-view.assign-to-customer' | translate }} @@ -49,13 +52,6 @@

entity-view.name-required
- - diff --git a/ui/src/app/entity-view/entity-view.controller.js b/ui/src/app/entity-view/entity-view.controller.js index fd3b5a836a..2c8c8ea036 100644 --- a/ui/src/app/entity-view/entity-view.controller.js +++ b/ui/src/app/entity-view/entity-view.controller.js @@ -194,7 +194,7 @@ export function EntityViewController($rootScope, userService, entityViewService, entityViewGroupActionsList.push( { onAction: function ($event, items) { - assignEntiyViewsToCustomer($event, items); + assignEntityViewsToCustomer($event, items); }, name: function() { return $translate.instant('entity-view.assign-entity-views') }, details: function(selectedCount) { @@ -221,10 +221,10 @@ export function EntityViewController($rootScope, userService, entityViewService, fetchEntityViewsFunction = function (pageLink, entityViewType) { return entityViewService.getCustomerEntityViews(customerId, pageLink, true, null, entityViewType); }; - deleteentityViewFunction = function (entityViewId) { + deleteEntityViewFunction = function (entityViewId) { return entityViewService.unassignEntityViewFromCustomer(entityViewId); }; - refreshentityViewsParamsFunction = function () { + refreshEntityViewsParamsFunction = function () { return {"customerId": customerId, "topIndex": vm.topIndex}; }; @@ -271,9 +271,9 @@ export function EntityViewController($rootScope, userService, entityViewService, } } - vm.entityViewGridConfig.refreshParamsFunc = refreshentityViewsParamsFunction; - vm.entityViewGridConfig.fetchItemsFunc = fetchentityViewsFunction; - vm.entityViewGridConfig.deleteItemFunc = deleteentityViewFunction; + vm.entityViewGridConfig.refreshParamsFunc = refreshEntityViewsParamsFunction; + vm.entityViewGridConfig.fetchItemsFunc = fetchEntityViewsFunction; + vm.entityViewGridConfig.deleteItemFunc = deleteEntityViewFunction; } diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index f98027062e..5a3d8765a3 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -27,6 +27,7 @@ export default function EntityViewDirective($compile, $templateCache, toast, $tr scope.types = types; scope.isAssignedToCustomer = false; + scope.isPublic = false; scope.assignedCustomer = null; scope.$watch('entityView', function(newVal) { @@ -36,10 +37,12 @@ export default function EntityViewDirective($compile, $templateCache, toast, $tr customerService.getShortCustomerInfo(scope.entityView.customerId.id).then( function success(customer) { scope.assignedCustomer = customer; + scope.isPublic = customer.isPublic; } ); } else { scope.isAssignedToCustomer = false; + scope.isPublic = false; scope.assignedCustomer = null; } } @@ -60,6 +63,7 @@ export default function EntityViewDirective($compile, $templateCache, toast, $tr entityViewScope: '=', theForm: '=', onAssignToCustomer: '&', + onMakePublic: '&', onUnassignFromCustomer: '&', onDeleteEntityView: '&' } diff --git a/ui/src/app/entity-view/entity-view.routes.js b/ui/src/app/entity-view/entity-view.routes.js index 14f5079d41..481dfc8f90 100644 --- a/ui/src/app/entity-view/entity-view.routes.js +++ b/ui/src/app/entity-view/entity-view.routes.js @@ -35,14 +35,14 @@ export default function EntityViewRoutes($stateProvider, types) { } }, data: { - entityViewsTypes: 'tenant', + entityViewsType: 'tenant', searchEnabled: true, searchByEntitySubtype: true, searchEntityType: types.entityType.entityview, - pageTitle: 'entity-views.entity-views' + pageTitle: 'entity-view.entity-views' }, ncyBreadcrumb: { - label: '{"icon": "devices_other", "label": "entity-view.entity-views"}' + label: '{"icon": "view_stream", "label": "entity-view.entity-views"}' } }) .state('home.customers.entityViews', { @@ -58,14 +58,14 @@ export default function EntityViewRoutes($stateProvider, types) { } }, data: { - entityViewsTypes: 'customer', + entityViewsType: 'customer', searchEnabled: true, searchByEntitySubtype: true, searchEntityType: types.entityType.entityview, pageTitle: 'customer.entity-views' }, ncyBreadcrumb: { - label: '{"icon": "devices_other", "label": "{{ vm.customerEntityViewsTitle }}", "translate": "false"}' + label: '{"icon": "view_stream", "label": "{{ vm.customerEntityViewsTitle }}", "translate": "false"}' } }); diff --git a/ui/src/app/entity-view/entity-views.tpl.html b/ui/src/app/entity-view/entity-views.tpl.html index 5398449273..50a0adb5b1 100644 --- a/ui/src/app/entity-view/entity-views.tpl.html +++ b/ui/src/app/entity-view/entity-views.tpl.html @@ -29,13 +29,12 @@ on-assign-to-customer="vm.assignToCustomer(event, [ vm.grid.detailsConfig.currentItem.id.id ])" on-make-public="vm.makePublic(event, vm.grid.detailsConfig.currentItem)" on-unassign-from-customer="vm.unassignFromCustomer(event, vm.grid.detailsConfig.currentItem, isPublic)" - on-manage-credentials="vm.manageCredentials(event, vm.grid.detailsConfig.currentItem)" on-delete-entity-view="vm.grid.deleteItem(event, vm.grid.detailsConfig.currentItem)"> @@ -43,19 +42,19 @@ - - @@ -64,18 +63,11 @@ + entity-type="{{vm.types.entityType.entityView}}"> - - - - - diff --git a/ui/src/app/entity-view/index.js b/ui/src/app/entity-view/index.js index ebdd3ea282..69f669e4cf 100644 --- a/ui/src/app/entity-view/index.js +++ b/ui/src/app/entity-view/index.js @@ -20,7 +20,7 @@ import thingsboardApiEntityView from '../api/entity-view.service'; import thingsboardApiCustomer from '../api/customer.service'; import EntityViewRoutes from './entity-view.routes'; -import EntityViewCardController from './entity-view.controller'; +import {EntityViewController, EntityViewCardController} from './entity-view.controller'; import AssignEntityViewToCustomerController from './assign-to-customer.controller'; import AddEntityViewsToCustomerController from './add-entity-views-to-customer.controller'; import EntityViewDirective from './entity-view.directive'; @@ -33,7 +33,7 @@ export default angular.module('thingsboard.entityView', [ thingsboardApiCustomer ]) .config(EntityViewRoutes) - .controller('EntityViewController', EntityViewCardController) + .controller('EntityViewController', EntityViewController) .controller('EntityViewCardController', EntityViewCardController) .controller('AssignEntityViewToCustomerController', AssignEntityViewToCustomerController) .controller('AddEntityViewsToCustomerController', AddEntityViewsToCustomerController) diff --git a/ui/src/app/layout/index.js b/ui/src/app/layout/index.js index 8f2958d9e3..192400a646 100644 --- a/ui/src/app/layout/index.js +++ b/ui/src/app/layout/index.js @@ -48,6 +48,7 @@ import thingsboardAdmin from '../admin'; import thingsboardProfile from '../profile'; import thingsboardAsset from '../asset'; import thingsboardDevice from '../device'; +import thingsboardEntityView from '../entity-view'; import thingsboardWidgetLibrary from '../widget'; import thingsboardDashboard from '../dashboard'; import thingsboardRuleChain from '../rulechain'; @@ -79,6 +80,7 @@ export default angular.module('thingsboard.home', [ thingsboardProfile, thingsboardAsset, thingsboardDevice, + thingsboardEntityView, thingsboardWidgetLibrary, thingsboardDashboard, thingsboardRuleChain, diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 5e04a52a00..09769edc47 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -821,7 +821,8 @@ "assignedToCustomer": "Assigned to customer", "unable-entity-view-device-alias-title": "Unable to delete entity view alias", "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", - "select-entity-view": "Select entity view" + "select-entity-view": "Select entity view", + "make-public": "Make entity view public" }, "event": { "event-type": "Event type", diff --git a/ui/src/app/services/menu.service.js b/ui/src/app/services/menu.service.js index 1c33d1f18c..869a25d860 100644 --- a/ui/src/app/services/menu.service.js +++ b/ui/src/app/services/menu.service.js @@ -167,6 +167,12 @@ function Menu(userService, $state, $rootScope) { state: 'home.devices', icon: 'devices_other' }, + { + name: 'entity-view.entity-views', + type: 'link', + state: 'home.entityViews', + icon: 'view_stream' + }, { name: 'widget.widget-library', type: 'link', @@ -227,6 +233,16 @@ function Menu(userService, $state, $rootScope) { } ] }, + { + name: 'entity-view.management', + places: [ + { + name: 'entity-view.entity-views', + icon: 'view_stream', + state: 'home.entityViews' + } + ] + }, { name: 'dashboard.management', places: [ @@ -273,6 +289,12 @@ function Menu(userService, $state, $rootScope) { state: 'home.devices', icon: 'devices_other' }, + { + name: 'entity-view.entity-views', + type: 'link', + state: 'home.entityViews', + icon: 'view_stream' + }, { name: 'dashboard.dashboards', type: 'link', @@ -301,16 +323,26 @@ function Menu(userService, $state, $rootScope) { } ] }, - { - name: 'dashboard.view-dashboards', - places: [ - { - name: 'dashboard.dashboards', - icon: 'dashboard', - state: 'home.dashboards' - } - ] - }]; + { + name: 'entity-view.management', + places: [ + { + name: 'entity-view.entity-views', + icon: 'view_stream', + state: 'home.entityViews' + } + ] + }, + { + name: 'dashboard.view-dashboards', + places: [ + { + name: 'dashboard.dashboards', + icon: 'dashboard', + state: 'home.dashboards' + } + ] + }]; } } } From 06a0941dbea59cb975f6f62215e34fd38eb9be07 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 10 Sep 2018 14:37:46 +0300 Subject: [PATCH 067/307] Entity view misc fixes --- .../controller/EntityViewController.java | 84 +++++++++++++++++++ .../entity-view/entity-view-fieldset.tpl.html | 24 ++++++ .../app/entity-view/entity-view.directive.js | 36 +++++++- ui/src/app/entity-view/entity-view.routes.js | 4 +- ui/src/app/locale/locale.constant-en_US.json | 4 +- 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 227bed01f3..b5adc327f5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -19,16 +19,22 @@ import com.google.common.util.concurrent.ListenableFuture; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.service.security.model.SecurityUser; import java.util.ArrayList; @@ -100,6 +106,84 @@ public class EntityViewController extends BaseController { } } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) + @ResponseBody + public EntityView assignEntityViewToCustomer(@PathVariable("customerId") String strCustomerId, + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId); + + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + checkEntityViewId(entityViewId); + + EntityView savedEntityView = checkNotNull(entityViewService.assignEntityViewToCustomer(entityViewId, customerId)); + + logEntityAction(entityViewId, savedEntityView, + savedEntityView.getCustomerId(), + ActionType.ASSIGNED_TO_CUSTOMER, null, strEntityViewId, strCustomerId, customer.getName()); + + return savedEntityView; + } catch (Exception e) { + logEntityAction(emptyId(EntityType.ENTITY_VIEW), null, + null, + ActionType.ASSIGNED_TO_CUSTOMER, e, strEntityViewId, strCustomerId); + throw handleException(e); + } + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/entityView/{entityViewId}", method = RequestMethod.DELETE) + @ResponseBody + public EntityView unassignEntityViewFromCustomer(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + EntityView entityView = checkEntityViewId(entityViewId); + if (entityView.getCustomerId() == null || entityView.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + throw new IncorrectParameterException("Entity View isn't assigned to any customer!"); + } + Customer customer = checkCustomerId(entityView.getCustomerId()); + + EntityView savedEntityView = checkNotNull(entityViewService.unassignEntityViewFromCustomer(entityViewId)); + + logEntityAction(entityViewId, entityView, + entityView.getCustomerId(), + ActionType.UNASSIGNED_FROM_CUSTOMER, null, strEntityViewId, customer.getId().toString(), customer.getName()); + + return savedEntityView; + } catch (Exception e) { + logEntityAction(emptyId(EntityType.ENTITY_VIEW), null, + null, + ActionType.UNASSIGNED_FROM_CUSTOMER, e, strEntityViewId); + throw handleException(e); + } + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/entityViews", params = {"limit"}, method = RequestMethod.GET) + @ResponseBody + public TextPageData getCustomerEntityViews( + @PathVariable("customerId") String strCustomerId, + @RequestParam int limit, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String idOffset, + @RequestParam(required = false) String textOffset) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId); + TextPageLink pageLink = createPageLink(limit, textSearch, idOffset, textOffset); + return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/tenant/entityViews", params = {"limit"}, method = RequestMethod.GET) @ResponseBody diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index cb46f3ddf7..c73b0a2493 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -52,9 +52,33 @@
entity-view.name-required
+ + +
+ + +
+
+ + +
diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index 5a3d8765a3..b07d4372fb 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -20,7 +20,7 @@ import entityViewFieldsetTemplate from './entity-view-fieldset.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function EntityViewDirective($compile, $templateCache, toast, $translate, types, clipboardService, entityViewService, customerService) { +export default function EntityViewDirective($compile, $templateCache, $filter, toast, $translate, types, clipboardService, entityViewService, customerService) { var linker = function (scope, element) { var template = $templateCache.get(entityViewFieldsetTemplate); element.html(template); @@ -30,6 +30,8 @@ export default function EntityViewDirective($compile, $templateCache, toast, $tr scope.isPublic = false; scope.assignedCustomer = null; + scope.allowedEntityTypes = [types.entityType.device, types.entityType.asset]; + scope.$watch('entityView', function(newVal) { if (newVal) { if (scope.entityView.customerId && scope.entityView.customerId.id !== types.id.nullUid) { @@ -45,9 +47,41 @@ export default function EntityViewDirective($compile, $templateCache, toast, $tr scope.isPublic = false; scope.assignedCustomer = null; } + scope.startTs = $filter('date')(scope.entityView.endTs, 'yyyy-MM-dd HH:mm:ss'); + scope.endTs = $filter('date')(scope.entityView.startTs, 'yyyy-MM-dd HH:mm:ss'); + } + }); + + + scope.$watch('startTs', function (newDate) { + if (newDate) { + if (newDate.getTime() > scope.maxStartTs) { + scope.startTs = angular.copy(scope.maxStartTs); + } + updateMinMaxDates(); + } + }); + + scope.$watch('endTs', function (newDate) { + if (newDate) { + if (newDate.getTime() < scope.minEndTs) { + scope.endTs = angular.copy(scope.minEndTs); + } + updateMinMaxDates(); } }); + function updateMinMaxDates() { + if (scope.endTs) { + scope.maxStartTs = angular.copy(new Date(scope.endTs.getTime() - 1000)); + scope.entityView.endTs = $filter('date')(scope.endTs, 'yyyy-MM-dd HH:mm:ss'); + } + if (scope.startTs) { + scope.minEndTs = angular.copy(new Date(scope.startTs.getTime() + 1000)); + scope.entityView.startTs = $filter('date')(scope.startTs, 'yyyy-MM-dd HH:mm:ss'); + } + } + scope.onEntityViewIdCopied = function() { toast.showSuccess($translate.instant('entity-view.idCopiedMessage'), 750, angular.element(element).parent().parent(), 'bottom left'); }; diff --git a/ui/src/app/entity-view/entity-view.routes.js b/ui/src/app/entity-view/entity-view.routes.js index 481dfc8f90..c14d430f32 100644 --- a/ui/src/app/entity-view/entity-view.routes.js +++ b/ui/src/app/entity-view/entity-view.routes.js @@ -38,7 +38,7 @@ export default function EntityViewRoutes($stateProvider, types) { entityViewsType: 'tenant', searchEnabled: true, searchByEntitySubtype: true, - searchEntityType: types.entityType.entityview, + searchEntityType: types.entityType.entityView, pageTitle: 'entity-view.entity-views' }, ncyBreadcrumb: { @@ -61,7 +61,7 @@ export default function EntityViewRoutes($stateProvider, types) { entityViewsType: 'customer', searchEnabled: true, searchByEntitySubtype: true, - searchEntityType: types.entityType.entityview, + searchEntityType: types.entityType.entityView, pageTitle: 'customer.entity-views' }, ncyBreadcrumb: { diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 09769edc47..e98d4fe1ca 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -822,7 +822,9 @@ "unable-entity-view-device-alias-title": "Unable to delete entity view alias", "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", "select-entity-view": "Select entity view", - "make-public": "Make entity view public" + "make-public": "Make entity view public", + "start-ts": "Start ts", + "end-ts": "End ts" }, "event": { "event-type": "Event type", From 5139299fd1eb64718528833f45b53f4f91aaebce Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 10 Sep 2018 14:42:21 +0300 Subject: [PATCH 068/307] Entity view fixes --- .../server/dao/entityview/EntityViewServiceImpl.java | 2 +- ui/src/app/entity-view/entity-view.directive.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 2551183dcf..e90767695d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -136,7 +136,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti List list = new ArrayList<>(); list.add(entityView.getTenantId()); list.add(entityView.getName()); - cache.evict(list); +// cache.evict(list); entityViewDao.removeById(entityViewId.getId()); } diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index b07d4372fb..4d4e84b737 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -74,11 +74,11 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t function updateMinMaxDates() { if (scope.endTs) { scope.maxStartTs = angular.copy(new Date(scope.endTs.getTime() - 1000)); - scope.entityView.endTs = $filter('date')(scope.endTs, 'yyyy-MM-dd HH:mm:ss'); + scope.entityView.endTs = scope.endTs.getTime(); } if (scope.startTs) { scope.minEndTs = angular.copy(new Date(scope.startTs.getTime() + 1000)); - scope.entityView.startTs = $filter('date')(scope.startTs, 'yyyy-MM-dd HH:mm:ss'); + scope.entityView.startTs = scope.startTs.getTime(); } } From 33fc8f0ba5167130cfc7bb7752142e2bc70ba2c0 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 10 Sep 2018 15:31:36 +0300 Subject: [PATCH 069/307] Entity view fixes --- ui/src/app/api/entity-view.service.js | 2 +- ui/src/app/api/entity.service.js | 17 +++++++++++++++-- ui/src/app/common/types.constant.js | 6 ++++++ ui/src/app/entity-view/entity-view.directive.js | 4 ++-- .../app/entity/entity-autocomplete.directive.js | 6 ++++++ ui/src/app/locale/locale.constant-en_US.json | 4 ++++ 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/ui/src/app/api/entity-view.service.js b/ui/src/app/api/entity-view.service.js index f2d190863e..d6d5868959 100644 --- a/ui/src/app/api/entity-view.service.js +++ b/ui/src/app/api/entity-view.service.js @@ -157,7 +157,7 @@ function EntityViewService($http, $q, $window, userService, attributeService, cu var url = '/api/entityView'; entityView.keys = {}; - entityView.keys.timeseries = ['a', 'b']; + entityView.keys.timeseries = ['temp']; $http.post(url, entityView).then(function success(response) { deferred.resolve(response.data); diff --git a/ui/src/app/api/entity.service.js b/ui/src/app/api/entity.service.js index 2e29238dfe..ce4dc42dcd 100644 --- a/ui/src/app/api/entity.service.js +++ b/ui/src/app/api/entity.service.js @@ -20,8 +20,9 @@ export default angular.module('thingsboard.api.entity', [thingsboardTypes]) .name; /*@ngInject*/ -function EntityService($http, $q, $filter, $translate, $log, userService, deviceService, - assetService, tenantService, customerService, ruleChainService, dashboardService, entityRelationService, attributeService, types, utils) { +function EntityService($http, $q, $filter, $translate, $log, userService, deviceService, assetService, tenantService, + customerService, ruleChainService, dashboardService, entityRelationService, attributeService, + entityViewService, types, utils) { var service = { getEntity: getEntity, getEntities: getEntities, @@ -54,6 +55,9 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case types.entityType.asset: promise = assetService.getAsset(entityId, true, config); break; + case types.entityType.entityView: + promise = entityViewService.getEntityView(entityId, true, config); + break; case types.entityType.tenant: promise = tenantService.getTenant(entityId, config); break; @@ -239,6 +243,13 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device promise = assetService.getTenantAssets(pageLink, false, config, subType); } break; + case types.entityType.entityView: + if (user.authority === 'CUSTOMER_USER') { + promise = entityViewService.getCustomerEntityViews(customerId, pageLink, false, config, subType); + } else { + promise = entityViewService.getTenantEntityViews(pageLink, false, config, subType); + } + break; case types.entityType.tenant: if (user.authority === 'TENANT_ADMIN') { promise = getSingleTenantByPageLinkPromise(pageLink, config); @@ -725,6 +736,7 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case 'TENANT_ADMIN': entityTypes.device = types.entityType.device; entityTypes.asset = types.entityType.asset; + entityTypes.entityView = types.entityType.entityView; entityTypes.tenant = types.entityType.tenant; entityTypes.customer = types.entityType.customer; entityTypes.dashboard = types.entityType.dashboard; @@ -735,6 +747,7 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case 'CUSTOMER_USER': entityTypes.device = types.entityType.device; entityTypes.asset = types.entityType.asset; + entityTypes.entityView = types.entityType.entityView; entityTypes.customer = types.entityType.customer; entityTypes.dashboard = types.entityType.dashboard; if (useAliasEntityTypes) { diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 060b557b5d..15da584968 100644 --- a/ui/src/app/common/types.constant.js +++ b/ui/src/app/common/types.constant.js @@ -346,6 +346,12 @@ export default angular.module('thingsboard.types', []) list: 'entity.list-of-assets', nameStartsWith: 'entity.asset-name-starts-with' }, + "ENTITY_VIEW": { + type: 'entity.type-entity-view', + typePlural: 'entity.type-entity-views', + list: 'entity.list-of-entity-views', + nameStartsWith: 'entity.entity-view-name-starts-with' + }, "TENANT": { type: 'entity.type-tenant', typePlural: 'entity.type-tenants', diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index 4d4e84b737..4ec03a12c7 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -47,8 +47,8 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t scope.isPublic = false; scope.assignedCustomer = null; } - scope.startTs = $filter('date')(scope.entityView.endTs, 'yyyy-MM-dd HH:mm:ss'); - scope.endTs = $filter('date')(scope.entityView.startTs, 'yyyy-MM-dd HH:mm:ss'); + scope.startTs = new Date(scope.entityView.startTs); + scope.endTs = new Date(scope.entityView.endTs); } }); diff --git a/ui/src/app/entity/entity-autocomplete.directive.js b/ui/src/app/entity/entity-autocomplete.directive.js index e46c614c33..8c1fed76f1 100644 --- a/ui/src/app/entity/entity-autocomplete.directive.js +++ b/ui/src/app/entity/entity-autocomplete.directive.js @@ -131,6 +131,12 @@ export default function EntityAutocomplete($compile, $templateCache, $q, $filter scope.noEntitiesMatchingText = 'device.no-devices-matching'; scope.entityRequiredText = 'device.device-required'; break; + case types.entityType.entityView: + scope.selectEntityText = 'entity-view.select-entity-view'; + scope.entityText = 'entity-view.entity-view'; + scope.noEntitiesMatchingText = 'entity-view.no-entity-views-matching'; + scope.entityRequiredText = 'entity-view.entity-view-required'; + break; case types.entityType.rulechain: scope.selectEntityText = 'rulechain.select-rulechain'; scope.entityText = 'rulechain.rulechain'; diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index e98d4fe1ca..87a1b1bef4 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -708,6 +708,10 @@ "type-assets": "Assets", "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", "asset-name-starts-with": "Assets whose names start with '{{prefix}}'", + "type-entity-view": "Entity View", + "type-entity-views": "Entity Views", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {List of # entity views} }", + "entity-view-name-starts-with": "Entity Views whose names start with '{{prefix}}'", "type-rule": "Rule", "type-rules": "Rules", "list-of-rules": "{ count, plural, 1 {One rule} other {List of # rules} }", From 1adb36cd477d673e02359941eba5068efe3ee8c5 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 10 Sep 2018 17:15:11 +0300 Subject: [PATCH 070/307] Was added the filds of start-time and end-time --- .../server/service/telemetry/sub/Subscription.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java index 811c055372..90f606760c 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java @@ -30,9 +30,11 @@ public class Subscription { private final SubscriptionState sub; private final boolean local; private ServerAddress server; + private long startTime; + private long endTime; public Subscription(SubscriptionState sub, boolean local) { - this(sub, local, null); + this(sub, local, null, 0L, 0L); } public String getWsSessionId() { From 4e39ed53fe3b2ed6a21cebdb0f2da67007a09850 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 10 Sep 2018 19:09:12 +0300 Subject: [PATCH 071/307] Minor changes --- .../DefaultTelemetrySubscriptionService.java | 23 +++++++++++++++++-- .../service/telemetry/sub/Subscription.java | 4 ++++ .../server/dao/model/ModelConstants.java | 2 ++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 263b4849fa..ed9e5bc641 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -27,9 +27,11 @@ import org.springframework.util.StringUtils; import org.thingsboard.rule.engine.api.util.DonAsynchron; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseTsKvQuery; @@ -44,6 +46,8 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvQuery; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; @@ -97,6 +101,9 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Autowired private ClusterRpcService rpcService; + /*@Autowired + private EntityViewService entityViewService;*/ + @Autowired @Lazy private DeviceStateService stateService; @@ -125,17 +132,29 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Override public void addLocalWsSubscription(String sessionId, EntityId entityId, SubscriptionState sub) { + String familyName = ModelConstants.DEVICE_FAMILY_NAME; + + //To do + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + familyName = ModelConstants.ENTITY_VIEW_FAMILY_NAME; + //EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId) + } + Optional server = routingService.resolveById(entityId); Subscription subscription; if (server.isPresent()) { ServerAddress address = server.get(); - log.trace("[{}] Forwarding subscription [{}] for device [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId, address); + log.trace("[{}] Forwarding subscription [{}] for " + familyName + " [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId, address); subscription = new Subscription(sub, true, address); tellNewSubscription(address, sessionId, subscription); } else { - log.trace("[{}] Registering local subscription [{}] for device [{}]", sessionId, sub.getSubscriptionId(), entityId); + log.trace("[{}] Registering local subscription [{}] for " + familyName + " [{}]", sessionId, sub.getSubscriptionId(), entityId); subscription = new Subscription(sub, true); } + + /*if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + registerSubscription(sessionId, entityId, subscription); + }*/ registerSubscription(sessionId, entityId, subscription); } diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java index 90f606760c..08440e4bbb 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java @@ -37,6 +37,10 @@ public class Subscription { this(sub, local, null, 0L, 0L); } + public Subscription(SubscriptionState sub, boolean local, ServerAddress server) { + this(sub, local, server, 0L, 0L); + } + public String getWsSessionId() { return getSub().getWsSessionId(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index a96f258043..7e9662ebe2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -131,6 +131,7 @@ public class ModelConstants { * Cassandra device constants. */ public static final String DEVICE_COLUMN_FAMILY_NAME = "device"; + public static final String DEVICE_FAMILY_NAME = "device"; public static final String DEVICE_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String DEVICE_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String DEVICE_NAME_PROPERTY = "name"; @@ -147,6 +148,7 @@ public class ModelConstants { * Cassandra entityView constants. */ public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_views"; + public static final String ENTITY_VIEW_FAMILY_NAME = "entity-view"; public static final String ENTITY_VIEW_ENTITY_ID_PROPERTY = ENTITY_ID_COLUMN; public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; From 0501e9d4b4b4e3a3ec15bf1241ac50550481fff8 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 10 Sep 2018 19:53:29 +0300 Subject: [PATCH 072/307] Was modified addLocalWsSubscription(...) --- .../DefaultTelemetrySubscriptionService.java | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index ed9e5bc641..9551f9fe9f 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -132,14 +132,8 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Override public void addLocalWsSubscription(String sessionId, EntityId entityId, SubscriptionState sub) { - String familyName = ModelConstants.DEVICE_FAMILY_NAME; - - //To do - if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { - familyName = ModelConstants.ENTITY_VIEW_FAMILY_NAME; - //EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId) - } - + String familyName = entityId.getEntityType().equals(EntityType.ENTITY_VIEW) + ? ModelConstants.ENTITY_VIEW_FAMILY_NAME : ModelConstants.DEVICE_FAMILY_NAME; Optional server = routingService.resolveById(entityId); Subscription subscription; if (server.isPresent()) { @@ -151,10 +145,6 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio log.trace("[{}] Registering local subscription [{}] for " + familyName + " [{}]", sessionId, sub.getSubscriptionId(), entityId); subscription = new Subscription(sub, true); } - - /*if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { - registerSubscription(sessionId, entityId, subscription); - }*/ registerSubscription(sessionId, entityId, subscription); } From 08e166d7030e54c15ddf00b379c5d7a1709d625f Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 11 Sep 2018 12:56:43 +0300 Subject: [PATCH 073/307] Added telemetry view --- ui/src/app/api/entity-view.service.js | 3 -- .../entity-view/entity-view-fieldset.tpl.html | 33 +++++++++++++++++++ .../app/entity-view/entity-view.directive.js | 14 +++++++- ui/src/app/locale/locale.constant-en_US.json | 6 +++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/ui/src/app/api/entity-view.service.js b/ui/src/app/api/entity-view.service.js index d6d5868959..e34d3a4aee 100644 --- a/ui/src/app/api/entity-view.service.js +++ b/ui/src/app/api/entity-view.service.js @@ -156,9 +156,6 @@ function EntityViewService($http, $q, $window, userService, attributeService, cu var deferred = $q.defer(); var url = '/api/entityView'; - entityView.keys = {}; - entityView.keys.timeseries = ['temp']; - $http.post(url, entityView).then(function success(response) { deferred.resolve(response.data); }, function fail() { diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index c73b0a2493..29287f9bce 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -62,6 +62,39 @@ +
+ + + + + + + + + + + + +
Date: Tue, 11 Sep 2018 13:22:34 +0300 Subject: [PATCH 074/307] Fixed dao for customer --- .../server/dao/sql/entityview/JpaEntityViewDao.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 6ef0acba7e..2f3edbe250 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -100,7 +100,7 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao Date: Tue, 11 Sep 2018 14:36:24 +0300 Subject: [PATCH 075/307] sync deduplicated script evaluation/release --- .../actors/ruleChain/DefaultTbContext.java | 2 +- .../controller/RuleChainController.java | 2 +- .../AbstractNashornJsSandboxService.java | 157 ++++++++++++------ .../service/script/JsSandboxService.java | 5 +- .../script/RuleNodeJsScriptEngine.java | 9 +- application/src/main/resources/logback.xml | 2 +- .../script/RuleNodeJsScriptEngineTest.java | 95 ++++++++++- 7 files changed, 206 insertions(+), 66 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 7279347b1c..d5260da10b 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -154,7 +154,7 @@ class DefaultTbContext implements TbContext { @Override public ScriptEngine createJsScriptEngine(String script, String... argNames) { - return new RuleNodeJsScriptEngine(mainCtx.getJsSandbox(), script, argNames); + return new RuleNodeJsScriptEngine(mainCtx.getJsSandbox(), nodeCtx.getSelf().getId(), script, argNames); } @Override diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index 86b8fdacd7..82ab1702fa 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -276,7 +276,7 @@ public class RuleChainController extends BaseController { String errorText = ""; ScriptEngine engine = null; try { - engine = new RuleNodeJsScriptEngine(jsSandboxService, script, argNames); + engine = new RuleNodeJsScriptEngine(jsSandboxService, getCurrentUser().getId(), script, argNames); TbMsg inMsg = new TbMsg(UUIDs.timeBased(), msgType, null, new TbMsgMetaData(metadata), data, null, null, 0L); switch (scriptType) { case "update": diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java index 724f652609..a0c4d5e4de 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java @@ -13,16 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.thingsboard.server.service.script; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; import delight.nashornsandbox.NashornSandbox; import delight.nashornsandbox.NashornSandboxes; import jdk.nashorn.api.scripting.NashornScriptEngineFactory; +import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; +import org.thingsboard.server.common.data.id.EntityId; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; @@ -42,17 +45,20 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic private NashornSandbox sandbox; private ScriptEngine engine; private ExecutorService monitorExecutorService; + private ListeningExecutorService evalExecutorService; private final Map functionsMap = new ConcurrentHashMap<>(); - private final Map blackListedFunctions = new ConcurrentHashMap<>(); - private final Map> scriptToId = new ConcurrentHashMap<>(); - private final Map scriptIdToCount = new ConcurrentHashMap<>(); + private final Map blackListedFunctions = new ConcurrentHashMap<>(); + + private final Map scriptKeyToInfo = new ConcurrentHashMap<>(); + private final Map scriptIdToInfo = new ConcurrentHashMap<>(); @PostConstruct public void init() { if (useJsSandbox()) { sandbox = NashornSandboxes.create(); monitorExecutorService = Executors.newFixedThreadPool(getMonitorThreadPoolSize()); + evalExecutorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); sandbox.setExecutor(monitorExecutorService); sandbox.setMaxCPUTime(getMaxCpuTime()); sandbox.allowNoBraces(false); @@ -65,9 +71,12 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic @PreDestroy public void stop() { - if (monitorExecutorService != null) { + if (monitorExecutorService != null) { monitorExecutorService.shutdownNow(); } + if (evalExecutorService != null) { + evalExecutorService.shutdownNow(); + } } protected abstract boolean useJsSandbox(); @@ -80,33 +89,39 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic @Override public ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames) { - Pair deduplicated = deduplicate(scriptType, scriptBody); - UUID scriptId = deduplicated.getLeft(); - AtomicInteger duplicateCount = deduplicated.getRight(); - - if(duplicateCount.compareAndSet(0, 1)) { - String functionName = "invokeInternal_" + scriptId.toString().replace('-', '_'); - String jsScript = generateJsScript(scriptType, functionName, scriptBody, argNames); - try { - if (useJsSandbox()) { - sandbox.eval(jsScript); - } else { - engine.eval(jsScript); + ScriptInfo scriptInfo = deduplicate(scriptType, scriptBody); + UUID scriptId = scriptInfo.getId(); + AtomicInteger duplicateCount = scriptInfo.getCount(); + + synchronized (scriptInfo.getLock()) { + if (duplicateCount.compareAndSet(0, 1)) { + try { + evaluate(scriptId, scriptType, scriptBody, argNames); + } catch (Exception e) { + duplicateCount.decrementAndGet(); + log.warn("Failed to compile JS script: {}", e.getMessage(), e); + return Futures.immediateFailedFuture(e); } - functionsMap.put(scriptId, functionName); - } catch (Exception e) { - duplicateCount.decrementAndGet(); - log.warn("Failed to compile JS script: {}", e.getMessage(), e); - return Futures.immediateFailedFuture(e); + } else { + duplicateCount.incrementAndGet(); } - } else { - duplicateCount.incrementAndGet(); } return Futures.immediateFuture(scriptId); } + private void evaluate(UUID scriptId, JsScriptType scriptType, String scriptBody, String... argNames) throws ScriptException { + String functionName = "invokeInternal_" + scriptId.toString().replace('-', '_'); + String jsScript = generateJsScript(scriptType, functionName, scriptBody, argNames); + if (useJsSandbox()) { + sandbox.eval(jsScript); + } else { + engine.eval(jsScript); + } + functionsMap.put(scriptId, functionName); + } + @Override - public ListenableFuture invokeFunction(UUID scriptId, Object... args) { + public ListenableFuture invokeFunction(UUID scriptId, EntityId entityId, Object... args) { String functionName = functionsMap.get(scriptId); if (functionName == null) { return Futures.immediateFailedFuture(new RuntimeException("No compiled script found for scriptId: [" + scriptId + "]!")); @@ -117,11 +132,12 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic if (useJsSandbox()) { result = sandbox.getSandboxedInvocable().invokeFunction(functionName, args); } else { - result = ((Invocable)engine).invokeFunction(functionName, args); + result = ((Invocable) engine).invokeFunction(functionName, args); } return Futures.immediateFuture(result); } catch (Exception e) { - blackListedFunctions.computeIfAbsent(scriptId, key -> new AtomicInteger(0)).incrementAndGet(); + BlackListKey blackListKey = new BlackListKey(scriptId, entityId); + blackListedFunctions.computeIfAbsent(blackListKey, key -> new AtomicInteger(0)).incrementAndGet(); return Futures.immediateFailedFuture(e); } } else { @@ -131,31 +147,41 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic } @Override - public ListenableFuture release(UUID scriptId) { - AtomicInteger count = scriptIdToCount.get(scriptId); - if(count != null) { - if(count.decrementAndGet() > 0) { + public ListenableFuture release(UUID scriptId, EntityId entityId) { + ScriptInfo scriptInfo = scriptIdToInfo.get(scriptId); + if (scriptInfo == null) { + log.warn("Script release called for not existing script id [{}]", scriptId); + return Futures.immediateFuture(null); + } + + synchronized (scriptInfo.getLock()) { + int remainingDuplicates = scriptInfo.getCount().decrementAndGet(); + if (remainingDuplicates > 0) { return Futures.immediateFuture(null); } - } - String functionName = functionsMap.get(scriptId); - if (functionName != null) { - try { - if (useJsSandbox()) { - sandbox.eval(functionName + " = undefined;"); - } else { - engine.eval(functionName + " = undefined;"); + String functionName = functionsMap.get(scriptId); + if (functionName != null) { + try { + if (useJsSandbox()) { + sandbox.eval(functionName + " = undefined;"); + } else { + engine.eval(functionName + " = undefined;"); + } + functionsMap.remove(scriptId); + blackListedFunctions.remove(new BlackListKey(scriptId, entityId)); + } catch (ScriptException e) { + log.error("Could not release script [{}] [{}]", scriptId, remainingDuplicates); + return Futures.immediateFailedFuture(e); } - functionsMap.remove(scriptId); - blackListedFunctions.remove(scriptId); - } catch (ScriptException e) { - return Futures.immediateFailedFuture(e); + } else { + log.warn("Function name do not exist for script [{}] [{}]", scriptId, remainingDuplicates); } } return Futures.immediateFuture(null); } + private boolean isBlackListed(UUID scriptId) { if (blackListedFunctions.containsKey(scriptId)) { AtomicInteger errorCount = blackListedFunctions.get(scriptId); @@ -174,15 +200,46 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic } } - private Pair deduplicate(JsScriptType scriptType, String scriptBody) { - Pair precomputed = Pair.of(UUID.randomUUID(), new AtomicInteger()); - - Pair pair = scriptToId.computeIfAbsent(deduplicateKey(scriptType, scriptBody), i -> precomputed); - AtomicInteger duplicateCount = scriptIdToCount.computeIfAbsent(pair.getLeft(), i -> pair.getRight()); - return Pair.of(pair.getLeft(), duplicateCount); + private ScriptInfo deduplicate(JsScriptType scriptType, String scriptBody) { + ScriptInfo meta = ScriptInfo.preInit(); + String key = deduplicateKey(scriptType, scriptBody); + ScriptInfo latestMeta = scriptKeyToInfo.computeIfAbsent(key, i -> meta); + return scriptIdToInfo.computeIfAbsent(latestMeta.getId(), i -> latestMeta); } private String deduplicateKey(JsScriptType scriptType, String scriptBody) { return scriptType + "_" + scriptBody; } + + @Getter + private static class ScriptInfo { + private final UUID id; + private final Object lock; + private final AtomicInteger count; + + ScriptInfo(UUID id, Object lock, AtomicInteger count) { + this.id = id; + this.lock = lock; + this.count = count; + } + + static ScriptInfo preInit() { + UUID preId = UUID.randomUUID(); + AtomicInteger preCount = new AtomicInteger(); + Object preLock = new Object(); + return new ScriptInfo(preId, preLock, preCount); + } + } + + @EqualsAndHashCode + @Getter + private static class BlackListKey { + private final UUID scriptId; + private final EntityId entityId; + + public BlackListKey(UUID scriptId, EntityId entityId) { + this.scriptId = scriptId; + this.entityId = entityId; + } + } } diff --git a/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java index ee86c62a25..5e1c676443 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.script; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.id.EntityId; import java.util.UUID; @@ -24,8 +25,8 @@ public interface JsSandboxService { ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames); - ListenableFuture invokeFunction(UUID scriptId, Object... args); + ListenableFuture invokeFunction(UUID scriptId, EntityId entityId, Object... args); - ListenableFuture release(UUID scriptId); + ListenableFuture release(UUID scriptId, EntityId entityId); } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index 2ba87ec8da..f2b5fd7c12 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.Sets; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -39,9 +40,11 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S private final JsSandboxService sandboxService; private final UUID scriptId; + private final EntityId entityId; - public RuleNodeJsScriptEngine(JsSandboxService sandboxService, String script, String... argNames) { + public RuleNodeJsScriptEngine(JsSandboxService sandboxService, EntityId entityId, String script, String... argNames) { this.sandboxService = sandboxService; + this.entityId = entityId; try { this.scriptId = this.sandboxService.eval(JsScriptType.RULE_NODE_SCRIPT, script, argNames).get(); } catch (Exception e) { @@ -162,7 +165,7 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S private JsonNode executeScript(TbMsg msg) throws ScriptException { try { String[] inArgs = prepareArgs(msg); - String eval = sandboxService.invokeFunction(this.scriptId, inArgs[0], inArgs[1], inArgs[2]).get().toString(); + String eval = sandboxService.invokeFunction(this.scriptId, this.entityId, inArgs[0], inArgs[1], inArgs[2]).get().toString(); return mapper.readTree(eval); } catch (ExecutionException e) { if (e.getCause() instanceof ScriptException) { @@ -176,6 +179,6 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S } public void destroy() { - sandboxService.release(this.scriptId); + sandboxService.release(this.scriptId, this.entityId); } } diff --git a/application/src/main/resources/logback.xml b/application/src/main/resources/logback.xml index 978a570e66..dcfc9301b2 100644 --- a/application/src/main/resources/logback.xml +++ b/application/src/main/resources/logback.xml @@ -17,7 +17,7 @@ --> - + diff --git a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java index ea7038442d..88961dc256 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java @@ -21,12 +21,18 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.thingsboard.rule.engine.api.ScriptEngine; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import javax.script.ScriptException; - +import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.*; @@ -35,6 +41,8 @@ public class RuleNodeJsScriptEngineTest { private ScriptEngine scriptEngine; private TestNashornJsSandboxService jsSandboxService; + private EntityId ruleNodeId = new RuleNodeId(UUIDs.timeBased()); + @Before public void beforeTest() throws Exception { jsSandboxService = new TestNashornJsSandboxService(false, 1, 100, 3); @@ -48,7 +56,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void msgCanBeUpdated() throws ScriptException { String function = "metadata.temp = metadata.temp * 10; return {metadata: metadata};"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); @@ -65,7 +73,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void newAttributesCanBeAddedInMsg() throws ScriptException { String function = "metadata.newAttr = metadata.humidity - msg.passed; return {metadata: metadata};"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -81,7 +89,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void payloadCanBeUpdated() throws ScriptException { String function = "msg.passed = msg.passed * metadata.temp; msg.bigObj.newProp = 'Ukraine'; return {msg: msg};"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -99,7 +107,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void metadataAccessibleForFilter() throws ScriptException { String function = "return metadata.humidity < 15;"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -113,7 +121,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void dataAccessibleForFilter() throws ScriptException { String function = "return msg.passed < 15 && msg.name === 'Vit' && metadata.temp == 7 && msg.bigObj.prop == 42;"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -134,7 +142,7 @@ public class RuleNodeJsScriptEngineTest { "};\n" + "\n" + "return nextRelation(metadata, msg);"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, jsCode); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, jsCode); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "10"); metaData.putValue("humidity", "99"); @@ -156,7 +164,7 @@ public class RuleNodeJsScriptEngineTest { "};\n" + "\n" + "return nextRelation(metadata, msg);"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, jsCode); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, jsCode); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "10"); metaData.putValue("humidity", "99"); @@ -168,4 +176,75 @@ public class RuleNodeJsScriptEngineTest { scriptEngine.destroy(); } + @Test + public void concurrentReleasedCorrectly() throws InterruptedException, ExecutionException { + String code = "metadata.temp = metadata.temp * 10; return {metadata: metadata};"; + + int repeat = 1000; + ExecutorService service = Executors.newFixedThreadPool(repeat); + Map scriptIds = new ConcurrentHashMap<>(); + CountDownLatch startLatch = new CountDownLatch(repeat); + CountDownLatch finishLatch = new CountDownLatch(repeat); + AtomicInteger failedCount = new AtomicInteger(0); + + for (int i = 0; i < repeat; i++) { + service.submit(() -> runScript(startLatch, finishLatch, failedCount, scriptIds, code)); + } + + finishLatch.await(); + assertTrue(scriptIds.size() == 1); + assertTrue(failedCount.get() == 0); + + CountDownLatch nextStart = new CountDownLatch(repeat); + CountDownLatch nextFinish = new CountDownLatch(repeat); + for (int i = 0; i < repeat; i++) { + service.submit(() -> runScript(nextStart, nextFinish, failedCount, scriptIds, code)); + } + + nextFinish.await(); + assertTrue(scriptIds.size() == 1); + assertTrue(failedCount.get() == 0); + service.shutdownNow(); + } + + @Test + public void concurrentFailedEvaluationShouldThrowException() throws InterruptedException { + String code = "metadata.temp = metadata.temp * 10; urn {metadata: metadata};"; + + int repeat = 10000; + ExecutorService service = Executors.newFixedThreadPool(repeat); + Map scriptIds = new ConcurrentHashMap<>(); + CountDownLatch startLatch = new CountDownLatch(repeat); + CountDownLatch finishLatch = new CountDownLatch(repeat); + AtomicInteger failedCount = new AtomicInteger(0); + for (int i = 0; i < repeat; i++) { + service.submit(() -> { + service.submit(() -> runScript(startLatch, finishLatch, failedCount, scriptIds, code)); + }); + } + + finishLatch.await(); + assertTrue(scriptIds.isEmpty()); + assertEquals(repeat, failedCount.get()); + service.shutdownNow(); + } + + private void runScript(CountDownLatch startLatch, CountDownLatch finishLatch, AtomicInteger failedCount, + Map scriptIds, String code) { + try { + for (int k = 0; k < 10; k++) { + startLatch.countDown(); + startLatch.await(); + UUID scriptId = jsSandboxService.eval(JsScriptType.RULE_NODE_SCRIPT, code).get(); + scriptIds.put(scriptId, new Object()); + jsSandboxService.invokeFunction(scriptId, ruleNodeId, "{}", "{}", "TEXT").get(); + jsSandboxService.release(scriptId, ruleNodeId).get(); + } + } catch (Throwable th) { + failedCount.incrementAndGet(); + } finally { + finishLatch.countDown(); + } + } + } \ No newline at end of file From 1e1beaaeac53284994781e85867cc6288413661c Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 11 Sep 2018 15:13:45 +0300 Subject: [PATCH 076/307] Was modified findLatest(...) method & BaseTimeseriesService(...) constructor for set limit and order by values --- .../thingsboard/server/common/data/kv/BaseReadTsKvQuery.java | 4 ++++ .../server/dao/timeseries/BaseTimeseriesService.java | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java index 3c48adfc18..739586e3e0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java @@ -42,4 +42,8 @@ public class BaseReadTsKvQuery extends BaseTsKvQuery implements ReadTsKvQuery { this(key, startTs, endTs, endTs - startTs, 1, Aggregation.AVG, "DESC"); } + public BaseReadTsKvQuery(String key, long startTs, long endTs, int limit, String orderBy) { + this(key, startTs, endTs, endTs - startTs, limit, Aggregation.AVG, orderBy); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 605c9410f6..3829df6904 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -75,7 +75,10 @@ public class BaseTimeseriesService implements TimeseriesService { EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); Collection matchingKeys = chooseKeysForEntityView(entityView, keys); List queries = new ArrayList<>(); - matchingKeys.forEach(key -> queries.add(new BaseReadTsKvQuery(key, entityView.getStartTs(), entityView.getEndTs()))); + + matchingKeys.forEach(key -> queries.add( + new BaseReadTsKvQuery(key, entityView.getStartTs(), entityView.getEndTs(), 1, "ASC"))); + return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, queries)); } keys.forEach(key -> futures.add(timeseriesDao.findLatest(entityId, key))); From 770253a32ce950225d8da9836814cc71eb2a3de6 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 11 Sep 2018 15:59:00 +0300 Subject: [PATCH 077/307] Was fixed the type of startTs & endTs fields of EntityViewEntity class and queries of schemas db tables --- .../main/data/upgrade/2.1.1/schema_update.cql | 20 +++++++++---------- .../main/data/upgrade/2.1.1/schema_update.sql | 4 ++-- .../server/dao/model/ModelConstants.java | 4 ++-- .../dao/model/nosql/EntityViewEntity.java | 16 +++++++-------- .../dao/model/sql/EntityViewEntity.java | 16 +++++++-------- dao/src/main/resources/cassandra/schema.cql | 12 +++++------ dao/src/main/resources/sql/schema.sql | 4 ++-- 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index 1329a0b546..e0cff4108f 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -29,8 +29,8 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( customer_id timeuuid, name text, keys text, - ts_begin bigint, - ts_end bigint, + start_ts bigint, + end_ts bigint, search_text text, additional_info text, PRIMARY KEY (id, entity_id, tenant_id, customer_id) @@ -43,8 +43,8 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_na AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL - AND ts_begin IS NOT NULL - AND ts_end IS NOT NULL + AND start_ts IS NOT NULL + AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) @@ -57,8 +57,8 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_en AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL - AND ts_begin IS NOT NULL - AND ts_end IS NOT NULL + AND start_ts IS NOT NULL + AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, entity_id, id, customer_id, name) @@ -71,8 +71,8 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_cu AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL - AND ts_begin IS NOT NULL - AND ts_end IS NOT NULL + AND start_ts IS NOT NULL + AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, id, entity_id, name) @@ -85,8 +85,8 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_cu AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL - AND ts_begin IS NOT NULL - AND ts_end IS NOT NULL + AND start_ts IS NOT NULL + AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, entity_id, id, name) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.sql b/application/src/main/data/upgrade/2.1.1/schema_update.sql index 3670aad035..bd2c341e7f 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.sql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.sql @@ -24,8 +24,8 @@ CREATE TABLE IF NOT EXISTS entity_views ( customer_id varchar(31), name varchar(255), keys varchar(255), - ts_begin varchar(255), - ts_end varchar(255), + start_ts bigint, + end_ts bigint, search_text varchar(255), additional_info varchar ); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 7e9662ebe2..a6787d0520 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -156,8 +156,8 @@ public class ModelConstants { public static final String ENTITY_VIEW_TYPE_PROPERTY = DEVICE_TYPE_PROPERTY; public static final String ENTITY_VIEW_TENANT_AND_NAME_VIEW_NAME = "entity_view_by_tenant_and_name"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; - public static final String ENTITY_VIEW_TS_BEGIN_PROPERTY = "ts_begin"; - public static final String ENTITY_VIEW_TS_END_PROPERTY = "ts_end"; + public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; + public static final String ENTITY_VIEW_END_TS_PROPERTY = "end_ts"; public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_tenant_and_search_text"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index 3f7571363a..91cd2ea563 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -75,11 +75,11 @@ public class EntityViewEntity implements SearchTextEntity { @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) private String keys; - @Column(name = ModelConstants.ENTITY_VIEW_TS_BEGIN_PROPERTY) - private String tsBegin; + @Column(name = ModelConstants.ENTITY_VIEW_START_TS_PROPERTY) + private long startTs; - @Column(name = ModelConstants.ENTITY_VIEW_TS_END_PROPERTY) - private String tsEnd; + @Column(name = ModelConstants.ENTITY_VIEW_END_TS_PROPERTY) + private long endTs; @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY) private String searchText; @@ -114,8 +114,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = entityView.getStartTs() != 0L ? String.valueOf(entityView.getStartTs()) : "0"; - this.tsEnd = entityView.getEndTs() != 0L ? String.valueOf(entityView.getEndTs()) : "0"; + this.startTs = entityView.getStartTs() != 0L ? entityView.getStartTs() : 0L; + this.endTs = entityView.getEndTs() != 0L ? entityView.getEndTs() : 0L; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -144,8 +144,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - entityView.setStartTs(Long.parseLong(tsBegin)); - entityView.setEndTs(Long.parseLong(tsEnd)); + entityView.setStartTs(startTs); + entityView.setEndTs(endTs); entityView.setAdditionalInfo(additionalInfo); return entityView; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index a89d3dec84..9492f191db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -66,11 +66,11 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) private String keys; - @Column(name = ModelConstants.ENTITY_VIEW_TS_BEGIN_PROPERTY) - private String tsBegin; + @Column(name = ModelConstants.ENTITY_VIEW_START_TS_PROPERTY) + private long startTs; - @Column(name = ModelConstants.ENTITY_VIEW_TS_END_PROPERTY) - private String tsEnd; + @Column(name = ModelConstants.ENTITY_VIEW_END_TS_PROPERTY) + private long endTs; @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY) private String searchText; @@ -105,8 +105,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - this.tsBegin = entityView.getStartTs() != 0L ? String.valueOf(entityView.getStartTs()) : "0"; - this.tsEnd = entityView.getEndTs() != 0L ? String.valueOf(entityView.getEndTs()) : "0"; + this.startTs = entityView.getStartTs() != 0L ? entityView.getStartTs() : 0L; + this.endTs = entityView.getEndTs() != 0L ? entityView.getEndTs() : 0L; this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -141,8 +141,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - entityView.setStartTs(Long.parseLong(tsBegin)); - entityView.setEndTs(Long.parseLong(tsEnd)); + entityView.setStartTs(startTs); + entityView.setEndTs(endTs); entityView.setAdditionalInfo(additionalInfo); return entityView; } diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql index 89df266d75..3cefacb970 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema.cql @@ -647,8 +647,8 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( customer_id timeuuid, name text, keys text, - ts_begin bigint, - ts_end bigint, + start_ts bigint, + end_ts bigint, search_text text, additional_info text, PRIMARY KEY (id, tenant_id, customer_id) @@ -657,27 +657,27 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_entity AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, entity_id, id, customer_id, name) WITH CLUSTERING ORDER BY (entity_id ASC, customer_id ASC, id DESC, name DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, id, entity_id, name) WITH CLUSTERING ORDER BY (customer_id ASC, id DESC, entity_id DESC, name DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND ts_begin IS NOT NULL AND ts_end IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, entity_id, id, name) WITH CLUSTERING ORDER BY (customer_id ASC, entity_id DESC, id DESC, name DESC); diff --git a/dao/src/main/resources/sql/schema.sql b/dao/src/main/resources/sql/schema.sql index ed3583cef3..a3197b7e2f 100644 --- a/dao/src/main/resources/sql/schema.sql +++ b/dao/src/main/resources/sql/schema.sql @@ -260,8 +260,8 @@ CREATE TABLE IF NOT EXISTS entity_views ( customer_id varchar(31), name varchar(255), keys varchar(255), - ts_begin varchar(255), - ts_end varchar(255), + start_ts bigint, + end_ts bigint, search_text varchar(255), additional_info varchar ); From 5ea053b71b385e53dc92728ea0486e39b3851b7c Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Tue, 11 Sep 2018 16:33:39 +0300 Subject: [PATCH 078/307] blacklisted Script error should show initial exception --- .../AbstractNashornJsSandboxService.java | 91 +++++++++++-------- .../script/RuleNodeJsScriptEngine.java | 4 +- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java index a0c4d5e4de..1d89c9d860 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java @@ -17,13 +17,13 @@ package org.thingsboard.server.service.script; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; import delight.nashornsandbox.NashornSandbox; import delight.nashornsandbox.NashornSandboxes; import jdk.nashorn.api.scripting.NashornScriptEngineFactory; +import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.EntityId; @@ -45,10 +45,9 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic private NashornSandbox sandbox; private ScriptEngine engine; private ExecutorService monitorExecutorService; - private ListeningExecutorService evalExecutorService; private final Map functionsMap = new ConcurrentHashMap<>(); - private final Map blackListedFunctions = new ConcurrentHashMap<>(); + private final Map blackListedFunctions = new ConcurrentHashMap<>(); private final Map scriptKeyToInfo = new ConcurrentHashMap<>(); private final Map scriptIdToInfo = new ConcurrentHashMap<>(); @@ -58,7 +57,6 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic if (useJsSandbox()) { sandbox = NashornSandboxes.create(); monitorExecutorService = Executors.newFixedThreadPool(getMonitorThreadPoolSize()); - evalExecutorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); sandbox.setExecutor(monitorExecutorService); sandbox.setMaxCPUTime(getMaxCpuTime()); sandbox.allowNoBraces(false); @@ -74,9 +72,6 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic if (monitorExecutorService != null) { monitorExecutorService.shutdownNow(); } - if (evalExecutorService != null) { - evalExecutorService.shutdownNow(); - } } protected abstract boolean useJsSandbox(); @@ -124,26 +119,35 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic public ListenableFuture invokeFunction(UUID scriptId, EntityId entityId, Object... args) { String functionName = functionsMap.get(scriptId); if (functionName == null) { - return Futures.immediateFailedFuture(new RuntimeException("No compiled script found for scriptId: [" + scriptId + "]!")); - } - if (!isBlackListed(scriptId)) { - try { - Object result; - if (useJsSandbox()) { - result = sandbox.getSandboxedInvocable().invokeFunction(functionName, args); - } else { - result = ((Invocable) engine).invokeFunction(functionName, args); - } - return Futures.immediateFuture(result); - } catch (Exception e) { - BlackListKey blackListKey = new BlackListKey(scriptId, entityId); - blackListedFunctions.computeIfAbsent(blackListKey, key -> new AtomicInteger(0)).incrementAndGet(); - return Futures.immediateFailedFuture(e); - } + String message = "No compiled script found for scriptId: [" + scriptId + "]!"; + log.warn(message); + return Futures.immediateFailedFuture(new RuntimeException(message)); + } + + BlackListInfo blackListInfo = blackListedFunctions.get(new BlackListKey(scriptId, entityId)); + if (blackListInfo != null && blackListInfo.getCount() >= getMaxErrors()) { + RuntimeException throwable = new RuntimeException("Script is blacklisted due to maximum error count " + getMaxErrors() + "!", blackListInfo.getCause()); + throwable.printStackTrace(); + return Futures.immediateFailedFuture(throwable); + } + + try { + return invoke(functionName, args); + } catch (Exception e) { + BlackListKey blackListKey = new BlackListKey(scriptId, entityId); + blackListedFunctions.computeIfAbsent(blackListKey, key -> new BlackListInfo()).incrementWithReason(e); + return Futures.immediateFailedFuture(e); + } + } + + private ListenableFuture invoke(String functionName, Object... args) throws ScriptException, NoSuchMethodException { + Object result; + if (useJsSandbox()) { + result = sandbox.getSandboxedInvocable().invokeFunction(functionName, args); } else { - return Futures.immediateFailedFuture( - new RuntimeException("Script is blacklisted due to maximum error count " + getMaxErrors() + "!")); + result = ((Invocable) engine).invokeFunction(functionName, args); } + return Futures.immediateFuture(result); } @Override @@ -182,15 +186,6 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic } - private boolean isBlackListed(UUID scriptId) { - if (blackListedFunctions.containsKey(scriptId)) { - AtomicInteger errorCount = blackListedFunctions.get(scriptId); - return errorCount.get() >= getMaxErrors(); - } else { - return false; - } - } - private String generateJsScript(JsScriptType scriptType, String functionName, String scriptBody, String... argNames) { switch (scriptType) { case RULE_NODE_SCRIPT: @@ -233,13 +228,33 @@ public abstract class AbstractNashornJsSandboxService implements JsSandboxServic @EqualsAndHashCode @Getter + @RequiredArgsConstructor private static class BlackListKey { private final UUID scriptId; private final EntityId entityId; - public BlackListKey(UUID scriptId, EntityId entityId) { - this.scriptId = scriptId; - this.entityId = entityId; + } + + @Data + private static class BlackListInfo { + private final AtomicInteger count; + private Exception ex; + + BlackListInfo() { + this.count = new AtomicInteger(0); + } + + void incrementWithReason(Exception e) { + count.incrementAndGet(); + ex = e; + } + + int getCount() { + return count.get(); + } + + Exception getCause() { + return ex; } } } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index f2b5fd7c12..767dc05a23 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -171,10 +171,10 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S if (e.getCause() instanceof ScriptException) { throw (ScriptException)e.getCause(); } else { - throw new ScriptException("Failed to execute js script: " + e.getMessage()); + throw new ScriptException(e); } } catch (Exception e) { - throw new ScriptException("Failed to execute js script: " + e.getMessage()); + throw new ScriptException(e); } } From 8c91b7fb765c24f9763b6b31f27697d4a2b0f5ff Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Tue, 11 Sep 2018 17:12:01 +0300 Subject: [PATCH 079/307] fix license headers --- .../rule/engine/metadata/TbGetTelemetryNode.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java index 5a5e9c6630..260669f9ff 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. From cb087d0b53ff3d35b8954c0c73e6ee0bfc8734ad Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 11 Sep 2018 17:18:51 +0300 Subject: [PATCH 080/307] For testing of entiti-view controller --- .../thingsboard/server/controller/ControllerSqlTestSuite.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index c8a5da8151..a9e94e9184 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.controller.sql.*Test", + "org.thingsboard.server.controller.sql.EntityViewControllerSqlTest", }) public class ControllerSqlTestSuite { From 20e3dc254b9b5a9302a6c8ba4a41b8af7033da3b Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 11 Sep 2018 17:21:11 +0300 Subject: [PATCH 081/307] To repair the cache --- .../server/dao/entityview/EntityViewServiceImpl.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index e90767695d..5646ac9f53 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; @@ -88,7 +89,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CacheManager cacheManager; -// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") + @Cacheable(cacheNames = ENTITY_VIEW_CACHE) @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); @@ -104,7 +105,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti .orElse(null); } -// @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}") + @CachePut(cacheNames = ENTITY_VIEW_CACHE) @Override public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); @@ -136,7 +137,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti List list = new ArrayList<>(); list.add(entityView.getTenantId()); list.add(entityView.getName()); -// cache.evict(list); + cache.evict(list); entityViewDao.removeById(entityViewId.getId()); } @@ -149,7 +150,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } -// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #entityId, #pageLink}") + @Cacheable(cacheNames = ENTITY_VIEW_CACHE) @Override public TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TextPageLink pageLink) { @@ -189,7 +190,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } -// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") @Override public TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, CustomerId customerId, From dba1c026a773453191da6eefc30a9aa4a9e55bcb Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 11 Sep 2018 17:36:54 +0300 Subject: [PATCH 082/307] UI: Fix datetime period selector. --- ui/src/app/components/datetime-period.tpl.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/app/components/datetime-period.tpl.html b/ui/src/app/components/datetime-period.tpl.html index 605c3a8b20..7479576f96 100644 --- a/ui/src/app/components/datetime-period.tpl.html +++ b/ui/src/app/components/datetime-period.tpl.html @@ -18,14 +18,14 @@
+ > + mdp-auto-switch="true">
+ > + mdp-auto-switch="true">
\ No newline at end of file From 01e06fcd344ef23ecb56c9b57e77170cebcb2b14 Mon Sep 17 00:00:00 2001 From: Dima Landiak Date: Tue, 11 Sep 2018 18:11:04 +0300 Subject: [PATCH 083/307] jpa orderBy fix --- .../server/dao/sql/timeseries/JpaTimeseriesDao.java | 5 ++++- .../server/dao/sql/timeseries/TsKvRepository.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java index 1eb2f0026d..761138649d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java @@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.UUIDConverter; import org.thingsboard.server.common.data.id.EntityId; @@ -238,7 +239,9 @@ public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService imp query.getKey(), query.getStartTs(), query.getEndTs(), - new PageRequest(0, query.getLimit())))); + new PageRequest(0, query.getLimit(), + new Sort(Sort.Direction.fromString( + query.getOrderBy()), "ts"))))); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java index 2b39d2596e..4c743e5ab6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java @@ -35,7 +35,7 @@ public interface TsKvRepository extends CrudRepository :startTs AND tskv.ts < :endTs ORDER BY tskv.ts DESC") + "AND tskv.ts > :startTs AND tskv.ts < :endTs") List findAllWithLimit(@Param("entityId") String entityId, @Param("entityType") EntityType entityType, @Param("entityKey") String key, From 09e90b30898575853c020bd4bc55b94f9695a51c Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 12 Sep 2018 09:08:30 +0300 Subject: [PATCH 084/307] Code review fixes --- .../DefaultTelemetrySubscriptionService.java | 12 +--- .../service/telemetry/sub/Subscription.java | 2 +- .../controller/ControllerSqlTestSuite.java | 2 +- .../server/common/data/EntityView.java | 2 +- .../common/data/kv/BaseReadTsKvQuery.java | 2 +- .../dao/entityview/EntityViewServiceImpl.java | 18 +++--- .../dao/timeseries/BaseTimeseriesService.java | 55 +++++++------------ 7 files changed, 34 insertions(+), 59 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 35455b9f2e..bdf8e747d4 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -33,11 +33,8 @@ import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; -//<<<<<<< HEAD import org.thingsboard.server.common.data.id.EntityViewId; -//======= import org.thingsboard.server.common.data.id.TenantId; -//>>>>>>> d909192071880b7af2137333142bc62ece369ec1 import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; @@ -108,9 +105,6 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Autowired private ClusterRpcService rpcService; - /*@Autowired - private EntityViewService entityViewService;*/ - @Autowired @Lazy private DeviceStateService stateService; @@ -143,17 +137,15 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Override public void addLocalWsSubscription(String sessionId, EntityId entityId, SubscriptionState sub) { - String familyName = entityId.getEntityType().equals(EntityType.ENTITY_VIEW) - ? ModelConstants.ENTITY_VIEW_FAMILY_NAME : ModelConstants.DEVICE_FAMILY_NAME; Optional server = routingService.resolveById(entityId); Subscription subscription; if (server.isPresent()) { ServerAddress address = server.get(); - log.trace("[{}] Forwarding subscription [{}] for " + familyName + " [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId, address); + log.trace("[{}] Forwarding subscription [{}] for [{}] entity [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId, address); subscription = new Subscription(sub, true, address); tellNewSubscription(address, sessionId, subscription); } else { - log.trace("[{}] Registering local subscription [{}] for " + familyName + " [{}]", sessionId, sub.getSubscriptionId(), entityId); + log.trace("[{}] Registering local subscription [{}] for [{}] entity [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId); subscription = new Subscription(sub, true); } registerSubscription(sessionId, entityId, subscription); diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java index 08440e4bbb..47c80e9aa6 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java @@ -34,7 +34,7 @@ public class Subscription { private long endTime; public Subscription(SubscriptionState sub, boolean local) { - this(sub, local, null, 0L, 0L); + this(sub, local, null); } public Subscription(SubscriptionState sub, boolean local, ServerAddress server) { diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index a9e94e9184..c8a5da8151 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.controller.sql.EntityViewControllerSqlTest", + "org.thingsboard.server.controller.sql.*Test", }) public class ControllerSqlTestSuite { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index d7ab6e3d54..49dd20959f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -1,4 +1,4 @@ - /** +/** * Copyright © 2016-2018 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java index 739586e3e0..3e4e8ef7a5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java @@ -43,7 +43,7 @@ public class BaseReadTsKvQuery extends BaseTsKvQuery implements ReadTsKvQuery { } public BaseReadTsKvQuery(String key, long startTs, long endTs, int limit, String orderBy) { - this(key, startTs, endTs, endTs - startTs, limit, Aggregation.AVG, orderBy); + this(key, startTs, endTs, endTs - startTs, limit, Aggregation.NONE, orderBy); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 5646ac9f53..d2a902b1b2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -89,7 +89,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CacheManager cacheManager; - @Cacheable(cacheNames = ENTITY_VIEW_CACHE) +// @Cacheable(cacheNames = ENTITY_VIEW_CACHE) @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); @@ -105,7 +105,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti .orElse(null); } - @CachePut(cacheNames = ENTITY_VIEW_CACHE) +// @CachePut(cacheNames = ENTITY_VIEW_CACHE) @Override public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); @@ -130,14 +130,14 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override public void deleteEntityView(EntityViewId entityViewId) { log.trace("Executing deleteEntityView [{}]", entityViewId); - Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); +// Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); deleteEntityRelations(entityViewId); EntityView entityView = entityViewDao.findById(entityViewId.getId()); - List list = new ArrayList<>(); - list.add(entityView.getTenantId()); - list.add(entityView.getName()); - cache.evict(list); +// List list = new ArrayList<>(); +// list.add(entityView.getTenantId()); +// list.add(entityView.getName()); +// cache.evict(list); entityViewDao.removeById(entityViewId.getId()); } @@ -150,7 +150,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } - @Cacheable(cacheNames = ENTITY_VIEW_CACHE) +// @Cacheable(cacheNames = ENTITY_VIEW_CACHE) @Override public TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TextPageLink pageLink) { @@ -190,7 +190,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } - @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") +// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") @Override public TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, CustomerId customerId, diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 3829df6904..3a1e8db02f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -36,6 +36,7 @@ import org.thingsboard.server.dao.service.Validator; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.isBlank; @@ -61,7 +62,11 @@ public class BaseTimeseriesService implements TimeseriesService { queries.forEach(BaseTimeseriesService::validate); if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); - return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, queries)); + List filteredQueries = + queries.stream() + .filter(query -> entityView.getKeys().getTimeseries().contains(query.getKey())) + .collect(Collectors.toList()); + return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, filteredQueries)); } return timeseriesDao.findAllAsync(entityId, queries); } @@ -73,11 +78,12 @@ public class BaseTimeseriesService implements TimeseriesService { keys.forEach(key -> Validator.validateString(key, "Incorrect key " + key)); if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); - Collection matchingKeys = chooseKeysForEntityView(entityView, keys); - List queries = new ArrayList<>(); - - matchingKeys.forEach(key -> queries.add( - new BaseReadTsKvQuery(key, entityView.getStartTs(), entityView.getEndTs(), 1, "ASC"))); + List filteredKeys = new ArrayList<>(keys); + filteredKeys.retainAll(entityView.getKeys().getTimeseries()); + List queries = + filteredKeys.stream() + .map(key -> new BaseReadTsKvQuery(key, entityView.getStartTs(), entityView.getEndTs(), 1, "ASC")) + .collect(Collectors.toList()); return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, queries)); } @@ -136,34 +142,11 @@ public class BaseTimeseriesService implements TimeseriesService { } private List updateQueriesForEntityView(EntityView entityView, List queries) { - List newQueries = new ArrayList<>(); - entityView.getKeys().getTimeseries() - .forEach(viewKey -> queries - .forEach(query -> { - if (query.getKey().equals(viewKey)) { - if (entityView.getStartTs() == 0 && entityView.getEndTs() == 0) { - newQueries.add(updateQuery(query.getStartTs(), query.getEndTs(), viewKey, query)); - } else if (entityView.getStartTs() == 0 && entityView.getEndTs() != 0) { - newQueries.add(updateQuery(query.getStartTs(), entityView.getEndTs(), viewKey, query)); - } else if (entityView.getStartTs() != 0 && entityView.getEndTs() == 0) { - newQueries.add(updateQuery(entityView.getStartTs(), query.getEndTs(), viewKey, query)); - } else { - newQueries.add(updateQuery(entityView.getStartTs(), entityView.getEndTs(), viewKey, query)); - } - }})); - return newQueries; - } - - private Collection chooseKeysForEntityView(EntityView entityView, Collection keys) { - Collection newKeys = new ArrayList<>(); - entityView.getKeys().getTimeseries() - .forEach(viewKey -> keys - .forEach(key -> { - if (key.equals(viewKey)) { - newKeys.add(key); - } - })); - return newKeys; + return queries.stream().map(query -> { + long startTs = entityView.getStartTs() == 0 ? query.getStartTs() : entityView.getStartTs(); + long endTs = entityView.getEndTs() == 0 ? query.getEndTs() : entityView.getEndTs(); + return updateQuery(startTs, endTs, query); + }).collect(Collectors.toList()); } @Override @@ -205,9 +188,9 @@ public class BaseTimeseriesService implements TimeseriesService { } } - private static ReadTsKvQuery updateQuery(Long startTs, Long endTs, String viewKey, ReadTsKvQuery query) { + private ReadTsKvQuery updateQuery(Long startTs, Long endTs, ReadTsKvQuery query) { return startTs <= query.getStartTs() && endTs >= query.getEndTs() ? query : - new BaseReadTsKvQuery(viewKey, startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); + new BaseReadTsKvQuery(query.getKey(), startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); } private static void checkForNonEntityView(EntityId entityId) throws Exception { From 5dc541eabf3a19522cbb0a522468731d1650cd49 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 12 Sep 2018 09:34:43 +0300 Subject: [PATCH 085/307] Misc fixes --- .../dao/model/nosql/EntityViewEntity.java | 4 +- .../dao/timeseries/BaseTimeseriesService.java | 37 +++++-------------- .../app/entity-view/entity-view.directive.js | 4 +- 3 files changed, 13 insertions(+), 32 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index 91cd2ea563..0545448e55 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -114,8 +114,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - this.startTs = entityView.getStartTs() != 0L ? entityView.getStartTs() : 0L; - this.endTs = entityView.getEndTs() != 0L ? entityView.getEndTs() : 0L; + this.startTs = entityView.getStartTs(); + this.endTs = entityView.getEndTs(); this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 3a1e8db02f..629f93a19b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -64,7 +64,7 @@ public class BaseTimeseriesService implements TimeseriesService { EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); List filteredQueries = queries.stream() - .filter(query -> entityView.getKeys().getTimeseries().contains(query.getKey())) + .filter(query -> entityView.getKeys().getTimeseries().isEmpty() || entityView.getKeys().getTimeseries().contains(query.getKey())) .collect(Collectors.toList()); return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, filteredQueries)); } @@ -79,7 +79,9 @@ public class BaseTimeseriesService implements TimeseriesService { if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); List filteredKeys = new ArrayList<>(keys); - filteredKeys.retainAll(entityView.getKeys().getTimeseries()); + if (!entityView.getKeys().getTimeseries().isEmpty()) { + filteredKeys.retainAll(entityView.getKeys().getTimeseries()); + } List queries = filteredKeys.stream() .map(key -> new BaseReadTsKvQuery(key, entityView.getStartTs(), entityView.getEndTs(), 1, "ASC")) @@ -100,11 +102,6 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> save(EntityId entityId, TsKvEntry tsKvEntry) { validate(entityId); - try { - checkForNonEntityView(entityId); - } catch (Exception e) { - e.printStackTrace(); - } if (tsKvEntry == null) { throw new IncorrectParameterException("Key value entry can't be null"); } @@ -115,11 +112,6 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> save(EntityId entityId, List tsKvEntries, long ttl) { - try { - checkForNonEntityView(entityId); - } catch (Exception e) { - e.printStackTrace(); - } List> futures = Lists.newArrayListWithExpectedSize(tsKvEntries.size() * INSERTS_PER_ENTRY); for (TsKvEntry tsKvEntry : tsKvEntries) { if (tsKvEntry == null) { @@ -131,10 +123,8 @@ public class BaseTimeseriesService implements TimeseriesService { } private void saveAndRegisterFutures(List> futures, EntityId entityId, TsKvEntry tsKvEntry, long ttl) { - try { - checkForNonEntityView(entityId); - } catch (Exception e) { - e.printStackTrace(); + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + throw new IncorrectParameterException("Telemetry data can't be stored for entity view. Only read only"); } futures.add(timeseriesDao.savePartition(entityId, tsKvEntry.getTs(), tsKvEntry.getKey(), ttl)); futures.add(timeseriesDao.saveLatest(entityId, tsKvEntry)); @@ -145,7 +135,9 @@ public class BaseTimeseriesService implements TimeseriesService { return queries.stream().map(query -> { long startTs = entityView.getStartTs() == 0 ? query.getStartTs() : entityView.getStartTs(); long endTs = entityView.getEndTs() == 0 ? query.getEndTs() : entityView.getEndTs(); - return updateQuery(startTs, endTs, query); + + return startTs <= query.getStartTs() && endTs >= query.getEndTs() ? query : + new BaseReadTsKvQuery(query.getKey(), startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); }).collect(Collectors.toList()); } @@ -187,15 +179,4 @@ public class BaseTimeseriesService implements TimeseriesService { throw new IncorrectParameterException("Incorrect DeleteTsKvQuery. Key can't be empty"); } } - - private ReadTsKvQuery updateQuery(Long startTs, Long endTs, ReadTsKvQuery query) { - return startTs <= query.getStartTs() && endTs >= query.getEndTs() ? query : - new BaseReadTsKvQuery(query.getKey(), startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); - } - - private static void checkForNonEntityView(EntityId entityId) throws Exception { - if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { - throw new Exception("Entity-views were read only"); - } - } } diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index e31b0d4acc..c4a35d8cc5 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -85,11 +85,11 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t function updateMinMaxDates() { if (scope.endTs) { - scope.maxStartTs = angular.copy(new Date(scope.endTs.getTime() - 1000)); + scope.maxStartTs = angular.copy(new Date(scope.endTs.getTime())); scope.entityView.endTs = scope.endTs.getTime(); } if (scope.startTs) { - scope.minEndTs = angular.copy(new Date(scope.startTs.getTime() + 1000)); + scope.minEndTs = angular.copy(new Date(scope.startTs.getTime())); scope.entityView.startTs = scope.startTs.getTime(); } } From 8ea7058da49e42439b94802515ca098e3a6d05a4 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 12 Sep 2018 12:54:29 +0300 Subject: [PATCH 086/307] Alarms and Entities table widgets improvements. --- ui/src/app/api/subscription.js | 8 + ui/src/app/locale/locale.constant-en_US.json | 10 +- .../widget/lib/alarm-status-filter-panel.scss | 31 +++ .../lib/alarm-status-filter-panel.tpl.html | 28 +++ ui/src/app/widget/lib/alarms-table-widget.js | 190 ++++++++++++++++-- .../app/widget/lib/alarms-table-widget.scss | 30 +++ .../widget/lib/alarms-table-widget.tpl.html | 40 ++-- .../app/widget/lib/display-columns-panel.scss | 31 +++ .../widget/lib/display-columns-panel.tpl.html | 24 +++ .../app/widget/lib/entities-table-widget.js | 79 +++++++- .../app/widget/lib/entities-table-widget.scss | 30 +++ .../widget/lib/entities-table-widget.tpl.html | 27 ++- 12 files changed, 486 insertions(+), 42 deletions(-) create mode 100644 ui/src/app/widget/lib/alarm-status-filter-panel.scss create mode 100644 ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html create mode 100644 ui/src/app/widget/lib/display-columns-panel.scss create mode 100644 ui/src/app/widget/lib/display-columns-panel.tpl.html diff --git a/ui/src/app/api/subscription.js b/ui/src/app/api/subscription.js index 350b1ef0b7..a66c4b1efa 100644 --- a/ui/src/app/api/subscription.js +++ b/ui/src/app/api/subscription.js @@ -267,6 +267,14 @@ export default class Subscription { } else { this.startWatchingTimewindow(); } + registration = this.ctx.$scope.$watch(function () { + return subscription.alarmSearchStatus; + }, function (newAlarmSearchStatus, prevAlarmSearchStatus) { + if (!angular.equals(newAlarmSearchStatus, prevAlarmSearchStatus)) { + subscription.update(); + } + }, true); + this.registrations.push(registration); } initDataSubscription() { diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 6321d63f46..2caf01331b 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -133,8 +133,13 @@ "min-polling-interval-message": "At least 1 sec polling interval is allowed.", "aknowledge-alarms-title": "Acknowledge { count, plural, 1 {1 alarm} other {# alarms} }", "aknowledge-alarms-text": "Are you sure you want to acknowledge { count, plural, 1 {1 alarm} other {# alarms} }?", + "aknowledge-alarm-title": "Acknowledge Alarm", + "aknowledge-alarm-text": "Are you sure you want to acknowledge Alarm?", "clear-alarms-title": "Clear { count, plural, 1 {1 alarm} other {# alarms} }", - "clear-alarms-text": "Are you sure you want to clear { count, plural, 1 {1 alarm} other {# alarms} }?" + "clear-alarms-text": "Are you sure you want to clear { count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarm-title": "Clear Alarm", + "clear-alarm-text": "Are you sure you want to clear Alarm?", + "alarm-status-filter": "Alarm Status Filter" }, "alias": { "add": "Add alias", @@ -748,7 +753,8 @@ "entity-name": "Entity name", "details": "Entity details", "no-entities-prompt": "No entities found", - "no-data": "No data to display" + "no-data": "No data to display", + "columns-to-display": "Columns to Display" }, "event": { "event-type": "Event type", diff --git a/ui/src/app/widget/lib/alarm-status-filter-panel.scss b/ui/src/app/widget/lib/alarm-status-filter-panel.scss new file mode 100644 index 0000000000..4bf6eeef11 --- /dev/null +++ b/ui/src/app/widget/lib/alarm-status-filter-panel.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-alarm-status-filter-panel { + min-width: 300px; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + md-content { + overflow: hidden; + background-color: #fff; + } +} diff --git a/ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html b/ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html new file mode 100644 index 0000000000..45e8414ef8 --- /dev/null +++ b/ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html @@ -0,0 +1,28 @@ + + + + + + + {{ ('alarm.search-status.' + searchStatus) | translate }} + + + diff --git a/ui/src/app/widget/lib/alarms-table-widget.js b/ui/src/app/widget/lib/alarms-table-widget.js index 0696a7b873..6ae17e0459 100644 --- a/ui/src/app/widget/lib/alarms-table-widget.js +++ b/ui/src/app/widget/lib/alarms-table-widget.js @@ -14,11 +14,15 @@ * limitations under the License. */ import './alarms-table-widget.scss'; +import './display-columns-panel.scss'; +import './alarm-status-filter-panel.scss'; /* eslint-disable import/no-unresolved, import/default */ import alarmsTableWidgetTemplate from './alarms-table-widget.tpl.html'; import alarmDetailsDialogTemplate from '../../alarm/alarm-details-dialog.tpl.html'; +import displayColumnsPanelTemplate from './display-columns-panel.tpl.html'; +import alarmStatusFilterPanelTemplate from './alarm-status-filter-panel.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ @@ -45,7 +49,7 @@ function AlarmsTableWidget() { } /*@ngInject*/ -function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDialog, $document, $translate, $q, $timeout, alarmService, utils, types) { +function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDialog, $mdPanel, $document, $translate, $q, $timeout, alarmService, utils, types) { var vm = this; vm.stylesInfo = {}; @@ -60,6 +64,7 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.selectedAlarms = [] vm.alarmSource = null; + vm.alarmSearchStatus = null; vm.allAlarms = []; vm.currentAlarm = null; @@ -95,14 +100,20 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.onPaginate = onPaginate; vm.onRowClick = onRowClick; vm.onActionButtonClick = onActionButtonClick; + vm.actionEnabled = actionEnabled; vm.isCurrent = isCurrent; vm.openAlarmDetails = openAlarmDetails; vm.ackAlarms = ackAlarms; + vm.ackAlarm = ackAlarm; vm.clearAlarms = clearAlarms; + vm.clearAlarm = clearAlarm; vm.cellStyle = cellStyle; vm.cellContent = cellContent; + vm.editAlarmStatusFilter = editAlarmStatusFilter; + vm.editColumnsToDisplay = editColumnsToDisplay; + $scope.$watch('vm.ctx', function() { if (vm.ctx) { vm.settings = vm.ctx.settings; @@ -158,7 +169,41 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.ctx.widgetActions = [ vm.searchAction ]; - vm.actionCellDescriptors = vm.ctx.actionsApi.getActionDescriptors('actionCellButton'); + vm.displayDetails = angular.isDefined(vm.settings.displayDetails) ? vm.settings.displayDetails : true; + vm.allowAcknowledgment = angular.isDefined(vm.settings.allowAcknowledgment) ? vm.settings.allowAcknowledgment : true; + vm.allowClear = angular.isDefined(vm.settings.allowClear) ? vm.settings.allowClear : true; + + if (vm.displayDetails) { + vm.actionCellDescriptors.push( + { + displayName: $translate.instant('alarm.details'), + icon: 'more_horiz', + details: true + } + ); + } + + if (vm.allowAcknowledgment) { + vm.actionCellDescriptors.push( + { + displayName: $translate.instant('alarm.acknowledge'), + icon: 'done', + acknowledge: true + } + ); + } + + if (vm.allowClear) { + vm.actionCellDescriptors.push( + { + displayName: $translate.instant('alarm.clear'), + icon: 'clear', + clear: true + } + ); + } + + vm.actionCellDescriptors = vm.actionCellDescriptors.concat(vm.ctx.actionsApi.getActionDescriptors('actionCellButton')); if (vm.settings.alarmsTitle && vm.settings.alarmsTitle.length) { vm.alarmsTitle = utils.customTranslation(vm.settings.alarmsTitle, vm.settings.alarmsTitle); @@ -170,9 +215,6 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.enableSelection = angular.isDefined(vm.settings.enableSelection) ? vm.settings.enableSelection : true; vm.searchAction.show = angular.isDefined(vm.settings.enableSearch) ? vm.settings.enableSearch : true; - vm.displayDetails = angular.isDefined(vm.settings.displayDetails) ? vm.settings.displayDetails : true; - vm.allowAcknowledgment = angular.isDefined(vm.settings.allowAcknowledgment) ? vm.settings.allowAcknowledgment : true; - vm.allowClear = angular.isDefined(vm.settings.allowClear) ? vm.settings.allowClear : true; if (!vm.allowAcknowledgment && !vm.allowClear) { vm.enableSelection = false; } @@ -305,16 +347,35 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } function onActionButtonClick($event, alarm, actionDescriptor) { - if ($event) { - $event.stopPropagation(); + if (actionDescriptor.details) { + vm.openAlarmDetails($event, alarm); + } else if (actionDescriptor.acknowledge) { + vm.ackAlarm($event, alarm); + } else if (actionDescriptor.clear) { + vm.clearAlarm($event, alarm); + } else { + if ($event) { + $event.stopPropagation(); + } + var entityId; + var entityName; + if (alarm && alarm.originator) { + entityId = alarm.originator; + entityName = alarm.originatorName; + } + vm.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, {alarm: alarm}); } - var entityId; - var entityName; - if (alarm && alarm.originator) { - entityId = alarm.originator; - entityName = alarm.originatorName; + } + + function actionEnabled(alarm, actionDescriptor) { + if (actionDescriptor.acknowledge) { + return (alarm.status == types.alarmStatus.activeUnack || + alarm.status == types.alarmStatus.clearedUnack); + } else if (actionDescriptor.clear) { + return (alarm.status == types.alarmStatus.activeAck || + alarm.status == types.alarmStatus.activeUnack); } - vm.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, { alarm: alarm }); + return true; } function isCurrent(alarm) { @@ -387,6 +448,25 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } + function ackAlarm($event, alarm) { + if ($event) { + $event.stopPropagation(); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('alarm.aknowledge-alarm-title')) + .htmlContent($translate.instant('alarm.aknowledge-alarm-text')) + .ariaLabel($translate.instant('alarm.acknowledge')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + alarmService.ackAlarm(alarm.id.id).then(function () { + vm.selectedAlarms = []; + vm.subscription.update(); + }); + }); + } + function clearAlarms($event) { if ($event) { $event.stopPropagation(); @@ -420,6 +500,24 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } + function clearAlarm($event, alarm) { + if ($event) { + $event.stopPropagation(); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('alarm.clear-alarm-title')) + .htmlContent($translate.instant('alarm.clear-alarm-text')) + .ariaLabel($translate.instant('alarm.clear')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + alarmService.clearAlarm(alarm.id.id).then(function () { + vm.selectedAlarms = []; + vm.subscription.update(); + }); + }); + } function updateAlarms(preserveSelections) { if (!preserveSelections) { @@ -558,6 +656,54 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } + function editAlarmStatusFilter($event) { + var element = angular.element($event.target); + var position = $mdPanel.newPanelPosition() + .relativeTo(element) + .addPanelPosition($mdPanel.xPosition.ALIGN_END, $mdPanel.yPosition.BELOW); + var config = { + attachTo: angular.element($document[0].body), + controller: AlarmStatusFilterPanelController, + controllerAs: 'vm', + templateUrl: alarmStatusFilterPanelTemplate, + panelClass: 'tb-alarm-status-filter-panel', + position: position, + fullscreen: false, + locals: { + 'subscription': vm.subscription + }, + openFrom: $event, + clickOutsideToClose: true, + escapeToClose: true, + focusOnOpen: false + }; + $mdPanel.open(config); + } + + function editColumnsToDisplay($event) { + var element = angular.element($event.target); + var position = $mdPanel.newPanelPosition() + .relativeTo(element) + .addPanelPosition($mdPanel.xPosition.ALIGN_END, $mdPanel.yPosition.BELOW); + var config = { + attachTo: angular.element($document[0].body), + controller: DisplayColumnsPanelController, + controllerAs: 'vm', + templateUrl: displayColumnsPanelTemplate, + panelClass: 'tb-display-columns-panel', + position: position, + fullscreen: false, + locals: { + 'columns': vm.alarmSource.dataKeys + }, + openFrom: $event, + clickOutsideToClose: true, + escapeToClose: true, + focusOnOpen: false + }; + $mdPanel.open(config); + } + function updateAlarmSource() { vm.ctx.widgetTitle = utils.createLabelFromDatasource(vm.alarmSource, vm.alarmsTitle); @@ -570,6 +716,7 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia var dataKey = vm.alarmSource.dataKeys[d]; dataKey.title = utils.customTranslation(dataKey.label, dataKey.label); + dataKey.display = true; var keySettings = dataKey.settings; @@ -618,4 +765,19 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } -} \ No newline at end of file +} + +/*@ngInject*/ +function DisplayColumnsPanelController(columns) { //eslint-disable-line + + var vm = this; + vm.columns = columns; +} + +/*@ngInject*/ +function AlarmStatusFilterPanelController(subscription, types) { //eslint-disable-line + + var vm = this; + vm.types = types; + vm.subscription = subscription; +} diff --git a/ui/src/app/widget/lib/alarms-table-widget.scss b/ui/src/app/widget/lib/alarms-table-widget.scss index 2922996700..a03152360f 100644 --- a/ui/src/app/widget/lib/alarms-table-widget.scss +++ b/ui/src/app/widget/lib/alarms-table-widget.scss @@ -44,6 +44,27 @@ &.tb-data-table { table.md-table, table.md-table.md-row-select { + th.md-column { + &.tb-action-cell { + .md-button { + /* stylelint-disable-next-line selector-max-class */ + &.md-icon-button { + width: 36px; + height: 36px; + padding: 6px; + margin: 0; + /* stylelint-disable-next-line selector-max-class */ + md-icon { + width: 24px; + height: 24px; + font-size: 24px !important; + line-height: 24px !important; + } + } + } + } + } + tbody { tr { td { @@ -51,6 +72,15 @@ width: 36px; min-width: 36px; max-width: 36px; + + .md-button[disabled] { + &.md-icon-button { + /* stylelint-disable-next-line selector-max-class */ + md-icon { + color: rgba(0, 0, 0, .38); + } + } + } } } } diff --git a/ui/src/app/widget/lib/alarms-table-widget.tpl.html b/ui/src/app/widget/lib/alarms-table-widget.tpl.html index 8480058bc2..39843e19ea 100644 --- a/ui/src/app/widget/lib/alarms-table-widget.tpl.html +++ b/ui/src/app/widget/lib/alarms-table-widget.tpl.html @@ -62,33 +62,45 @@ - - - + + - - - - - - - + + - - - - \");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n self.ctx.valueCells[i].html(value);\n }\n } \n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasoirceTitleFontSize+'px');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size', valueFontSize+'px');\n self.ctx.valueCells[i].css('height', valueFontSize*2.5+'px');\n self.ctx.valueCells[i].css('padding', '0px ' + valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size', labelFontSize+'px');\n self.ctx.labelCells[i].css('height', labelFontSize*2.5+'px');\n self.ctx.labelCells[i].css('padding', '0px ' + labelFontSize + 'px');\n } \n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
{{ key.title }}  {{ key.title }} + + filter_list + + + {{'alarm.alarm-status-filter' | translate}} + + + + view_column + + + {{'entity.columns-to-display' | translate}} + + +
- - more_horiz - - {{ 'alarm.details' | translate }} - - - {{actionDescriptor.icon}} diff --git a/ui/src/app/widget/lib/display-columns-panel.scss b/ui/src/app/widget/lib/display-columns-panel.scss new file mode 100644 index 0000000000..2e518cba96 --- /dev/null +++ b/ui/src/app/widget/lib/display-columns-panel.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-display-columns-panel { + min-width: 300px; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + md-content { + overflow: hidden; + background-color: #fff; + } +} diff --git a/ui/src/app/widget/lib/display-columns-panel.tpl.html b/ui/src/app/widget/lib/display-columns-panel.tpl.html new file mode 100644 index 0000000000..42e2471603 --- /dev/null +++ b/ui/src/app/widget/lib/display-columns-panel.tpl.html @@ -0,0 +1,24 @@ + + + + + {{ column.title }} + + diff --git a/ui/src/app/widget/lib/entities-table-widget.js b/ui/src/app/widget/lib/entities-table-widget.js index d0b629d290..620f313749 100644 --- a/ui/src/app/widget/lib/entities-table-widget.js +++ b/ui/src/app/widget/lib/entities-table-widget.js @@ -14,11 +14,13 @@ * limitations under the License. */ import './entities-table-widget.scss'; +import './display-columns-panel.scss'; /* eslint-disable import/no-unresolved, import/default */ import entitiesTableWidgetTemplate from './entities-table-widget.tpl.html'; //import entityDetailsDialogTemplate from './entitiy-details-dialog.tpl.html'; +import displayColumnsPanelTemplate from './display-columns-panel.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ @@ -45,7 +47,7 @@ function EntitiesTableWidget() { } /*@ngInject*/ -function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $translate, $timeout, utils, types) { +function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $mdPanel, $document, $translate, $timeout, utils, types) { var vm = this; vm.stylesInfo = {}; @@ -98,6 +100,8 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra vm.cellStyle = cellStyle; vm.cellContent = cellContent; + vm.editColumnsToDisplay = editColumnsToDisplay; + $scope.$watch('vm.ctx', function() { if (vm.ctx && vm.ctx.defaultSubscription) { vm.settings = vm.ctx.settings; @@ -414,12 +418,37 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra } } + function editColumnsToDisplay($event) { + var element = angular.element($event.target); + var position = $mdPanel.newPanelPosition() + .relativeTo(element) + .addPanelPosition($mdPanel.xPosition.ALIGN_END, $mdPanel.yPosition.BELOW); + var config = { + attachTo: angular.element($document[0].body), + controller: DisplayColumnsPanelController, + controllerAs: 'vm', + templateUrl: displayColumnsPanelTemplate, + panelClass: 'tb-display-columns-panel', + position: position, + fullscreen: false, + locals: { + 'columns': vm.columns + }, + openFrom: $event, + clickOutsideToClose: true, + escapeToClose: true, + focusOnOpen: false + }; + $mdPanel.open(config); + } + function updateDatasources() { vm.stylesInfo = {}; vm.contentsInfo = {}; vm.columnWidth = {}; vm.dataKeys = []; + vm.columns = []; vm.allEntities = []; var datasource; @@ -429,6 +458,42 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra vm.ctx.widgetTitle = utils.createLabelFromDatasource(datasource, vm.entitiesTitle); + if (vm.displayEntityName) { + vm.columns.push( + { + name: 'entityName', + label: 'entityName', + title: vm.entityNameColumnTitle, + display: true + } + ); + vm.contentsInfo['entityName'] = { + useCellContentFunction: false + }; + vm.stylesInfo['entityName'] = { + useCellStyleFunction: false + }; + vm.columnWidth['entityName'] = '0px'; + } + + if (vm.displayEntityType) { + vm.columns.push( + { + name: 'entityType', + label: 'entityType', + title: $translate.instant('entity.entity-type'), + display: true + } + ); + vm.contentsInfo['entityType'] = { + useCellContentFunction: false + }; + vm.stylesInfo['entityType'] = { + useCellStyleFunction: false + }; + vm.columnWidth['entityType'] = '0px'; + } + for (var d = 0; d < datasource.dataKeys.length; d++ ) { dataKey = angular.copy(datasource.dataKeys[d]); if (dataKey.type == types.dataKeyType.function) { @@ -482,6 +547,9 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra var columnWidth = angular.isDefined(keySettings.columnWidth) ? keySettings.columnWidth : '0px'; vm.columnWidth[dataKey.label] = columnWidth; + + dataKey.display = true; + vm.columns.push(dataKey); } for (var i=0;i
{{vm.entityNameColumnTitle}}entity.entity-type{{ key.title }} {{ column.title }} + + view_column + + + {{'entity.columns-to-display' | translate}} + + +
{{entity.entityName}}{{entity.entityType}} + From 63395fc8e5c98c70b951623db7ad29939301b2b3 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 12 Sep 2018 12:56:35 +0300 Subject: [PATCH 087/307] Added attribute copying on entity view creation --- .../dao/entityview/EntityViewServiceImpl.java | 49 ++++++++++++++++++- .../attribute/attribute-table.directive.js | 2 +- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index d2a902b1b2..f5afad6ec9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.entityview; import com.google.common.base.Function; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; @@ -28,6 +29,7 @@ import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; @@ -40,10 +42,12 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.exception.DataValidationException; @@ -53,6 +57,7 @@ import org.thingsboard.server.dao.tenant.TenantDao; import javax.annotation.Nullable; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -86,6 +91,9 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CustomerDao customerDao; + @Autowired + private AttributesService attributesService; + @Autowired private CacheManager cacheManager; @@ -110,7 +118,46 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); entityViewValidator.validate(entityView); - return entityViewDao.save(entityView); + EntityView savedEntityView = entityViewDao.save(entityView); + copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs()); + copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs()); + copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh()); + return savedEntityView; + } + + private void copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { + if (!keys.isEmpty()) { + ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); + Futures.addCallback(getAttrFuture, new FutureCallback>() { + @Override + public void onSuccess(@Nullable List attributeKvEntries) { + if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { + List filteredAttributes = + attributeKvEntries.stream() + .filter(attributeKvEntry -> { + if (entityView.getStartTs() == 0 && entityView.getEndTs() == 0) { + return true; + } + if (entityView.getEndTs() == 0 && entityView.getStartTs() < attributeKvEntry.getLastUpdateTs()) { + return true; + } + if (entityView.getStartTs() == 0 && entityView.getEndTs() > attributeKvEntry.getLastUpdateTs()) { + return true; + } + return entityView.getStartTs() < attributeKvEntry.getLastUpdateTs() + && entityView.getEndTs() > attributeKvEntry.getLastUpdateTs(); + }).collect(Collectors.toList()); + attributesService.save(entityView.getId(), scope, filteredAttributes); + } + } + + @Override + public void onFailure(Throwable throwable) { + log.error("Failed to fetch [{}] attributes [{}] for [{}] entity [{}]", + scope, keys, entityView.getEntityId().getEntityType().name(), entityView.getEntityId().getId().toString(), throwable); + } + }); + } } @Override diff --git a/ui/src/app/entity/attribute/attribute-table.directive.js b/ui/src/app/entity/attribute/attribute-table.directive.js index 00618541ce..3a7c164885 100644 --- a/ui/src/app/entity/attribute/attribute-table.directive.js +++ b/ui/src/app/entity/attribute/attribute-table.directive.js @@ -54,7 +54,7 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS scope.entityType = attrs.entityType; - if (scope.entityType === types.entityType.device) { + if (scope.entityType === types.entityType.device || scope.entityType === types.entityType.entityView) { scope.attributeScopes = types.attributesScope; scope.attributeScopeSelectionReadonly = false; } else { From b7d21a66a16fc150f873981f44c712ac8d920cd8 Mon Sep 17 00:00:00 2001 From: Geremia Taglialatela Date: Wed, 12 Sep 2018 13:23:33 +0200 Subject: [PATCH 088/307] Update postcss-loader to 3.0.0 Also adds browserslist and postcss properties --- ui/package.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ui/package.json b/ui/package.json index 498de06d4f..ca4b6272dd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -119,7 +119,7 @@ "ng-annotate-loader": "^0.1.1", "ngtemplate-loader": "^1.3.1", "node-sass": "^4.5.3", - "postcss-loader": "^0.13.0", + "postcss-loader": "^3.0.0", "raw-loader": "^0.5.1", "react-hot-loader": "^3.0.0-beta.6", "sass-loader": "^4.0.2", @@ -145,5 +145,19 @@ "node_modules", "target" ] + }, + "browserslist": [ + "> 0.5%", + "last 2 versions", + "Firefox ESR", + "not ie <= 10", + "not ie_mob <= 10", + "not bb <= 10", + "not op_mob <= 12.1" + ], + "postcss": { + "plugins": { + "autoprefixer": true + } } } From 28da7fcb56b595ee8f5f981aa26d530c0f28a116 Mon Sep 17 00:00:00 2001 From: Geremia Taglialatela Date: Wed, 12 Sep 2018 13:23:42 +0200 Subject: [PATCH 089/307] Update stylelint configuration --- ui/.stylelintrc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/.stylelintrc b/ui/.stylelintrc index c145c8b0cb..e878729b53 100644 --- a/ui/.stylelintrc +++ b/ui/.stylelintrc @@ -251,7 +251,7 @@ "fill", "stroke" ], - "property-no-vendor-prefix": null, + "property-no-vendor-prefix": true, "rule-empty-line-before": ["always", { "except": ["first-nested"], "ignore": ["after-comment"] @@ -272,7 +272,7 @@ "selector-max-type": 5, "selector-max-universal": 1, "selector-no-qualifying-type": null, - "selector-no-vendor-prefix": null, + "selector-no-vendor-prefix": true, "selector-type-no-unknown": [true, { "ignoreTypes": [ "/^md-/", @@ -287,6 +287,6 @@ "value-list-comma-newline-after": "always-multi-line", "value-list-comma-newline-before": "never-multi-line", "value-list-comma-space-after": "always-single-line", - "value-no-vendor-prefix": null + "value-no-vendor-prefix": true } } From 813f7e1c64eef914fdc64d1469eabb959577771c Mon Sep 17 00:00:00 2001 From: Geremia Taglialatela Date: Wed, 12 Sep 2018 13:26:09 +0200 Subject: [PATCH 090/307] Remove vendor prefixes --- ui/src/app/rulechain/rulechain.scss | 16 +--------------- ui/src/app/widget/lib/rpc/knob.scss | 2 -- ui/src/app/widget/lib/rpc/led-indicator.scss | 8 -------- ui/src/app/widget/lib/rpc/round-switch.scss | 17 ----------------- ui/src/scss/main.scss | 8 +------- 5 files changed, 2 insertions(+), 49 deletions(-) diff --git a/ui/src/app/rulechain/rulechain.scss b/ui/src/app/rulechain/rulechain.scss index 8f773005fa..d6d264d5ef 100644 --- a/ui/src/app/rulechain/rulechain.scss +++ b/ui/src/app/rulechain/rulechain.scss @@ -84,9 +84,6 @@ .tb-panel-title { min-width: 150px; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; user-select: none; } @@ -163,10 +160,6 @@ .fc-canvas { min-width: 100%; min-height: 100%; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; user-select: none; outline: none; -webkit-touch-callout: none; @@ -441,13 +434,7 @@ } .fc-noselect { - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently - supported by Chrome and Opera */ + user-select: none; } .fc-edge-label { @@ -495,7 +482,6 @@ font-weight: 600; text-align: center; white-space: nowrap; - -webkit-transform: translate(-50%, -50%); transform: translate(-50%, -50%); span { diff --git a/ui/src/app/widget/lib/rpc/knob.scss b/ui/src/app/widget/lib/rpc/knob.scss index bf2d3e019f..722275fc00 100644 --- a/ui/src/app/widget/lib/rpc/knob.scss +++ b/ui/src/app/widget/lib/rpc/knob.scss @@ -37,8 +37,6 @@ $background-color: #e6e7e8 !default; position: relative; &[draggable] { - -moz-user-select: none; - -webkit-user-select: none; user-select: none; } diff --git a/ui/src/app/widget/lib/rpc/led-indicator.scss b/ui/src/app/widget/lib/rpc/led-indicator.scss index b1da5c6a40..3083e7274c 100644 --- a/ui/src/app/widget/lib/rpc/led-indicator.scss +++ b/ui/src/app/widget/lib/rpc/led-indicator.scss @@ -60,19 +60,11 @@ $background-color: #e6e7e8 !default; .led { position: relative; cursor: pointer; - background-image: -owg-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); - background-image: -webkit-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); - background-image: -moz-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); - background-image: -o-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); background-image: radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); border-radius: 50%; transition: background-color .5s, box-shadow .5s; &.disabled { - background-image: -owg-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); - background-image: -webkit-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); - background-image: -moz-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); - background-image: -o-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); background-image: radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); } } diff --git a/ui/src/app/widget/lib/rpc/round-switch.scss b/ui/src/app/widget/lib/rpc/round-switch.scss index 2e37f632a8..342c995313 100644 --- a/ui/src/app/widget/lib/rpc/round-switch.scss +++ b/ui/src/app/widget/lib/rpc/round-switch.scss @@ -69,12 +69,7 @@ $background-color: #e6e7e8 !default; color: #424242; cursor: pointer; - -pie-background: -pie-linear-gradient(270deg, #bbb, #ddd); background: #ddd; - background: -owg-linear-gradient(270deg, #bbb, #ddd); - background: -webkit-linear-gradient(270deg, #bbb, #ddd); - background: -moz-linear-gradient(270deg, #bbb, #ddd); - background: -o-linear-gradient(270deg, #bbb, #ddd); background: linear-gradient(180deg, #bbb, #ddd); border-radius: 130px; @@ -141,10 +136,6 @@ $background-color: #e6e7e8 !default; padding: 4px 4px; cursor: pointer; background-color: #888787; - background-image: -owg-linear-gradient(0deg, transparent 30%, transparent 70%), -owg-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); - background-image: -webkit-linear-gradient(0deg, transparent 30%, transparent 70%), -webkit-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); - background-image: -moz-linear-gradient(0deg, transparent 30%, transparent 70%), -moz-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); - background-image: -o-linear-gradient(0deg, transparent 30%, transparent 70%), -o-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); background-image: linear-gradient(-90deg, transparent 30%, transparent 70%), linear-gradient(0deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); border-radius: 105px; @@ -181,10 +172,6 @@ $background-color: #e6e7e8 !default; input:checked + .back .but{ margin-top: 20px; background: #dcdcdc; - background-image: -owg-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); - background-image: -webkit-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); - background-image: -moz-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); - background-image: -o-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); background-image: radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); border-radius: 400px 400px 400px 400px / 300px 300px 400px 400px; @@ -199,10 +186,6 @@ $background-color: #e6e7e8 !default; input:checked + .back{ padding: 2px 4px; - background-image: -owg-linear-gradient(90deg, #868686 30%, transparent 70%), -owg-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - background-image: -webkit-linear-gradient(90deg, #868686 30%, transparent 70%), -webkit-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - background-image: -moz-linear-gradient(90deg, #868686 30%, transparent 70%), -moz-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - background-image: -o-linear-gradient(90deg, #868686 30%, transparent 70%), -o-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); background-image: linear-gradient(0deg, #868686 30%, transparent 70%), linear-gradient(90deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); @include box-shadow(30px 30px 30px -20px rgba(49, 49, 49, .1), diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss index 27c2d3b245..cd699b2563 100644 --- a/ui/src/scss/main.scss +++ b/ui/src/scss/main.scss @@ -258,13 +258,7 @@ label { } .tb-noselect { - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently - supported by Chrome and Opera */ + user-select: none; } .tb-readonly-label { From dda10775a34e45489d487c9e2d09d0b123d0ccf0 Mon Sep 17 00:00:00 2001 From: Geremia Taglialatela Date: Wed, 12 Sep 2018 13:27:49 +0200 Subject: [PATCH 091/307] Disable stylelint to keep some vendor prefix --- ui/src/app/dashboard/dashboard-card.scss | 4 ++-- ui/src/scss/main.scss | 2 +- ui/src/scss/mixins.scss | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/src/app/dashboard/dashboard-card.scss b/ui/src/app/dashboard/dashboard-card.scss index 8e6626c728..111d67a4de 100644 --- a/ui/src/app/dashboard/dashboard-card.scss +++ b/ui/src/app/dashboard/dashboard-card.scss @@ -16,12 +16,12 @@ .tb-dashboard-assigned-customers { display: block; - display: -webkit-box; + display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */ height: 34px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + -webkit-box-orient: vertical; /* stylelint-disable-line property-no-vendor-prefix */ } diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss index cd699b2563..a32f76b6b1 100644 --- a/ui/src/scss/main.scss +++ b/ui/src/scss/main.scss @@ -42,7 +42,7 @@ textarea { word-wrap: normal; white-space: nowrap; direction: ltr; - -webkit-font-feature-settings: "liga"; + -webkit-font-feature-settings: "liga"; /* stylelint-disable-line property-no-vendor-prefix */ } a { diff --git a/ui/src/scss/mixins.scss b/ui/src/scss/mixins.scss index a7545386aa..4d2c6045d6 100644 --- a/ui/src/scss/mixins.scss +++ b/ui/src/scss/mixins.scss @@ -15,6 +15,7 @@ */ @import "~compass-sass-mixins/lib/compass"; +/* stylelint-disable selector-no-vendor-prefix */ @mixin input-placeholder { // replaces compass/css/user-interface/input-placeholder() @@ -36,6 +37,7 @@ @content; } } +/* stylelint-enable selector-no-vendor-prefix */ @mixin line-clamp($numLines: 1, $lineHeight: 1.412) { position: relative; From 0b379d6b6aacd6fcaa4bb08e6347b9b84561825d Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 12 Sep 2018 15:51:18 +0300 Subject: [PATCH 092/307] Fixes for telemetry service subscription --- .../DefaultTelemetrySubscriptionService.java | 7 ++++ .../server/common/data/EntityView.java | 4 +-- .../dao/entityview/EntityViewServiceImpl.java | 23 +++--------- .../dao/model/nosql/EntityViewEntity.java | 8 ++--- .../dao/model/sql/EntityViewEntity.java | 8 ++--- .../dao/timeseries/BaseTimeseriesService.java | 6 ++-- .../entity-view/entity-view-fieldset.tpl.html | 36 ++++++++++--------- .../app/entity-view/entity-view.directive.js | 32 +++++++++-------- ui/src/app/locale/locale.constant-en_US.json | 5 +-- 9 files changed, 65 insertions(+), 64 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index bdf8e747d4..3ded8827fb 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -105,6 +105,9 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Autowired private ClusterRpcService rpcService; + @Autowired + private EntityViewService entityViewService; + @Autowired @Lazy private DeviceStateService stateService; @@ -137,6 +140,10 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Override public void addLocalWsSubscription(String sessionId, EntityId entityId, SubscriptionState sub) { + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + EntityView entityView = entityViewService.findEntityViewById(new EntityViewId(entityId.getId())); + entityId = entityView.getEntityId(); + } Optional server = routingService.resolveById(entityId); Subscription subscription; if (server.isPresent()) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 49dd20959f..b86c605464 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -39,8 +39,8 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo private CustomerId customerId; private String name; private TelemetryEntityView keys; - private long startTs; - private long endTs; + private long startTimeMs; + private long endTimeMs; public EntityView() { super(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index f5afad6ec9..d86eabb510 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -15,30 +15,21 @@ */ package org.thingsboard.server.dao.entityview; -import com.google.common.base.Function; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.CachePut; -import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; @@ -58,13 +49,9 @@ import org.thingsboard.server.dao.tenant.TenantDao; import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; -import static org.thingsboard.server.common.data.CacheConstants.DEVICE_CACHE; -import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -135,17 +122,17 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti List filteredAttributes = attributeKvEntries.stream() .filter(attributeKvEntry -> { - if (entityView.getStartTs() == 0 && entityView.getEndTs() == 0) { + if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() == 0) { return true; } - if (entityView.getEndTs() == 0 && entityView.getStartTs() < attributeKvEntry.getLastUpdateTs()) { + if (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs()) { return true; } - if (entityView.getStartTs() == 0 && entityView.getEndTs() > attributeKvEntry.getLastUpdateTs()) { + if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs()) { return true; } - return entityView.getStartTs() < attributeKvEntry.getLastUpdateTs() - && entityView.getEndTs() > attributeKvEntry.getLastUpdateTs(); + return entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs() + && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs(); }).collect(Collectors.toList()); attributesService.save(entityView.getId(), scope, filteredAttributes); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index 0545448e55..79dd497173 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -114,8 +114,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - this.startTs = entityView.getStartTs(); - this.endTs = entityView.getEndTs(); + this.startTs = entityView.getStartTimeMs(); + this.endTs = entityView.getEndTimeMs(); this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -144,8 +144,8 @@ public class EntityViewEntity implements SearchTextEntity { } catch (IOException e) { e.printStackTrace(); } - entityView.setStartTs(startTs); - entityView.setEndTs(endTs); + entityView.setStartTimeMs(startTs); + entityView.setEndTimeMs(endTs); entityView.setAdditionalInfo(additionalInfo); return entityView; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index 9492f191db..96d187cfb6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -105,8 +105,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - this.startTs = entityView.getStartTs() != 0L ? entityView.getStartTs() : 0L; - this.endTs = entityView.getEndTs() != 0L ? entityView.getEndTs() : 0L; + this.startTs = entityView.getStartTimeMs(); + this.endTs = entityView.getEndTimeMs(); this.searchText = entityView.getSearchText(); this.additionalInfo = entityView.getAdditionalInfo(); } @@ -141,8 +141,8 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc } catch (IOException e) { e.printStackTrace(); } - entityView.setStartTs(startTs); - entityView.setEndTs(endTs); + entityView.setStartTimeMs(startTs); + entityView.setEndTimeMs(endTs); entityView.setAdditionalInfo(additionalInfo); return entityView; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 629f93a19b..264cdc8f8d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -84,7 +84,7 @@ public class BaseTimeseriesService implements TimeseriesService { } List queries = filteredKeys.stream() - .map(key -> new BaseReadTsKvQuery(key, entityView.getStartTs(), entityView.getEndTs(), 1, "ASC")) + .map(key -> new BaseReadTsKvQuery(key, entityView.getStartTimeMs(), entityView.getEndTimeMs(), 1, "ASC")) .collect(Collectors.toList()); return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, queries)); @@ -133,8 +133,8 @@ public class BaseTimeseriesService implements TimeseriesService { private List updateQueriesForEntityView(EntityView entityView, List queries) { return queries.stream().map(query -> { - long startTs = entityView.getStartTs() == 0 ? query.getStartTs() : entityView.getStartTs(); - long endTs = entityView.getEndTs() == 0 ? query.getEndTs() : entityView.getEndTs(); + long startTs = entityView.getStartTimeMs() == 0 ? query.getStartTs() : entityView.getStartTimeMs(); + long endTs = entityView.getEndTimeMs() == 0 ? query.getEndTs() : entityView.getEndTimeMs(); return startTs <= query.getStartTs() && endTs >= query.getEndTs() ? query : new BaseReadTsKvQuery(query.getKey(), startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index 29287f9bce..b9808e9cea 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -95,23 +95,25 @@ md-separator-keys="separatorKeys"> -
- - -
-
- - +
+
+ + +
+
+ + +
diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index c4a35d8cc5..42b3a120fe 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -51,8 +51,12 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t scope.isPublic = false; scope.assignedCustomer = null; } - scope.startTs = new Date(scope.entityView.startTs); - scope.endTs = new Date(scope.entityView.endTs); + if (scope.entityView.startTimeMs > 0) { + scope.startTimeMs = new Date(scope.entityView.startTimeMs); + } + if (scope.entityView.endTimeTs > 0) { + scope.endTimeTs = new Date(scope.entityView.endTimeTs); + } if (!scope.entityView.keys) { scope.entityView.keys = {}; scope.entityView.keys.timeseries = []; @@ -65,32 +69,32 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t }); - scope.$watch('startTs', function (newDate) { + scope.$watch('startTimeMs', function (newDate) { if (newDate) { - if (newDate.getTime() > scope.maxStartTs) { - scope.startTs = angular.copy(scope.maxStartTs); + if (newDate.getTime() > scope.maxStartTimeMs) { + scope.startTimeMs = angular.copy(scope.maxStartTimeMs); } updateMinMaxDates(); } }); - scope.$watch('endTs', function (newDate) { + scope.$watch('endTimeTs', function (newDate) { if (newDate) { - if (newDate.getTime() < scope.minEndTs) { - scope.endTs = angular.copy(scope.minEndTs); + if (newDate.getTime() < scope.minEndTimeTs) { + scope.endTimeTs = angular.copy(scope.minEndTimeTs); } updateMinMaxDates(); } }); function updateMinMaxDates() { - if (scope.endTs) { - scope.maxStartTs = angular.copy(new Date(scope.endTs.getTime())); - scope.entityView.endTs = scope.endTs.getTime(); + if (scope.endTimeTs) { + scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeTs.getTime())); + scope.entityView.endTimeTs = scope.endTimeTs.getTime(); } - if (scope.startTs) { - scope.minEndTs = angular.copy(new Date(scope.startTs.getTime())); - scope.entityView.startTs = scope.startTs.getTime(); + if (scope.startTimeMs) { + scope.minEndTimeTs = angular.copy(new Date(scope.startTimeMs.getTime())); + scope.entityView.startTimeMs = scope.startTimeMs.getTime(); } } diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 4018c3ac2d..eef7d89056 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -827,8 +827,9 @@ "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", "select-entity-view": "Select entity view", "make-public": "Make entity view public", - "start-ts": "Start ts", - "end-ts": "End ts", + "start-ts": "Start time", + "end-ts": "End time", + "date-limits": "Date limits", "client-attributes": "Client attributes", "shared-attributes": "Shared attributes", "server-attributes": "Server attributes", From 36ce6b8b01f0868dba5af8c57342fc3b529a46ee Mon Sep 17 00:00:00 2001 From: Geremia Taglialatela Date: Wed, 12 Sep 2018 14:35:54 +0200 Subject: [PATCH 093/307] Use standard css instead of compass mixins Autoprefixer will take care to add vendor prefixes if necessary --- ui/src/app/components/dashboard.scss | 4 +- ui/src/app/components/grid.scss | 4 +- ui/src/app/components/menu-link.scss | 6 +- ui/src/app/components/react/json-form.scss | 11 ++- ui/src/app/components/side-menu.scss | 2 +- ui/src/app/dashboard/dashboard-toolbar.scss | 4 +- ui/src/app/dashboard/dashboard.scss | 8 +- ui/src/app/layout/home.scss | 2 +- .../rulechain/script/node-script-test.scss | 2 +- ui/src/app/widget/lib/rpc/round-switch.scss | 78 ++++++++++--------- ui/src/app/widget/widget-editor.scss | 2 +- ui/src/scss/animations.scss | 16 ++-- ui/src/scss/main.scss | 16 ++-- 13 files changed, 79 insertions(+), 76 deletions(-) diff --git a/ui/src/app/components/dashboard.scss b/ui/src/app/components/dashboard.scss index e0a9afd54e..b4a696a85c 100644 --- a/ui/src/app/components/dashboard.scss +++ b/ui/src/app/components/dashboard.scss @@ -22,7 +22,7 @@ div.tb-widget { overflow: hidden; outline: none; - @include transition(all .2s ease-in-out); + transition: all .2s ease-in-out; .tb-widget-title { max-height: 60px; @@ -99,7 +99,7 @@ md-content.tb-dashboard-content { outline: none; .gridster-item { - @include transition(none); + transition: none; } } diff --git a/ui/src/app/components/grid.scss b/ui/src/app/components/grid.scss index 43138481a9..50e110bb0c 100644 --- a/ui/src/app/components/grid.scss +++ b/ui/src/app/components/grid.scss @@ -20,7 +20,7 @@ } .tb-card-item { - @include transition(all .2s ease-in-out); + transition: all .2s ease-in-out; md-card-content { max-height: 53px; @@ -46,7 +46,7 @@ .tb-current-item { opacity: .5; - @include transform(scale(1.05)); + transform: scale(1.05); } #tb-vertical-container { diff --git a/ui/src/app/components/menu-link.scss b/ui/src/app/components/menu-link.scss index 299797ce6b..be274d30df 100644 --- a/ui/src/app/components/menu-link.scss +++ b/ui/src/app/components/menu-link.scss @@ -16,7 +16,7 @@ @import "~compass-sass-mixins/lib/compass"; .md-button-toggle .md-toggle-icon.tb-toggled { - @include transform(rotateZ(180deg)); + transform: rotateZ(180deg); } .tb-menu-toggle-list.ng-hide { @@ -28,7 +28,7 @@ z-index: 1; overflow: hidden; - @include transition(.75s cubic-bezier(.35, 0, .25, 1)); + transition: .75s cubic-bezier(.35, 0, .25, 1); - @include transition-property(height); + transition-property: height; } diff --git a/ui/src/app/components/react/json-form.scss b/ui/src/app/components/react/json-form.scss index cca9d07a07..d324abef93 100644 --- a/ui/src/app/components/react/json-form.scss +++ b/ui/src/app/components/react/json-form.scss @@ -28,7 +28,7 @@ $input-label-float-scale: .75 !default; line-height: 12px; color: rgb(244, 67, 54); - @include transition(all 450ms cubic-bezier(.23, 1, .32, 1) 0ms); + transition: all 450ms cubic-bezier(.23, 1, .32, 1) 0ms; } .tb-container { @@ -77,13 +77,12 @@ label.tb-label { bottom: 100%; left: 0; color: rgba(0, 0, 0, .54); - transform-origin: left top; - -webkit-font-smoothing: antialiased; - @include transform(translate3d(0, $input-label-float-offset, 0) scale($input-label-float-scale)); + transition: transform $swift-ease-out-timing-function $swift-ease-out-duration, width $swift-ease-out-timing-function $swift-ease-out-duration; - @include transition(transform $swift-ease-out-timing-function $swift-ease-out-duration, - width $swift-ease-out-timing-function $swift-ease-out-duration); + transform: translate3d(0, $input-label-float-offset, 0) scale($input-label-float-scale); + transform-origin: left top; + -webkit-font-smoothing: antialiased; &.tb-focused { color: rgb(96, 125, 139); diff --git a/ui/src/app/components/side-menu.scss b/ui/src/app/components/side-menu.scss index 46da8db448..f6efcb963b 100644 --- a/ui/src/app/components/side-menu.scss +++ b/ui/src/app/components/side-menu.scss @@ -53,7 +53,7 @@ margin: auto 0 auto auto; background-size: 100% auto; - @include transition(transform .3s, ease-in-out); + transition: transform .3s, ease-in-out; } .tb-side-menu .md-button { diff --git a/ui/src/app/dashboard/dashboard-toolbar.scss b/ui/src/app/dashboard/dashboard-toolbar.scss index 80fd876ad3..c2476341e8 100644 --- a/ui/src/app/dashboard/dashboard-toolbar.scss +++ b/ui/src/app/dashboard/dashboard-toolbar.scss @@ -31,7 +31,7 @@ tb-dashboard-toolbar { &.md-fab { opacity: 1; - @include transition(opacity .3s cubic-bezier(.55,0,.55,.2)); + transition: opacity .3s cubic-bezier(.55, 0, .55, .2); .md-fab-toolbar-background { background-color: $primary-default !important; @@ -50,7 +50,7 @@ tb-dashboard-toolbar { line-height: 36px; opacity: .5; - @include transition(opacity .3s cubic-bezier(.55,0,.55,.2) .2s); + transition: opacity .3s cubic-bezier(.55, 0, .55, .2) .2s; md-icon { position: absolute; diff --git a/ui/src/app/dashboard/dashboard.scss b/ui/src/app/dashboard/dashboard.scss index 00a0653de7..8ba4401e7c 100644 --- a/ui/src/app/dashboard/dashboard.scss +++ b/ui/src/app/dashboard/dashboard.scss @@ -75,13 +75,13 @@ section.tb-dashboard-toolbar { &.tb-dashboard-toolbar-opened { right: 0; - // @include transition(right .3s cubic-bezier(.55,0,.55,.2)); + // transition: right .3s cubic-bezier(.55, 0, .55, .2); } &.tb-dashboard-toolbar-closed { right: 18px; - @include transition(right .3s cubic-bezier(.55,0,.55,.2) .2s); + transition: right .3s cubic-bezier(.55, 0, .55, .2) .2s; } } @@ -102,14 +102,14 @@ section.tb-dashboard-toolbar { margin-top: $toolbar-height; } - @include transition(margin-top .3s cubic-bezier(.55,0,.55,.2)); + transition: margin-top .3s cubic-bezier(.55, 0, .55, .2); } } &.tb-dashboard-toolbar-closed { margin-top: 0; - @include transition(margin-top .3s cubic-bezier(.55,0,.55,.2) .2s); + transition: margin-top .3s cubic-bezier(.55, 0, .55, .2) .2s; } .tb-dashboard-layouts { diff --git a/ui/src/app/layout/home.scss b/ui/src/app/layout/home.scss index 48c1553877..43b83297a9 100644 --- a/ui/src/app/layout/home.scss +++ b/ui/src/app/layout/home.scss @@ -42,7 +42,7 @@ border: none; opacity: .75; - @include transition(opacity .35s); + transition: opacity .35s; } a:hover, diff --git a/ui/src/app/rulechain/script/node-script-test.scss b/ui/src/app/rulechain/script/node-script-test.scss index 1dd174dd87..e68691d062 100644 --- a/ui/src/app/rulechain/script/node-script-test.scss +++ b/ui/src/app/rulechain/script/node-script-test.scss @@ -29,7 +29,7 @@ md-dialog.tb-node-script-test-dialog { } .tb-split { - @include box-sizing(border-box); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; } diff --git a/ui/src/app/widget/lib/rpc/round-switch.scss b/ui/src/app/widget/lib/rpc/round-switch.scss index 342c995313..c89b26bd45 100644 --- a/ui/src/app/widget/lib/rpc/round-switch.scss +++ b/ui/src/app/widget/lib/rpc/round-switch.scss @@ -59,6 +59,8 @@ $background-color: #e6e7e8 !default; .switch { position: relative; + + box-sizing: border-box; width: 260px; min-width: 260px; height: 260px; @@ -73,12 +75,10 @@ $background-color: #e6e7e8 !default; background: linear-gradient(180deg, #bbb, #ddd); border-radius: 130px; - @include box-sizing(border-box); - - @include box-shadow( - 0 0 0 8px rgba(0,0,0,.1) - ,0 0 3px 1px rgba(0,0,0,.1) - ,inset 0 8px 3px -8px rgba(255,255,255,.4)); + box-shadow: + 0 0 0 8px rgba(0, 0, 0, .1), + 0 0 3px 1px rgba(0, 0, 0, .1), + inset 0 8px 3px -8px rgba(255, 255, 255, .4); input { display: none; @@ -90,7 +90,7 @@ $background-color: #e6e7e8 !default; width: 100%; text-align: center; - @include text-shadow(1px 1px 4px #4a4a4a); + text-shadow: 1px 1px 4px #4a4a4a; } .on { @@ -98,15 +98,15 @@ $background-color: #e6e7e8 !default; font-family: sans-serif; color: #444; - @include transition(all .1s); + transition: all .1s; } .off { bottom: 5px; - @include transition(all .1s); + transition: all .1s; - @include transform(scaleY(.85)); + transform: scaleY(.85); } .but { @@ -120,17 +120,20 @@ $background-color: #e6e7e8 !default; border-bottom-width: 0; border-radius: 400px 400px 400px 400px / 400px 400px 300px 300px; - @include box-shadow(inset 8px 6px 5px -7px #a2a2a2, - inset -8px 6px 5px -7px #a2a2a2, - inset 0 -3px 2px -2px rgba(200, 200, 200, .5), - 0 3px 3px -2px #fff, - inset 0 -230px 60px -200px rgba(255, 255, 255, .2), - inset 0 220px 40px -200px rgba(0, 0, 0, .3)); + box-shadow: + inset 8px 6px 5px -7px #a2a2a2, + inset -8px 6px 5px -7px #a2a2a2, + inset 0 -3px 2px -2px rgba(200, 200, 200, .5), + 0 3px 3px -2px #fff, + inset 0 -230px 60px -200px rgba(255, 255, 255, .2), + inset 0 220px 40px -200px rgba(0, 0, 0, .3); - @include transition(all .2s); + transition: all .2s; } .back { + + box-sizing: border-box; width: 210px; height: 210px; padding: 4px 4px; @@ -139,34 +142,33 @@ $background-color: #e6e7e8 !default; background-image: linear-gradient(-90deg, transparent 30%, transparent 70%), linear-gradient(0deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); border-radius: 105px; - @include box-shadow(30px 30px 30px -20px rgba(58, 58, 58, .3), - -30px 30px 30px -20px rgba(58, 58, 58, .3), - 0 30px 30px 0 rgba(16, 16, 16, .3), - inset 0 -1px 0 0 #484848); - - @include box-sizing(border-box); + box-shadow: + 30px 30px 30px -20px rgba(58, 58, 58, .3), + -30px 30px 30px -20px rgba(58, 58, 58, .3), + 0 30px 30px 0 rgba(16, 16, 16, .3), + inset 0 -1px 0 0 #484848; - @include transition(all .2s); + transition: all .2s; } input:checked + .back .on, input:checked + .back .off{ - @include text-shadow(1px 1px 4px #4a4a4a); + text-shadow: 1px 1px 4px #4a4a4a; } input:checked + .back .on{ top: 10px; color: #4c4c4c; - @include transform(scaleY(.85)); + transform: scaleY(.85); } input:checked + .back .off{ bottom: 5px; color: #444; - @include transform(scaleY(1)); + transform: scaleY(1); } input:checked + .back .but{ @@ -175,12 +177,13 @@ $background-color: #e6e7e8 !default; background-image: radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); border-radius: 400px 400px 400px 400px / 300px 300px 400px 400px; - @include box-shadow(inset 8px -4px 5px -7px #a9a9a9, - inset -8px -4px 5px -7px #808080, - 0 -3px 8px -4px rgba(50, 50, 50, .4), - inset 0 3px 4px -2px #9c9c9c, - inset 0 280px 40px -200px rgba(0, 0, 0, .2), - inset 0 -200px 40px -200px rgba(180, 180, 180, .2)); + box-shadow: + inset 8px -4px 5px -7px #a9a9a9, + inset -8px -4px 5px -7px #808080, + 0 -3px 8px -4px rgba(50, 50, 50, .4), + inset 0 3px 4px -2px #9c9c9c, + inset 0 280px 40px -200px rgba(0, 0, 0, .2), + inset 0 -200px 40px -200px rgba(180, 180, 180, .2); } input:checked + .back{ @@ -188,10 +191,11 @@ $background-color: #e6e7e8 !default; background-image: linear-gradient(0deg, #868686 30%, transparent 70%), linear-gradient(90deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - @include box-shadow(30px 30px 30px -20px rgba(49, 49, 49, .1), - -30px 30px 30px -20px rgba(111, 111, 111, .1), - 0 30px 30px 0 rgba(0, 0, 0, .2), - inset 0 1px 2px 0 rgba(167, 167, 167, .6)); + box-shadow: + 30px 30px 30px -20px rgba(49, 49, 49, .1), + -30px 30px 30px -20px rgba(111, 111, 111, .1), + 0 30px 30px 0 rgba(0, 0, 0, .2), + inset 0 1px 2px 0 rgba(167, 167, 167, .6); } } } diff --git a/ui/src/app/widget/widget-editor.scss b/ui/src/app/widget/widget-editor.scss index cd374e37aa..0eb37afee0 100644 --- a/ui/src/app/widget/widget-editor.scss +++ b/ui/src/app/widget/widget-editor.scss @@ -19,7 +19,7 @@ $edit-toolbar-height: 40px !default; .tb-editor { .tb-split { - @include box-sizing(border-box); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; } diff --git a/ui/src/scss/animations.scss b/ui/src/scss/animations.scss index 4ecfc34743..61b9bac890 100644 --- a/ui/src/scss/animations.scss +++ b/ui/src/scss/animations.scss @@ -15,34 +15,34 @@ */ @import "~compass-sass-mixins/lib/animate"; -@include keyframes(tbMoveFromTopFade) { +@keyframes tbMoveFromTopFade { from { opacity: 0; - @include transform(translate(0, -100%)); + transform: translate(0, -100%); } } -@include keyframes(tbMoveToTopFade) { +@keyframes tbMoveToTopFade { to { opacity: 0; - @include transform(translate(0, -100%)); + transform: translate(0, -100%); } } -@include keyframes(tbMoveFromBottomFade) { +@keyframes tbMoveFromBottomFade { from { opacity: 0; - @include transform(translate(0, 100%)); + transform: translate(0, 100%); } } -@include keyframes(tbMoveToBottomFade) { +@keyframes tbMoveToBottomFade { to { opacity: 0; - @include transform(translate(0, 150%)); + transform: translate(0, 150%); } } diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss index a32f76b6b1..f35ea9d0b6 100644 --- a/ui/src/scss/main.scss +++ b/ui/src/scss/main.scss @@ -51,7 +51,7 @@ a { text-decoration: none; border-bottom: 1px solid rgba(64, 84, 178, .25); - @include transition(border-bottom .35s); + transition: border-bottom .35s; } a:hover, @@ -550,7 +550,7 @@ $previewSize: 100px !default; } .tb-error-message.ng-animate { - @include transition(all .3s cubic-bezier(.55, 0, .55, .2)); + transition: all .3s cubic-bezier(.55, 0, .55, .2); } .tb-error-message.ng-enter-prepare, @@ -646,13 +646,13 @@ section.tb-top-header-buttons { } .tb-header-buttons .tb-btn-header { - @include animation(tbMoveFromTopFade .3s ease both); position: relative !important; display: inline-block !important; + animation: tbMoveFromTopFade .3s ease both; } .tb-header-buttons .tb-btn-header.ng-hide { - @include animation(tbMoveToTopFade .3s ease both); + animation: tbMoveToTopFade .3s ease both; } /*********************** @@ -668,19 +668,19 @@ section.tb-footer-buttons { } .tb-footer-buttons .tb-btn-footer { - @include animation(tbMoveFromBottomFade .3s ease both); position: relative !important; display: inline-block !important; + animation: tbMoveFromBottomFade .3s ease both; } .tb-footer-buttons .tb-btn-footer.ng-hide { - @include animation(tbMoveToBottomFade .3s ease both); + animation: tbMoveToBottomFade .3s ease both; } ._md-toast-open-bottom .tb-footer-buttons { - @include transition(all .4s cubic-bezier(.25, .8, .25, 1)); + transition: all .4s cubic-bezier(.25, .8, .25, 1); - @include transform(translate3d(0, -42px, 0)); + transform: translate3d(0, -42px, 0); } /*********************** From 8ca3e594e8b335a6634e4fc338d9ef3045c3e74c Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 12 Sep 2018 18:24:45 +0300 Subject: [PATCH 094/307] Was added field wich needed for caching --- application/src/main/resources/thingsboard.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index be5149dcb8..362cc29a9b 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -298,6 +298,9 @@ caffeine: assets: timeToLiveInMinutes: 1440 maxSize: 100000 + entityViews: + timeToLiveInMinutes: 1440 + maxSize: 100000 redis: # standalone or cluster From 9a77b2b1997ce09a36396db94a7f2120be362fd9 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 12 Sep 2018 18:43:42 +0300 Subject: [PATCH 095/307] Was fixed the cache --- .../dao/entityview/EntityViewServiceImpl.java | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index f5afad6ec9..dada337f61 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.entityview; -import com.google.common.base.Function; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -25,20 +24,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; @@ -58,12 +52,9 @@ import org.thingsboard.server.dao.tenant.TenantDao; import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; -import static org.thingsboard.server.common.data.CacheConstants.DEVICE_CACHE; import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -97,7 +88,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CacheManager cacheManager; -// @Cacheable(cacheNames = ENTITY_VIEW_CACHE) + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId.getId()}") @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); @@ -105,6 +96,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return entityViewDao.findById(entityViewId.getId()); } + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #name}") @Override public EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name) { log.trace("Executing findEntityViewByTenantIdAndName [{}][{}]", tenantId, name); @@ -113,7 +105,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti .orElse(null); } -// @CachePut(cacheNames = ENTITY_VIEW_CACHE) + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id, #entityView.tenantId, #entityView.name}") @Override public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); @@ -177,14 +169,13 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override public void deleteEntityView(EntityViewId entityViewId) { log.trace("Executing deleteEntityView [{}]", entityViewId); -// Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); deleteEntityRelations(entityViewId); EntityView entityView = entityViewDao.findById(entityViewId.getId()); -// List list = new ArrayList<>(); -// list.add(entityView.getTenantId()); -// list.add(entityView.getName()); -// cache.evict(list); + cache.evict(entityView.getId()); + cache.evict(entityView.getTenantId()); + cache.evict(entityView.getName()); entityViewDao.removeById(entityViewId.getId()); } @@ -197,7 +188,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } -// @Cacheable(cacheNames = ENTITY_VIEW_CACHE) @Override public TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TextPageLink pageLink) { @@ -237,7 +227,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } -// @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #customerId, #entityId, #pageLink}") @Override public TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, CustomerId customerId, From 8f72adfb1d08c03558d8e2a56b2e88fa3fab1e9c Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 13 Sep 2018 14:08:55 +0300 Subject: [PATCH 096/307] Fixes for time picker --- .../entity-view/entity-view-fieldset.tpl.html | 8 ++++---- .../app/entity-view/entity-view.directive.js | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index b9808e9cea..3894eb9d63 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -98,19 +98,19 @@
diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index 42b3a120fe..e1ae82fa6a 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -54,8 +54,8 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t if (scope.entityView.startTimeMs > 0) { scope.startTimeMs = new Date(scope.entityView.startTimeMs); } - if (scope.entityView.endTimeTs > 0) { - scope.endTimeTs = new Date(scope.entityView.endTimeTs); + if (scope.entityView.endTimeMs > 0) { + scope.endTimeMs = new Date(scope.entityView.endTimeMs); } if (!scope.entityView.keys) { scope.entityView.keys = {}; @@ -78,22 +78,22 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t } }); - scope.$watch('endTimeTs', function (newDate) { + scope.$watch('endTimeMs', function (newDate) { if (newDate) { - if (newDate.getTime() < scope.minEndTimeTs) { - scope.endTimeTs = angular.copy(scope.minEndTimeTs); + if (newDate.getTime() < scope.minEndTimeMs) { + scope.endTimeMs = angular.copy(scope.minEndTimeMs); } updateMinMaxDates(); } }); function updateMinMaxDates() { - if (scope.endTimeTs) { - scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeTs.getTime())); - scope.entityView.endTimeTs = scope.endTimeTs.getTime(); + if (scope.endTimeMs) { + scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeMs.getTime())); + scope.entityView.endTimeMs = scope.endTimeMs.getTime(); } if (scope.startTimeMs) { - scope.minEndTimeTs = angular.copy(new Date(scope.startTimeMs.getTime())); + scope.minEndTimeMs = angular.copy(new Date(scope.startTimeMs.getTime())); scope.entityView.startTimeMs = scope.startTimeMs.getTime(); } } From 3fba214509bcf88f1511ee4c93caa369d56434f8 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 13 Sep 2018 19:50:49 +0300 Subject: [PATCH 097/307] Was modified values of request mapping annotation --- .../server/controller/EntityViewController.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index b5adc327f5..48dddcb2b5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -51,7 +51,7 @@ public class EntityViewController extends BaseController { public static final String ENTITY_VIEW_ID = "entityViewId"; @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.GET) + @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.GET) @ResponseBody public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -66,7 +66,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityView", method = RequestMethod.POST) + @RequestMapping(value = "/entity-view", method = RequestMethod.POST) @ResponseBody public EntityView saveEntityView(@RequestBody EntityView entityView) throws ThingsboardException { try { @@ -86,7 +86,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.DELETE) + @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.DELETE) @ResponseStatus(value = HttpStatus.OK) public void deleteEntityView(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(ENTITY_VIEW_ID, strEntityViewId); @@ -107,7 +107,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) + @RequestMapping(value = "/customer/{customerId}/entity-view/{entityViewId}", method = RequestMethod.POST) @ResponseBody public EntityView assignEntityViewToCustomer(@PathVariable("customerId") String strCustomerId, @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -136,7 +136,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/entityView/{entityViewId}", method = RequestMethod.DELETE) + @RequestMapping(value = "/customer/entity-view/{entityViewId}", method = RequestMethod.DELETE) @ResponseBody public EntityView unassignEntityViewFromCustomer(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(ENTITY_VIEW_ID, strEntityViewId); @@ -164,7 +164,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/entityViews", params = {"limit"}, method = RequestMethod.GET) + @RequestMapping(value = "/customer/{customerId}/entity-views", params = {"limit"}, method = RequestMethod.GET) @ResponseBody public TextPageData getCustomerEntityViews( @PathVariable("customerId") String strCustomerId, @@ -185,7 +185,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/entityViews", params = {"limit"}, method = RequestMethod.GET) + @RequestMapping(value = "/tenant/entity-views", params = {"limit"}, method = RequestMethod.GET) @ResponseBody public TextPageData getTenantEntityViews( @RequestParam int limit, @@ -202,7 +202,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityViews", method = RequestMethod.POST) + @RequestMapping(value = "/entity-views", method = RequestMethod.POST) @ResponseBody public List findByQuery(@RequestBody EntityViewSearchQuery query) throws ThingsboardException { checkNotNull(query); From 7c14df56e3aa71a5eb9289e080dfff0704cbbb38 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 13 Sep 2018 19:51:40 +0300 Subject: [PATCH 098/307] Was added new tests --- .../BaseEntityViewControllerTest.java | 275 ++++++++++++++++-- 1 file changed, 244 insertions(+), 31 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index c7ac98d413..920ce139cc 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -15,25 +15,34 @@ */ package org.thingsboard.server.controller; +import com.datastax.driver.core.utils.UUIDs; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.*; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.objects.AttributesEntityView; import org.thingsboard.server.common.data.objects.TelemetryEntityView; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.model.ModelConstants; -import java.util.Arrays; +import java.util.*; +import java.util.stream.Stream; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; public abstract class BaseEntityViewControllerTest extends AbstractControllerTest { + private IdComparator idComparator; private Tenant savedTenant; private User tenantAdmin; private Device testDevice; @@ -43,6 +52,8 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes public void beforeTest() throws Exception { loginSysAdmin(); + idComparator = new IdComparator<>(); + Tenant tenant = new Tenant(); tenant.setTitle("My tenant"); savedTenant = doPost("/api/tenant", tenant, Tenant.class); @@ -62,13 +73,14 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes device.setName("Test device"); device.setType("default"); testDevice = doPost("/api/device", device, Device.class); - obj = new TelemetryEntityView( Arrays.asList("109L", "209L"), new AttributesEntityView( - Arrays.asList("caKey1", "caKey2", "caKey3"), - Arrays.asList("saKey1", "saKey2", "saKey3", "saKey4"), - Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4", "shKey5"))); + Arrays.asList("caKey1", "caKey2"), + Arrays.asList("saKey1", "saKey2", "saKey3"), + Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4") + ) + ); } @After @@ -81,24 +93,15 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes @Test public void testFindEntityViewById() throws Exception { - EntityView view = new EntityView(); - view.setName("Test entity view"); - view.setEntityId(testDevice.getId()); - view.setKeys(new TelemetryEntityView(obj)); - EntityView savedView = doPost("/api/entity-view", view, EntityView.class); + EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); EntityView foundView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertNotNull(foundView); Assert.assertEquals(savedView, foundView); } @Test - public void testSaveEntityViewWithIdOfDevice() throws Exception { - EntityView view = new EntityView(); - view.setEntityId(testDevice.getId()); - view.setName("Test entity view"); - view.setTenantId(savedTenant.getId()); - view.setKeys(new TelemetryEntityView(obj)); - EntityView savedView = doPost("/api/entity-view", view, EntityView.class); + public void testSaveEntityView() throws Exception { + EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); Assert.assertNotNull(savedView); Assert.assertNotNull(savedView.getId()); @@ -110,25 +113,235 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes savedView.setName("New test entity view"); doPost("/api/entity-view", savedView, EntityView.class); - - EntityView foundEntityView = doGet("/api/entity-view/" - + savedView.getId().getId().toString(), EntityView.class); + EntityView foundEntityView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(foundEntityView.getName(), savedView.getName()); } @Test public void testDeleteEntityView() throws Exception { - EntityView view = new EntityView(); - view.setName("Test entity view"); - view.setEntityId(testDevice.getId()); - view.setKeys(new TelemetryEntityView((TelemetryEntityView) obj)); - EntityView savedView = doPost("/api/entity-view", view, EntityView.class); - + EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); doDelete("/api/entity-view/" + savedView.getId().getId().toString()) .andExpect(status().isOk()); - doGet("/api/entity-view/" + savedView.getId().getId().toString()) .andExpect(status().isNotFound()); } + + @Test + public void testSaveEntityViewWithEmptyName() throws Exception { + doPost("/api/entity-view", getNewEntityView("Entity view")) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Entity-view name should be specified"))); + } + + @Test + public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception { + EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); + Customer savedCustomer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); + EntityView assignedView = doPost("/api/customer/" + savedCustomer.getId().getId().toString() + + "/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + Assert.assertEquals(savedCustomer.getId(), assignedView.getCustomerId()); + + EntityView foundView = doGet("/api/entity-view" + savedView.getId().getId().toString(), EntityView.class); + Assert.assertEquals(savedCustomer.getId(), foundView.getCustomerId()); + + EntityView unAssignedView = doDelete("/api/customer/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + Assert.assertEquals(ModelConstants.NULL_UUID, unAssignedView.getCustomerId().getId()); + + foundView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + Assert.assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId()); + } + + @Test + public void testAssignEntityViewToNonExistentCustomer() throws Exception { + EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); + doPost("/api/customer/" + UUIDs.timeBased().toString() + "/device/" + savedView.getId().getId().toString()) + .andExpect(status().isNotFound()); + } + + @Test + public void testAssignEntityViewToCustomerFromDifferentTenant() throws Exception { + loginSysAdmin(); + + Tenant savedAnotherTenant = doPost("/api/tenant", getNewTenant("Different tenant"), Tenant.class); + Assert.assertNotNull(savedAnotherTenant); + + User adminAnotherTenant = new User(); + adminAnotherTenant.setAuthority(Authority.TENANT_ADMIN); + adminAnotherTenant.setTenantId(savedAnotherTenant.getId()); + adminAnotherTenant.setEmail("tenant3@thingsboard.org"); + adminAnotherTenant.setFirstName("Joe"); + adminAnotherTenant.setLastName("Downs"); + createUserAndLogin(adminAnotherTenant, "testPassword1"); + + Customer savedCustomer = doPost("/api/customer", getNewCustomer("Different customer"), Customer.class); + login(tenantAdmin.getEmail(), "testPassword1"); + + EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); + doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/entity-view/" + savedView.getId().getId().toString()) + .andExpect(status().isForbidden()); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedAnotherTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testGetCustomerEntityViews() throws Exception { + CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); + String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entity-views?"; + + List views = new ArrayList<>(); + for (int i = 0; i < 128; i++) { + views.add(doPost("/api/customer/" + customerId.getId().toString() + "/entity-view/" + + getNewEntityView("Test entity view " + i).getId().getId().toString(), EntityView.class)); + } + + List loadedViews = loadListOf(new TextPageLink(23), urlTemplate); + + Collections.sort(views, idComparator); + Collections.sort(loadedViews, idComparator); + + Assert.assertEquals(views, loadedViews); + } + + @Test + public void testGetCustomerEntityViewsByName() throws Exception { + CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); + String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entity-views?"; + + String name1 = "Entity view name1"; + List namesOfView1 = fillListOf(125, name1, "/api/customer/" + customerId.getId().toString() + + "/entity-view/"); + List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), urlTemplate); + Collections.sort(namesOfView1, idComparator); + Collections.sort(loadedNamesOfView1, idComparator); + Assert.assertEquals(namesOfView1, loadedNamesOfView1); + + String name2 = "Entity view name2"; + List NamesOfView2 = fillListOf(143, name2, "/api/customer/" + customerId.getId().toString() + + "/entity-view/"); + List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), urlTemplate); + Collections.sort(NamesOfView2, idComparator); + Collections.sort(loadedNamesOfView2, idComparator); + Assert.assertEquals(NamesOfView2, loadedNamesOfView2); + + for (EntityView view : loadedNamesOfView1) { + doDelete("/api/customer/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + TextPageData pageData = doGetTypedWithPageLink(urlTemplate, + new TypeReference>(){}, new TextPageLink(4, name1)); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (EntityView view : loadedNamesOfView2) { + doDelete("/api/customer/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>(){}, + new TextPageLink(4, name2)); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testGetTenantEntityViews() throws Exception { + + List views = new ArrayList<>(); + for (int i = 0; i < 178; i++) { + views.add(doPost("/api/entity-view/", getNewEntityView("Test entity view" + i), EntityView.class)); + } + List loadedViews = loadListOf(new TextPageLink(23), "/api/tenant/entity-views?"); + + Collections.sort(views, idComparator); + Collections.sort(loadedViews, idComparator); + + Assert.assertEquals(views, loadedViews); + } + + @Test + public void testGetTenantEntityViewsByName() throws Exception { + String name1 = "Entity view name1"; + List namesOfView1 = fillListOf(143, name1); + List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), "/api/tenant/entity-views?"); + Collections.sort(namesOfView1, idComparator); + Collections.sort(loadedNamesOfView1, idComparator); + Assert.assertEquals(namesOfView1, loadedNamesOfView1); + + String name2 = "Entity view name2"; + List NamesOfView2 = fillListOf(75, name2); + List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), "/api/tenant/entity-views?"); + Collections.sort(NamesOfView2, idComparator); + Collections.sort(loadedNamesOfView2, idComparator); + Assert.assertEquals(NamesOfView2, loadedNamesOfView2); + + for (EntityView view : loadedNamesOfView1) { + doDelete("/api/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + TextPageData pageData = doGetTypedWithPageLink("/api/tenant/entity-views?", + new TypeReference>(){}, new TextPageLink(4, name1)); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (EntityView view : loadedNamesOfView2) { + doDelete("/api/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + pageData = doGetTypedWithPageLink("/api/tenant/entity-views?", new TypeReference>(){}, + new TextPageLink(4, name2)); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + private EntityView getNewEntityView(String name) throws Exception { + EntityView view = new EntityView(); + view.setName(name); + view.setEntityId(testDevice.getId()); + view.setTenantId(savedTenant.getId()); + view.setKeys(new TelemetryEntityView(obj)); + return doPost("/api/entity-view", view, EntityView.class); + } + + private Customer getNewCustomer(String title) { + Customer customer = new Customer(); + customer.setTitle(title); + return customer; + } + + private Tenant getNewTenant(String title) { + Tenant tenant = new Tenant(); + tenant.setTitle(title); + return tenant; + } + + private List fillListOf(int limit, String partOfName, String urlTemplate) throws Exception { + List views = new ArrayList<>(); + for (EntityView view : fillListOf(limit, partOfName)) { + views.add(doPost(urlTemplate + view.getId().getId().toString(), EntityView.class)); + } + return views; + } + + private List fillListOf(int limit, String partOfName) throws Exception { + List viewNames = new ArrayList<>(); + for (int i = 0; i < limit; i++) { + String fullName = partOfName + ' ' + RandomStringUtils.randomAlphanumeric(15); + fullName = i % 2 == 0 ? fullName.toLowerCase() : fullName.toUpperCase(); + viewNames.add(doPost("/api/entity-view", getNewEntityView(fullName), EntityView.class)); + } + return viewNames; + } + + private List loadListOf(TextPageLink pageLink, String urlTemplate) throws Exception { + List loadedItems = new ArrayList<>(); + TextPageData pageData; + do { + pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>(){}, pageLink); + loadedItems.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageData.getNextPageLink(); + } + } while (pageData.hasNext()); + + return loadedItems; + } } From 55ece585b5ee34967048053138b93004d0bd81c4 Mon Sep 17 00:00:00 2001 From: Chun-Yeow Yeoh Date: Fri, 14 Sep 2018 11:17:11 +0800 Subject: [PATCH 099/307] add missing API limits on CoAP POST method Signed-off-by: Chun-Yeow Yeoh --- .../server/transport/coap/CoapTransportResource.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java index bac68ffc58..8a596d7119 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -113,6 +113,12 @@ public class CoapTransportResource extends CoapResource { @Override public void handlePOST(CoapExchange exchange) { + if(quotaService.isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { + log.warn("COAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); + exchange.respond(ResponseCode.BAD_REQUEST); + return; + } + Optional featureType = getFeatureType(exchange.advanced().getRequest()); if (!featureType.isPresent()) { log.trace("Missing feature type parameter"); From 020b7a4ec68a8e4006d78697cbb81480989209c9 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Fri, 14 Sep 2018 14:04:12 +0300 Subject: [PATCH 100/307] Added copy attributes to entity view rule node --- .../server/actors/ActorSystemContext.java | 5 + .../actors/ruleChain/DefaultTbContext.java | 6 + .../entityview/CassandraEntityViewDao.java | 7 + .../server/dao/entityview/EntityViewDao.java | 4 + .../dao/entityview/EntityViewService.java | 3 + .../dao/entityview/EntityViewServiceImpl.java | 12 ++ .../sql/entityview/EntityViewRepository.java | 3 + .../dao/sql/entityview/JpaEntityViewDao.java | 8 ++ .../rule/engine/api/TbContext.java | 3 + .../TbCopyAttributesToEntityViewNode.java | 122 ++++++++++++++++++ 10 files changed, 173 insertions(+) create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index d33734e14c..e3682dec8c 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -48,6 +48,7 @@ import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; @@ -159,6 +160,10 @@ public class ActorSystemContext { @Getter private AuditLogService auditLogService; + @Autowired + @Getter + private EntityViewService entityViewService; + @Autowired @Getter private TelemetrySubscriptionService tsSubService; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 7279347b1c..acfc269fd5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -41,6 +41,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; @@ -212,6 +213,11 @@ class DefaultTbContext implements TbContext { return mainCtx.getRelationService(); } + @Override + public EntityViewService getEntityViewService() { + return mainCtx.getEntityViewService(); + } + @Override public MailService getMailService() { if (mainCtx.isAllowSystemMailService()) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index 5716460d20..5de34e4b71 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.entityview; import com.datastax.driver.core.Statement; import com.datastax.driver.core.querybuilder.Select; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntitySubtype; @@ -105,4 +106,10 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, UUID customerId, UUID entityId, TextPageLink pageLink) { return null; } + + @Override + public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { + // TODO: implement this + return null; + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index 56a688ae47..be643ef363 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.entityview; +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.Dao; @@ -91,4 +93,6 @@ public interface EntityViewDao extends Dao { UUID customerId, UUID entityId, TextPageLink pageLink); + + ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index a60376e501..b9c238a79a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.*; @@ -65,4 +66,6 @@ public interface EntityViewService { ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId); ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query); + + ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index d86eabb510..598897aac5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -25,11 +25,13 @@ import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; @@ -52,8 +54,10 @@ import java.util.Collection; import java.util.List; import java.util.stream.Collectors; +import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validateIds; import static org.thingsboard.server.dao.service.Validator.validatePageLink; import static org.thingsboard.server.dao.service.Validator.validateString; @@ -277,6 +281,14 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return entityViews; } + @Override + public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId) { + log.trace("Executing findEntityViewsByTenantIdAndEntityIdAsync, tenantId [{}], entityId [{}]", tenantId, entityId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(entityId.getId(), "Incorrect entityId" + entityId); + return entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); + } + private DataValidator entityViewValidator = new DataValidator() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 27bd8395ac..f1ee3fd272 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -19,6 +19,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.dao.model.sql.EntityViewEntity; import org.thingsboard.server.dao.util.SqlDao; @@ -78,4 +79,6 @@ public interface EntityViewRepository extends CrudRepository entityViewsIds); List findAllByTenantIdAndIdIn(String tenantId, List entityViewsIds); + + List findAllByTenantIdAndEntityId(String tenantId, String entityId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 2f3edbe250..4ff21ab892 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -23,6 +23,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.UUIDConverter; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageLink; @@ -40,6 +41,7 @@ import java.util.Optional; import java.util.UUID; import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; +import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUIDs; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID_STR; /** @@ -121,4 +123,10 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { + return service.submit(() -> DaoUtil.convertDataList( + entityViewRepository.findAllByTenantIdAndEntityId(UUIDConverter.fromTimeUUID(tenantId), UUIDConverter.fromTimeUUID(entityId)))); + } } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index e7ef0dd40f..f9d3c64211 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -26,6 +26,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; @@ -83,6 +84,8 @@ public interface TbContext { RelationService getRelationService(); + EntityViewService getEntityViewService(); + ListeningExecutor getJsExecutor(); ListeningExecutor getMailExecutor(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java new file mode 100644 index 0000000000..73999afb97 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -0,0 +1,122 @@ +package org.thingsboard.rule.engine.action; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.gson.JsonParser; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; +import org.thingsboard.rule.engine.api.RuleNode; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.rule.engine.api.TbRelationTypes; +import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import static org.thingsboard.rule.engine.api.util.DonAsynchron.withCallback; + +@Slf4j +@RuleNode( + type = ComponentType.ACTION, + name = "copy attributes", + configClazz = EmptyNodeConfiguration.class, + nodeDescription = "Copy attributes from asset/device to entity view", + nodeDetails = "Copy attributes from asset/device to related entity view according to entity view configuration. \n " + + "Copy will be done only for attributes that are between start and end dates and according to attribute keys configuration", + uiResources = {"static/rulenode/rulenode-core-config.js"}, + configDirective = "tbNodeEmptyConfig", + icon = "content_copy" +) +public class TbCopyAttributesToEntityViewNode implements TbNode { + + EmptyNodeConfiguration config; + + @Override + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); + } + + @Override + public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { + if (msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()) || + msg.getType().equals(DataConstants.ATTRIBUTES_DELETED) || + msg.getType().equals(DataConstants.ATTRIBUTES_UPDATED)) { + long now = System.currentTimeMillis(); + String scope; + if (msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name())) { + scope = DataConstants.CLIENT_SCOPE; + } else { + scope = msg.getMetaData().getValue("scope"); + } + ListenableFuture> entityViewsFuture = + ctx.getEntityViewService().findEntityViewsByTenantIdAndEntityIdAsync(ctx.getTenantId(), msg.getOriginator()); + withCallback(entityViewsFuture, + entityViews -> { + List>> saveFutures = new ArrayList<>(); + for (EntityView entityView : entityViews) { + if ((entityView.getEndTimeMs() != 0 && entityView.getEndTimeMs() > now && entityView.getStartTimeMs() < now) || + (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < now)) { + Set attributes = JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())).getAttributes(); + List filteredAttributes = + attributes.stream() + .filter(attr -> { + switch (scope) { + case DataConstants.CLIENT_SCOPE: + if (entityView.getKeys().getAttributes().getCs().isEmpty()) { + return true; + } + return entityView.getKeys().getAttributes().getCs().contains(attr.getKey()); + case DataConstants.SERVER_SCOPE: + if (entityView.getKeys().getAttributes().getSs().isEmpty()) { + return true; + } + return entityView.getKeys().getAttributes().getSs().contains(attr.getKey()); + case DataConstants.SHARED_SCOPE: + if (entityView.getKeys().getAttributes().getSh().isEmpty()) { + return true; + } + return entityView.getKeys().getAttributes().getSh().contains(attr.getKey()); + } + return false; + }) + .collect(Collectors.toList()); + saveFutures.add(ctx.getAttributesService().save(entityView.getId(), scope, new ArrayList<>(filteredAttributes))); + } + } + Futures.transform(Futures.allAsList(saveFutures), new Function>, Object>() { + @Nullable + @Override + public Object apply(@Nullable List> lists) { + ctx.tellNext(msg, TbRelationTypes.SUCCESS); + return null; + } + }); + }, + t -> ctx.tellFailure(msg, t)); + } else { + ctx.tellNext(msg, TbRelationTypes.FAILURE); + } + } + + @Override + public void destroy() { + + } +} From 3b0d7204bc93c17ef837f3be748710ad85dbc528 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Fri, 14 Sep 2018 14:05:29 +0300 Subject: [PATCH 101/307] Added copy attributes to entity view rule node --- .../action/TbCopyAttributesToEntityViewNode.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 73999afb97..d609620edf 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.rule.engine.action; import com.fasterxml.jackson.databind.ObjectMapper; From 5180961ecb4611c23baa2ed079433e62fe3da5d3 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 14 Sep 2018 15:12:55 +0300 Subject: [PATCH 102/307] Was modified of request mapping values --- .../controller/EntityViewController.java | 16 ++-- .../BaseEntityViewControllerTest.java | 96 ++++++++++--------- 2 files changed, 58 insertions(+), 54 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 48dddcb2b5..b5adc327f5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -51,7 +51,7 @@ public class EntityViewController extends BaseController { public static final String ENTITY_VIEW_ID = "entityViewId"; @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.GET) + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.GET) @ResponseBody public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -66,7 +66,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entity-view", method = RequestMethod.POST) + @RequestMapping(value = "/entityView", method = RequestMethod.POST) @ResponseBody public EntityView saveEntityView(@RequestBody EntityView entityView) throws ThingsboardException { try { @@ -86,7 +86,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/entity-view/{entityViewId}", method = RequestMethod.DELETE) + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.DELETE) @ResponseStatus(value = HttpStatus.OK) public void deleteEntityView(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(ENTITY_VIEW_ID, strEntityViewId); @@ -107,7 +107,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/{customerId}/entity-view/{entityViewId}", method = RequestMethod.POST) + @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) @ResponseBody public EntityView assignEntityViewToCustomer(@PathVariable("customerId") String strCustomerId, @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -136,7 +136,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/entity-view/{entityViewId}", method = RequestMethod.DELETE) + @RequestMapping(value = "/customer/entityView/{entityViewId}", method = RequestMethod.DELETE) @ResponseBody public EntityView unassignEntityViewFromCustomer(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(ENTITY_VIEW_ID, strEntityViewId); @@ -164,7 +164,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/entity-views", params = {"limit"}, method = RequestMethod.GET) + @RequestMapping(value = "/customer/{customerId}/entityViews", params = {"limit"}, method = RequestMethod.GET) @ResponseBody public TextPageData getCustomerEntityViews( @PathVariable("customerId") String strCustomerId, @@ -185,7 +185,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/entity-views", params = {"limit"}, method = RequestMethod.GET) + @RequestMapping(value = "/tenant/entityViews", params = {"limit"}, method = RequestMethod.GET) @ResponseBody public TextPageData getTenantEntityViews( @RequestParam int limit, @@ -202,7 +202,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entity-views", method = RequestMethod.POST) + @RequestMapping(value = "/entityViews", method = RequestMethod.POST) @ResponseBody public List findByQuery(@RequestBody EntityViewSearchQuery query) throws ThingsboardException { checkNotNull(query); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 920ce139cc..b3da7ad6e2 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -57,7 +57,6 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Tenant tenant = new Tenant(); tenant.setTitle("My tenant"); savedTenant = doPost("/api/tenant", tenant, Tenant.class); - Assert.assertNotNull(savedTenant); tenantAdmin = new User(); @@ -93,15 +92,15 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes @Test public void testFindEntityViewById() throws Exception { - EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); - EntityView foundView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); + EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertNotNull(foundView); Assert.assertEquals(savedView, foundView); } @Test public void testSaveEntityView() throws Exception { - EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); + EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); Assert.assertNotNull(savedView); Assert.assertNotNull(savedView.getId()); @@ -112,49 +111,49 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Assert.assertEquals(savedView.getName(), savedView.getName()); savedView.setName("New test entity view"); - doPost("/api/entity-view", savedView, EntityView.class); - EntityView foundEntityView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + doPost("/api/entityView", savedView, EntityView.class); + EntityView foundEntityView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(foundEntityView.getName(), savedView.getName()); } @Test public void testDeleteEntityView() throws Exception { - EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); - doDelete("/api/entity-view/" + savedView.getId().getId().toString()) + EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); + doDelete("/api/entityView/" + savedView.getId().getId().toString()) .andExpect(status().isOk()); - doGet("/api/entity-view/" + savedView.getId().getId().toString()) + doGet("/api/entityView/" + savedView.getId().getId().toString()) .andExpect(status().isNotFound()); } @Test public void testSaveEntityViewWithEmptyName() throws Exception { - doPost("/api/entity-view", getNewEntityView("Entity view")) + doPost("/api/entityView", getNewEntityView("")) .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString("Entity-view name should be specified"))); } @Test public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception { - EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); + EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); Customer savedCustomer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); EntityView assignedView = doPost("/api/customer/" + savedCustomer.getId().getId().toString() - + "/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + + "/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(savedCustomer.getId(), assignedView.getCustomerId()); - EntityView foundView = doGet("/api/entity-view" + savedView.getId().getId().toString(), EntityView.class); + EntityView foundView = doGet("/api/entityView" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(savedCustomer.getId(), foundView.getCustomerId()); - EntityView unAssignedView = doDelete("/api/customer/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + EntityView unAssignedView = doDelete("/api/customer/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(ModelConstants.NULL_UUID, unAssignedView.getCustomerId().getId()); - foundView = doGet("/api/entity-view/" + savedView.getId().getId().toString(), EntityView.class); + foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId()); } @Test public void testAssignEntityViewToNonExistentCustomer() throws Exception { - EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); + EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); doPost("/api/customer/" + UUIDs.timeBased().toString() + "/device/" + savedView.getId().getId().toString()) .andExpect(status().isNotFound()); } @@ -163,38 +162,43 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes public void testAssignEntityViewToCustomerFromDifferentTenant() throws Exception { loginSysAdmin(); - Tenant savedAnotherTenant = doPost("/api/tenant", getNewTenant("Different tenant"), Tenant.class); - Assert.assertNotNull(savedAnotherTenant); + Tenant tenant2 = getNewTenant("Different tenant"); + Tenant savedTenant2 = doPost("/api/tenant", tenant2, Tenant.class); + Assert.assertNotNull(savedTenant2); + + User tenantAdmin2 = new User(); + tenantAdmin2.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin2.setTenantId(savedTenant2.getId()); + tenantAdmin2.setEmail("tenant3@thingsboard.org"); + tenantAdmin2.setFirstName("Joe"); + tenantAdmin2.setLastName("Downs"); + tenantAdmin2 = createUserAndLogin(tenantAdmin2, "testPassword1"); - User adminAnotherTenant = new User(); - adminAnotherTenant.setAuthority(Authority.TENANT_ADMIN); - adminAnotherTenant.setTenantId(savedAnotherTenant.getId()); - adminAnotherTenant.setEmail("tenant3@thingsboard.org"); - adminAnotherTenant.setFirstName("Joe"); - adminAnotherTenant.setLastName("Downs"); - createUserAndLogin(adminAnotherTenant, "testPassword1"); + Customer customer = getNewCustomer("Different customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); - Customer savedCustomer = doPost("/api/customer", getNewCustomer("Different customer"), Customer.class); login(tenantAdmin.getEmail(), "testPassword1"); - EntityView savedView = doPost("/api/entity-view", getNewEntityView("Test entity view"), EntityView.class); - doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/entity-view/" + savedView.getId().getId().toString()) + EntityView view = getNewEntityView("Test entity view"); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString()) .andExpect(status().isForbidden()); loginSysAdmin(); - doDelete("/api/tenant/" + savedAnotherTenant.getId().getId().toString()) + doDelete("/api/tenant/" + savedTenant2.getId().getId().toString()) .andExpect(status().isOk()); } @Test public void testGetCustomerEntityViews() throws Exception { CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); - String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entity-views?"; + String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; List views = new ArrayList<>(); for (int i = 0; i < 128; i++) { - views.add(doPost("/api/customer/" + customerId.getId().toString() + "/entity-view/" + views.add(doPost("/api/customer/" + customerId.getId().toString() + "/entityView/" + getNewEntityView("Test entity view " + i).getId().getId().toString(), EntityView.class)); } @@ -209,11 +213,11 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes @Test public void testGetCustomerEntityViewsByName() throws Exception { CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); - String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entity-views?"; + String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; String name1 = "Entity view name1"; List namesOfView1 = fillListOf(125, name1, "/api/customer/" + customerId.getId().toString() - + "/entity-view/"); + + "/entityView/"); List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), urlTemplate); Collections.sort(namesOfView1, idComparator); Collections.sort(loadedNamesOfView1, idComparator); @@ -221,14 +225,14 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes String name2 = "Entity view name2"; List NamesOfView2 = fillListOf(143, name2, "/api/customer/" + customerId.getId().toString() - + "/entity-view/"); + + "/entityView/"); List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), urlTemplate); Collections.sort(NamesOfView2, idComparator); Collections.sort(loadedNamesOfView2, idComparator); Assert.assertEquals(NamesOfView2, loadedNamesOfView2); for (EntityView view : loadedNamesOfView1) { - doDelete("/api/customer/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } TextPageData pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>(){}, new TextPageLink(4, name1)); @@ -236,7 +240,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Assert.assertEquals(0, pageData.getData().size()); for (EntityView view : loadedNamesOfView2) { - doDelete("/api/customer/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>(){}, new TextPageLink(4, name2)); @@ -249,9 +253,9 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes List views = new ArrayList<>(); for (int i = 0; i < 178; i++) { - views.add(doPost("/api/entity-view/", getNewEntityView("Test entity view" + i), EntityView.class)); + views.add(doPost("/api/entityView/", getNewEntityView("Test entity view" + i), EntityView.class)); } - List loadedViews = loadListOf(new TextPageLink(23), "/api/tenant/entity-views?"); + List loadedViews = loadListOf(new TextPageLink(23), "/api/tenant/entityViews?"); Collections.sort(views, idComparator); Collections.sort(loadedViews, idComparator); @@ -263,30 +267,30 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes public void testGetTenantEntityViewsByName() throws Exception { String name1 = "Entity view name1"; List namesOfView1 = fillListOf(143, name1); - List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), "/api/tenant/entity-views?"); + List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), "/api/tenant/entityViews?"); Collections.sort(namesOfView1, idComparator); Collections.sort(loadedNamesOfView1, idComparator); Assert.assertEquals(namesOfView1, loadedNamesOfView1); String name2 = "Entity view name2"; List NamesOfView2 = fillListOf(75, name2); - List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), "/api/tenant/entity-views?"); + List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), "/api/tenant/entityViews?"); Collections.sort(NamesOfView2, idComparator); Collections.sort(loadedNamesOfView2, idComparator); Assert.assertEquals(NamesOfView2, loadedNamesOfView2); for (EntityView view : loadedNamesOfView1) { - doDelete("/api/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } - TextPageData pageData = doGetTypedWithPageLink("/api/tenant/entity-views?", + TextPageData pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", new TypeReference>(){}, new TextPageLink(4, name1)); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); for (EntityView view : loadedNamesOfView2) { - doDelete("/api/entity-view/" + view.getId().getId().toString()).andExpect(status().isOk()); + doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } - pageData = doGetTypedWithPageLink("/api/tenant/entity-views?", new TypeReference>(){}, + pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", new TypeReference>(){}, new TextPageLink(4, name2)); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); @@ -298,7 +302,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setEntityId(testDevice.getId()); view.setTenantId(savedTenant.getId()); view.setKeys(new TelemetryEntityView(obj)); - return doPost("/api/entity-view", view, EntityView.class); + return doPost("/api/entityView", view, EntityView.class); } private Customer getNewCustomer(String title) { @@ -326,7 +330,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes for (int i = 0; i < limit; i++) { String fullName = partOfName + ' ' + RandomStringUtils.randomAlphanumeric(15); fullName = i % 2 == 0 ? fullName.toLowerCase() : fullName.toUpperCase(); - viewNames.add(doPost("/api/entity-view", getNewEntityView(fullName), EntityView.class)); + viewNames.add(doPost("/api/entityView", getNewEntityView(fullName), EntityView.class)); } return viewNames; } From 7bfccc0f8b09e5d2063f88c9b92f16b84cc2577c Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 17 Sep 2018 17:27:37 +0300 Subject: [PATCH 103/307] Was fixed cache to pass tests --- .../server/dao/entityview/EntityViewServiceImpl.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 54d8cc9981..c13261c003 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -51,6 +51,7 @@ import org.thingsboard.server.dao.tenant.TenantDao; import javax.annotation.Nullable; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; @@ -88,7 +89,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CacheManager cacheManager; - @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId.getId()}") @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); @@ -105,7 +105,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti .orElse(null); } - @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id, #entityView.tenantId, #entityView.name}") + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.name}") @Override public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); @@ -173,9 +173,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); deleteEntityRelations(entityViewId); EntityView entityView = entityViewDao.findById(entityViewId.getId()); - cache.evict(entityView.getId()); - cache.evict(entityView.getTenantId()); - cache.evict(entityView.getName()); + cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getName())); entityViewDao.removeById(entityViewId.getId()); } @@ -310,9 +308,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override protected void validateDataImpl(EntityView entityView) { - if (StringUtils.isEmpty(entityView.getKeys().toString())) { - throw new DataValidationException("Entity view type should be specified!"); - } if (StringUtils.isEmpty(entityView.getName())) { throw new DataValidationException("Entity view name should be specified!"); } From 825d7094b303640fa79cbe327fbe53994a9fc68c Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 17 Sep 2018 17:31:59 +0300 Subject: [PATCH 104/307] Was cleared of unnecessary --- .../org/thingsboard/server/dao/model/ModelConstants.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index a6787d0520..83a89212d6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -45,7 +45,6 @@ public class ModelConstants { public static final String SEARCH_TEXT_PROPERTY = "search_text"; public static final String ADDITIONAL_INFO_PROPERTY = "additional_info"; public static final String ENTITY_TYPE_PROPERTY = "entity_type"; - /*public static final String ENTITY_VIEW_ID_PROPERTY = "entity_view_id";*/ public static final String ENTITY_TYPE_COLUMN = ENTITY_TYPE_PROPERTY; public static final String ENTITY_ID_COLUMN = "entity_id"; @@ -53,7 +52,6 @@ public class ModelConstants { public static final String ATTRIBUTE_KEY_COLUMN = "attribute_key"; public static final String LAST_UPDATE_TS_COLUMN = "last_update_ts"; - /** * Cassandra user constants. */ @@ -148,12 +146,11 @@ public class ModelConstants { * Cassandra entityView constants. */ public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_views"; - public static final String ENTITY_VIEW_FAMILY_NAME = "entity-view"; public static final String ENTITY_VIEW_ENTITY_ID_PROPERTY = ENTITY_ID_COLUMN; public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; - public static final String ENTITY_VIEW_TYPE_PROPERTY = DEVICE_TYPE_PROPERTY; + public static final String ENTITY_VIEW_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_customer_and_search_text"; public static final String ENTITY_VIEW_TENANT_AND_NAME_VIEW_NAME = "entity_view_by_tenant_and_name"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; From 72f0d90c90dca06b8f45bcc69595ff1d0349a2bd Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 17 Sep 2018 17:33:05 +0300 Subject: [PATCH 105/307] Was fixed the last tests --- .../BaseEntityViewControllerTest.java | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index b3da7ad6e2..5c62e6ad13 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -24,7 +24,6 @@ import org.junit.Before; import org.junit.Test; import org.thingsboard.server.common.data.*; import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.objects.AttributesEntityView; import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.data.page.TextPageData; @@ -33,11 +32,9 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.model.ModelConstants; import java.util.*; -import java.util.stream.Stream; import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; public abstract class BaseEntityViewControllerTest extends AbstractControllerTest { @@ -54,9 +51,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes idComparator = new IdComparator<>(); - Tenant tenant = new Tenant(); - tenant.setTitle("My tenant"); - savedTenant = doPost("/api/tenant", tenant, Tenant.class); + savedTenant = doPost("/api/tenant", getNewTenant("My tenant"), Tenant.class); Assert.assertNotNull(savedTenant); tenantAdmin = new User(); @@ -119,29 +114,38 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes @Test public void testDeleteEntityView() throws Exception { - EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); + EntityView view = getNewEntityView("Test entity view"); + Customer customer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); + view.setCustomerId(customer.getId()); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + doDelete("/api/entityView/" + savedView.getId().getId().toString()) .andExpect(status().isOk()); + doGet("/api/entityView/" + savedView.getId().getId().toString()) .andExpect(status().isNotFound()); } @Test public void testSaveEntityViewWithEmptyName() throws Exception { - doPost("/api/entityView", getNewEntityView("")) + doPost("/api/entityView", new EntityView()) .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Entity-view name should be specified"))); + .andExpect(statusReason(containsString("Entity view name should be specified!"))); } @Test public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception { - EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); + EntityView view = getNewEntityView("Test entity view"); Customer savedCustomer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); - EntityView assignedView = doPost("/api/customer/" + savedCustomer.getId().getId().toString() - + "/entityView/" + savedView.getId().getId().toString(), EntityView.class); + view.setCustomerId(savedCustomer.getId()); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + EntityView assignedView = doPost( + "/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString(), + EntityView.class); Assert.assertEquals(savedCustomer.getId(), assignedView.getCustomerId()); - EntityView foundView = doGet("/api/entityView" + savedView.getId().getId().toString(), EntityView.class); + EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(savedCustomer.getId(), foundView.getCustomerId()); EntityView unAssignedView = doDelete("/api/customer/entityView/" + savedView.getId().getId().toString(), EntityView.class); @@ -172,7 +176,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes tenantAdmin2.setEmail("tenant3@thingsboard.org"); tenantAdmin2.setFirstName("Joe"); tenantAdmin2.setLastName("Downs"); - tenantAdmin2 = createUserAndLogin(tenantAdmin2, "testPassword1"); + createUserAndLogin(tenantAdmin2, "testPassword1"); Customer customer = getNewCustomer("Different customer"); Customer savedCustomer = doPost("/api/customer", customer, Customer.class); @@ -298,9 +302,9 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes private EntityView getNewEntityView(String name) throws Exception { EntityView view = new EntityView(); - view.setName(name); view.setEntityId(testDevice.getId()); view.setTenantId(savedTenant.getId()); + view.setName(name); view.setKeys(new TelemetryEntityView(obj)); return doPost("/api/entityView", view, EntityView.class); } @@ -330,7 +334,10 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes for (int i = 0; i < limit; i++) { String fullName = partOfName + ' ' + RandomStringUtils.randomAlphanumeric(15); fullName = i % 2 == 0 ? fullName.toLowerCase() : fullName.toUpperCase(); - viewNames.add(doPost("/api/entityView", getNewEntityView(fullName), EntityView.class)); + EntityView view = getNewEntityView(fullName); + Customer customer = getNewCustomer("Test customer " + String.valueOf(Math.random())); + view.setCustomerId(doPost("/api/customer", customer, Customer.class).getId()); + viewNames.add(doPost("/api/entityView", view, EntityView.class)); } return viewNames; } From 9237a5dda9aa2cc5202efb58b73c0117aa3ae9b5 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 17 Sep 2018 17:43:42 +0300 Subject: [PATCH 106/307] Was deleted unnecessesary lines --- .../server/controller/EntityViewController.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index b5adc327f5..26a48daff6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -15,32 +15,28 @@ */ package org.thingsboard.server.controller; -import com.google.common.util.concurrent.ListenableFuture; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.thingsboard.server.common.data.Customer; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.model.ModelConstants; -import org.thingsboard.server.service.security.model.SecurityUser; -import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import static org.thingsboard.server.controller.CustomerController.CUSTOMER_ID; + /** * Created by Victor Basanets on 8/28/2017. */ @@ -109,9 +105,9 @@ public class EntityViewController extends BaseController { @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) @ResponseBody - public EntityView assignEntityViewToCustomer(@PathVariable("customerId") String strCustomerId, + public EntityView assignEntityViewToCustomer(@PathVariable(CUSTOMER_ID) String strCustomerId, @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { - checkParameter("customerId", strCustomerId); + checkParameter(CUSTOMER_ID, strCustomerId); checkParameter(ENTITY_VIEW_ID, strEntityViewId); try { CustomerId customerId = new CustomerId(toUUID(strCustomerId)); From 77de5522af9eaab7a6e3a60e0d504f3a8b04aac5 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 17 Sep 2018 19:54:34 +0300 Subject: [PATCH 107/307] Were fixed materialized views --- .../main/data/upgrade/2.1.1/schema_update.cql | 105 +++++++----------- dao/src/main/resources/cassandra/schema.cql | 31 ++++-- 2 files changed, 61 insertions(+), 75 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index e0cff4108f..c5d919bc7c 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -22,72 +22,51 @@ DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_customer DROP TABLE IF EXISTS thingsboard.entity_views; CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( - id timeuuid, - entity_id timeuuid, - entity_type text, - tenant_id timeuuid, - customer_id timeuuid, - name text, - keys text, - start_ts bigint, - end_ts bigint, - search_text text, - additional_info text, - PRIMARY KEY (id, entity_id, tenant_id, customer_id) +id timeuuid, +entity_id timeuuid, +entity_type text, +tenant_id timeuuid, +customer_id timeuuid, +name text, +keys text, +start_ts bigint, +end_ts bigint, +search_text text, +additional_info text, +PRIMARY KEY (id, tenant_id, customer_id) ); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS - SELECT * - from thingsboard.entity_views - WHERE entity_id IS NOT NULL - AND tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND keys IS NOT NULL - AND start_ts IS NOT NULL - AND end_ts IS NOT NULL - AND name IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) - WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); +SELECT * +from thingsboard.entity_views +WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL +PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) +WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_entity AS - SELECT * - from thingsboard.entity_views - WHERE entity_id IS NOT NULL - AND tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND keys IS NOT NULL - AND start_ts IS NOT NULL - AND end_ts IS NOT NULL - AND name IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, entity_id, id, customer_id, name) - WITH CLUSTERING ORDER BY (entity_id ASC, customer_id ASC, id DESC, name DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS +SELECT * +from thingsboard.entity_views +WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL +PRIMARY KEY (tenant_id, search_text, id, customer_id, name) +WITH CLUSTERING ORDER BY (search_text ASC, customer_id ASC, id DESC, name DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS - SELECT * - from thingsboard.entity_views - WHERE entity_id IS NOT NULL - AND tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND keys IS NOT NULL - AND start_ts IS NOT NULL - AND end_ts IS NOT NULL - AND name IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, id, entity_id, name) - WITH CLUSTERING ORDER BY (customer_id ASC, id DESC, entity_id DESC, name DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_and_search_text AS +SELECT * +from thingsboard.entity_views +WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL +PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id, name) +WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, name DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS - SELECT * - from thingsboard.entity_views - WHERE entity_id IS NOT NULL - AND tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND keys IS NOT NULL - AND start_ts IS NOT NULL - AND end_ts IS NOT NULL - AND name IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, entity_id, id, name) - WITH CLUSTERING ORDER BY (customer_id ASC, entity_id DESC, id DESC, name DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_search_text AS +SELECT * +from thingsboard.entity_views +WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL +PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) +WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, id DESC, entity_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity_and_search_text AS +SELECT * +from thingsboard.entity_views +WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL +PRIMARY KEY (tenant_id, customer_id, search_text,entity_id, id) +WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, entity_id DESC, id DESC); diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql index 3cefacb970..674d30e824 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema.cql @@ -661,23 +661,30 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_na PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_entity AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, entity_id, id, customer_id, name) - WITH CLUSTERING ORDER BY (entity_id ASC, customer_id ASC, id DESC, name DESC); + WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, search_text, id, customer_id, name) + WITH CLUSTERING ORDER BY (search_text ASC, customer_id ASC, id DESC, name DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_and_search_text AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, id, entity_id, name) - WITH CLUSTERING ORDER BY (customer_id ASC, id DESC, entity_id DESC, name DESC); + WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id, name) + WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, name DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_search_text AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, entity_id, id, name) - WITH CLUSTERING ORDER BY (customer_id ASC, entity_id DESC, id DESC, name DESC); + WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) + WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, id DESC, entity_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity_and_search_text AS + SELECT * + from thingsboard.entity_views + WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, search_text,entity_id, id) + WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, entity_id DESC, id DESC); From d3ba64683c53c81c341976cb993f37e7bf354fd6 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 17 Sep 2018 19:56:17 +0300 Subject: [PATCH 108/307] Were added all necessary methods --- .../entityview/CassandraEntityViewDao.java | 68 +++++++++++++------ .../server/dao/model/ModelConstants.java | 6 +- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index 5de34e4b71..98f6105646 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -67,49 +67,75 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink) { - log.debug("Try to find entity-views by tenantId [{}] and pageLink [{}]", tenantId, pageLink); + log.debug("Try to find entity views by tenantId [{}] and pageLink [{}]", tenantId, pageLink); List entityViewEntities = findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME, - Collections.singletonList(eq(ENTITY_VIEW_TENANT_ID_PROPERTY, tenantId)), pageLink); - - log.trace("Found entity-views [{}] by tenantId [{}] and pageLink [{}]", entityViewEntities, tenantId, pageLink); + Collections.singletonList(eq(TENANT_ID_PROPERTY, tenantId)), pageLink); + log.trace("Found entity views [{}] by tenantId [{}] and pageLink [{}]", + entityViewEntities, tenantId, pageLink); return DaoUtil.convertDataList(entityViewEntities); } @Override - public Optional findEntityViewByTenantIdAndName(UUID tenantId, String entityViewName) { - return Optional.ofNullable(DaoUtil.getData( - findOneByStatement(select().from(ENTITY_VIEW_TENANT_AND_NAME_VIEW_NAME).where() - .and(eq(ENTITY_VIEW_TENANT_ID_PROPERTY, tenantId)) - .and(eq(ENTITY_VIEW_NAME_PROPERTY, entityViewName)))) - ); + public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { + Select.Where query = select().from(ENTITY_VIEW_BY_TENANT_AND_NAME).where(); + query.and(eq(ENTITY_VIEW_TENANT_ID_PROPERTY, tenantId)); + query.and(eq(ENTITY_VIEW_NAME_PROPERTY, name)); + return Optional.ofNullable(DaoUtil.getData(findOneByStatement(query))); } @Override public List findEntityViewByTenantIdAndEntityId(UUID tenantId, UUID entityId, TextPageLink pageLink) { - log.debug("Try to find entity-views by tenantId [{}], entityId[{}] and pageLink [{}]", tenantId, entityId, pageLink); - List entityViewEntities = findPageWithTextSearch(DEVICE_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME, - Arrays.asList(eq(DEVICE_CUSTOMER_ID_PROPERTY, entityId), - eq(DEVICE_TENANT_ID_PROPERTY, tenantId)), + log.debug("Try to find entity views by tenantId [{}], entityId [{}] and pageLink [{}]", + tenantId, entityId, pageLink); + List entityViewEntities = findPageWithTextSearch( + ENTITY_VIEW_BY_TENANT_AND_ENTITY_AND_SEARCH_TEXT, + Arrays.asList(eq(CUSTOMER_ID_PROPERTY, entityId), eq(TENANT_ID_PROPERTY, tenantId)), pageLink); - - log.trace("Found entity-views [{}] by tenantId [{}], entityId [{}] and pageLink [{}]", entityViewEntities, tenantId, entityId, pageLink); + log.trace("Found entity views [{}] by tenantId [{}], entityId [{}] and pageLink [{}]", + entityViewEntities, tenantId, entityId, pageLink); return DaoUtil.convertDataList(entityViewEntities); } @Override public List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, TextPageLink pageLink) { - return null; + log.debug("Try to find entity views by tenantId [{}], customerId[{}] and pageLink [{}]", + tenantId, customerId, pageLink); + List entityViewEntities = findPageWithTextSearch( + ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT, + Arrays.asList(eq(CUSTOMER_ID_PROPERTY, customerId), eq(TENANT_ID_PROPERTY, tenantId)), + pageLink); + log.trace("Found find entity views [{}] by tenantId [{}], customerId [{}] and pageLink [{}]", + entityViewEntities, tenantId, customerId, pageLink); + return DaoUtil.convertDataList(entityViewEntities); } @Override - public List findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, UUID customerId, UUID entityId, TextPageLink pageLink) { - return null; + public List findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, + UUID customerId, + UUID entityId, + TextPageLink pageLink) { + + log.debug("Try to find entity views by tenantId [{}], customerId [{}], entityId [{}] and pageLink [{}]", + tenantId, customerId, entityId, pageLink); + List entityViewEntities = findPageWithTextSearch( + ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_ENTITY_AND_SEARCH_TEXT, + Arrays.asList( + eq(TENANT_ID_PROPERTY, tenantId), + eq(CUSTOMER_ID_PROPERTY, customerId), + eq(ENTITY_ID_COLUMN, entityId)), + pageLink); + log.trace("Found devices [{}] by tenantId [{}], customerId [{}], entityId [{}] and pageLink [{}]", + entityViewEntities, tenantId, customerId, entityId, pageLink); + return DaoUtil.convertDataList(entityViewEntities); } @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { - // TODO: implement this - return null; + log.debug("Try to find entity views by tenantId [{}] and entityId [{}]", tenantId, entityId); + Select.Where query = select().from(getColumnFamilyName()).where(); + query.and(eq(TENANT_ID_PROPERTY, tenantId)); + query.and(eq(ENTITY_ID_COLUMN, entityId)); + return findListByStatementAsync(query); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 83a89212d6..da180af3a9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -150,13 +150,15 @@ public class ModelConstants { public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; - public static final String ENTITY_VIEW_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_customer_and_search_text"; - public static final String ENTITY_VIEW_TENANT_AND_NAME_VIEW_NAME = "entity_view_by_tenant_and_name"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_ENTITY_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer_and_entity_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer_and_search_text"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; public static final String ENTITY_VIEW_END_TS_PROPERTY = "end_ts"; public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_tenant_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_AND_NAME = "entity_views_by_tenant_and_name"; + public static final String ENTITY_VIEW_BY_TENANT_AND_ENTITY_AND_SEARCH_TEXT = "entity_view_by_tenant_and_entity_and_search_text"; /** * Cassandra audit log constants. From 1e6bd5513089a2e3c3b3aaf1de5c2d282a512ffe Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 18 Sep 2018 15:33:37 +0300 Subject: [PATCH 109/307] Was fixed Materialized View --- .../main/data/upgrade/2.1.1/schema_update.cql | 100 +++++++++++------- .../server/dao/model/ModelConstants.java | 6 +- dao/src/main/resources/cassandra/schema.cql | 90 +++++++++++----- 3 files changed, 128 insertions(+), 68 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index c5d919bc7c..888f43c5ad 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -22,51 +22,71 @@ DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_customer DROP TABLE IF EXISTS thingsboard.entity_views; CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( -id timeuuid, -entity_id timeuuid, -entity_type text, -tenant_id timeuuid, -customer_id timeuuid, -name text, -keys text, -start_ts bigint, -end_ts bigint, -search_text text, -additional_info text, -PRIMARY KEY (id, tenant_id, customer_id) + id timeuuid, + entity_id timeuuid, + entity_type text, + tenant_id timeuuid, + customer_id timeuuid, + name text, + keys text, + start_ts bigint, + end_ts bigint, + search_text text, + additional_info text, + PRIMARY KEY (id, entity_id, tenant_id, customer_id) ); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS -SELECT * -from thingsboard.entity_views -WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL -PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) -WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (name ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS -SELECT * -from thingsboard.entity_views -WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL -PRIMARY KEY (tenant_id, search_text, id, customer_id, name) -WITH CLUSTERING ORDER BY (search_text ASC, customer_id ASC, id DESC, name DESC); + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_and_search_text AS -SELECT * -from thingsboard.entity_views -WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL -PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id, name) -WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, name DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id) + WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, customer_id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_search_text AS -SELECT * -from thingsboard.entity_views -WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL -PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) -WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, id DESC, entity_id DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) + WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity_and_search_text AS -SELECT * -from thingsboard.entity_views -WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL -PRIMARY KEY (tenant_id, customer_id, search_text,entity_id, id) -WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, entity_id DESC, id DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, entity_id, search_text, id) + WITH CLUSTERING ORDER BY (customer_id DESC, entity_id ASC, search_text ASC, id DESC); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index da180af3a9..45d0ff9493 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -150,15 +150,15 @@ public class ModelConstants { public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; - public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_ENTITY_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer_and_entity_and_search_text"; - public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_ENTITY_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer_and_entity"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; public static final String ENTITY_VIEW_END_TS_PROPERTY = "end_ts"; public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_tenant_and_search_text"; public static final String ENTITY_VIEW_BY_TENANT_AND_NAME = "entity_views_by_tenant_and_name"; - public static final String ENTITY_VIEW_BY_TENANT_AND_ENTITY_AND_SEARCH_TEXT = "entity_view_by_tenant_and_entity_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_AND_ENTITY_AND_SEARCH_TEXT = "entity_view_by_tenant_and_entity"; /** * Cassandra audit log constants. diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql index 674d30e824..4645420eb8 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema.cql @@ -165,35 +165,55 @@ CREATE TABLE IF NOT EXISTS thingsboard.device ( CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_name AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( tenant_id, name, id, customer_id, type) WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_by_type_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_by_type_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); @@ -651,40 +671,60 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( end_ts bigint, search_text text, additional_info text, - PRIMARY KEY (id, tenant_id, customer_id) + PRIMARY KEY (id, entity_id, tenant_id, customer_id) ); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, name, id, entity_id, customer_id) - WITH CLUSTERING ORDER BY (name ASC, id DESC, entity_id DESC, customer_id DESC); + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (name ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, search_text, id, customer_id, name) - WITH CLUSTERING ORDER BY (search_text ASC, customer_id ASC, id DESC, name DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_and_search_text AS + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id, name) - WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, name DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_search_text AS + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id) + WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) - WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, id DESC, entity_id DESC); + WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity_and_search_text AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS SELECT * from thingsboard.entity_views - WHERE entity_id IS NOT NULL AND search_text IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND keys IS NOT NULL AND start_ts IS NOT NULL AND end_ts IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, search_text,entity_id, id) - WITH CLUSTERING ORDER BY (customer_id ASC, search_text ASC, entity_id DESC, id DESC); + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, entity_id, search_text, id) + WITH CLUSTERING ORDER BY (customer_id DESC, entity_id ASC, search_text ASC, id DESC); From 4182a70d5fcf8f5530a3de39a303218fa2c3d855 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 18 Sep 2018 19:42:14 +0300 Subject: [PATCH 110/307] Fixes for subscription --- .../DefaultTelemetrySubscriptionService.java | 56 +++++++++++++++++-- .../service/telemetry/sub/Subscription.java | 4 -- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 3ded8827fb..617cd20f88 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -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, @@ -68,6 +68,7 @@ import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; @@ -140,24 +141,64 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Override public void addLocalWsSubscription(String sessionId, EntityId entityId, SubscriptionState sub) { + long startTime = 0L; + long endTime = 0L; if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { EntityView entityView = entityViewService.findEntityViewById(new EntityViewId(entityId.getId())); entityId = entityView.getEntityId(); + startTime = entityView.getStartTimeMs(); + endTime = entityView.getEndTimeMs(); + sub = getUpdatedSubscriptionState(entityId, sub, entityView); } Optional server = routingService.resolveById(entityId); Subscription subscription; if (server.isPresent()) { ServerAddress address = server.get(); log.trace("[{}] Forwarding subscription [{}] for [{}] entity [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId, address); - subscription = new Subscription(sub, true, address); + subscription = new Subscription(sub, true, address, startTime, endTime); tellNewSubscription(address, sessionId, subscription); } else { log.trace("[{}] Registering local subscription [{}] for [{}] entity [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId); - subscription = new Subscription(sub, true); + subscription = new Subscription(sub, true, null, startTime, endTime); } registerSubscription(sessionId, entityId, subscription); } + private SubscriptionState getUpdatedSubscriptionState(EntityId entityId, SubscriptionState sub, EntityView entityView) { + boolean allKeys; + Map keyStates; + if (sub.getType().equals(TelemetryFeature.TIMESERIES) && !entityView.getKeys().getTimeseries().isEmpty()) { + allKeys = false; + keyStates = sub.getKeyStates().entrySet() + .stream().filter(entry -> entityView.getKeys().getTimeseries().contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } else if (sub.getType().equals(TelemetryFeature.ATTRIBUTES)) { + if (sub.getScope().equals(DataConstants.CLIENT_SCOPE) && !entityView.getKeys().getAttributes().getCs().isEmpty()) { + allKeys = false; + keyStates = filterMap(sub, entityView.getKeys().getAttributes().getCs()); + } else if (sub.getScope().equals(DataConstants.SERVER_SCOPE) && !entityView.getKeys().getAttributes().getSs().isEmpty()) { + allKeys = false; + keyStates = filterMap(sub, entityView.getKeys().getAttributes().getSs()); + } else if (sub.getScope().equals(DataConstants.SERVER_SCOPE) && !entityView.getKeys().getAttributes().getSh().isEmpty()) { + allKeys = false; + keyStates = filterMap(sub, entityView.getKeys().getAttributes().getSh()); + } else { + allKeys = sub.isAllKeys(); + keyStates = sub.getKeyStates(); + } + } else { + allKeys = sub.isAllKeys(); + keyStates = sub.getKeyStates(); + } + return new SubscriptionState(sub.getWsSessionId(), sub.getSubscriptionId(), entityId, sub.getType(), allKeys, keyStates, sub.getScope()); + } + + private Map filterMap(SubscriptionState sub, List allowedKeys) { + return sub.getKeyStates().entrySet() + .stream().filter(entry -> allowedKeys.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + @Override public void cleanupLocalWsSessionSubscriptions(TelemetryWebSocketSessionRef sessionRef, String sessionId) { cleanupLocalWsSessionSubscriptions(sessionId); @@ -426,7 +467,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio onLocalSubUpdate(entityId, s -> TelemetryFeature.ATTRIBUTES == s.getType() && (StringUtils.isEmpty(s.getScope()) || scope.equals(s.getScope())), s -> { List subscriptionUpdate = null; for (AttributeKvEntry kv : attributes) { - if (s.isAllKeys() || s.getKeyStates().containsKey(kv.getKey())) { + if (isInTimeRange(s, kv.getLastUpdateTs()) && (s.isAllKeys() || s.getKeyStates().containsKey(kv.getKey()))) { if (subscriptionUpdate == null) { subscriptionUpdate = new ArrayList<>(); } @@ -441,7 +482,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio onLocalSubUpdate(entityId, s -> TelemetryFeature.TIMESERIES == s.getType(), s -> { List subscriptionUpdate = null; for (TsKvEntry kv : ts) { - if (s.isAllKeys() || s.getKeyStates().containsKey((kv.getKey()))) { + if (isInTimeRange(s, kv.getTs()) && (s.isAllKeys() || s.getKeyStates().containsKey((kv.getKey())))) { if (subscriptionUpdate == null) { subscriptionUpdate = new ArrayList<>(); } @@ -452,6 +493,11 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio }); } + private boolean isInTimeRange(Subscription subscription, long kvTime) { + return (subscription.getStartTime() == 0 || subscription.getStartTime() <= kvTime) + && (subscription.getEndTime() == 0 || subscription.getEndTime() >= kvTime); + } + private void onLocalSubUpdate(EntityId entityId, Predicate filter, Function> f) { Set deviceSubscriptions = subscriptionsByEntityId.get(entityId); if (deviceSubscriptions != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java index 47c80e9aa6..32d6243a61 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java @@ -33,10 +33,6 @@ public class Subscription { private long startTime; private long endTime; - public Subscription(SubscriptionState sub, boolean local) { - this(sub, local, null); - } - public Subscription(SubscriptionState sub, boolean local, ServerAddress server) { this(sub, local, server, 0L, 0L); } From 531a16cd1223ef903a98261ddc8cd912f7dbb20b Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 18 Sep 2018 22:21:39 +0300 Subject: [PATCH 111/307] Fixes for subscription --- .../service/telemetry/DefaultTelemetrySubscriptionService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 617cd20f88..fb9a160a2c 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -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 b40b8c8e9f720a92fe00ff55f0d2341710726463 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 18 Sep 2018 23:00:27 +0300 Subject: [PATCH 112/307] Code review fixes --- .../main/data/upgrade/2.1.1/schema_update.cql | 33 ++----------- .../install/ThingsboardInstallService.java | 2 - .../install/DefaultDataUpdateService.java | 4 -- .../service/security/AccessValidator.java | 10 +++- .../BaseEntityViewControllerTest.java | 44 ++++++++++------- .../server/common/data/EntityView.java | 4 +- .../dao/customer/CustomerServiceImpl.java | 5 ++ .../entityview/CassandraEntityViewDao.java | 49 ++++++------------- .../server/dao/entityview/EntityViewDao.java | 25 ---------- .../dao/entityview/EntityViewService.java | 22 ++------- .../dao/entityview/EntityViewServiceImpl.java | 48 +----------------- .../server/dao/model/ModelConstants.java | 6 +-- .../dao/model/nosql/EntityViewEntity.java | 11 +++-- .../dao/model/sql/EntityViewEntity.java | 17 +++++-- .../sql/entityview/EntityViewRepository.java | 28 ----------- .../dao/sql/entityview/JpaEntityViewDao.java | 31 +----------- .../server/dao/tenant/TenantServiceImpl.java | 5 ++ dao/src/main/resources/cassandra/schema.cql | 26 +--------- .../test/resources/sql/drop-all-tables.sql | 2 +- 19 files changed, 102 insertions(+), 270 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index 888f43c5ad..a633634f60 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -14,10 +14,9 @@ -- limitations under the License. -- -DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_name; -DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_entity; -DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_customer; -DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_name; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_customer; DROP TABLE IF EXISTS thingsboard.entity_views; @@ -36,7 +35,7 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( PRIMARY KEY (id, entity_id, tenant_id, customer_id) ); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_name AS SELECT * from thingsboard.entity_views WHERE tenant_id IS NOT NULL @@ -58,18 +57,7 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_sea PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id) WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity AS - SELECT * - from thingsboard.entity_views - WHERE tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND entity_id IS NOT NULL - AND search_text IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id) - WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer AS SELECT * from thingsboard.entity_views WHERE tenant_id IS NOT NULL @@ -79,14 +67,3 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_cu AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS - SELECT * - from thingsboard.entity_views - WHERE tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND entity_id IS NOT NULL - AND search_text IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, entity_id, search_text, id) - WITH CLUSTERING ORDER BY (customer_id DESC, entity_id ASC, search_text ASC, id DESC); diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 347b05409e..5b2dab6bee 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -93,8 +93,6 @@ public class ThingsboardInstallService { databaseUpgradeService.upgradeDatabase("2.0.0"); - dataUpdateService.updateData("2.0.0"); - log.info("Updating system data..."); systemDataLoaderService.deleteSystemWidgetBundle("charts"); diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java index 0194a5dfd7..5daebcc3c5 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDataUpdateService.java @@ -49,10 +49,6 @@ public class DefaultDataUpdateService implements DataUpdateService { log.info("Updating data from version 1.4.0 to 2.0.0 ..."); tenantsDefaultRuleChainUpdater.updateEntities(null); break; - case "2.0.0": - log.info("Updating data from version 2.0.0 to 2.1.1 ..."); - tenantsDefaultRuleChainUpdater.updateEntities(null); - break; default: throw new RuntimeException("Unable to update data, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java index 7f4f23a0ef..eaf1018ff2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java @@ -30,7 +30,15 @@ import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.*; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.controller.HttpValidationCallback; diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 5c62e6ad13..98ca7c244a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -22,7 +22,11 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.common.data.*; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.objects.AttributesEntityView; import org.thingsboard.server.common.data.objects.TelemetryEntityView; @@ -31,7 +35,10 @@ import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.model.ModelConstants; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -43,7 +50,6 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes private Tenant savedTenant; private User tenantAdmin; private Device testDevice; - private TelemetryEntityView obj; @Before public void beforeTest() throws Exception { @@ -67,14 +73,6 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes device.setName("Test device"); device.setType("default"); testDevice = doPost("/api/device", device, Device.class); - obj = new TelemetryEntityView( - Arrays.asList("109L", "209L"), - new AttributesEntityView( - Arrays.asList("caKey1", "caKey2"), - Arrays.asList("saKey1", "saKey2", "saKey3"), - Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4") - ) - ); } @After @@ -239,14 +237,16 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } TextPageData pageData = doGetTypedWithPageLink(urlTemplate, - new TypeReference>(){}, new TextPageLink(4, name1)); + new TypeReference>() { + }, new TextPageLink(4, name1)); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); for (EntityView view : loadedNamesOfView2) { doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } - pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>(){}, + pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>() { + }, new TextPageLink(4, name2)); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); @@ -287,14 +287,16 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } TextPageData pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", - new TypeReference>(){}, new TextPageLink(4, name1)); + new TypeReference>() { + }, new TextPageLink(4, name1)); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); for (EntityView view : loadedNamesOfView2) { doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); } - pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", new TypeReference>(){}, + pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", new TypeReference>() { + }, new TextPageLink(4, name2)); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); @@ -305,7 +307,14 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setEntityId(testDevice.getId()); view.setTenantId(savedTenant.getId()); view.setName(name); - view.setKeys(new TelemetryEntityView(obj)); + + view.setKeys(new TelemetryEntityView( + Arrays.asList("109L", "209L"), + new AttributesEntityView( + Arrays.asList("caKey1", "caKey2"), + Arrays.asList("saKey1", "saKey2", "saKey3"), + Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4")))); + return doPost("/api/entityView", view, EntityView.class); } @@ -346,7 +355,8 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes List loadedItems = new ArrayList<>(); TextPageData pageData; do { - pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>(){}, pageLink); + pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>() { + }, pageLink); loadedItems.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageData.getNextPageLink(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index b86c605464..25b066d2ef 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -15,7 +15,9 @@ */ package org.thingsboard.server.common.data; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java index 36b250d218..9d96f3a97a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java @@ -32,6 +32,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.DataValidator; @@ -69,6 +70,9 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom @Autowired private DeviceService deviceService; + @Autowired + private EntityViewService entityViewService; + @Autowired private DashboardService dashboardService; @@ -113,6 +117,7 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom dashboardService.unassignCustomerDashboards(customerId); assetService.unassignCustomerAssets(customer.getTenantId(), customerId); deviceService.unassignCustomerDevices(customer.getTenantId(), customerId); + entityViewService.unassignCustomerEntityViews(customer.getTenantId(), customerId); userService.deleteCustomerUsers(customer.getTenantId(), customerId); deleteEntityRelations(customerId); customerDao.removeById(customerId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index 98f6105646..e866bc89b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -30,11 +30,23 @@ import org.thingsboard.server.dao.model.nosql.EntityViewEntity; import org.thingsboard.server.dao.nosql.CassandraAbstractSearchTextDao; import org.thingsboard.server.dao.util.NoSqlDao; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; import static com.datastax.driver.core.querybuilder.QueryBuilder.select; -import static org.thingsboard.server.dao.model.ModelConstants.*; +import static org.thingsboard.server.dao.model.ModelConstants.CUSTOMER_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_ID_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_NAME_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.TENANT_ID_PROPERTY; /** * Created by Victor Basanets on 9/06/2017. @@ -84,19 +96,6 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewByTenantIdAndEntityId(UUID tenantId, UUID entityId, TextPageLink pageLink) { - log.debug("Try to find entity views by tenantId [{}], entityId [{}] and pageLink [{}]", - tenantId, entityId, pageLink); - List entityViewEntities = findPageWithTextSearch( - ENTITY_VIEW_BY_TENANT_AND_ENTITY_AND_SEARCH_TEXT, - Arrays.asList(eq(CUSTOMER_ID_PROPERTY, entityId), eq(TENANT_ID_PROPERTY, tenantId)), - pageLink); - log.trace("Found entity views [{}] by tenantId [{}], entityId [{}] and pageLink [{}]", - entityViewEntities, tenantId, entityId, pageLink); - return DaoUtil.convertDataList(entityViewEntities); - } - @Override public List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, TextPageLink pageLink) { log.debug("Try to find entity views by tenantId [{}], customerId[{}] and pageLink [{}]", @@ -110,26 +109,6 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, - UUID customerId, - UUID entityId, - TextPageLink pageLink) { - - log.debug("Try to find entity views by tenantId [{}], customerId [{}], entityId [{}] and pageLink [{}]", - tenantId, customerId, entityId, pageLink); - List entityViewEntities = findPageWithTextSearch( - ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_ENTITY_AND_SEARCH_TEXT, - Arrays.asList( - eq(TENANT_ID_PROPERTY, tenantId), - eq(CUSTOMER_ID_PROPERTY, customerId), - eq(ENTITY_ID_COLUMN, entityId)), - pageLink); - log.trace("Found devices [{}] by tenantId [{}], customerId [{}], entityId [{}] and pageLink [{}]", - entityViewEntities, tenantId, customerId, entityId, pageLink); - return DaoUtil.convertDataList(entityViewEntities); - } - @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { log.debug("Try to find entity views by tenantId [{}] and entityId [{}]", tenantId, entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index be643ef363..742cb92d90 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -56,18 +56,6 @@ public interface EntityViewDao extends Dao { */ Optional findEntityViewByTenantIdAndName(UUID tenantId, String name); - /** - * Find entity views by tenantId, entityId and page link. - * - * @param tenantId the tenantId - * @param entityId the entityId - * @param pageLink the page link - * @return the list of entity view objects - */ - List findEntityViewByTenantIdAndEntityId(UUID tenantId, - UUID entityId, - TextPageLink pageLink); - /** * Find entity views by tenantId, customerId and page link. * @@ -80,19 +68,6 @@ public interface EntityViewDao extends Dao { UUID customerId, TextPageLink pageLink); - /** - * Find entity views by tenantId, customerId, entityId and page link. - * - * @param tenantId the tenantId - * @param customerId the customerId - * @param entityId the entityId - * @param pageLink the page link - * @return the list of entity view objects - */ - List findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, - UUID customerId, - UUID entityId, - TextPageLink pageLink); ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index b9c238a79a..89b7e2f85c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -16,14 +16,12 @@ package org.thingsboard.server.dao.entityview; import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.EntitySubtype; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; -import org.thingsboard.server.common.data.id.*; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; @@ -36,8 +34,6 @@ public interface EntityViewService { EntityView findEntityViewById(EntityViewId entityViewId); - EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name); - EntityView saveEntityView(EntityView entityView); EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId); @@ -48,19 +44,11 @@ public interface EntityViewService { TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); - TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, - TextPageLink pageLink); - - void deleteEntityViewByTenantId(TenantId tenantId); + void deleteEntityViewsByTenantId(TenantId tenantId); TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink); - TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, - CustomerId customerId, - EntityId entityId, - TextPageLink pageLink); - void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index c13261c003..dd3e61d611 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -96,15 +96,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return entityViewDao.findById(entityViewId.getId()); } - @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #name}") - @Override - public EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name) { - log.trace("Executing findEntityViewByTenantIdAndName [{}][{}]", tenantId, name); - validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - return entityViewDao.findEntityViewByTenantIdAndName(tenantId.getId(), name) - .orElse(null); - } - @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.name}") @Override public EntityView saveEntityView(EntityView entityView) { @@ -187,24 +178,8 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti } @Override - public TextPageData findEntityViewByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, - TextPageLink pageLink) { - - log.trace("Executing findEntityViewByTenantIdAndType, tenantId [{}], entityId [{}], pageLink [{}]", - tenantId, entityId, pageLink); - - validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - validateString(entityId.toString(), "Incorrect entityId " + entityId.toString()); - validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); - List entityViews = entityViewDao.findEntityViewByTenantIdAndEntityId(tenantId.getId(), - entityId.getId(), pageLink); - - return new TextPageData<>(entityViews, pageLink); - } - - @Override - public void deleteEntityViewByTenantId(TenantId tenantId) { - log.trace("Executing deleteEntityViewByTenantId, tenantId [{}]", tenantId); + public void deleteEntityViewsByTenantId(TenantId tenantId) { + log.trace("Executing deleteEntityViewsByTenantId, tenantId [{}]", tenantId); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); tenantEntityViewRemover.removeEntities(tenantId); } @@ -225,25 +200,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } - @Override - public TextPageData findEntityViewsByTenantIdAndCustomerIdAndEntityId(TenantId tenantId, - CustomerId customerId, - EntityId entityId, - TextPageLink pageLink) { - - log.trace("Executing findEntityViewsByTenantIdAndCustomerIdAndType, tenantId [{}], customerId [{}]," + - " entityId [{}], pageLink [{}]", tenantId, customerId, entityId, pageLink); - - validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); - validateString(entityId.toString(), "Incorrect entityId " + entityId.toString()); - validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); - List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndEntityId( - tenantId.getId(), customerId.getId(), entityId.getId(), pageLink); - - return new TextPageData<>(entityViews, pageLink); - } - @Override public void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId) { log.trace("Executing unassignCustomerEntityViews, tenantId [{}], customerId [{}]", tenantId, customerId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 45d0ff9493..9890ff62d3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -150,15 +150,13 @@ public class ModelConstants { public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; - public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_ENTITY_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer_and_entity"; - public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT = "entity_views_by_tenant_and_customer"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT = "entity_view_by_tenant_and_customer"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; public static final String ENTITY_VIEW_END_TS_PROPERTY = "end_ts"; public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_tenant_and_search_text"; - public static final String ENTITY_VIEW_BY_TENANT_AND_NAME = "entity_views_by_tenant_and_name"; - public static final String ENTITY_VIEW_BY_TENANT_AND_ENTITY_AND_SEARCH_TEXT = "entity_view_by_tenant_and_entity"; + public static final String ENTITY_VIEW_BY_TENANT_AND_NAME = "entity_view_by_tenant_and_name"; /** * Cassandra audit log constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index 79dd497173..eb7f4fe65e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -24,10 +24,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import lombok.extern.slf4j.Slf4j; import org.hibernate.annotations.Type; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.id.*; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.SearchTextEntity; @@ -48,6 +52,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.ID_PROPERTY; @Table(name = ENTITY_VIEW_TABLE_FAMILY_NAME) @EqualsAndHashCode @ToString +@Slf4j public class EntityViewEntity implements SearchTextEntity { @PartitionKey(value = 0) @@ -112,7 +117,7 @@ public class EntityViewEntity implements SearchTextEntity { try { this.keys = mapper.writeValueAsString(entityView.getKeys()); } catch (IOException e) { - e.printStackTrace(); + log.error("Unable to serialize entity view keys!", e); } this.startTs = entityView.getStartTimeMs(); this.endTs = entityView.getEndTimeMs(); @@ -142,7 +147,7 @@ public class EntityViewEntity implements SearchTextEntity { try { entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); } catch (IOException e) { - e.printStackTrace(); + log.error("Unable to read entity view keys!", e); } entityView.setStartTimeMs(startTs); entityView.setEndTimeMs(endTs); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index 96d187cfb6..60f6951b80 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -20,18 +20,26 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; import org.hibernate.annotations.Type; import org.hibernate.annotations.TypeDef; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.id.*; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.SearchTextEntity; import org.thingsboard.server.dao.util.mapping.JsonStringType; -import javax.persistence.*; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Table; import java.io.IOException; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPERTY; @@ -45,6 +53,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPER @Entity @TypeDef(name = "json", typeClass = JsonStringType.class) @Table(name = ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME) +@Slf4j public class EntityViewEntity extends BaseSqlEntity implements SearchTextEntity { @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) @@ -103,7 +112,7 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc try { this.keys = mapper.writeValueAsString(entityView.getKeys()); } catch (IOException e) { - e.printStackTrace(); + log.error("Unable to serialize entity view keys!", e); } this.startTs = entityView.getStartTimeMs(); this.endTs = entityView.getEndTimeMs(); @@ -139,7 +148,7 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc try { entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); } catch (IOException e) { - e.printStackTrace(); + log.error("Unable to read entity view keys!", e); } entityView.setStartTimeMs(startTs); entityView.setEndTimeMs(endTs); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index f1ee3fd272..0f5fdf5fba 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -40,16 +40,6 @@ public interface EntityViewRepository extends CrudRepository :idOffset ORDER BY e.id") - List findByTenantIdAndEntityId(@Param("tenantId") String tenantId, - @Param("entityId") String entityId, - @Param("textSearch") String textSearch, - @Param("idOffset") String idOffset, - Pageable pageable); - @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + "AND e.customerId = :customerId " + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:searchText, '%')) " + @@ -60,25 +50,7 @@ public interface EntityViewRepository extends CrudRepository :idOffset ORDER BY e.id") - List findByTenantIdAndCustomerIdAndEntityId(@Param("tenantId") String tenantId, - @Param("customerId") String customerId, - @Param("entityId") String entityId, - @Param("textSearch") String textSearch, - @Param("idOffset") String idOffset, - Pageable pageable); - EntityViewEntity findByTenantIdAndName(String tenantId, String name); - List findAllByTenantIdAndCustomerIdAndIdIn(String tenantId, - String customerId, - List entityViewsIds); - - List findAllByTenantIdAndIdIn(String tenantId, List entityViewsIds); - List findAllByTenantIdAndEntityId(String tenantId, String entityId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 4ff21ab892..0cd1b2b0d6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -53,7 +53,7 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao getEntityClass() { @@ -81,19 +81,6 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao findEntityViewByTenantIdAndEntityId(UUID tenantId, - UUID entityId, - TextPageLink pageLink) { - return DaoUtil.convertDataList( - entityViewRepository.findByTenantIdAndEntityId( - fromTimeUUID(tenantId), - fromTimeUUID(entityId), - Objects.toString(pageLink.getTextSearch(), ""), - pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), - new PageRequest(0, pageLink.getLimit()))); - } - @Override public List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, @@ -108,22 +95,6 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao findEntityViewsByTenantIdAndCustomerIdAndEntityId(UUID tenantId, - UUID customerId, - UUID entityId, - TextPageLink pageLink) { - return DaoUtil.convertDataList( - entityViewRepository.findByTenantIdAndCustomerIdAndEntityId( - fromTimeUUID(tenantId), - fromTimeUUID(customerId), - fromTimeUUID(entityId), - Objects.toString(pageLink.getTextSearch(), ""), - pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), - new PageRequest(0, pageLink.getLimit()) - )); - } - @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { return service.submit(() -> DaoUtil.convertDataList( diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index d92c9410e5..a94e715616 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -29,6 +29,7 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DataValidator; @@ -63,6 +64,9 @@ public class TenantServiceImpl extends AbstractEntityService implements TenantSe @Autowired private DeviceService deviceService; + @Autowired + private EntityViewService entityViewService; + @Autowired private WidgetsBundleService widgetsBundleService; @@ -103,6 +107,7 @@ public class TenantServiceImpl extends AbstractEntityService implements TenantSe dashboardService.deleteDashboardsByTenantId(tenantId); assetService.deleteAssetsByTenantId(tenantId); deviceService.deleteDevicesByTenantId(tenantId); + entityViewService.deleteEntityViewsByTenantId(tenantId); userService.deleteTenantAdmins(tenantId); ruleChainService.deleteRuleChainsByTenantId(tenantId); tenantDao.removeById(tenantId.getId()); diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql index 4645420eb8..07cecd0453 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema.cql @@ -674,7 +674,7 @@ CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( PRIMARY KEY (id, entity_id, tenant_id, customer_id) ); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_name AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_name AS SELECT * from thingsboard.entity_views WHERE tenant_id IS NOT NULL @@ -696,18 +696,7 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_sea PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id) WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity AS - SELECT * - from thingsboard.entity_views - WHERE tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND entity_id IS NOT NULL - AND search_text IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, entity_id, search_text, id, customer_id) - WITH CLUSTERING ORDER BY (entity_id ASC, search_text ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer AS +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer AS SELECT * from thingsboard.entity_views WHERE tenant_id IS NOT NULL @@ -717,14 +706,3 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_cu AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_views_by_tenant_and_customer_and_entity AS - SELECT * - from thingsboard.entity_views - WHERE tenant_id IS NOT NULL - AND customer_id IS NOT NULL - AND entity_id IS NOT NULL - AND search_text IS NOT NULL - AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, entity_id, search_text, id) - WITH CLUSTERING ORDER BY (customer_id DESC, entity_id ASC, search_text ASC, id DESC); diff --git a/dao/src/test/resources/sql/drop-all-tables.sql b/dao/src/test/resources/sql/drop-all-tables.sql index ebc04b3938..b1fb72cc07 100644 --- a/dao/src/test/resources/sql/drop-all-tables.sql +++ b/dao/src/test/resources/sql/drop-all-tables.sql @@ -19,4 +19,4 @@ DROP TABLE IF EXISTS widget_type; DROP TABLE IF EXISTS widgets_bundle; DROP TABLE IF EXISTS rule_node; DROP TABLE IF EXISTS rule_chain; -DROP TABLE IF EXISTS entity_views; \ No newline at end of file +DROP TABLE IF EXISTS entity_views; From 8d7a27c49c9c2a9412489bfab3ae2fb3c2a2297c Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 19 Sep 2018 10:44:29 +0300 Subject: [PATCH 113/307] Added check for keys --- .../controller/BaseEntityViewControllerTest.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 98ca7c244a..b368ea1989 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -50,6 +50,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes private Tenant savedTenant; private User tenantAdmin; private Device testDevice; + private TelemetryEntityView keys; @Before public void beforeTest() throws Exception { @@ -73,6 +74,13 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes device.setName("Test device"); device.setType("default"); testDevice = doPost("/api/device", device, Device.class); + + keys = new TelemetryEntityView( + Arrays.asList("109L", "209L"), + new AttributesEntityView( + Arrays.asList("caKey1", "caKey2"), + Arrays.asList("saKey1", "saKey2", "saKey3"), + Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4"))); } @After @@ -108,6 +116,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes EntityView foundEntityView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertEquals(foundEntityView.getName(), savedView.getName()); + Assert.assertEquals(foundEntityView.getKeys(), keys); } @Test @@ -308,12 +317,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setTenantId(savedTenant.getId()); view.setName(name); - view.setKeys(new TelemetryEntityView( - Arrays.asList("109L", "209L"), - new AttributesEntityView( - Arrays.asList("caKey1", "caKey2"), - Arrays.asList("saKey1", "saKey2", "saKey3"), - Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4")))); + view.setKeys(keys); return doPost("/api/entityView", view, EntityView.class); } From 3727519e511597dc7ec2d7db828976f1bae20eda Mon Sep 17 00:00:00 2001 From: Sergey Tarnavskiy Date: Wed, 19 Sep 2018 12:27:38 +0300 Subject: [PATCH 114/307] fixed issue with gauge value --- ui/src/app/widget/lib/CanvasDigitalGauge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/app/widget/lib/CanvasDigitalGauge.js b/ui/src/app/widget/lib/CanvasDigitalGauge.js index ee8b0ed559..331ce6c5aa 100644 --- a/ui/src/app/widget/lib/CanvasDigitalGauge.js +++ b/ui/src/app/widget/lib/CanvasDigitalGauge.js @@ -209,7 +209,7 @@ export default class CanvasDigitalGauge extends canvasGauges.BaseGauge { this.elementValueClone.renderedValue = this._value; } if (angular.isUndefined(this.elementValueClone.renderedValue)) { - this.elementValueClone.renderedValue = options.minValue; + this.elementValueClone.renderedValue = this.value; } let context = this.contextValueClone; // clear the cache From 2adb39ba6c11733ffd763e59e04ed28e6fd434f3 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 20 Sep 2018 16:03:44 +0300 Subject: [PATCH 115/307] code review fixes --- .../src/main/resources/thingsboard.yml | 6 +- .../controller/ControllerNoSqlTestSuite.java | 3 +- .../controller/ControllerSqlTestSuite.java | 2 +- .../server/mqtt/MqttNoSqlTestSuite.java | 3 +- .../server/mqtt/MqttSqlTestSuite.java | 4 +- .../rules/RuleEngineNoSqlTestSuite.java | 3 +- .../server/rules/RuleEngineSqlTestSuite.java | 2 +- .../server/system/SystemNoSqlTestSuite.java | 3 +- .../server/system/SystemSqlTestSuite.java | 2 +- .../server/dao/util/HybridDao.java | 22 - .../server/dao/util/NoSqlAnyDao.java | 2 +- .../thingsboard/server/dao/util/NoSqlDao.java | 2 +- .../thingsboard/server/dao/util/SqlDao.java | 2 +- dao/src/main/resources/cassandra/schema.cql | 640 ------------------ dao/src/main/resources/sql/schema.sql | 253 ------- .../server/dao/JpaDaoTestSuite.java | 2 +- .../server/dao/NoSqlDaoServiceTestSuite.java | 4 +- .../server/dao/SqlDaoServiceTestSuite.java | 2 +- dao/src/test/resources/nosql-test.properties | 3 +- dao/src/test/resources/sql-test.properties | 3 +- docker/k8s/cassandra-setup.yaml | 4 +- docker/k8s/cassandra-upgrade.yaml | 4 +- docker/k8s/tb.yaml | 7 +- docker/tb.env | 3 +- docker/tb/run-application.sh | 4 +- 25 files changed, 42 insertions(+), 943 deletions(-) delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java delete mode 100644 dao/src/main/resources/cassandra/schema.cql delete mode 100644 dao/src/main/resources/sql/schema.sql diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index a379e472aa..0cc075c1fc 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -151,7 +151,6 @@ quota: # Enable Host API Limits enabled: "${QUOTA_TENANT_ENABLED:false}" # Array of whitelist tenants - # Array of whitelist tenants whitelist: "${QUOTA_TENANT_WHITELIST:}" # Array of blacklist tenants blacklist: "${QUOTA_HOST_BLACKLIST:}" @@ -160,11 +159,10 @@ quota: intervalMin: 2 database: - type: "${DATABASE_TYPE:sql}" # cassandra OR sql entities: - type: "${DATABASE_TYPE:sql}" # cassandra OR sql + type: "${DATABASE_TS_TYPE:sql}" # cassandra OR sql ts: - type: "${DATABASE_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) + type: "${DATABASE_CASSANDRA_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) # Cassandra driver configuration parameters diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java index 2e8848305f..f378437312 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java @@ -32,7 +32,8 @@ public class ControllerNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false), new ClassPathCQLDataSet("cassandra/system-test.cql", false, false)), "cassandra-test.yaml", 30000l); diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index f316051212..3b3d9b04a6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -30,7 +30,7 @@ public class ControllerSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); } diff --git a/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java index c4a969b1a9..2bb8a81ef2 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java @@ -32,7 +32,8 @@ public class MqttNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false)), "cassandra-test.yaml", 30000l); } diff --git a/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java index 5ddbb67c97..1389c7edde 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java @@ -15,11 +15,9 @@ */ package org.thingsboard.server.mqtt; -import org.cassandraunit.dataset.cql.ClassPathCQLDataSet; import org.junit.ClassRule; import org.junit.extensions.cpsuite.ClasspathSuite; import org.junit.runner.RunWith; -import org.thingsboard.server.dao.CustomCassandraCQLUnit; import org.thingsboard.server.dao.CustomSqlUnit; import java.util.Arrays; @@ -31,7 +29,7 @@ public class MqttSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); } diff --git a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java index bffe4913ef..c1d1e6cbaf 100644 --- a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java @@ -35,7 +35,8 @@ public class RuleEngineNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false)), "cassandra-test.yaml", 30000l); diff --git a/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java index 7b13e2fd54..e09d820777 100644 --- a/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java @@ -30,7 +30,7 @@ public class RuleEngineSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); } diff --git a/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java index 70e3fe2147..ebde304fd6 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java @@ -34,7 +34,8 @@ public class SystemNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false)), "cassandra-test.yaml", 30000l); } diff --git a/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java index 97c67491f1..8ca6dcc877 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java @@ -31,7 +31,7 @@ public class SystemSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java deleted file mode 100644 index 2caf8ccace..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/util/HybridDao.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.util; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; - -@ConditionalOnExpression("'${database.entity.type}'=='sql' && '${database.ts.type}'=='cassandra'") -public @interface HybridDao { -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java index 7e7aefa3c3..a8049eec5f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java @@ -17,6 +17,6 @@ package org.thingsboard.server.dao.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; -@ConditionalOnExpression("'${database.type}'=='cassandra' || '${database.ts.type}'=='cassandra'") +@ConditionalOnExpression("'${database.ts.type}'=='cassandra' || '${database.entities.type}'=='cassandra'") public @interface NoSqlAnyDao { } diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java index 96dbdab758..c3a719d893 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java @@ -17,6 +17,6 @@ package org.thingsboard.server.dao.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -@ConditionalOnProperty(prefix = "database", value = "type", havingValue = "cassandra") +@ConditionalOnProperty(prefix = "database.entities", value = "type", havingValue = "cassandra") public @interface NoSqlDao { } diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java index 3986f022d9..ab39c847b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java @@ -17,6 +17,6 @@ package org.thingsboard.server.dao.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -@ConditionalOnProperty(prefix = "database", value = "type", havingValue = "sql") +@ConditionalOnProperty(prefix = "database.entities", value = "type", havingValue = "sql") public @interface SqlDao { } diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema.cql deleted file mode 100644 index f03122ab6b..0000000000 --- a/dao/src/main/resources/cassandra/schema.cql +++ /dev/null @@ -1,640 +0,0 @@ --- --- Copyright © 2016-2018 The Thingsboard Authors --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. --- - -CREATE KEYSPACE IF NOT EXISTS thingsboard -WITH replication = { - 'class' : 'SimpleStrategy', - 'replication_factor' : 1 -}; - -CREATE TABLE IF NOT EXISTS thingsboard.user ( - id timeuuid, - tenant_id timeuuid, - customer_id timeuuid, - email text, - search_text text, - authority text, - first_name text, - last_name text, - additional_info text, - PRIMARY KEY (id, tenant_id, customer_id, authority) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_by_email AS - SELECT * - from thingsboard.user - WHERE email IS NOT NULL AND tenant_id IS NOT NULL AND customer_id IS NOT NULL AND id IS NOT NULL AND authority IS NOT - NULL - PRIMARY KEY ( email, tenant_id, customer_id, id, authority ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_by_tenant_and_search_text AS - SELECT * - from thingsboard.user - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND authority IS NOT NULL AND search_text IS NOT NULL AND id - IS NOT NULL - PRIMARY KEY ( tenant_id, customer_id, authority, search_text, id ) - WITH CLUSTERING ORDER BY ( customer_id DESC, authority DESC, search_text ASC, id DESC ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_by_customer_and_search_text AS - SELECT * - from thingsboard.user - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND authority IS NOT NULL AND search_text IS NOT NULL AND id - IS NOT NULL - PRIMARY KEY ( customer_id, tenant_id, authority, search_text, id ) - WITH CLUSTERING ORDER BY ( tenant_id DESC, authority DESC, search_text ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.user_credentials ( - id timeuuid PRIMARY KEY, - user_id timeuuid, - enabled boolean, - password text, - activate_token text, - reset_token text -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_credentials_by_user AS - SELECT * - from thingsboard.user_credentials - WHERE user_id IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( user_id, id ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_credentials_by_activate_token AS - SELECT * - from thingsboard.user_credentials - WHERE activate_token IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( activate_token, id ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.user_credentials_by_reset_token AS - SELECT * - from thingsboard.user_credentials - WHERE reset_token IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( reset_token, id ); - -CREATE TABLE IF NOT EXISTS thingsboard.admin_settings ( - id timeuuid PRIMARY KEY, - key text, - json_value text -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.admin_settings_by_key AS - SELECT * - from thingsboard.admin_settings - WHERE key IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( key, id ) - WITH CLUSTERING ORDER BY ( id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.tenant ( - id timeuuid, - title text, - search_text text, - region text, - country text, - state text, - city text, - address text, - address2 text, - zip text, - phone text, - email text, - additional_info text, - PRIMARY KEY (id, region) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.tenant_by_region_and_search_text AS - SELECT * - from thingsboard.tenant - WHERE region IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( region, search_text, id ) - WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.customer ( - id timeuuid, - tenant_id timeuuid, - title text, - search_text text, - country text, - state text, - city text, - address text, - address2 text, - zip text, - phone text, - email text, - additional_info text, - PRIMARY KEY (id, tenant_id) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.customer_by_tenant_and_title AS - SELECT * - from thingsboard.customer - WHERE tenant_id IS NOT NULL AND title IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, title, id ) - WITH CLUSTERING ORDER BY ( title ASC, id DESC ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.customer_by_tenant_and_search_text AS - SELECT * - from thingsboard.customer - WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, search_text, id ) - WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.device ( - id timeuuid, - tenant_id timeuuid, - customer_id timeuuid, - name text, - type text, - search_text text, - additional_info text, - PRIMARY KEY (id, tenant_id, customer_id, type) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_name AS - SELECT * - from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, name, id, customer_id, type) - WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_search_text AS - SELECT * - from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) - WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_by_type_and_search_text AS - SELECT * - from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) - WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_and_search_text AS - SELECT * - from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) - WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_by_type_and_search_text AS - SELECT * - from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) - WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.device_credentials ( - id timeuuid PRIMARY KEY, - device_id timeuuid, - credentials_type text, - credentials_id text, - credentials_value text -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_credentials_by_device AS - SELECT * - from thingsboard.device_credentials - WHERE device_id IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( device_id, id ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_credentials_by_credentials_id AS - SELECT * - from thingsboard.device_credentials - WHERE credentials_id IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( credentials_id, id ); - -CREATE TABLE IF NOT EXISTS thingsboard.asset ( - id timeuuid, - tenant_id timeuuid, - customer_id timeuuid, - name text, - type text, - search_text text, - additional_info text, - PRIMARY KEY (id, tenant_id, customer_id, type) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_and_name AS - SELECT * - from thingsboard.asset - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, name, id, customer_id, type) - WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_and_search_text AS - SELECT * - from thingsboard.asset - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) - WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_by_type_and_search_text AS - SELECT * - from thingsboard.asset - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) - WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_customer_and_search_text AS - SELECT * - from thingsboard.asset - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) - WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_customer_by_type_and_search_text AS - SELECT * - from thingsboard.asset - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) - WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.entity_subtype ( - tenant_id timeuuid, - entity_type text, // (DEVICE, ASSET) - type text, - PRIMARY KEY (tenant_id, entity_type, type) -); - -CREATE TABLE IF NOT EXISTS thingsboard.alarm ( - id timeuuid, - tenant_id timeuuid, - type text, - originator_id timeuuid, - originator_type text, - severity text, - status text, - start_ts bigint, - end_ts bigint, - ack_ts bigint, - clear_ts bigint, - details text, - propagate boolean, - PRIMARY KEY ((tenant_id, originator_id, originator_type), type, id) -) WITH CLUSTERING ORDER BY ( type ASC, id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.alarm_by_id AS - SELECT * - from thingsboard.alarm - WHERE tenant_id IS NOT NULL AND originator_id IS NOT NULL AND originator_type IS NOT NULL AND type IS NOT NULL - AND type IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (id, tenant_id, originator_id, originator_type, type) - WITH CLUSTERING ORDER BY ( tenant_id ASC, originator_id ASC, originator_type ASC, type ASC); - -CREATE TABLE IF NOT EXISTS thingsboard.relation ( - from_id timeuuid, - from_type text, - to_id timeuuid, - to_type text, - relation_type_group text, - relation_type text, - additional_info text, - PRIMARY KEY ((from_id, from_type), relation_type_group, relation_type, to_id, to_type) -) WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, to_id ASC, to_type ASC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.relation_by_type_and_child_type AS - SELECT * - from thingsboard.relation - WHERE from_id IS NOT NULL AND from_type IS NOT NULL AND relation_type_group IS NOT NULL AND relation_type IS NOT NULL AND to_id IS NOT NULL AND to_type IS NOT NULL - PRIMARY KEY ((from_id, from_type), relation_type_group, relation_type, to_type, to_id) - WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, to_type ASC, to_id DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.reverse_relation AS - SELECT * - from thingsboard.relation - WHERE from_id IS NOT NULL AND from_type IS NOT NULL AND relation_type_group IS NOT NULL AND relation_type IS NOT NULL AND to_id IS NOT NULL AND to_type IS NOT NULL - PRIMARY KEY ((to_id, to_type), relation_type_group, relation_type, from_id, from_type) - WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, from_id ASC, from_type ASC); - -CREATE TABLE IF NOT EXISTS thingsboard.widgets_bundle ( - id timeuuid, - tenant_id timeuuid, - alias text, - title text, - search_text text, - image blob, - PRIMARY KEY (id, tenant_id) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.widgets_bundle_by_tenant_and_search_text AS - SELECT * - from thingsboard.widgets_bundle - WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, search_text, id ) - WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.widgets_bundle_by_tenant_and_alias AS - SELECT * - from thingsboard.widgets_bundle - WHERE tenant_id IS NOT NULL AND alias IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, alias, id ) - WITH CLUSTERING ORDER BY ( alias ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.widget_type ( - id timeuuid, - tenant_id timeuuid, - bundle_alias text, - alias text, - name text, - descriptor text, - PRIMARY KEY (id, tenant_id, bundle_alias) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.widget_type_by_tenant_and_aliases AS - SELECT * - from thingsboard.widget_type - WHERE tenant_id IS NOT NULL AND bundle_alias IS NOT NULL AND alias IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, bundle_alias, alias, id ) - WITH CLUSTERING ORDER BY ( bundle_alias ASC, alias ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.dashboard ( - id timeuuid, - tenant_id timeuuid, - title text, - search_text text, - assigned_customers text, - configuration text, - PRIMARY KEY (id, tenant_id) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.dashboard_by_tenant_and_search_text AS - SELECT * - from thingsboard.dashboard - WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, search_text, id ) - WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_cf ( - entity_type text, // (DEVICE, CUSTOMER, TENANT) - entity_id timeuuid, - key text, - partition bigint, - ts bigint, - bool_v boolean, - str_v text, - long_v bigint, - dbl_v double, - PRIMARY KEY (( entity_type, entity_id, key, partition ), ts) -); - -CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_partitions_cf ( - entity_type text, // (DEVICE, CUSTOMER, TENANT) - entity_id timeuuid, - key text, - partition bigint, - PRIMARY KEY (( entity_type, entity_id, key ), partition) -) WITH CLUSTERING ORDER BY ( partition ASC ) - AND compaction = { 'class' : 'LeveledCompactionStrategy' }; - -CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_latest_cf ( - entity_type text, // (DEVICE, CUSTOMER, TENANT) - entity_id timeuuid, - key text, - ts bigint, - bool_v boolean, - str_v text, - long_v bigint, - dbl_v double, - PRIMARY KEY (( entity_type, entity_id ), key) -) WITH compaction = { 'class' : 'LeveledCompactionStrategy' }; - - -CREATE TABLE IF NOT EXISTS thingsboard.attributes_kv_cf ( - entity_type text, // (DEVICE, CUSTOMER, TENANT) - entity_id timeuuid, - attribute_type text, // (CLIENT_SIDE, SHARED, SERVER_SIDE) - attribute_key text, - bool_v boolean, - str_v text, - long_v bigint, - dbl_v double, - last_update_ts bigint, - PRIMARY KEY ((entity_type, entity_id, attribute_type), attribute_key) -) WITH compaction = { 'class' : 'LeveledCompactionStrategy' }; - -CREATE TABLE IF NOT EXISTS thingsboard.component_descriptor ( - id timeuuid, - type text, - scope text, - name text, - search_text text, - clazz text, - configuration_descriptor text, - actions text, - PRIMARY KEY (clazz, id, type, scope) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.component_desc_by_type_search_text AS - SELECT * - from thingsboard.component_descriptor - WHERE type IS NOT NULL AND scope IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL AND clazz IS NOT NULL - PRIMARY KEY ( type, search_text, id, clazz, scope) - WITH CLUSTERING ORDER BY ( search_text DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.component_desc_by_scope_type_search_text AS - SELECT * - from thingsboard.component_descriptor - WHERE type IS NOT NULL AND scope IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL AND clazz IS NOT NULL - PRIMARY KEY ( (scope, type), search_text, id, clazz) - WITH CLUSTERING ORDER BY ( search_text DESC); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.component_desc_by_id AS - SELECT * - from thingsboard.component_descriptor - WHERE type IS NOT NULL AND scope IS NOT NULL AND id IS NOT NULL AND clazz IS NOT NULL - PRIMARY KEY ( id, clazz, scope, type ) - WITH CLUSTERING ORDER BY ( clazz ASC, scope ASC, type DESC); - -CREATE TABLE IF NOT EXISTS thingsboard.event ( - tenant_id timeuuid, // tenant or system - id timeuuid, - event_type text, - event_uid text, - entity_type text, - entity_id timeuuid, - body text, - PRIMARY KEY ((tenant_id, entity_type, entity_id), event_type, event_uid) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.event_by_type_and_id AS - SELECT * - FROM thingsboard.event - WHERE tenant_id IS NOT NULL AND entity_type IS NOT NULL AND entity_id IS NOT NULL AND id IS NOT NULL - AND event_type IS NOT NULL AND event_uid IS NOT NULL - PRIMARY KEY ((tenant_id, entity_type, entity_id), event_type, id, event_uid) - WITH CLUSTERING ORDER BY (event_type ASC, id ASC, event_uid ASC); - - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.event_by_id AS - SELECT * - FROM thingsboard.event - WHERE tenant_id IS NOT NULL AND entity_type IS NOT NULL AND entity_id IS NOT NULL AND id IS NOT NULL - AND event_type IS NOT NULL AND event_uid IS NOT NULL - PRIMARY KEY ((tenant_id, entity_type, entity_id), id, event_type, event_uid) - WITH CLUSTERING ORDER BY (id ASC, event_type ASC, event_uid ASC); - -CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_entity_id ( - tenant_id timeuuid, - id timeuuid, - customer_id timeuuid, - entity_id timeuuid, - entity_type text, - entity_name text, - user_id timeuuid, - user_name text, - action_type text, - action_data text, - action_status text, - action_failure_details text, - PRIMARY KEY ((tenant_id, entity_id, entity_type), id) -); - -CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_customer_id ( - tenant_id timeuuid, - id timeuuid, - customer_id timeuuid, - entity_id timeuuid, - entity_type text, - entity_name text, - user_id timeuuid, - user_name text, - action_type text, - action_data text, - action_status text, - action_failure_details text, - PRIMARY KEY ((tenant_id, customer_id), id) -); - -CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_user_id ( - tenant_id timeuuid, - id timeuuid, - customer_id timeuuid, - entity_id timeuuid, - entity_type text, - entity_name text, - user_id timeuuid, - user_name text, - action_type text, - action_data text, - action_status text, - action_failure_details text, - PRIMARY KEY ((tenant_id, user_id), id) -); - -CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_tenant_id ( - tenant_id timeuuid, - id timeuuid, - partition bigint, - customer_id timeuuid, - entity_id timeuuid, - entity_type text, - entity_name text, - user_id timeuuid, - user_name text, - action_type text, - action_data text, - action_status text, - action_failure_details text, - PRIMARY KEY ((tenant_id, partition), id) -); - -CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_tenant_id_partitions ( - tenant_id timeuuid, - partition bigint, - PRIMARY KEY (( tenant_id ), partition) -) WITH CLUSTERING ORDER BY ( partition ASC ) -AND compaction = { 'class' : 'LeveledCompactionStrategy' }; - -CREATE TABLE IF NOT EXISTS thingsboard.msg_queue ( - node_id timeuuid, - cluster_partition bigint, - ts_partition bigint, - ts bigint, - msg blob, - PRIMARY KEY ((node_id, cluster_partition, ts_partition), ts)) -WITH CLUSTERING ORDER BY (ts DESC) -AND compaction = { - 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', - 'min_threshold': '5', - 'base_time_seconds': '43200', - 'max_window_size_seconds': '43200', - 'tombstone_threshold': '0.9', - 'unchecked_tombstone_compaction': 'true' -}; - -CREATE TABLE IF NOT EXISTS thingsboard.msg_ack_queue ( - node_id timeuuid, - cluster_partition bigint, - ts_partition bigint, - msg_id timeuuid, - PRIMARY KEY ((node_id, cluster_partition, ts_partition), msg_id)) -WITH CLUSTERING ORDER BY (msg_id DESC) -AND compaction = { - 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', - 'min_threshold': '5', - 'base_time_seconds': '43200', - 'max_window_size_seconds': '43200', - 'tombstone_threshold': '0.9', - 'unchecked_tombstone_compaction': 'true' -}; - -CREATE TABLE IF NOT EXISTS thingsboard.processed_msg_partitions ( - node_id timeuuid, - cluster_partition bigint, - ts_partition bigint, - PRIMARY KEY ((node_id, cluster_partition), ts_partition)) -WITH CLUSTERING ORDER BY (ts_partition DESC) -AND compaction = { - 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', - 'min_threshold': '5', - 'base_time_seconds': '43200', - 'max_window_size_seconds': '43200', - 'tombstone_threshold': '0.9', - 'unchecked_tombstone_compaction': 'true' -}; - -CREATE TABLE IF NOT EXISTS thingsboard.rule_chain ( - id uuid, - tenant_id uuid, - name text, - search_text text, - first_rule_node_id uuid, - root boolean, - debug_mode boolean, - configuration text, - additional_info text, - PRIMARY KEY (id, tenant_id) -); - -CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.rule_chain_by_tenant_and_search_text AS - SELECT * - from thingsboard.rule_chain - WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY ( tenant_id, search_text, id ) - WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); - -CREATE TABLE IF NOT EXISTS thingsboard.rule_node ( - id uuid, - rule_chain_id uuid, - type text, - name text, - debug_mode boolean, - search_text text, - configuration text, - additional_info text, - PRIMARY KEY (id) -); diff --git a/dao/src/main/resources/sql/schema.sql b/dao/src/main/resources/sql/schema.sql deleted file mode 100644 index 91e77da503..0000000000 --- a/dao/src/main/resources/sql/schema.sql +++ /dev/null @@ -1,253 +0,0 @@ --- --- Copyright © 2016-2018 The Thingsboard Authors --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. --- - - -CREATE TABLE IF NOT EXISTS admin_settings ( - id varchar(31) NOT NULL CONSTRAINT admin_settings_pkey PRIMARY KEY, - json_value varchar, - key varchar(255) -); - -CREATE TABLE IF NOT EXISTS alarm ( - id varchar(31) NOT NULL CONSTRAINT alarm_pkey PRIMARY KEY, - ack_ts bigint, - clear_ts bigint, - additional_info varchar, - end_ts bigint, - originator_id varchar(31), - originator_type integer, - propagate boolean, - severity varchar(255), - start_ts bigint, - status varchar(255), - tenant_id varchar(31), - type varchar(255) -); - -CREATE TABLE IF NOT EXISTS asset ( - id varchar(31) NOT NULL CONSTRAINT asset_pkey PRIMARY KEY, - additional_info varchar, - customer_id varchar(31), - name varchar(255), - search_text varchar(255), - tenant_id varchar(31), - type varchar(255) -); - -CREATE TABLE IF NOT EXISTS audit_log ( - id varchar(31) NOT NULL CONSTRAINT audit_log_pkey PRIMARY KEY, - tenant_id varchar(31), - customer_id varchar(31), - entity_id varchar(31), - entity_type varchar(255), - entity_name varchar(255), - user_id varchar(31), - user_name varchar(255), - action_type varchar(255), - action_data varchar(1000000), - action_status varchar(255), - action_failure_details varchar(1000000) -); - -CREATE TABLE IF NOT EXISTS attribute_kv ( - entity_type varchar(255), - entity_id varchar(31), - attribute_type varchar(255), - attribute_key varchar(255), - bool_v boolean, - str_v varchar(10000000), - long_v bigint, - dbl_v double precision, - last_update_ts bigint, - CONSTRAINT attribute_kv_unq_key UNIQUE (entity_type, entity_id, attribute_type, attribute_key) -); - -CREATE TABLE IF NOT EXISTS component_descriptor ( - id varchar(31) NOT NULL CONSTRAINT component_descriptor_pkey PRIMARY KEY, - actions varchar(255), - clazz varchar, - configuration_descriptor varchar, - name varchar(255), - scope varchar(255), - search_text varchar(255), - type varchar(255) -); - -CREATE TABLE IF NOT EXISTS customer ( - id varchar(31) NOT NULL CONSTRAINT customer_pkey PRIMARY KEY, - additional_info varchar, - address varchar, - address2 varchar, - city varchar(255), - country varchar(255), - email varchar(255), - phone varchar(255), - search_text varchar(255), - state varchar(255), - tenant_id varchar(31), - title varchar(255), - zip varchar(255) -); - -CREATE TABLE IF NOT EXISTS dashboard ( - id varchar(31) NOT NULL CONSTRAINT dashboard_pkey PRIMARY KEY, - configuration varchar(10000000), - assigned_customers varchar(1000000), - search_text varchar(255), - tenant_id varchar(31), - title varchar(255) -); - -CREATE TABLE IF NOT EXISTS device ( - id varchar(31) NOT NULL CONSTRAINT device_pkey PRIMARY KEY, - additional_info varchar, - customer_id varchar(31), - type varchar(255), - name varchar(255), - search_text varchar(255), - tenant_id varchar(31) -); - -CREATE TABLE IF NOT EXISTS device_credentials ( - id varchar(31) NOT NULL CONSTRAINT device_credentials_pkey PRIMARY KEY, - credentials_id varchar, - credentials_type varchar(255), - credentials_value varchar, - device_id varchar(31) -); - -CREATE TABLE IF NOT EXISTS event ( - id varchar(31) NOT NULL CONSTRAINT event_pkey PRIMARY KEY, - body varchar, - entity_id varchar(31), - entity_type varchar(255), - event_type varchar(255), - event_uid varchar(255), - tenant_id varchar(31), - CONSTRAINT event_unq_key UNIQUE (tenant_id, entity_type, entity_id, event_type, event_uid) -); - -CREATE TABLE IF NOT EXISTS relation ( - from_id varchar(31), - from_type varchar(255), - to_id varchar(31), - to_type varchar(255), - relation_type_group varchar(255), - relation_type varchar(255), - additional_info varchar, - CONSTRAINT relation_unq_key UNIQUE (from_id, from_type, relation_type_group, relation_type, to_id, to_type) -); - -CREATE TABLE IF NOT EXISTS tb_user ( - id varchar(31) NOT NULL CONSTRAINT tb_user_pkey PRIMARY KEY, - additional_info varchar, - authority varchar(255), - customer_id varchar(31), - email varchar(255) UNIQUE, - first_name varchar(255), - last_name varchar(255), - search_text varchar(255), - tenant_id varchar(31) -); - -CREATE TABLE IF NOT EXISTS tenant ( - id varchar(31) NOT NULL CONSTRAINT tenant_pkey PRIMARY KEY, - additional_info varchar, - address varchar, - address2 varchar, - city varchar(255), - country varchar(255), - email varchar(255), - phone varchar(255), - region varchar(255), - search_text varchar(255), - state varchar(255), - title varchar(255), - zip varchar(255) -); - -CREATE TABLE IF NOT EXISTS ts_kv ( - entity_type varchar(255) NOT NULL, - entity_id varchar(31) NOT NULL, - key varchar(255) NOT NULL, - ts bigint NOT NULL, - bool_v boolean, - str_v varchar(10000000), - long_v bigint, - dbl_v double precision, - CONSTRAINT ts_kv_unq_key UNIQUE (entity_type, entity_id, key, ts) -); - -CREATE TABLE IF NOT EXISTS ts_kv_latest ( - entity_type varchar(255) NOT NULL, - entity_id varchar(31) NOT NULL, - key varchar(255) NOT NULL, - ts bigint NOT NULL, - bool_v boolean, - str_v varchar(10000000), - long_v bigint, - dbl_v double precision, - CONSTRAINT ts_kv_latest_unq_key UNIQUE (entity_type, entity_id, key) -); - -CREATE TABLE IF NOT EXISTS user_credentials ( - id varchar(31) NOT NULL CONSTRAINT user_credentials_pkey PRIMARY KEY, - activate_token varchar(255) UNIQUE, - enabled boolean, - password varchar(255), - reset_token varchar(255) UNIQUE, - user_id varchar(31) UNIQUE -); - -CREATE TABLE IF NOT EXISTS widget_type ( - id varchar(31) NOT NULL CONSTRAINT widget_type_pkey PRIMARY KEY, - alias varchar(255), - bundle_alias varchar(255), - descriptor varchar(1000000), - name varchar(255), - tenant_id varchar(31) -); - -CREATE TABLE IF NOT EXISTS widgets_bundle ( - id varchar(31) NOT NULL CONSTRAINT widgets_bundle_pkey PRIMARY KEY, - alias varchar(255), - search_text varchar(255), - tenant_id varchar(31), - title varchar(255) -); - -CREATE TABLE IF NOT EXISTS rule_chain ( - id varchar(31) NOT NULL CONSTRAINT rule_chain_pkey PRIMARY KEY, - additional_info varchar, - configuration varchar(10000000), - name varchar(255), - first_rule_node_id varchar(31), - root boolean, - debug_mode boolean, - search_text varchar(255), - tenant_id varchar(31) -); - -CREATE TABLE IF NOT EXISTS rule_node ( - id varchar(31) NOT NULL CONSTRAINT rule_node_pkey PRIMARY KEY, - rule_chain_id varchar(31), - additional_info varchar, - configuration varchar(10000000), - type varchar(255), - name varchar(255), - debug_mode boolean, - search_text varchar(255) -); diff --git a/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java index 48ba2fae68..a9d2cbc4f9 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java @@ -30,7 +30,7 @@ public class JpaDaoTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties" ); diff --git a/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java index f10462d77f..55c2f70f1d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java @@ -34,7 +34,9 @@ public class NoSqlDaoServiceTestSuite { @ClassRule public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( - Arrays.asList(new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + Arrays.asList( + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false), new ClassPathCQLDataSet("cassandra/system-test.cql", false, false)), "cassandra-test.yaml", 30000L); diff --git a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java index 3f65184bfb..9e56d64aba 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java @@ -30,7 +30,7 @@ public class SqlDaoServiceTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql", "sql/system-test.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql", "sql/system-test.sql"), "sql/drop-all-tables.sql", "sql-test.properties" ); diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index e37e228b40..06a92faa01 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -1,4 +1,5 @@ -database.type=cassandra +database.entities.type=cassandra +database.ts.type=cassandra cassandra.queue.partitioning=HOURS cassandra.queue.ack.ttl=3600 diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index 1f34b98222..3357425fce 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -1,4 +1,5 @@ -database.type=sql +database.ts.type=sql +database.entities.type=sql sql.ts_inserts_executor_type=fixed sql.ts_inserts_fixed_thread_pool_size=10 diff --git a/docker/k8s/cassandra-setup.yaml b/docker/k8s/cassandra-setup.yaml index 381df77e65..03a2739446 100644 --- a/docker/k8s/cassandra-setup.yaml +++ b/docker/k8s/cassandra-setup.yaml @@ -30,7 +30,9 @@ spec: value: "cassandra-headless" - name : CASSANDRA_PORT value: "9042" - - name : DATABASE_TYPE + - name : DATABASE_ENTITIES_TYPE + value: "cassandra" + - name : DATABASE_TS_TYPE value: "cassandra" - name : CASSANDRA_URL value: "cassandra-headless:9042" diff --git a/docker/k8s/cassandra-upgrade.yaml b/docker/k8s/cassandra-upgrade.yaml index a78136ec41..9276776678 100644 --- a/docker/k8s/cassandra-upgrade.yaml +++ b/docker/k8s/cassandra-upgrade.yaml @@ -30,7 +30,9 @@ spec: value: "cassandra-headless" - name : CASSANDRA_PORT value: "9042" - - name : DATABASE_TYPE + - name : DATABASE_ENTITIES_TYPE + value: "cassandra" + - name : DATABASE_TS_TYPE value: "cassandra" - name : CASSANDRA_URL value: "cassandra-headless:9042" diff --git a/docker/k8s/tb.yaml b/docker/k8s/tb.yaml index f38e1f12b1..741bbe0f26 100644 --- a/docker/k8s/tb.yaml +++ b/docker/k8s/tb.yaml @@ -120,7 +120,12 @@ spec: configMapKeyRef: name: tb-config key: cassandra.url - - name: DATABASE_TYPE + - name: DATABASE_ENTITIES_TYPE + valueFrom: + configMapKeyRef: + name: tb-config + key: database.type + - name: DATABASE_TS_TYPE valueFrom: configMapKeyRef: name: tb-config diff --git a/docker/tb.env b/docker/tb.env index 76afc29962..bc92de5c8b 100644 --- a/docker/tb.env +++ b/docker/tb.env @@ -8,7 +8,8 @@ COAP_BIND_PORT=5683 ZOOKEEPER_URL=zk:2181 # type of database to use: sql[DEFAULT] or cassandra -DATABASE_TYPE=sql +DATABASE_TS_TYPE=sql +DATABASE_ENTITIES_TYPE=sql # cassandra db config CASSANDRA_URL=cassandra:9042 diff --git a/docker/tb/run-application.sh b/docker/tb/run-application.sh index a2a1e2beba..e6b59f9aee 100755 --- a/docker/tb/run-application.sh +++ b/docker/tb/run-application.sh @@ -23,7 +23,7 @@ printenv | awk -F "=" '{print "export " $1 "='\''" $2 "'\''"}' >> /usr/share/thi cat /usr/share/thingsboard/conf/thingsboard.conf -if [ "$DATABASE_TYPE" == "cassandra" ]; then +if [ "$DATABASE_ENTITIES_TYPE" == "cassandra" ]; then until nmap $CASSANDRA_HOST -p $CASSANDRA_PORT | grep "$CASSANDRA_PORT/tcp open\|filtered" do echo "Wait for cassandra db to start..." @@ -31,7 +31,7 @@ if [ "$DATABASE_TYPE" == "cassandra" ]; then done fi -if [ "$DATABASE_TYPE" == "sql" ]; then +if [ "$DATABASE_ENTITIES_TYPE" == "sql" ]; then if [ "$SPRING_DRIVER_CLASS_NAME" == "org.postgresql.Driver" ]; then until nmap $POSTGRES_HOST -p $POSTGRES_PORT | grep "$POSTGRES_PORT/tcp open" do From 014ed4f97939583246e3f0032408276bdf142525 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 20 Sep 2018 17:08:03 +0300 Subject: [PATCH 116/307] Fixed tests --- .../service/install/CassandraEntityDatabaseSchemaService.java | 2 ++ .../service/install/CassandraTsDatabaseSchemaService.java | 2 ++ .../server/service/install/SqlEntityDatabaseSchemaService.java | 2 ++ .../server/service/install/SqlTsDatabaseSchemaService.java | 2 ++ 4 files changed, 8 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java index 7937ef23be..b492a51cbc 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.service.install; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.util.NoSqlDao; @Service @NoSqlDao +@Profile("install") public class CassandraEntityDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService implements EntityDatabaseSchemaService { public CassandraEntityDatabaseSchemaService() { diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java index ba18b57ff1..1a3a2e429b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.service.install; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.util.NoSqlTsDao; @Service @NoSqlTsDao +@Profile("install") public class CassandraTsDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService implements TsDatabaseSchemaService { public CassandraTsDatabaseSchemaService() { diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java index 16453c6ebb..bfd371b28a 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.service.install; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.util.SqlDao; @Service @SqlDao +@Profile("install") public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaService implements EntityDatabaseSchemaService { public SqlEntityDatabaseSchemaService() { diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java index 82daf904df..03a35178bf 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.service.install; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.util.SqlTsDao; @Service @SqlTsDao +@Profile("install") public class SqlTsDatabaseSchemaService extends SqlAbstractDatabaseSchemaService implements TsDatabaseSchemaService { public SqlTsDatabaseSchemaService() { From 56397e0d9000665e37607738ef03c6025621381a Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 21 Sep 2018 17:37:04 +0300 Subject: [PATCH 117/307] Added several checks for equality to null --- .../server/dao/entityview/EntityViewServiceImpl.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index dd3e61d611..8647af7fe4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -102,14 +102,16 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti log.trace("Executing save entity view [{}]", entityView); entityViewValidator.validate(entityView); EntityView savedEntityView = entityViewDao.save(entityView); - copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs()); - copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs()); - copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh()); + if (savedEntityView.getKeys() != null) { + copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs()); + copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs()); + copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh()); + } return savedEntityView; } private void copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { - if (!keys.isEmpty()) { + if (keys != null && !keys.isEmpty()) { ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); Futures.addCallback(getAttrFuture, new FutureCallback>() { @Override From b45662c630b342fb9995e424a65b80daddee13ba Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 21 Sep 2018 17:38:23 +0300 Subject: [PATCH 118/307] Added test for checking the copy attributes --- .../BaseEntityViewControllerTest.java | 163 ++++++++++++------ 1 file changed, 109 insertions(+), 54 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index b368ea1989..80e384afdf 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -18,10 +18,12 @@ package org.thingsboard.server.controller; import com.datastax.driver.core.utils.UUIDs; import com.fasterxml.jackson.core.type.TypeReference; import org.apache.commons.lang3.RandomStringUtils; +import org.eclipse.paho.client.mqttv3.MqttAsyncClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttMessage; import org.junit.After; import org.junit.Assert; import org.junit.Before; -import org.junit.Test; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityView; @@ -33,14 +35,21 @@ import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.dao.model.ModelConstants; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; @@ -50,12 +59,11 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes private Tenant savedTenant; private User tenantAdmin; private Device testDevice; - private TelemetryEntityView keys; + private TelemetryEntityView telemetry; @Before public void beforeTest() throws Exception { loginSysAdmin(); - idComparator = new IdComparator<>(); savedTenant = doPost("/api/tenant", getNewTenant("My tenant"), Tenant.class); @@ -67,7 +75,6 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes tenantAdmin.setEmail("tenant2@thingsboard.org"); tenantAdmin.setFirstName("Joe"); tenantAdmin.setLastName("Downs"); - tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); Device device = new Device(); @@ -75,12 +82,12 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes device.setType("default"); testDevice = doPost("/api/device", device, Device.class); - keys = new TelemetryEntityView( - Arrays.asList("109L", "209L"), + telemetry = new TelemetryEntityView( + Arrays.asList("109", "209", "309"), new AttributesEntityView( - Arrays.asList("caKey1", "caKey2"), - Arrays.asList("saKey1", "saKey2", "saKey3"), - Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4"))); + Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4"), + Arrays.asList("saValue1", "saValue2", "saValue3", "saValue4"), + Arrays.asList("shValue1", "shValue2", "shValue3", "shValue4"))); } @After @@ -91,37 +98,37 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes .andExpect(status().isOk()); } - @Test + //@Test public void testFindEntityViewById() throws Exception { - EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); + EntityView savedView = getNewSavedEntityView("Test entity view"); EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); Assert.assertNotNull(foundView); - Assert.assertEquals(savedView, foundView); + assertEquals(savedView, foundView); } - @Test + //@Test public void testSaveEntityView() throws Exception { - EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); + EntityView savedView = getNewSavedEntityView("Test entity view"); Assert.assertNotNull(savedView); Assert.assertNotNull(savedView.getId()); Assert.assertTrue(savedView.getCreatedTime() > 0); - Assert.assertEquals(savedTenant.getId(), savedView.getTenantId()); + assertEquals(savedTenant.getId(), savedView.getTenantId()); Assert.assertNotNull(savedView.getCustomerId()); - Assert.assertEquals(NULL_UUID, savedView.getCustomerId().getId()); - Assert.assertEquals(savedView.getName(), savedView.getName()); + assertEquals(NULL_UUID, savedView.getCustomerId().getId()); + assertEquals(savedView.getName(), savedView.getName()); savedView.setName("New test entity view"); doPost("/api/entityView", savedView, EntityView.class); EntityView foundEntityView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); - Assert.assertEquals(foundEntityView.getName(), savedView.getName()); - Assert.assertEquals(foundEntityView.getKeys(), keys); + assertEquals(foundEntityView.getName(), savedView.getName()); + assertEquals(foundEntityView.getKeys(), telemetry); } - @Test + //@Test public void testDeleteEntityView() throws Exception { - EntityView view = getNewEntityView("Test entity view"); + EntityView view = getNewSavedEntityView("Test entity view"); Customer customer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); view.setCustomerId(customer.getId()); EntityView savedView = doPost("/api/entityView", view, EntityView.class); @@ -133,16 +140,16 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes .andExpect(status().isNotFound()); } - @Test + //@Test public void testSaveEntityViewWithEmptyName() throws Exception { doPost("/api/entityView", new EntityView()) .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString("Entity view name should be specified!"))); } - @Test + //@Test public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception { - EntityView view = getNewEntityView("Test entity view"); + EntityView view = getNewSavedEntityView("Test entity view"); Customer savedCustomer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); view.setCustomerId(savedCustomer.getId()); EntityView savedView = doPost("/api/entityView", view, EntityView.class); @@ -150,26 +157,26 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes EntityView assignedView = doPost( "/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString(), EntityView.class); - Assert.assertEquals(savedCustomer.getId(), assignedView.getCustomerId()); + assertEquals(savedCustomer.getId(), assignedView.getCustomerId()); EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); - Assert.assertEquals(savedCustomer.getId(), foundView.getCustomerId()); + assertEquals(savedCustomer.getId(), foundView.getCustomerId()); EntityView unAssignedView = doDelete("/api/customer/entityView/" + savedView.getId().getId().toString(), EntityView.class); - Assert.assertEquals(ModelConstants.NULL_UUID, unAssignedView.getCustomerId().getId()); + assertEquals(ModelConstants.NULL_UUID, unAssignedView.getCustomerId().getId()); foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); - Assert.assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId()); + assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId()); } - @Test + //@Test public void testAssignEntityViewToNonExistentCustomer() throws Exception { - EntityView savedView = doPost("/api/entityView", getNewEntityView("Test entity view"), EntityView.class); + EntityView savedView = getNewSavedEntityView("Test entity view"); doPost("/api/customer/" + UUIDs.timeBased().toString() + "/device/" + savedView.getId().getId().toString()) .andExpect(status().isNotFound()); } - @Test + //@Test public void testAssignEntityViewToCustomerFromDifferentTenant() throws Exception { loginSysAdmin(); @@ -190,8 +197,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes login(tenantAdmin.getEmail(), "testPassword1"); - EntityView view = getNewEntityView("Test entity view"); - EntityView savedView = doPost("/api/entityView", view, EntityView.class); + EntityView savedView = getNewSavedEntityView("Test entity view"); doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString()) .andExpect(status().isForbidden()); @@ -202,7 +208,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes .andExpect(status().isOk()); } - @Test + //@Test public void testGetCustomerEntityViews() throws Exception { CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; @@ -210,7 +216,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes List views = new ArrayList<>(); for (int i = 0; i < 128; i++) { views.add(doPost("/api/customer/" + customerId.getId().toString() + "/entityView/" - + getNewEntityView("Test entity view " + i).getId().getId().toString(), EntityView.class)); + + getNewSavedEntityView("Test entity view " + i).getId().getId().toString(), EntityView.class)); } List loadedViews = loadListOf(new TextPageLink(23), urlTemplate); @@ -218,10 +224,10 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Collections.sort(views, idComparator); Collections.sort(loadedViews, idComparator); - Assert.assertEquals(views, loadedViews); + assertEquals(views, loadedViews); } - @Test + //@Test public void testGetCustomerEntityViewsByName() throws Exception { CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; @@ -232,7 +238,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), urlTemplate); Collections.sort(namesOfView1, idComparator); Collections.sort(loadedNamesOfView1, idComparator); - Assert.assertEquals(namesOfView1, loadedNamesOfView1); + assertEquals(namesOfView1, loadedNamesOfView1); String name2 = "Entity view name2"; List NamesOfView2 = fillListOf(143, name2, "/api/customer/" + customerId.getId().toString() @@ -240,7 +246,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), urlTemplate); Collections.sort(NamesOfView2, idComparator); Collections.sort(loadedNamesOfView2, idComparator); - Assert.assertEquals(NamesOfView2, loadedNamesOfView2); + assertEquals(NamesOfView2, loadedNamesOfView2); for (EntityView view : loadedNamesOfView1) { doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); @@ -249,7 +255,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes new TypeReference>() { }, new TextPageLink(4, name1)); Assert.assertFalse(pageData.hasNext()); - Assert.assertEquals(0, pageData.getData().size()); + assertEquals(0, pageData.getData().size()); for (EntityView view : loadedNamesOfView2) { doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); @@ -258,39 +264,39 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes }, new TextPageLink(4, name2)); Assert.assertFalse(pageData.hasNext()); - Assert.assertEquals(0, pageData.getData().size()); + assertEquals(0, pageData.getData().size()); } - @Test + //@Test public void testGetTenantEntityViews() throws Exception { List views = new ArrayList<>(); for (int i = 0; i < 178; i++) { - views.add(doPost("/api/entityView/", getNewEntityView("Test entity view" + i), EntityView.class)); + views.add(getNewSavedEntityView("Test entity view" + i)); } List loadedViews = loadListOf(new TextPageLink(23), "/api/tenant/entityViews?"); Collections.sort(views, idComparator); Collections.sort(loadedViews, idComparator); - Assert.assertEquals(views, loadedViews); + assertEquals(views, loadedViews); } - @Test + //@Test public void testGetTenantEntityViewsByName() throws Exception { String name1 = "Entity view name1"; List namesOfView1 = fillListOf(143, name1); List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), "/api/tenant/entityViews?"); Collections.sort(namesOfView1, idComparator); Collections.sort(loadedNamesOfView1, idComparator); - Assert.assertEquals(namesOfView1, loadedNamesOfView1); + assertEquals(namesOfView1, loadedNamesOfView1); String name2 = "Entity view name2"; List NamesOfView2 = fillListOf(75, name2); List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), "/api/tenant/entityViews?"); Collections.sort(NamesOfView2, idComparator); Collections.sort(loadedNamesOfView2, idComparator); - Assert.assertEquals(NamesOfView2, loadedNamesOfView2); + assertEquals(NamesOfView2, loadedNamesOfView2); for (EntityView view : loadedNamesOfView1) { doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); @@ -299,7 +305,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes new TypeReference>() { }, new TextPageLink(4, name1)); Assert.assertFalse(pageData.hasNext()); - Assert.assertEquals(0, pageData.getData().size()); + assertEquals(0, pageData.getData().size()); for (EntityView view : loadedNamesOfView2) { doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); @@ -308,17 +314,66 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes }, new TextPageLink(4, name2)); Assert.assertFalse(pageData.hasNext()); - Assert.assertEquals(0, pageData.getData().size()); + assertEquals(0, pageData.getData().size()); } - private EntityView getNewEntityView(String name) throws Exception { + //@Test + public void testTheCopyOfAttrsThatMatchWithDeviceCriteriaForTheView() throws Exception { + + String viewDeviceId = testDevice.getId().getId().toString(); + DeviceCredentials deviceCredentials + = doGet("/api/device/" + viewDeviceId + "/credentials", DeviceCredentials.class); + + assertEquals(testDevice.getId(), deviceCredentials.getDeviceId()); + + String accessToken = deviceCredentials.getCredentialsId(); + assertNotNull(accessToken); + + String clientId = MqttAsyncClient.generateClientId(); + MqttAsyncClient client = new MqttAsyncClient("tcp://localhost:1883", clientId); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options); + Thread.sleep(3000); + + MqttMessage message = new MqttMessage(); + message.setPayload(("{\"caValue1\":\"value1\", \"caValue2\":true, \"caValue3\":42.0, \"caValue4\":73}").getBytes()); + client.publish("v1/devices/me/attributes", message); + Thread.sleep(1000); + + List actualTelemetry = + doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class); + Set actualTelemetrySet = new HashSet<>(actualTelemetry); + + List expectedActualTelemetry = Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4"); + Set expectedActualTelemetrySet = new HashSet<>(expectedActualTelemetry); + assertTrue(actualTelemetrySet.containsAll(expectedActualTelemetrySet)); + Thread.sleep(1000); + + EntityView savedView = getNewSavedEntityView("Test entity view"); + String urlOfTelemetryValues = "/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualTelemetrySet); + List> values = doGetAsync(urlOfTelemetryValues, List.class); + + assertEquals("value1", getValueOfMap(values, "caValue1")); + assertEquals(true, getValueOfMap(values, "caValue2")); + assertEquals(42.0, getValueOfMap(values, "caValue3")); + assertEquals(73, getValueOfMap(values, "caValue4")); + } + + private Object getValueOfMap(List> values, String stringValue) { + return values.stream() + .filter(value -> value.get("key").equals(stringValue)) + .findFirst().get().get("value"); + } + + private EntityView getNewSavedEntityView(String name) throws Exception { EntityView view = new EntityView(); view.setEntityId(testDevice.getId()); view.setTenantId(savedTenant.getId()); view.setName(name); - - view.setKeys(keys); - + view.setKeys(telemetry); return doPost("/api/entityView", view, EntityView.class); } @@ -347,7 +402,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes for (int i = 0; i < limit; i++) { String fullName = partOfName + ' ' + RandomStringUtils.randomAlphanumeric(15); fullName = i % 2 == 0 ? fullName.toLowerCase() : fullName.toUpperCase(); - EntityView view = getNewEntityView(fullName); + EntityView view = getNewSavedEntityView(fullName); Customer customer = getNewCustomer("Test customer " + String.valueOf(Math.random())); view.setCustomerId(doPost("/api/customer", customer, Customer.class).getId()); viewNames.add(doPost("/api/entityView", view, EntityView.class)); From 584bb763ca30adab744d45acd2c5ac876c99dcc0 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 21 Sep 2018 18:23:31 +0300 Subject: [PATCH 119/307] Fixed annotations @Test --- .../BaseEntityViewControllerTest.java | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 80e384afdf..9dc13a8d37 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -24,6 +24,7 @@ import org.eclipse.paho.client.mqttv3.MqttMessage; import org.junit.After; import org.junit.Assert; import org.junit.Before; +import org.junit.Test; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityView; @@ -98,7 +99,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes .andExpect(status().isOk()); } - //@Test + @Test public void testFindEntityViewById() throws Exception { EntityView savedView = getNewSavedEntityView("Test entity view"); EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); @@ -106,7 +107,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(savedView, foundView); } - //@Test + @Test public void testSaveEntityView() throws Exception { EntityView savedView = getNewSavedEntityView("Test entity view"); @@ -126,7 +127,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(foundEntityView.getKeys(), telemetry); } - //@Test + @Test public void testDeleteEntityView() throws Exception { EntityView view = getNewSavedEntityView("Test entity view"); Customer customer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); @@ -140,14 +141,14 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes .andExpect(status().isNotFound()); } - //@Test + @Test public void testSaveEntityViewWithEmptyName() throws Exception { doPost("/api/entityView", new EntityView()) .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString("Entity view name should be specified!"))); } - //@Test + @Test public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception { EntityView view = getNewSavedEntityView("Test entity view"); Customer savedCustomer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); @@ -169,14 +170,14 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId()); } - //@Test + @Test public void testAssignEntityViewToNonExistentCustomer() throws Exception { EntityView savedView = getNewSavedEntityView("Test entity view"); doPost("/api/customer/" + UUIDs.timeBased().toString() + "/device/" + savedView.getId().getId().toString()) .andExpect(status().isNotFound()); } - //@Test + @Test public void testAssignEntityViewToCustomerFromDifferentTenant() throws Exception { loginSysAdmin(); @@ -208,7 +209,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes .andExpect(status().isOk()); } - //@Test + @Test public void testGetCustomerEntityViews() throws Exception { CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; @@ -227,7 +228,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(views, loadedViews); } - //@Test + @Test public void testGetCustomerEntityViewsByName() throws Exception { CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; @@ -267,7 +268,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(0, pageData.getData().size()); } - //@Test + @Test public void testGetTenantEntityViews() throws Exception { List views = new ArrayList<>(); @@ -282,7 +283,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(views, loadedViews); } - //@Test + @Test public void testGetTenantEntityViewsByName() throws Exception { String name1 = "Entity view name1"; List namesOfView1 = fillListOf(143, name1); @@ -317,7 +318,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(0, pageData.getData().size()); } - //@Test + @Test public void testTheCopyOfAttrsThatMatchWithDeviceCriteriaForTheView() throws Exception { String viewDeviceId = testDevice.getId().getId().toString(); From 99694c99c3ff88a781bc5e3b49fbb5b2befe4a92 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Sun, 23 Sep 2018 16:11:09 +0300 Subject: [PATCH 120/307] Initial refactoring --- .../server/actors/ActorSystemContext.java | 4 +- .../server/actors/rpc/RpcManagerActor.java | 6 +- .../actors/service/DefaultActorService.java | 4 +- .../controller/RuleChainController.java | 6 +- .../CurrentServerInstanceService.java | 6 +- .../cluster/discovery/DiscoveryService.java | 4 -- .../discovery/DummyDiscoveryService.java | 8 --- .../cluster/discovery/ServerInstance.java | 7 --- .../cluster/discovery/ZkDiscoveryService.java | 61 +++++++++++++----- .../routing/ClusterRoutingService.java | 9 ++- .../ConsistentClusterRoutingService.java | 38 ++++++++--- .../cluster/routing/ConsistentHashCircle.java | 63 +++++++++++++++++++ .../service/cluster/rpc/GrpcSession.java | 5 +- ...va => AbstractNashornJsInvokeService.java} | 2 +- ...ndboxService.java => JsInvokeService.java} | 2 +- ...rvice.java => NashornJsInvokeService.java} | 2 +- .../script/RuleNodeJsScriptEngine.java | 4 +- .../DefaultTelemetrySubscriptionService.java | 2 +- application/src/main/proto/jsinvoke.proto | 37 +++++++++++ .../script/RuleNodeJsScriptEngineTest.java | 5 +- ...e.java => TestNashornJsInvokeService.java} | 13 +--- .../common/msg/cluster/ServerAddress.java | 1 + .../server/common/msg/cluster/ServerType.java | 16 +++-- 23 files changed, 214 insertions(+), 91 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java rename application/src/main/java/org/thingsboard/server/service/script/{AbstractNashornJsSandboxService.java => AbstractNashornJsInvokeService.java} (99%) rename application/src/main/java/org/thingsboard/server/service/script/{JsSandboxService.java => JsInvokeService.java} (96%) rename application/src/main/java/org/thingsboard/server/service/script/{NashornJsSandboxService.java => NashornJsInvokeService.java} (95%) create mode 100644 application/src/main/proto/jsinvoke.proto rename application/src/test/java/org/thingsboard/server/service/script/{TestNashornJsSandboxService.java => TestNashornJsInvokeService.java} (73%) rename application/src/main/proto/discovery.proto => common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java (72%) diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index d33734e14c..2f78672800 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -65,7 +65,7 @@ import org.thingsboard.server.service.mail.MailExecutorService; import org.thingsboard.server.service.queue.MsgQueueService; import org.thingsboard.server.service.rpc.DeviceRpcService; import org.thingsboard.server.service.script.JsExecutorService; -import org.thingsboard.server.service.script.JsSandboxService; +import org.thingsboard.server.service.script.JsInvokeService; import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; @@ -169,7 +169,7 @@ public class ActorSystemContext { @Autowired @Getter - private JsSandboxService jsSandbox; + private JsInvokeService jsSandbox; @Autowired @Getter diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java index 9e38c17aff..608a77fd7a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java @@ -26,6 +26,7 @@ import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import org.thingsboard.server.service.cluster.discovery.ServerInstance; @@ -100,8 +101,7 @@ public class RpcManagerActor extends ContextAwareActor { private void onMsg(ClusterAPIProtos.ClusterMessage msg) { if (msg.hasServerAddress()) { - ServerAddress address = new ServerAddress(msg.getServerAddress().getHost(), - msg.getServerAddress().getPort()); + ServerAddress address = new ServerAddress(msg.getServerAddress().getHost(), msg.getServerAddress().getPort(), ServerType.CORE); SessionActorInfo session = sessionActors.get(address); if (session != null) { log.debug("{} Forwarding msg to session actor", address); @@ -112,7 +112,7 @@ public class RpcManagerActor extends ContextAwareActor { if (queue == null) { queue = new LinkedList<>(); pendingMsgs.put(new ServerAddress( - msg.getServerAddress().getHost(), msg.getServerAddress().getPort()), queue); + msg.getServerAddress().getHost(), msg.getServerAddress().getPort(), ServerType.CORE), queue); } queue.add(msg); } diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java index fac5d97175..1e9e23bae3 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -115,8 +115,6 @@ public class DefaultActorService implements ActorService { actorContext.setStatsActor(statsActor); rpcService.init(this); - - discoveryService.addListener(this); log.info("Actor system initialized."); } @@ -202,7 +200,7 @@ public class DefaultActorService implements ActorService { @Override public void onReceivedMsg(ServerAddress source, ClusterAPIProtos.ClusterMessage msg) { - ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort()); + ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort(), source.getServerType()); log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress); if (log.isDebugEnabled()) { log.info("MSG: ", msg); diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index 82ab1702fa..fbd5faf75b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -50,7 +50,7 @@ import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.event.EventService; -import org.thingsboard.server.service.script.JsSandboxService; +import org.thingsboard.server.service.script.JsInvokeService; import org.thingsboard.server.service.script.RuleNodeJsScriptEngine; import java.util.List; @@ -71,7 +71,7 @@ public class RuleChainController extends BaseController { private EventService eventService; @Autowired - private JsSandboxService jsSandboxService; + private JsInvokeService jsInvokeService; @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.GET) @@ -276,7 +276,7 @@ public class RuleChainController extends BaseController { String errorText = ""; ScriptEngine engine = null; try { - engine = new RuleNodeJsScriptEngine(jsSandboxService, getCurrentUser().getId(), script, argNames); + engine = new RuleNodeJsScriptEngine(jsInvokeService, getCurrentUser().getId(), script, argNames); TbMsg inMsg = new TbMsg(UUIDs.timeBased(), msgType, null, new TbMsgMetaData(metadata), data, null, null, 0L); switch (scriptType) { case "update": diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java index 2232ef898c..3ae7b16af1 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java @@ -19,7 +19,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.Assert; -import org.thingsboard.server.gen.discovery.ServerInstanceProtos; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import javax.annotation.PostConstruct; @@ -43,8 +44,7 @@ public class CurrentServerInstanceService implements ServerInstanceService { public void init() { Assert.hasLength(rpcHost, missingProperty("rpc.bind_host")); Assert.notNull(rpcPort, missingProperty("rpc.bind_port")); - - self = new ServerInstance(ServerInstanceProtos.ServerInfo.newBuilder().setHost(rpcHost).setPort(rpcPort).setTs(System.currentTimeMillis()).build()); + self = new ServerInstance(new ServerAddress(rpcHost, rpcPort, ServerType.CORE)); log.info("Current server instance: [{};{}]", self.getHost(), self.getPort()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java index 516fca9484..f9caafa412 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java @@ -30,8 +30,4 @@ public interface DiscoveryService { List getOtherServers(); - boolean addListener(DiscoveryServiceListener listener); - - boolean removeListener(DiscoveryServiceListener listener); - } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java index c21f1aa84c..fd91643cfb 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java @@ -62,13 +62,5 @@ public class DummyDiscoveryService implements DiscoveryService { return Collections.emptyList(); } - @Override - public boolean addListener(DiscoveryServiceListener listener) { - return false; - } - @Override - public boolean removeListener(DiscoveryServiceListener listener) { - return false; - } } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java index 6eee5f3061..8f4525c20d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java @@ -19,7 +19,6 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.gen.discovery.ServerInstanceProtos; /** * @author Andrew Shvayka @@ -41,12 +40,6 @@ public final class ServerInstance implements Comparable { this.port = serverAddress.getPort(); } - public ServerInstance(ServerInstanceProtos.ServerInfo serverInfo) { - this.host = serverInfo.getHost(); - this.port = serverInfo.getPort(); - this.serverAddress = new ServerAddress(host, port); - } - @Override public int compareTo(ServerInstance o) { return this.serverAddress.compareTo(o.serverAddress); diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java index c3ffbabbea..19a04d83ab 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java @@ -24,6 +24,8 @@ import org.apache.curator.framework.recipes.cache.ChildData; import org.apache.curator.framework.recipes.cache.PathChildrenCache; import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent; import org.apache.curator.framework.recipes.cache.PathChildrenCacheListener; +import org.apache.curator.framework.state.ConnectionState; +import org.apache.curator.framework.state.ConnectionStateListener; import org.apache.curator.retry.RetryForever; import org.apache.curator.utils.CloseableUtils; import org.apache.zookeeper.CreateMode; @@ -35,7 +37,9 @@ import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.util.Assert; +import org.thingsboard.server.actors.service.ActorService; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.utils.MiscUtils; @@ -44,7 +48,6 @@ import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.List; import java.util.NoSuchElementException; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; /** @@ -79,7 +82,13 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi @Lazy private DeviceStateService deviceStateService; - private final List listeners = new CopyOnWriteArrayList<>(); + @Autowired + @Lazy + private ActorService actorService; + + @Autowired + @Lazy + private ClusterRoutingService routingService; private CuratorFramework client; private PathChildrenCache cache; @@ -127,12 +136,37 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi .creatingParentsIfNeeded() .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress())); log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath); + client.getConnectionStateListenable().addListener(checkReconnect(self)); } catch (Exception e) { log.error("Failed to create ZK node", e); throw new RuntimeException(e); } } + private boolean reconnectInProgress = false; + + private synchronized ConnectionStateListener checkReconnect(ServerInstance self) { + return (client, newState) -> { + log.info("[{}:{}] ZK state changed: {}", self.getHost(), self.getPort(), newState); + if (newState == ConnectionState.LOST) { + if (!reconnectInProgress) { + reconnectInProgress = true; + reconnect(); + } + } + }; + } + + private void reconnect() { + try { + client.blockUntilConnected(); + } catch (InterruptedException e) { + log.error("Failed to reconnect to ZK: {}", e.getMessage(), e); + } + publishCurrentServer(); + reconnectInProgress = false; + } + @Override public void unpublishCurrentServer() { try { @@ -156,7 +190,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi .filter(cd -> !cd.getPath().equals(nodePath)) .map(cd -> { try { - return new ServerInstance( (ServerAddress) SerializationUtils.deserialize(cd.getData())); + return new ServerInstance((ServerAddress) SerializationUtils.deserialize(cd.getData())); } catch (NoSuchElementException e) { log.error("Failed to decode ZK node", e); throw new RuntimeException(e); @@ -165,16 +199,6 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi .collect(Collectors.toList()); } - @Override - public boolean addListener(DiscoveryServiceListener listener) { - return listeners.add(listener); - } - - @Override - public boolean removeListener(DiscoveryServiceListener listener) { - return listeners.remove(listener); - } - @Override public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { publishCurrentServer(); @@ -198,7 +222,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } ServerInstance instance; try { - ServerAddress serverAddress = SerializationUtils.deserialize(data.getData()); + ServerAddress serverAddress = SerializationUtils.deserialize(data.getData()); instance = new ServerInstance(serverAddress); } catch (SerializationException e) { log.error("Failed to decode server instance for node {}", data.getPath(), e); @@ -207,17 +231,20 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi log.info("Processing [{}] event for [{}:{}]", pathChildrenCacheEvent.getType(), instance.getHost(), instance.getPort()); switch (pathChildrenCacheEvent.getType()) { case CHILD_ADDED: + routingService.onServerAdded(instance); tsSubService.onClusterUpdate(); deviceStateService.onClusterUpdate(); - listeners.forEach(listener -> listener.onServerAdded(instance)); + actorService.onServerAdded(instance); break; case CHILD_UPDATED: - listeners.forEach(listener -> listener.onServerUpdated(instance)); + routingService.onServerUpdated(instance); + actorService.onServerUpdated(instance); break; case CHILD_REMOVED: + routingService.onServerRemoved(instance); tsSubService.onClusterUpdate(); deviceStateService.onClusterUpdate(); - listeners.forEach(listener -> listener.onServerRemoved(instance)); + actorService.onServerRemoved(instance); break; default: break; diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java index 272073dd23..bb76c74e28 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java @@ -17,6 +17,8 @@ package org.thingsboard.server.service.cluster.routing; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; +import org.thingsboard.server.service.cluster.discovery.DiscoveryServiceListener; import java.util.Optional; import java.util.UUID; @@ -24,7 +26,7 @@ import java.util.UUID; /** * @author Andrew Shvayka */ -public interface ClusterRoutingService { +public interface ClusterRoutingService extends DiscoveryServiceListener { ServerAddress getCurrentServer(); @@ -32,4 +34,9 @@ public interface ClusterRoutingService { Optional resolveById(EntityId entityId); + Optional resolveByUuid(ServerType server, UUID uuid); + + Optional resolveById(ServerType server, EntityId entityId); + + } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java index 5087b5ce46..f47f6d2e8e 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java @@ -24,12 +24,14 @@ import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.service.cluster.discovery.DiscoveryService; import org.thingsboard.server.service.cluster.discovery.DiscoveryServiceListener; import org.thingsboard.server.service.cluster.discovery.ServerInstance; import org.thingsboard.server.utils.MiscUtils; import javax.annotation.PostConstruct; +import java.util.Arrays; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentNavigableMap; @@ -41,7 +43,7 @@ import java.util.concurrent.ConcurrentSkipListMap; @Service @Slf4j -public class ConsistentClusterRoutingService implements ClusterRoutingService, DiscoveryServiceListener { +public class ConsistentClusterRoutingService implements ClusterRoutingService { @Autowired private DiscoveryService discoveryService; @@ -55,15 +57,19 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D private HashFunction hashFunction; - private final ConcurrentNavigableMap circle = - new ConcurrentSkipListMap<>(); + private ConsistentHashCircle[] circles; + private ConsistentHashCircle rootCircle; @PostConstruct public void init() { log.info("Initializing Cluster routing service!"); - hashFunction = MiscUtils.forName(hashFunctionName); - discoveryService.addListener(this); + this.hashFunction = MiscUtils.forName(hashFunctionName); this.currentServer = discoveryService.getCurrentServer(); + this.circles = new ConsistentHashCircle[ServerType.values().length]; + for (ServerType serverType : ServerType.values()) { + circles[serverType.ordinal()] = new ConsistentHashCircle(); + } + rootCircle = circles[ServerType.CORE.ordinal()]; addNode(discoveryService.getCurrentServer()); for (ServerInstance instance : discoveryService.getOtherServers()) { addNode(instance); @@ -79,11 +85,25 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D @Override public Optional resolveById(EntityId entityId) { - return resolveByUuid(entityId.getId()); + return resolveByUuid(rootCircle, entityId.getId()); } @Override public Optional resolveByUuid(UUID uuid) { + return resolveByUuid(rootCircle, uuid); + } + + @Override + public Optional resolveByUuid(ServerType server, UUID uuid) { + return resolveByUuid(circles[server.ordinal()], uuid); + } + + @Override + public Optional resolveById(ServerType server, EntityId entityId) { + return resolveByUuid(circles[server.ordinal()], entityId.getId()); + } + + private Optional resolveByUuid(ConsistentHashCircle circle, UUID uuid) { Assert.notNull(uuid); if (circle.isEmpty()) { return Optional.empty(); @@ -125,13 +145,13 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D private void addNode(ServerInstance instance) { for (int i = 0; i < virtualNodesSize; i++) { - circle.put(hash(instance, i).asLong(), instance); + circles[instance.getServerAddress().getServerType().ordinal()].put(hash(instance, i).asLong(), instance); } } private void removeNode(ServerInstance instance) { for (int i = 0; i < virtualNodesSize; i++) { - circle.remove(hash(instance, i).asLong()); + circles[instance.getServerAddress().getServerType().ordinal()].remove(hash(instance, i).asLong()); } } @@ -141,7 +161,7 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D private void logCircle() { log.trace("Consistent Hash Circle Start"); - circle.entrySet().forEach((e) -> log.debug("{} -> {}", e.getKey(), e.getValue().getServerAddress())); + Arrays.asList(circles).forEach(ConsistentHashCircle::log); log.trace("Consistent Hash Circle End"); } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java new file mode 100644 index 0000000000..a8c8f37a02 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cluster.routing; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.service.cluster.discovery.ServerInstance; + +import java.util.concurrent.ConcurrentNavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +/** + * Created by ashvayka on 23.09.18. + */ +@Slf4j +public class ConsistentHashCircle { + private final ConcurrentNavigableMap circle = + new ConcurrentSkipListMap<>(); + + public void put(long hash, ServerInstance instance) { + circle.put(hash, instance); + } + + public void remove(long hash) { + circle.remove(hash); + } + + public boolean isEmpty() { + return circle.isEmpty(); + } + + public boolean containsKey(Long hash) { + return circle.containsKey(hash); + } + + public ConcurrentNavigableMap tailMap(Long hash) { + return circle.tailMap(hash); + } + + public Long firstKey() { + return circle.firstKey(); + } + + public ServerInstance get(Long hash) { + return circle.get(hash); + } + + public void log() { + circle.entrySet().forEach((e) -> log.debug("{} -> {}", e.getKey(), e.getValue().getServerAddress())); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java b/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java index 7216c4319f..55cf52c37b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java @@ -19,6 +19,7 @@ import io.grpc.stub.StreamObserver; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import java.io.Closeable; @@ -61,8 +62,8 @@ public final class GrpcSession implements Closeable { public void onNext(ClusterAPIProtos.ClusterMessage clusterMessage) { if (!connected && clusterMessage.getMessageType() == ClusterAPIProtos.MessageType.CONNECT_RPC_MESSAGE) { connected = true; - ServerAddress rpcAddress = new ServerAddress(clusterMessage.getServerAddress().getHost(), clusterMessage.getServerAddress().getPort()); - remoteServer = new ServerAddress(rpcAddress.getHost(), rpcAddress.getPort()); + ServerAddress rpcAddress = new ServerAddress(clusterMessage.getServerAddress().getHost(), clusterMessage.getServerAddress().getPort(), ServerType.CORE); + remoteServer = new ServerAddress(rpcAddress.getHost(), rpcAddress.getPort(), ServerType.CORE); listener.onConnected(GrpcSession.this); } if (connected) { diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java similarity index 99% rename from application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java rename to application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java index 1d89c9d860..4acef05cac 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java @@ -40,7 +40,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @Slf4j -public abstract class AbstractNashornJsSandboxService implements JsSandboxService { +public abstract class AbstractNashornJsInvokeService implements JsInvokeService { private NashornSandbox sandbox; private ScriptEngine engine; diff --git a/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java similarity index 96% rename from application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java rename to application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java index 5e1c676443..1da9859c23 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java @@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.id.EntityId; import java.util.UUID; -public interface JsSandboxService { +public interface JsInvokeService { ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames); diff --git a/application/src/main/java/org/thingsboard/server/service/script/NashornJsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java similarity index 95% rename from application/src/main/java/org/thingsboard/server/service/script/NashornJsSandboxService.java rename to application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java index 3e8b4e9c55..c17430fac9 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/NashornJsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java @@ -22,7 +22,7 @@ import org.springframework.stereotype.Service; @Slf4j @Service -public class NashornJsSandboxService extends AbstractNashornJsSandboxService { +public class NashornJsInvokeService extends AbstractNashornJsInvokeService { @Value("${actors.rule.js_sandbox.use_js_sandbox}") private boolean useJsSandbox; diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index 767dc05a23..c001a1a725 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -37,12 +37,12 @@ import java.util.concurrent.ExecutionException; public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.ScriptEngine { private static final ObjectMapper mapper = new ObjectMapper(); - private final JsSandboxService sandboxService; + private final JsInvokeService sandboxService; private final UUID scriptId; private final EntityId entityId; - public RuleNodeJsScriptEngine(JsSandboxService sandboxService, EntityId entityId, String script, String... argNames) { + public RuleNodeJsScriptEngine(JsInvokeService sandboxService, EntityId entityId, String script, String... argNames) { this.sandboxService = sandboxService; this.entityId = entityId; try { diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 548c41739e..5c3b88a253 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -232,7 +232,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio new SubscriptionState(proto.getSessionId(), proto.getSubscriptionId(), EntityIdFactory.getByTypeAndId(proto.getEntityType(), proto.getEntityId()), TelemetryFeature.valueOf(proto.getType()), proto.getAllKeys(), statesMap, proto.getScope()), - false, new ServerAddress(serverAddress.getHost(), serverAddress.getPort())); + false, new ServerAddress(serverAddress.getHost(), serverAddress.getPort(), serverAddress.getServerType())); addRemoteWsSubscription(serverAddress, proto.getSessionId(), subscription); } diff --git a/application/src/main/proto/jsinvoke.proto b/application/src/main/proto/jsinvoke.proto new file mode 100644 index 0000000000..c465309689 --- /dev/null +++ b/application/src/main/proto/jsinvoke.proto @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; +package cluster; + +option java_package = "org.thingsboard.server.gen.js"; +option java_outer_classname = "JsInvokeProtos"; + +service JsInvokeRpcService { + rpc handleMsgs(stream JsInvokeRequest) returns (stream JsInvokeResponse) {} +} + +message JsInvokeRequest { + string scriptId = 1; + string scriptBody = 2; + repeated string args = 3; +} + +message JsInvokeResponse { + string result = 1; + string errorName = 2; + string errorDetails = 3; +} + diff --git a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java index 88961dc256..730121f4fa 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java @@ -31,7 +31,6 @@ import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.*; @@ -39,13 +38,13 @@ import static org.junit.Assert.*; public class RuleNodeJsScriptEngineTest { private ScriptEngine scriptEngine; - private TestNashornJsSandboxService jsSandboxService; + private TestNashornJsInvokeService jsSandboxService; private EntityId ruleNodeId = new RuleNodeId(UUIDs.timeBased()); @Before public void beforeTest() throws Exception { - jsSandboxService = new TestNashornJsSandboxService(false, 1, 100, 3); + jsSandboxService = new TestNashornJsInvokeService(false, 1, 100, 3); } @After diff --git a/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsSandboxService.java b/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsInvokeService.java similarity index 73% rename from application/src/test/java/org/thingsboard/server/service/script/TestNashornJsSandboxService.java rename to application/src/test/java/org/thingsboard/server/service/script/TestNashornJsInvokeService.java index 731c4bb147..3a83f40bf3 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsSandboxService.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsInvokeService.java @@ -16,23 +16,14 @@ package org.thingsboard.server.service.script; -import com.google.common.util.concurrent.ListenableFuture; -import delight.nashornsandbox.NashornSandbox; -import delight.nashornsandbox.NashornSandboxes; - -import javax.script.ScriptException; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class TestNashornJsSandboxService extends AbstractNashornJsSandboxService { +public class TestNashornJsInvokeService extends AbstractNashornJsInvokeService { private boolean useJsSandbox; private final int monitorThreadPoolSize; private final long maxCpuTime; private final int maxErrors; - public TestNashornJsSandboxService(boolean useJsSandbox, int monitorThreadPoolSize, long maxCpuTime, int maxErrors) { + public TestNashornJsInvokeService(boolean useJsSandbox, int monitorThreadPoolSize, long maxCpuTime, int maxErrors) { this.useJsSandbox = useJsSandbox; this.monitorThreadPoolSize = monitorThreadPoolSize; this.maxCpuTime = maxCpuTime; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java index 4b65d6fe54..60c9d12b51 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java @@ -29,6 +29,7 @@ public class ServerAddress implements Comparable, Serializable { private final String host; private final int port; + private final ServerType serverType; @Override public int compareTo(ServerAddress o) { diff --git a/application/src/main/proto/discovery.proto b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java similarity index 72% rename from application/src/main/proto/discovery.proto rename to common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java index be4ec92d2f..f9b057c463 100644 --- a/application/src/main/proto/discovery.proto +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java @@ -13,14 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -syntax = "proto3"; -package discovery; +package org.thingsboard.server.common.msg.cluster; -option java_package = "org.thingsboard.server.gen.discovery"; -option java_outer_classname = "ServerInstanceProtos"; - -message ServerInfo { - string host = 1; - int32 port = 2; - int64 ts = 3; +/** + * Created by ashvayka on 23.09.18. + */ +public enum ServerType { + //Should match content of enum in discovery.proto. + CORE, JS_EVALUATOR } From 6c618a7db35ecc1a757f09869fcb16465f7de465 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Sun, 23 Sep 2018 16:22:04 +0300 Subject: [PATCH 121/307] Fixed issue with Zookeeper reconnect --- .../cluster/discovery/ZkDiscoveryService.java | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java index c3ffbabbea..6f51ceed26 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java @@ -24,6 +24,8 @@ import org.apache.curator.framework.recipes.cache.ChildData; import org.apache.curator.framework.recipes.cache.PathChildrenCache; import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent; import org.apache.curator.framework.recipes.cache.PathChildrenCacheListener; +import org.apache.curator.framework.state.ConnectionState; +import org.apache.curator.framework.state.ConnectionStateListener; import org.apache.curator.retry.RetryForever; import org.apache.curator.utils.CloseableUtils; import org.apache.zookeeper.CreateMode; @@ -127,12 +129,38 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi .creatingParentsIfNeeded() .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress())); log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath); + client.getConnectionStateListenable().addListener(checkReconnect(self)); } catch (Exception e) { log.error("Failed to create ZK node", e); throw new RuntimeException(e); } } + private ConnectionStateListener checkReconnect(ServerInstance self) { + return (client, newState) -> { + log.info("[{}:{}] ZK state changed: {}", self.getHost(), self.getPort(), newState); + if (newState == ConnectionState.LOST) { + reconnect(); + } + }; + } + + private boolean reconnectInProgress = false; + + private synchronized void reconnect() { + if (!reconnectInProgress) { + reconnectInProgress = true; + try { + client.blockUntilConnected(); + publishCurrentServer(); + } catch (InterruptedException e) { + log.error("Failed to reconnect to ZK: {}", e.getMessage(), e); + } finally { + reconnectInProgress = false; + } + } + } + @Override public void unpublishCurrentServer() { try { @@ -156,7 +184,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi .filter(cd -> !cd.getPath().equals(nodePath)) .map(cd -> { try { - return new ServerInstance( (ServerAddress) SerializationUtils.deserialize(cd.getData())); + return new ServerInstance((ServerAddress) SerializationUtils.deserialize(cd.getData())); } catch (NoSuchElementException e) { log.error("Failed to decode ZK node", e); throw new RuntimeException(e); @@ -198,7 +226,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } ServerInstance instance; try { - ServerAddress serverAddress = SerializationUtils.deserialize(data.getData()); + ServerAddress serverAddress = SerializationUtils.deserialize(data.getData()); instance = new ServerInstance(serverAddress); } catch (SerializationException e) { log.error("Failed to decode server instance for node {}", data.getPath(), e); From 4489440ae341615daf32c024c39caedca28bc670 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Mon, 24 Sep 2018 14:55:49 +0300 Subject: [PATCH 122/307] Were renamed variables --- .../controller/BaseEntityViewControllerTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 9dc13a8d37..0daaeca72f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -343,18 +343,18 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes client.publish("v1/devices/me/attributes", message); Thread.sleep(1000); - List actualTelemetry = + List actualAttributes = doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class); - Set actualTelemetrySet = new HashSet<>(actualTelemetry); + Set actualAttributesSet = new HashSet<>(actualAttributes); - List expectedActualTelemetry = Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4"); - Set expectedActualTelemetrySet = new HashSet<>(expectedActualTelemetry); - assertTrue(actualTelemetrySet.containsAll(expectedActualTelemetrySet)); + List expectedActualAttributes = Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4"); + Set expectedActualAttributesSet = new HashSet<>(expectedActualAttributes); + assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); Thread.sleep(1000); EntityView savedView = getNewSavedEntityView("Test entity view"); String urlOfTelemetryValues = "/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + - "/values/attributes?keys=" + String.join(",", actualTelemetrySet); + "/values/attributes?keys=" + String.join(",", actualAttributesSet); List> values = doGetAsync(urlOfTelemetryValues, List.class); assertEquals("value1", getValueOfMap(values, "caValue1")); From 2269656b095340b4d2892bfc4fc31cf483121663 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Tue, 25 Sep 2018 18:57:04 +0300 Subject: [PATCH 123/307] Were fixed --- .../server/controller/BaseController.java | 6 +- .../controller/EntityViewController.java | 26 +-- .../entityview/CassandraEntityViewDao.java | 2 +- .../server/dao/entityview/EntityViewDao.java | 2 +- .../dao/entityview/EntityViewService.java | 19 +- .../dao/entityview/EntityViewServiceImpl.java | 210 +++++++++++------- .../dao/sql/entityview/JpaEntityViewDao.java | 2 +- .../TbCopyAttributesToEntityViewNode.java | 59 +++-- 8 files changed, 184 insertions(+), 142 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index af62e04cff..84a8151316 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -318,7 +318,7 @@ public abstract class BaseController { checkUserId(new UserId(entityId.getId())); return; case ENTITY_VIEW: - checkEntityView(entityViewService.findEntityViewById(new EntityViewId(entityId.getId()))); + checkEntityViewId(entityViewService.findEntityViewById(new EntityViewId(entityId.getId()))); return; default: throw new IllegalArgumentException("Unsupported entity type: " + entityId.getEntityType()); @@ -351,14 +351,14 @@ public abstract class BaseController { try { validateId(entityViewId, "Incorrect entityViewId " + entityViewId); EntityView entityView = entityViewService.findEntityViewById(entityViewId); - checkEntityView(entityView); + checkEntityViewId(entityView); return entityView; } catch (Exception e) { throw handleException(e, false); } } - protected void checkEntityView(EntityView entityView) throws ThingsboardException { + protected void checkEntityViewId(EntityView entityView) throws ThingsboardException { checkNotNull(entityView); checkTenantId(entityView.getTenantId()); if (entityView.getCustomerId() != null && !entityView.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 26a48daff6..e6f149e05c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -17,7 +17,14 @@ package org.thingsboard.server.controller; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; @@ -49,13 +56,10 @@ public class EntityViewController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.GET) @ResponseBody - public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) - throws ThingsboardException { - + public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(ENTITY_VIEW_ID, strEntityViewId); try { - EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); - return checkEntityViewId(entityViewId); + return checkEntityViewId(new EntityViewId(toUUID(strEntityViewId))); } catch (Exception e) { throw handleException(e); } @@ -70,13 +74,10 @@ public class EntityViewController extends BaseController { EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); logEntityAction(savedEntityView.getId(), savedEntityView, null, entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); - return savedEntityView; - } catch (Exception e) { logEntityAction(emptyId(EntityType.ENTITY_VIEW), entityView, null, entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, e); - throw handleException(e); } } @@ -90,7 +91,6 @@ public class EntityViewController extends BaseController { EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); EntityView entityView = checkEntityViewId(entityViewId); entityViewService.deleteEntityView(entityViewId); - logEntityAction(entityViewId, entityView, entityView.getCustomerId(), ActionType.DELETED,null, strEntityViewId); } catch (Exception e) { @@ -117,11 +117,9 @@ public class EntityViewController extends BaseController { checkEntityViewId(entityViewId); EntityView savedEntityView = checkNotNull(entityViewService.assignEntityViewToCustomer(entityViewId, customerId)); - logEntityAction(entityViewId, savedEntityView, savedEntityView.getCustomerId(), ActionType.ASSIGNED_TO_CUSTOMER, null, strEntityViewId, strCustomerId, customer.getName()); - return savedEntityView; } catch (Exception e) { logEntityAction(emptyId(EntityType.ENTITY_VIEW), null, @@ -143,9 +141,7 @@ public class EntityViewController extends BaseController { throw new IncorrectParameterException("Entity View isn't assigned to any customer!"); } Customer customer = checkCustomerId(entityView.getCustomerId()); - EntityView savedEntityView = checkNotNull(entityViewService.unassignEntityViewFromCustomer(entityViewId)); - logEntityAction(entityViewId, entityView, entityView.getCustomerId(), ActionType.UNASSIGNED_FROM_CUSTOMER, null, strEntityViewId, customer.getId().toString(), customer.getName()); @@ -208,7 +204,7 @@ public class EntityViewController extends BaseController { List entityViews = checkNotNull(entityViewService.findEntityViewsByQuery(query).get()); entityViews = entityViews.stream().filter(entityView -> { try { - checkEntityView(entityView); + checkEntityViewId(entityView); return true; } catch (ThingsboardException e) { return false; diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index e866bc89b8..395f9022b0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -78,7 +78,7 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink) { + public List findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink) { log.debug("Try to find entity views by tenantId [{}] and pageLink [{}]", tenantId, pageLink); List entityViewEntities = findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME, diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index 742cb92d90..ba433855e2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -45,7 +45,7 @@ public interface EntityViewDao extends Dao { * @param pageLink the page link * @return the list of entity view objects */ - List findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink); + List findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink); /** * Find entity views by tenantId and entity view name. diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 89b7e2f85c..19f326c0bc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -32,28 +32,27 @@ import java.util.List; */ public interface EntityViewService { - EntityView findEntityViewById(EntityViewId entityViewId); - EntityView saveEntityView(EntityView entityView); EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId); EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId); - void deleteEntityView(EntityViewId entityViewId); + void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); - TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); + EntityView findEntityViewById(EntityViewId entityViewId); - void deleteEntityViewsByTenantId(TenantId tenantId); + TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); - TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, - TextPageLink pageLink); + TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink); - void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); + ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query); ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId); - ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query); - ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId); + + void deleteEntityView(EntityViewId entityViewId); + + void deleteEntityViewsByTenantId(TenantId tenantId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 8647af7fe4..79ceff2351 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -24,7 +24,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; @@ -41,6 +40,7 @@ import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; @@ -54,13 +54,14 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; -import static org.thingsboard.server.dao.service.Validator.validateString; /** * Created by Victor Basanets on 8/28/2017. @@ -73,6 +74,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; public static final String INCORRECT_ENTITY_VIEW_ID = "Incorrect entityViewId "; + private static final int DEFAULT_LIMIT = 100; @Autowired private EntityViewDao entityViewDao; @@ -89,13 +91,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CacheManager cacheManager; - @Override - public EntityView findEntityViewById(EntityViewId entityViewId) { - log.trace("Executing findEntityViewById [{}]", entityViewId); - validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); - return entityViewDao.findById(entityViewId.getId()); - } - @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.name}") @Override public EntityView saveEntityView(EntityView entityView) { @@ -110,41 +105,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return savedEntityView; } - private void copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { - if (keys != null && !keys.isEmpty()) { - ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); - Futures.addCallback(getAttrFuture, new FutureCallback>() { - @Override - public void onSuccess(@Nullable List attributeKvEntries) { - if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { - List filteredAttributes = - attributeKvEntries.stream() - .filter(attributeKvEntry -> { - if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() == 0) { - return true; - } - if (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs()) { - return true; - } - if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs()) { - return true; - } - return entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs() - && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs(); - }).collect(Collectors.toList()); - attributesService.save(entityView.getId(), scope, filteredAttributes); - } - } - - @Override - public void onFailure(Throwable throwable) { - log.error("Failed to fetch [{}] attributes [{}] for [{}] entity [{}]", - scope, keys, entityView.getEntityId().getEntityType().name(), entityView.getEntityId().getId().toString(), throwable); - } - }); - } - } - @Override public EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId) { EntityView entityView = findEntityViewById(entityViewId); @@ -160,63 +120,66 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti } @Override - public void deleteEntityView(EntityViewId entityViewId) { - log.trace("Executing deleteEntityView [{}]", entityViewId); - Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + public void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId) { + log.trace("Executing unassignCustomerEntityViews, tenantId [{}], customerId [{}]", tenantId, customerId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + new CustomerEntityViewsUnAssigner(tenantId).removeEntities(customerId); + } + + + @Override + public EntityView findEntityViewById(EntityViewId entityViewId) { + log.trace("Executing findEntityViewById [{}]", entityViewId); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); - deleteEntityRelations(entityViewId); - EntityView entityView = entityViewDao.findById(entityViewId.getId()); - cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getName())); - entityViewDao.removeById(entityViewId.getId()); + List ids = Arrays.asList(entityViewId.getId()); + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + EntityView fromCache = cache.get(ids, EntityView.class); + if (fromCache != null) { + return fromCache; + } else { + ListenableFuture entityViewFuture + = Futures.immediateFuture(entityViewDao.findById(entityViewId.getId())); + Futures.addCallback(entityViewFuture, + new FutureCallback() { + @Override + public void onSuccess(@Nullable EntityView result) { + cache.putIfAbsent(ids, result); + } + @Override + public void onFailure(Throwable t) {} + }); + try { + return entityViewFuture.get(); + } catch (Exception e) { + log.error(e.getMessage()); + } + } + return entityViewDao.findById(entityViewId.getId()); } @Override public TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink) { - log.trace("Executing findEntityViewByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink); + log.trace("Executing findEntityViewsByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); - List entityViews = entityViewDao.findEntityViewByTenantId(tenantId.getId(), pageLink); + List entityViews = entityViewDao.findEntityViewsByTenantId(tenantId.getId(), pageLink); return new TextPageData<>(entityViews, pageLink); } - @Override - public void deleteEntityViewsByTenantId(TenantId tenantId) { - log.trace("Executing deleteEntityViewsByTenantId, tenantId [{}]", tenantId); - validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - tenantEntityViewRemover.removeEntities(tenantId); - } - @Override public TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink) { - log.trace("Executing findEntityViewByTenantIdAndCustomerId, tenantId [{}], customerId [{}]," + " pageLink [{}]", tenantId, customerId, pageLink); - validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(), customerId.getId(), pageLink); - return new TextPageData<>(entityViews, pageLink); } - @Override - public void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId) { - log.trace("Executing unassignCustomerEntityViews, tenantId [{}], customerId [{}]", tenantId, customerId); - validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); - new CustomerEntityViewsUnAssigner(tenantId).removeEntities(customerId); - } - - @Override - public ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId) { - log.trace("Executing findEntityViewById [{}]", entityViewId); - validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); - return entityViewDao.findByIdAsync(entityViewId.getId()); - } - @Override public ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query) { ListenableFuture> relations = relationService.findByQuery(query.toEntitySearchQuery()); @@ -235,12 +198,99 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return entityViews; } + @Override + public ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId) { + log.trace("Executing findEntityViewById [{}]", entityViewId); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + return entityViewDao.findByIdAsync(entityViewId.getId()); + } + @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId) { log.trace("Executing findEntityViewsByTenantIdAndEntityIdAsync, tenantId [{}], entityId [{}]", tenantId, entityId); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(entityId.getId(), "Incorrect entityId" + entityId); - return entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); + + List tenantAndEntityIds = Arrays.asList(tenantId, entityId); + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + List fromCache = cache.get(tenantAndEntityIds, List.class); + if (fromCache != null) { + return Futures.immediateFuture(fromCache); + } else { + ListenableFuture> entityViewsFuture = + entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); + Futures.addCallback(entityViewsFuture, + new FutureCallback>() { + @Override + public void onSuccess(@Nullable List result) { + cache.putIfAbsent(tenantAndEntityIds, result); + } + @Override + public void onFailure(Throwable t) {} + }); + return entityViewsFuture; + } + } + + @Override + public void deleteEntityView(EntityViewId entityViewId) { + log.trace("Executing deleteEntityView [{}]", entityViewId); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + deleteEntityRelations(entityViewId); + cacheEvict(entityViewId, cacheManager.getCache(ENTITY_VIEW_CACHE)); + entityViewDao.removeById(entityViewId.getId()); + } + + @Override + public void deleteEntityViewsByTenantId(TenantId tenantId) { + log.trace("Executing deleteEntityViewsByTenantId, tenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + entityViewDao.findEntityViewsByTenantId(tenantId.getId(), new TextPageLink(DEFAULT_LIMIT)).stream() + .map(view -> view.getId()) + .collect(Collectors.toList()) + .forEach(id -> cacheEvict(id, cacheManager.getCache(ENTITY_VIEW_CACHE))); + tenantEntityViewRemover.removeEntities(tenantId); + } + + private void cacheEvict(EntityViewId entityViewId, Cache cache) { + EntityView entityView = entityViewDao.findById(entityViewId.getId()); + cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getName())); + cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); + } + + private void copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { + if (keys != null && !keys.isEmpty()) { + ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); + Futures.addCallback(getAttrFuture, new FutureCallback>() { + @Override + public void onSuccess(@Nullable List attributeKvEntries) { + if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { + List filteredAttributes = + attributeKvEntries.stream() + .filter(attributeKvEntry -> { + if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() == 0) { + return true; + } + if (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs()) { + return true; + } + if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs()) { + return true; + } + return entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs() + && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs(); + }).collect(Collectors.toList()); + attributesService.save(entityView.getId(), scope, filteredAttributes); + } + } + + @Override + public void onFailure(Throwable throwable) { + log.error("Failed to fetch [{}] attributes [{}] for [{}] entity [{}]", + scope, keys, entityView.getEntityId().getEntityType().name(), entityView.getEntityId().getId().toString(), throwable); + } + }); + } } private DataValidator entityViewValidator = @@ -296,7 +346,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override protected List findEntities(TenantId id, TextPageLink pageLink) { - return entityViewDao.findEntityViewByTenantId(id.getId(), pageLink); + return entityViewDao.findEntityViewsByTenantId(id.getId(), pageLink); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 0cd1b2b0d6..912c9d5eed 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -66,7 +66,7 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao findEntityViewByTenantId(UUID tenantId, TextPageLink pageLink) { + public List findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink) { return DaoUtil.convertDataList( entityViewRepository.findByTenantId( fromTimeUUID(tenantId), diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index d609620edf..c5dcc0f62d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -15,9 +15,7 @@ */ package org.thingsboard.rule.engine.action; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.base.Function; -import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; import com.google.gson.JsonParser; import lombok.extern.slf4j.Slf4j; @@ -28,24 +26,23 @@ import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TbRelationTypes; +import org.thingsboard.rule.engine.api.util.DonAsynchron; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.session.SessionMsgType; import org.thingsboard.server.common.transport.adaptor.JsonConverter; import javax.annotation.Nullable; -import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; -import static org.thingsboard.rule.engine.api.util.DonAsynchron.withCallback; +import static org.thingsboard.rule.engine.api.TbRelationTypes.SUCCESS; @Slf4j @RuleNode( @@ -70,25 +67,22 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { - if (msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()) || - msg.getType().equals(DataConstants.ATTRIBUTES_DELETED) || - msg.getType().equals(DataConstants.ATTRIBUTES_UPDATED)) { + if (!msg.getMetaData().getData().isEmpty()) { long now = System.currentTimeMillis(); - String scope; - if (msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name())) { - scope = DataConstants.CLIENT_SCOPE; - } else { - scope = msg.getMetaData().getValue("scope"); - } + String scope = msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()) ? + DataConstants.CLIENT_SCOPE : msg.getMetaData().getValue("scope"); + ListenableFuture> entityViewsFuture = ctx.getEntityViewService().findEntityViewsByTenantIdAndEntityIdAsync(ctx.getTenantId(), msg.getOriginator()); - withCallback(entityViewsFuture, + + DonAsynchron.withCallback(entityViewsFuture, entityViews -> { - List>> saveFutures = new ArrayList<>(); for (EntityView entityView : entityViews) { - if ((entityView.getEndTimeMs() != 0 && entityView.getEndTimeMs() > now && entityView.getStartTimeMs() < now) || - (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < now)) { - Set attributes = JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())).getAttributes(); + long startTime = entityView.getStartTimeMs(); + long endTime = entityView.getEndTimeMs(); + if ((endTime != 0 && endTime > now && startTime < now) || (endTime == 0 && startTime < now)) { + Set attributes = + JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())).getAttributes(); List filteredAttributes = attributes.stream() .filter(attr -> { @@ -110,19 +104,22 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { return entityView.getKeys().getAttributes().getSh().contains(attr.getKey()); } return false; - }) - .collect(Collectors.toList()); - saveFutures.add(ctx.getAttributesService().save(entityView.getId(), scope, new ArrayList<>(filteredAttributes))); + }).collect(Collectors.toList()); + + ctx.getTelemetryService().saveAndNotify(entityView.getId(), scope, filteredAttributes, + new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + ctx.tellNext(msg, SUCCESS); + } + + @Override + public void onFailure(Throwable t) { + ctx.tellFailure(msg, t); + } + }); } } - Futures.transform(Futures.allAsList(saveFutures), new Function>, Object>() { - @Nullable - @Override - public Object apply(@Nullable List> lists) { - ctx.tellNext(msg, TbRelationTypes.SUCCESS); - return null; - } - }); }, t -> ctx.tellFailure(msg, t)); } else { From 7bcb64958dd325e3ac401f57c4ebffb1418cc379 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Wed, 26 Sep 2018 11:43:15 +0300 Subject: [PATCH 124/307] Fixed --- .../controller/ControllerSqlTestSuite.java | 2 +- .../dao/entityview/EntityViewServiceImpl.java | 78 ++++++++++--------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index c8a5da8151..a9e94e9184 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.controller.sql.*Test", + "org.thingsboard.server.controller.sql.EntityViewControllerSqlTest", }) public class ControllerSqlTestSuite { diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 79ceff2351..c1c2b6548d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -97,10 +97,19 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti log.trace("Executing save entity view [{}]", entityView); entityViewValidator.validate(entityView); EntityView savedEntityView = entityViewDao.save(entityView); + List>> futures = new ArrayList<>(); if (savedEntityView.getKeys() != null) { - copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs()); - copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs()); - copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh()); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs())); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs())); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh())); + } + for (ListenableFuture> future : futures) { + try { + future.get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Failed to copy attributes to entity view", e); + throw new RuntimeException("Failed to copy attributes to entity view", e); + } } return savedEntityView; } @@ -169,9 +178,9 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override public TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, - TextPageLink pageLink) { + TextPageLink pageLink) { log.trace("Executing findEntityViewByTenantIdAndCustomerId, tenantId [{}], customerId [{}]," + - " pageLink [{}]", tenantId, customerId, pageLink); + " pageLink [{}]", tenantId, customerId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); @@ -246,7 +255,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti log.trace("Executing deleteEntityViewsByTenantId, tenantId [{}]", tenantId); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); entityViewDao.findEntityViewsByTenantId(tenantId.getId(), new TextPageLink(DEFAULT_LIMIT)).stream() - .map(view -> view.getId()) + .map(view -> view.getId()) .collect(Collectors.toList()) .forEach(id -> cacheEvict(id, cacheManager.getCache(ENTITY_VIEW_CACHE))); tenantEntityViewRemover.removeEntities(tenantId); @@ -258,38 +267,37 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); } - private void copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { + private ListenableFuture> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { if (keys != null && !keys.isEmpty()) { ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); - Futures.addCallback(getAttrFuture, new FutureCallback>() { - @Override - public void onSuccess(@Nullable List attributeKvEntries) { - if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { - List filteredAttributes = - attributeKvEntries.stream() - .filter(attributeKvEntry -> { - if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() == 0) { - return true; - } - if (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs()) { - return true; - } - if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs()) { - return true; - } - return entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs() - && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs(); - }).collect(Collectors.toList()); - attributesService.save(entityView.getId(), scope, filteredAttributes); - } + return Futures.transform(getAttrFuture, attributeKvEntries -> { + List filteredAttributes = new ArrayList<>(); + if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { + filteredAttributes = + attributeKvEntries.stream() + .filter(attributeKvEntry -> { + if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() == 0) { + return true; + } + if (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs()) { + return true; + } + if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs()) { + return true; + } + return entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs() + && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs(); + }).collect(Collectors.toList()); } - - @Override - public void onFailure(Throwable throwable) { - log.error("Failed to fetch [{}] attributes [{}] for [{}] entity [{}]", - scope, keys, entityView.getEntityId().getEntityType().name(), entityView.getEntityId().getId().toString(), throwable); + try { + return attributesService.save(entityView.getId(), scope, filteredAttributes).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Failed to copy attributes to entity view", e); + throw new RuntimeException("Failed to copy attributes to entity view", e); } }); + } else { + return Futures.immediateFuture(null); } } @@ -299,7 +307,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override protected void validateCreate(EntityView entityView) { entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) - .ifPresent( e -> { + .ifPresent(e -> { throw new DataValidationException("Entity view with such name already exists!"); }); } @@ -307,7 +315,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override protected void validateUpdate(EntityView entityView) { entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) - .ifPresent( e -> { + .ifPresent(e -> { if (!e.getUuidId().equals(entityView.getUuidId())) { throw new DataValidationException("Entity view with such name already exists!"); } From 2ada821d6c3cadb8e0f7fd2f5286bf1955a073ce Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 26 Sep 2018 14:54:59 +0300 Subject: [PATCH 125/307] Implementation draft --- application/pom.xml | 4 + .../cluster/discovery/DiscoveryService.java | 2 + .../discovery/DummyDiscoveryService.java | 5 + .../cluster/discovery/ZkDiscoveryService.java | 10 +- .../script/AbstractJsInvokeService.java | 102 +++++++++ .../AbstractNashornJsInvokeService.java | 195 ++---------------- .../service/script/JsInvokeRequest.java | 29 +++ .../service/script/JsInvokeResponse.java | 29 +++ .../service/script/JsInvokeService.java | 4 +- .../service/script/RemoteJsInvokeService.java | 158 +++++++++++++- .../script/RemoteJsRequestEncoder.java | 29 +++ .../script/RemoteJsResponseDecoder.java | 32 +++ .../script/RuleNodeJsScriptEngine.java | 4 +- application/src/main/proto/jsinvoke.proto | 62 +++++- .../src/main/resources/thingsboard.yml | 23 ++- .../script/RuleNodeJsScriptEngineTest.java | 4 +- common/pom.xml | 3 +- common/queue/pom.xml | 95 +++++++++ .../server/kafka/TBKafkaAdmin.java | 49 +++++ .../server/kafka/TBKafkaConsumerTemplate.java | 71 +++++++ .../server/kafka/TBKafkaProducerTemplate.java | 83 ++++++++ .../server/kafka/TbJsEvaluator.java | 68 ++++++ .../server/kafka/TbKafkaDecoder.java | 27 +++ .../server/kafka/TbKafkaEncoder.java | 25 +++ .../server/kafka/TbKafkaPartitioner.java | 30 +++ .../server/kafka/TbKafkaProperty.java | 32 +++ .../server/kafka/TbKafkaRequestTemplate.java | 173 ++++++++++++++++ .../server/kafka/TbKafkaSettings.java | 73 +++++++ .../server/kafka/TbRuleEngineEmulator.java | 114 ++++++++++ common/queue/src/main/resources/logback.xml | 35 ++++ pom.xml | 27 +-- rule-engine/rule-engine-components/pom.xml | 2 +- 32 files changed, 1382 insertions(+), 217 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/script/JsInvokeRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/script/JsInvokeResponse.java create mode 100644 application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java create mode 100644 application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java create mode 100644 common/queue/pom.xml create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaDecoder.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEncoder.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaPartitioner.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaProperty.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java create mode 100644 common/queue/src/main/resources/logback.xml diff --git a/application/pom.xml b/application/pom.xml index 3d7cbe1848..eadcf7ba98 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -76,6 +76,10 @@ org.thingsboard dao + + org.thingsboard.common + queue + org.thingsboard dao diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java index f9caafa412..04c4cedabd 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java @@ -26,6 +26,8 @@ public interface DiscoveryService { void unpublishCurrentServer(); + String getNodeId(); + ServerInstance getCurrentServer(); List getOtherServers(); diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java index fd91643cfb..64d1456987 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java @@ -42,6 +42,11 @@ public class DummyDiscoveryService implements DiscoveryService { log.info("Initializing..."); } + @Override + public String getNodeId() { + return null; + } + @Override public void publishCurrentServer() { //Do nothing diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java index 14196a6bee..b4829f27aa 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.cluster.discovery; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.SerializationException; import org.apache.commons.lang3.SerializationUtils; import org.apache.curator.framework.CuratorFramework; @@ -93,11 +94,13 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi private CuratorFramework client; private PathChildrenCache cache; private String nodePath; - + //TODO: make persistent? + private String nodeId; @PostConstruct public void init() { log.info("Initializing..."); + this.nodeId = RandomStringUtils.randomAlphabetic(10); Assert.hasLength(zkUrl, MiscUtils.missingProperty("zk.url")); Assert.notNull(zkRetryInterval, MiscUtils.missingProperty("zk.retry_interval_ms")); Assert.notNull(zkConnectionTimeout, MiscUtils.missingProperty("zk.connection_timeout_ms")); @@ -180,6 +183,11 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } } + @Override + public String getNodeId() { + return nodeId; + } + @Override public ServerInstance getCurrentServer() { return serverInstance.getSelf(); diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java new file mode 100644 index 0000000000..461b89447f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java @@ -0,0 +1,102 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Created by ashvayka on 26.09.18. + */ +@Slf4j +public abstract class AbstractJsInvokeService implements JsInvokeService { + + protected Map scriptIdToNameMap = new ConcurrentHashMap<>(); + protected Map blackListedFunctions = new ConcurrentHashMap<>(); + + @Override + public ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames) { + UUID scriptId = UUID.randomUUID(); + String functionName = "invokeInternal_" + scriptId.toString().replace('-', '_'); + String jsScript = generateJsScript(scriptType, functionName, scriptBody, argNames); + return doEval(scriptId, functionName, jsScript); + } + + @Override + public ListenableFuture invokeFunction(UUID scriptId, Object... args) { + String functionName = scriptIdToNameMap.get(scriptId); + if (functionName == null) { + return Futures.immediateFailedFuture(new RuntimeException("No compiled script found for scriptId: [" + scriptId + "]!")); + } + if (!isBlackListed(scriptId)) { + return doInvokeFunction(scriptId, functionName, args); + } else { + return Futures.immediateFailedFuture( + new RuntimeException("Script is blacklisted due to maximum error count " + getMaxErrors() + "!")); + } + } + + @Override + public ListenableFuture release(UUID scriptId) { + String functionName = scriptIdToNameMap.get(scriptId); + if (functionName != null) { + try { + scriptIdToNameMap.remove(scriptId); + blackListedFunctions.remove(scriptId); + doRelease(scriptId, functionName); + } catch (Exception e) { + return Futures.immediateFailedFuture(e); + } + } + return Futures.immediateFuture(null); + } + + protected abstract ListenableFuture doEval(UUID scriptId, String functionName, String scriptBody); + + protected abstract ListenableFuture doInvokeFunction(UUID scriptId, String functionName, Object[] args); + + protected abstract void doRelease(UUID scriptId, String functionName) throws Exception; + + protected abstract int getMaxErrors(); + + protected void onScriptExecutionError(UUID scriptId) { + blackListedFunctions.computeIfAbsent(scriptId, key -> new AtomicInteger(0)).incrementAndGet(); + } + + private String generateJsScript(JsScriptType scriptType, String functionName, String scriptBody, String... argNames) { + switch (scriptType) { + case RULE_NODE_SCRIPT: + return RuleNodeScriptFactory.generateRuleNodeScript(functionName, scriptBody, argNames); + default: + throw new RuntimeException("No script factory implemented for scriptType: " + scriptType); + } + } + + private boolean isBlackListed(UUID scriptId) { + if (blackListedFunctions.containsKey(scriptId)) { + AtomicInteger errorCount = blackListedFunctions.get(scriptId); + return errorCount.get() >= getMaxErrors(); + } else { + return false; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java index 4acef05cac..7c8eb2a89f 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java @@ -20,38 +20,24 @@ import com.google.common.util.concurrent.ListenableFuture; import delight.nashornsandbox.NashornSandbox; import delight.nashornsandbox.NashornSandboxes; import jdk.nashorn.api.scripting.NashornScriptEngineFactory; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.data.id.EntityId; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import javax.script.Invocable; import javax.script.ScriptEngine; import javax.script.ScriptException; -import java.util.Map; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; @Slf4j -public abstract class AbstractNashornJsInvokeService implements JsInvokeService { +public abstract class AbstractNashornJsInvokeService extends AbstractJsInvokeService { private NashornSandbox sandbox; private ScriptEngine engine; private ExecutorService monitorExecutorService; - private final Map functionsMap = new ConcurrentHashMap<>(); - private final Map blackListedFunctions = new ConcurrentHashMap<>(); - - private final Map scriptKeyToInfo = new ConcurrentHashMap<>(); - private final Map scriptIdToInfo = new ConcurrentHashMap<>(); - @PostConstruct public void init() { if (useJsSandbox()) { @@ -80,181 +66,44 @@ public abstract class AbstractNashornJsInvokeService implements JsInvokeService protected abstract long getMaxCpuTime(); - protected abstract int getMaxErrors(); - @Override - public ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames) { - ScriptInfo scriptInfo = deduplicate(scriptType, scriptBody); - UUID scriptId = scriptInfo.getId(); - AtomicInteger duplicateCount = scriptInfo.getCount(); - - synchronized (scriptInfo.getLock()) { - if (duplicateCount.compareAndSet(0, 1)) { - try { - evaluate(scriptId, scriptType, scriptBody, argNames); - } catch (Exception e) { - duplicateCount.decrementAndGet(); - log.warn("Failed to compile JS script: {}", e.getMessage(), e); - return Futures.immediateFailedFuture(e); - } + protected ListenableFuture doEval(UUID scriptId, String functionName, String jsScript) { + try { + if (useJsSandbox()) { + sandbox.eval(jsScript); } else { - duplicateCount.incrementAndGet(); + engine.eval(jsScript); } + scriptIdToNameMap.put(scriptId, functionName); + } catch (Exception e) { + log.warn("Failed to compile JS script: {}", e.getMessage(), e); + return Futures.immediateFailedFuture(e); } return Futures.immediateFuture(scriptId); } - private void evaluate(UUID scriptId, JsScriptType scriptType, String scriptBody, String... argNames) throws ScriptException { - String functionName = "invokeInternal_" + scriptId.toString().replace('-', '_'); - String jsScript = generateJsScript(scriptType, functionName, scriptBody, argNames); - if (useJsSandbox()) { - sandbox.eval(jsScript); - } else { - engine.eval(jsScript); - } - functionsMap.put(scriptId, functionName); - } - @Override - public ListenableFuture invokeFunction(UUID scriptId, EntityId entityId, Object... args) { - String functionName = functionsMap.get(scriptId); - if (functionName == null) { - String message = "No compiled script found for scriptId: [" + scriptId + "]!"; - log.warn(message); - return Futures.immediateFailedFuture(new RuntimeException(message)); - } - - BlackListInfo blackListInfo = blackListedFunctions.get(new BlackListKey(scriptId, entityId)); - if (blackListInfo != null && blackListInfo.getCount() >= getMaxErrors()) { - RuntimeException throwable = new RuntimeException("Script is blacklisted due to maximum error count " + getMaxErrors() + "!", blackListInfo.getCause()); - throwable.printStackTrace(); - return Futures.immediateFailedFuture(throwable); - } - + protected ListenableFuture doInvokeFunction(UUID scriptId, String functionName, Object[] args) { try { - return invoke(functionName, args); + Object result; + if (useJsSandbox()) { + result = sandbox.getSandboxedInvocable().invokeFunction(functionName, args); + } else { + result = ((Invocable) engine).invokeFunction(functionName, args); + } + return Futures.immediateFuture(result); } catch (Exception e) { - BlackListKey blackListKey = new BlackListKey(scriptId, entityId); - blackListedFunctions.computeIfAbsent(blackListKey, key -> new BlackListInfo()).incrementWithReason(e); + onScriptExecutionError(scriptId); return Futures.immediateFailedFuture(e); } } - private ListenableFuture invoke(String functionName, Object... args) throws ScriptException, NoSuchMethodException { - Object result; + protected void doRelease(UUID scriptId, String functionName) throws ScriptException { if (useJsSandbox()) { - result = sandbox.getSandboxedInvocable().invokeFunction(functionName, args); + sandbox.eval(functionName + " = undefined;"); } else { - result = ((Invocable) engine).invokeFunction(functionName, args); - } - return Futures.immediateFuture(result); - } - - @Override - public ListenableFuture release(UUID scriptId, EntityId entityId) { - ScriptInfo scriptInfo = scriptIdToInfo.get(scriptId); - if (scriptInfo == null) { - log.warn("Script release called for not existing script id [{}]", scriptId); - return Futures.immediateFuture(null); - } - - synchronized (scriptInfo.getLock()) { - int remainingDuplicates = scriptInfo.getCount().decrementAndGet(); - if (remainingDuplicates > 0) { - return Futures.immediateFuture(null); - } - - String functionName = functionsMap.get(scriptId); - if (functionName != null) { - try { - if (useJsSandbox()) { - sandbox.eval(functionName + " = undefined;"); - } else { - engine.eval(functionName + " = undefined;"); - } - functionsMap.remove(scriptId); - blackListedFunctions.remove(new BlackListKey(scriptId, entityId)); - } catch (ScriptException e) { - log.error("Could not release script [{}] [{}]", scriptId, remainingDuplicates); - return Futures.immediateFailedFuture(e); - } - } else { - log.warn("Function name do not exist for script [{}] [{}]", scriptId, remainingDuplicates); - } + engine.eval(functionName + " = undefined;"); } - return Futures.immediateFuture(null); - } - - - private String generateJsScript(JsScriptType scriptType, String functionName, String scriptBody, String... argNames) { - switch (scriptType) { - case RULE_NODE_SCRIPT: - return RuleNodeScriptFactory.generateRuleNodeScript(functionName, scriptBody, argNames); - default: - throw new RuntimeException("No script factory implemented for scriptType: " + scriptType); - } - } - - private ScriptInfo deduplicate(JsScriptType scriptType, String scriptBody) { - ScriptInfo meta = ScriptInfo.preInit(); - String key = deduplicateKey(scriptType, scriptBody); - ScriptInfo latestMeta = scriptKeyToInfo.computeIfAbsent(key, i -> meta); - return scriptIdToInfo.computeIfAbsent(latestMeta.getId(), i -> latestMeta); } - private String deduplicateKey(JsScriptType scriptType, String scriptBody) { - return scriptType + "_" + scriptBody; - } - - @Getter - private static class ScriptInfo { - private final UUID id; - private final Object lock; - private final AtomicInteger count; - - ScriptInfo(UUID id, Object lock, AtomicInteger count) { - this.id = id; - this.lock = lock; - this.count = count; - } - - static ScriptInfo preInit() { - UUID preId = UUID.randomUUID(); - AtomicInteger preCount = new AtomicInteger(); - Object preLock = new Object(); - return new ScriptInfo(preId, preLock, preCount); - } - } - - @EqualsAndHashCode - @Getter - @RequiredArgsConstructor - private static class BlackListKey { - private final UUID scriptId; - private final EntityId entityId; - - } - - @Data - private static class BlackListInfo { - private final AtomicInteger count; - private Exception ex; - - BlackListInfo() { - this.count = new AtomicInteger(0); - } - - void incrementWithReason(Exception e) { - count.incrementAndGet(); - ex = e; - } - - int getCount() { - return count.get(); - } - - Exception getCause() { - return ex; - } - } } diff --git a/application/src/main/java/org/thingsboard/server/service/script/JsInvokeRequest.java b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeRequest.java new file mode 100644 index 0000000000..1041a26c75 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeRequest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import java.util.List; + +/** + * Created by ashvayka on 25.09.18. + */ +public class JsInvokeRequest { + + private String scriptId; + private String scriptBody; + private List args; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/JsInvokeResponse.java b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeResponse.java new file mode 100644 index 0000000000..1638de8417 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeResponse.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import java.util.List; + +/** + * Created by ashvayka on 25.09.18. + */ +public class JsInvokeResponse { + + private String scriptId; + private String scriptBody; + private List args; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java index 1da9859c23..031a5ee657 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java @@ -25,8 +25,8 @@ public interface JsInvokeService { ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames); - ListenableFuture invokeFunction(UUID scriptId, EntityId entityId, Object... args); + ListenableFuture invokeFunction(UUID scriptId, Object... args); - ListenableFuture release(UUID scriptId, EntityId entityId); + ListenableFuture release(UUID scriptId); } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index 3a85cc5ffa..da8d62a231 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -15,32 +15,172 @@ */ package org.thingsboard.server.service.script; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; -import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaRequestTemplate; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.service.cluster.discovery.DiscoveryService; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; @Slf4j -@ConditionalOnProperty(prefix = "js", value = "evaluator", havingValue = "local", matchIfMissing = true) +@ConditionalOnProperty(prefix = "js", value = "evaluator", havingValue = "remote", matchIfMissing = true) @Service -public class RemoteJsInvokeService implements JsInvokeService { +public class RemoteJsInvokeService extends AbstractJsInvokeService { + + @Autowired + private DiscoveryService discoveryService; + + @Autowired + private TbKafkaSettings kafkaSettings; + + @Value("${js.remote.use_js_sandbox}") + private boolean useJsSandbox; + + @Value("${js.remote.request_topic}") + private String requestTopic; + + @Value("${js.remote.response_topic_prefix}") + private String responseTopicPrefix; + + @Value("${js.remote.max_pending_requests}") + private long maxPendingRequests; + + @Value("${js.remote.max_requests_timeout}") + private long maxRequestsTimeout; + + @Value("${js.remote.response_poll_duration}") + private long responsePollDuration; + + @Getter + @Value("${js.remote.max_errors}") + private int maxErrors; + + private TbKafkaRequestTemplate kafkaTemplate; + protected Map scriptIdToBodysMap = new ConcurrentHashMap<>(); + + @PostConstruct + public void init() { + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder requestBuilder = TBKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.defaultTopic(requestTopic); + requestBuilder.encoder(new RemoteJsRequestEncoder()); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); + responseBuilder.settings(kafkaSettings); + responseBuilder.topic(responseTopicPrefix + "." + discoveryService.getNodeId()); + responseBuilder.clientId(discoveryService.getNodeId()); + responseBuilder.groupId("rule-engine-node"); + responseBuilder.autoCommit(true); + responseBuilder.autoCommitIntervalMs(100); + responseBuilder.decoder(new RemoteJsResponseDecoder()); + + TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder + builder = TbKafkaRequestTemplate.builder(); + builder.requestTemplate(requestBuilder.build()); + builder.responseTemplate(responseBuilder.build()); + builder.maxPendingRequests(maxPendingRequests); + builder.maxRequestTimeout(maxRequestsTimeout); + builder.pollInterval(responsePollDuration); + kafkaTemplate = builder.build(); + } + + @PreDestroy + public void destroy(){ + if(kafkaTemplate != null){ + kafkaTemplate.stop(); + } + } @Override - public ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames) { - return null; + protected ListenableFuture doEval(UUID scriptId, String functionName, String scriptBody) { + JsInvokeProtos.JsCompileRequest jsRequest = JsInvokeProtos.JsCompileRequest.newBuilder() + .setScriptIdMSB(scriptId.getMostSignificantBits()) + .setScriptIdLSB(scriptId.getLeastSignificantBits()) + .setFunctionName(functionName) + .setScriptBody(scriptBody).build(); + + JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() + .setCompileRequest(jsRequest) + .build(); + + ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); + return Futures.transform(future, response -> { + JsInvokeProtos.JsCompileResponse compilationResult = response.getCompileResponse(); + UUID compiledScriptId = new UUID(compilationResult.getScriptIdMSB(), compilationResult.getScriptIdLSB()); + if (compilationResult.getSuccess()) { + scriptIdToNameMap.put(scriptId, functionName); + scriptIdToBodysMap.put(scriptId, scriptBody); + return compiledScriptId; + } else { + log.debug("[{}] Failed to compile script due to [{}]: {}", compiledScriptId, compilationResult.getErrorCode().name(), compilationResult.getErrorDetails()); + throw new RuntimeException(compilationResult.getErrorCode().name()); + } + }); } @Override - public ListenableFuture invokeFunction(UUID scriptId, EntityId entityId, Object... args) { - return null; + protected ListenableFuture doInvokeFunction(UUID scriptId, String functionName, Object[] args) { + String scriptBody = scriptIdToBodysMap.get(scriptId); + if (scriptBody == null) { + return Futures.immediateFailedFuture(new RuntimeException("No script body found for scriptId: [" + scriptId + "]!")); + } + JsInvokeProtos.JsInvokeRequest jsRequest = JsInvokeProtos.JsInvokeRequest.newBuilder() + .setScriptIdMSB(scriptId.getMostSignificantBits()) + .setScriptIdLSB(scriptId.getLeastSignificantBits()) + .setFunctionName(functionName) + .setScriptBody(scriptIdToBodysMap.get(scriptId)).build(); + + JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() + .setInvokeRequest(jsRequest) + .build(); + + ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); + return Futures.transform(future, response -> { + JsInvokeProtos.JsInvokeResponse invokeResult = response.getInvokeResponse(); + if (invokeResult.getSuccess()) { + return invokeResult.getResult(); + } else { + log.debug("[{}] Failed to compile script due to [{}]: {}", scriptId, invokeResult.getErrorCode().name(), invokeResult.getErrorDetails()); + throw new RuntimeException(invokeResult.getErrorCode().name()); + } + }); } @Override - public ListenableFuture release(UUID scriptId, EntityId entityId) { - return null; + protected void doRelease(UUID scriptId, String functionName) throws Exception { + JsInvokeProtos.JsReleaseRequest jsRequest = JsInvokeProtos.JsReleaseRequest.newBuilder() + .setScriptIdMSB(scriptId.getMostSignificantBits()) + .setScriptIdLSB(scriptId.getLeastSignificantBits()) + .setFunctionName(functionName).build(); + + JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() + .setReleaseRequest(jsRequest) + .build(); + + ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); + JsInvokeProtos.RemoteJsResponse response = future.get(); + + JsInvokeProtos.JsReleaseResponse compilationResult = response.getReleaseResponse(); + UUID compiledScriptId = new UUID(compilationResult.getScriptIdMSB(), compilationResult.getScriptIdLSB()); + if (compilationResult.getSuccess()) { + scriptIdToBodysMap.remove(scriptId); + } else { + log.debug("[{}] Failed to release script due", compiledScriptId); + } } } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java new file mode 100644 index 0000000000..7d5d547b8c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.kafka.TbKafkaEncoder; + +/** + * Created by ashvayka on 25.09.18. + */ +public class RemoteJsRequestEncoder implements TbKafkaEncoder { + @Override + public byte[] encode(JsInvokeProtos.RemoteJsRequest value) { + return value.toByteArray(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java new file mode 100644 index 0000000000..1975988d2b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 25.09.18. + */ +public class RemoteJsResponseDecoder implements TbKafkaDecoder { + + @Override + public JsInvokeProtos.RemoteJsResponse decode(byte[] data) throws IOException { + return JsInvokeProtos.RemoteJsResponse.parseFrom(data); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index c001a1a725..021e6da119 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -165,7 +165,7 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S private JsonNode executeScript(TbMsg msg) throws ScriptException { try { String[] inArgs = prepareArgs(msg); - String eval = sandboxService.invokeFunction(this.scriptId, this.entityId, inArgs[0], inArgs[1], inArgs[2]).get().toString(); + String eval = sandboxService.invokeFunction(this.scriptId, inArgs[0], inArgs[1], inArgs[2]).get().toString(); return mapper.readTree(eval); } catch (ExecutionException e) { if (e.getCause() instanceof ScriptException) { @@ -179,6 +179,6 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S } public void destroy() { - sandboxService.release(this.scriptId, this.entityId); + sandboxService.release(this.scriptId); } } diff --git a/application/src/main/proto/jsinvoke.proto b/application/src/main/proto/jsinvoke.proto index c465309689..475c7bd82c 100644 --- a/application/src/main/proto/jsinvoke.proto +++ b/application/src/main/proto/jsinvoke.proto @@ -14,24 +14,68 @@ * limitations under the License. */ syntax = "proto3"; -package cluster; +package js; option java_package = "org.thingsboard.server.gen.js"; option java_outer_classname = "JsInvokeProtos"; -service JsInvokeRpcService { - rpc handleMsgs(stream JsInvokeRequest) returns (stream JsInvokeResponse) {} +enum JsInvokeErrorCode{ + COMPILATION_ERROR = 0; + RUNTIME_ERROR = 1; + CPU_USAGE_ERROR = 2; +} + +message RemoteJsRequest { + JsCompileRequest compileRequest = 1; + JsInvokeRequest invokeRequest = 2; + JsReleaseRequest releaseRequest = 3; +} + +message RemoteJsResponse { + JsCompileResponse compileResponse = 1; + JsInvokeResponse invokeResponse = 2; + JsReleaseResponse releaseResponse = 3; +} + +message JsCompileRequest { + int64 scriptIdMSB = 1; + int64 scriptIdLSB = 2; + string functionName = 3; + string scriptBody = 4; +} + +message JsReleaseRequest { + int64 scriptIdMSB = 1; + int64 scriptIdLSB = 2; + string functionName = 3; +} + +message JsReleaseResponse { + bool success = 1; + int64 scriptIdMSB = 2; + int64 scriptIdLSB = 3; +} + +message JsCompileResponse { + bool success = 1; + int64 scriptIdMSB = 2; + int64 scriptIdLSB = 3; + JsInvokeErrorCode errorCode = 4; + string errorDetails = 5; } message JsInvokeRequest { - string scriptId = 1; - string scriptBody = 2; - repeated string args = 3; + int64 scriptIdMSB = 1; + int64 scriptIdLSB = 2; + string functionName = 3; + string scriptBody = 4; + repeated string args = 5; } message JsInvokeResponse { - string result = 1; - string errorName = 2; - string errorDetails = 3; + bool success = 1; + string result = 2; + JsInvokeErrorCode errorCode = 3; + string errorDetails = 4; } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 6851b586f8..eb40ac506a 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -405,8 +405,17 @@ state: defaultInactivityTimeoutInSec: 10 defaultStateCheckIntervalInSec: 10 +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + js: - evaluator: "${JS_EVALUATOR:external}" # local/external + evaluator: "${JS_EVALUATOR:local}" # local/external # Built-in JVM JavaScript environment properties local: # Use Sandboxed (secured) JVM JavaScript environment @@ -421,3 +430,15 @@ js: remote: # Use Sandboxed (secured) JVM JavaScript environment use_js_sandbox: "${USE_REMOTE_JS_SANDBOX:true}" + # JS Eval request topic + request_topic: "${REMOTE_JS_EVAL_REQUEST_TOPIC:js.eval.requests}" + # JS Eval responses topic prefix that is combined with node id + response_topic_prefix: "${REMOTE_JS_EVAL_REQUEST_TOPIC:js.eval.responses}" + # JS Eval max pending requests + max_pending_requests: "${REMOTE_JS_MAX_PENDING_REQUESTS:10000}" + # JS Eval max request timeout + max_requests_timeout: "${REMOTE_JS_MAX_REQUEST_TIMEOUT:20000}" + # JS response poll interval + response_poll_interval: "${REMOTE_JS_RESPONSE_POLL_INTERVAL_MS:25}" + # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted + max_errors: "${REMOTE_JS_SANDBOX_MAX_ERRORS:3}" diff --git a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java index 730121f4fa..fe0f381893 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java @@ -236,8 +236,8 @@ public class RuleNodeJsScriptEngineTest { startLatch.await(); UUID scriptId = jsSandboxService.eval(JsScriptType.RULE_NODE_SCRIPT, code).get(); scriptIds.put(scriptId, new Object()); - jsSandboxService.invokeFunction(scriptId, ruleNodeId, "{}", "{}", "TEXT").get(); - jsSandboxService.release(scriptId, ruleNodeId).get(); + jsSandboxService.invokeFunction(scriptId, "{}", "{}", "TEXT").get(); + jsSandboxService.release(scriptId).get(); } } catch (Throwable th) { failedCount.incrementAndGet(); diff --git a/common/pom.xml b/common/pom.xml index fbff206408..9cac6a7b60 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -37,7 +37,8 @@ data message - transport + transport + queue diff --git a/common/queue/pom.xml b/common/queue/pom.xml new file mode 100644 index 0000000000..eb722648d5 --- /dev/null +++ b/common/queue/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + org.thingsboard + 2.1.1-SNAPSHOT + common + + org.thingsboard.common + queue + jar + + Thingsboard Server Queue components + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + + + + + org.thingsboard.common + data + + + org.thingsboard.common + message + + + org.apache.kafka + kafka-clients + + + org.springframework + spring-context-support + + + org.springframework.boot + spring-boot-autoconfigure + + + com.google.guava + guava + + + com.google.code.gson + gson + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java new file mode 100644 index 0000000000..7ea40cd17c --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; + +import java.time.Duration; +import java.util.Collections; +import java.util.Properties; + +/** + * Created by ashvayka on 24.09.18. + */ +public class TBKafkaAdmin { + + AdminClient client; + + public TBKafkaAdmin() { + Properties props = new Properties(); + props.put("bootstrap.servers", "localhost:9092"); + props.put("group.id", "test"); + props.put("enable.auto.commit", "true"); + props.put("auto.commit.interval.ms", "1000"); + props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); + props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); + client = AdminClient.create(props); + } + + public CreateTopicsResult createTopic(NewTopic topic){ + return client.createTopics(Collections.singletonList(topic)); + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java new file mode 100644 index 0000000000..1b1bb5b2b7 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Builder; +import lombok.Getter; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; + +import java.io.IOException; +import java.time.Duration; +import java.util.Collections; +import java.util.Properties; + +/** + * Created by ashvayka on 24.09.18. + */ +public class TBKafkaConsumerTemplate { + + private final KafkaConsumer consumer; + private final TbKafkaDecoder decoder; + @Getter + private final String topic; + + @Builder + private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, + String clientId, String groupId, String topic, + boolean autoCommit, long autoCommitIntervalMs) { + Properties props = settings.toProps(); + props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, autoCommit); + props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitIntervalMs); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer"); + this.consumer = new KafkaConsumer<>(props); + this.decoder = decoder; + this.topic = topic; + } + + public void subscribe() { + consumer.subscribe(Collections.singletonList(topic)); + } + + public void unsubscribe() { + consumer.unsubscribe(); + } + + public ConsumerRecords poll(Duration duration) { + return consumer.poll(duration); + } + + public T decode(ConsumerRecord record) throws IOException { + return decoder.decode(record.value()); + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java new file mode 100644 index 0000000000..b39e63e1ef --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -0,0 +1,83 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Builder; +import lombok.Getter; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.PartitionInfo; +import org.apache.kafka.common.header.Header; + +import java.util.List; +import java.util.Properties; +import java.util.concurrent.Future; + +/** + * Created by ashvayka on 24.09.18. + */ +public class TBKafkaProducerTemplate { + + private final KafkaProducer producer; + private final TbKafkaEncoder encoder; + private final TbKafkaPartitioner partitioner; + private final List partitionInfoList; + @Getter + private final String defaultTopic; + + @Builder + private TBKafkaProducerTemplate(TbKafkaSettings settings, TbKafkaEncoder encoder, TbKafkaPartitioner partitioner, String defaultTopic) { + Properties props = settings.toProps(); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer"); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArraySerializer"); + this.producer = new KafkaProducer<>(props); + //Maybe this should not be cached, but we don't plan to change size of partitions + this.partitionInfoList = producer.partitionsFor(defaultTopic); + this.encoder = encoder; + this.partitioner = partitioner; + this.defaultTopic = defaultTopic; + } + + public Future send(String key, T value) { + return send(key, value, null, null); + } + + public Future send(String key, T value, Iterable
headers) { + return send(key, value, null, headers); + } + + public Future send(String key, T value, Long timestamp, Iterable
headers) { + return send(this.defaultTopic, key, value, timestamp, headers); + } + + public Future send(String topic, String key, T value, Long timestamp, Iterable
headers) { + byte[] data = encoder.encode(value); + ProducerRecord record; + Integer partition = getPartition(topic, key, value, data); + record = new ProducerRecord<>(this.defaultTopic, partition, timestamp, key, data, headers); + return producer.send(record); + } + + private Integer getPartition(String topic, String key, T value, byte[] data) { + if (partitioner == null) { + return null; + } else { + return partitioner.partition(this.defaultTopic, key, value, data, partitionInfoList); + } + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java new file mode 100644 index 0000000000..92e46b5459 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Header; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.LongAdder; + +/** + * Created by ashvayka on 24.09.18. + */ +@Slf4j +public class TbJsEvaluator { + +// public static void main(String[] args) { +// ExecutorService executorService = Executors.newCachedThreadPool(); +// +// TBKafkaConsumerTemplate requestConsumer = new TBKafkaConsumerTemplate(); +// requestConsumer.subscribe("requests"); +// +// LongAdder responseCounter = new LongAdder(); +// TBKafkaProducerTemplate responseProducer = new TBKafkaProducerTemplate(); +// executorService.submit((Runnable) () -> { +// while (true) { +// ConsumerRecords requests = requestConsumer.poll(100); +// requests.forEach(request -> { +// Header header = request.headers().lastHeader("responseTopic"); +// ProducerRecord response = new ProducerRecord<>(new String(header.value(), StandardCharsets.UTF_8), +// request.key(), request.value()); +// responseProducer.send(response); +// responseCounter.add(1); +// }); +// } +// }); +// +// executorService.submit((Runnable) () -> { +// while (true) { +// log.warn("Requests: [{}], Responses: [{}]", responseCounter.longValue(), responseCounter.longValue()); +// try { +// Thread.sleep(1000L); +// } catch (InterruptedException e) { +// e.printStackTrace(); +// } +// } +// }); +// +// } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaDecoder.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaDecoder.java new file mode 100644 index 0000000000..746dfda0bb --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaDecoder.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import java.io.IOException; + +/** + * Created by ashvayka on 25.09.18. + */ +public interface TbKafkaDecoder { + + T decode(byte[] data) throws IOException; + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEncoder.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEncoder.java new file mode 100644 index 0000000000..a3616cbf15 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEncoder.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +/** + * Created by ashvayka on 25.09.18. + */ +public interface TbKafkaEncoder { + + byte[] encode(T value); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaPartitioner.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaPartitioner.java new file mode 100644 index 0000000000..2d6e0d2f44 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaPartitioner.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import org.apache.kafka.clients.producer.Partitioner; +import org.apache.kafka.common.PartitionInfo; + +import java.util.List; + +/** + * Created by ashvayka on 25.09.18. + */ +public interface TbKafkaPartitioner extends Partitioner { + + int partition(String topic, String key, T value, byte[] encodedValue, List partitions); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaProperty.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaProperty.java new file mode 100644 index 0000000000..784a5c5645 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaProperty.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Created by ashvayka on 25.09.18. + */ +@Data +public class TbKafkaProperty { + + private String key; + private String value; +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java new file mode 100644 index 0000000000..be8c087060 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -0,0 +1,173 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +public class TbKafkaRequestTemplate { + + private final TBKafkaProducerTemplate requestTemplate; + private final TBKafkaConsumerTemplate responseTemplate; + private final ConcurrentMap> pendingRequests; + private final ExecutorService executor; + private final long maxRequestTimeout; + private final long maxPendingRequests; + private final long pollInterval; + private volatile long tickTs = 0L; + private volatile long tickSize = 0L; + private volatile boolean stopped = false; + + @Builder + public TbKafkaRequestTemplate(TBKafkaProducerTemplate requestTemplate, TBKafkaConsumerTemplate responseTemplate, + long maxRequestTimeout, + long maxPendingRequests, + long pollInterval, + ExecutorService executor) { + this.requestTemplate = requestTemplate; + this.responseTemplate = responseTemplate; + this.pendingRequests = new ConcurrentHashMap<>(); + this.maxRequestTimeout = maxRequestTimeout; + this.maxPendingRequests = maxPendingRequests; + this.pollInterval = pollInterval; + if (executor != null) { + this.executor = executor; + } else { + this.executor = Executors.newSingleThreadExecutor(); + } + } + + public void init() { + try { + TBKafkaAdmin admin = new TBKafkaAdmin(); + CreateTopicsResult result = admin.createTopic(new NewTopic(responseTemplate.getTopic(), 1, (short) 1)); + result.all().get(); + } catch (Exception e) { + log.trace("Failed to create topic: {}", e.getMessage(), e); + } + tickTs = System.currentTimeMillis(); + responseTemplate.subscribe(); + executor.submit(() -> { + long nextCleanupMs = 0L; + while (!stopped) { + ConsumerRecords responses = responseTemplate.poll(Duration.ofMillis(pollInterval)); + responses.forEach(response -> { + Header requestIdHeader = response.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); + if (requestIdHeader == null) { + log.error("[{}] Missing requestIdHeader", response); + } + UUID requestId = bytesToUuid(requestIdHeader.value()); + ResponseMetaData expectedResponse = pendingRequests.remove(requestId); + if (expectedResponse == null) { + log.trace("[{}] Invalid or stale request", requestId); + } else { + try { + expectedResponse.future.set(responseTemplate.decode(response)); + } catch (IOException e) { + expectedResponse.future.setException(e); + } + } + }); + tickTs = System.currentTimeMillis(); + tickSize = pendingRequests.size(); + if (nextCleanupMs < tickTs) { + //cleanup; + pendingRequests.entrySet().forEach(kv -> { + if (kv.getValue().expTime < tickTs) { + ResponseMetaData staleRequest = pendingRequests.remove(kv.getKey()); + if (staleRequest != null) { + staleRequest.future.setException(new TimeoutException()); + } + } + }); + nextCleanupMs = tickTs + maxRequestTimeout; + } + } + }); + } + + public void stop() { + stopped = true; + } + + public ListenableFuture post(String key, Request request) { + if (tickSize > maxPendingRequests) { + return Futures.immediateFailedFuture(new RuntimeException("Pending request map is full!")); + } + UUID requestId = UUID.randomUUID(); + List

headers = new ArrayList<>(2); + headers.add(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))); + headers.add(new RecordHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER, stringToBytes(responseTemplate.getTopic()))); + SettableFuture future = SettableFuture.create(); + pendingRequests.putIfAbsent(requestId, new ResponseMetaData<>(tickTs + maxRequestTimeout, future)); + requestTemplate.send(key, request, headers); + return future; + } + + private byte[] uuidToBytes(UUID uuid) { + ByteBuffer buf = ByteBuffer.allocate(16); + buf.putLong(uuid.getMostSignificantBits()); + buf.putLong(uuid.getLeastSignificantBits()); + return buf.array(); + } + + private static UUID bytesToUuid(byte[] bytes) { + ByteBuffer bb = ByteBuffer.wrap(bytes); + long firstLong = bb.getLong(); + long secondLong = bb.getLong(); + return new UUID(firstLong, secondLong); + } + + private byte[] stringToBytes(String string) { + return string.getBytes(StandardCharsets.UTF_8); + } + + private static class ResponseMetaData { + private final long expTime; + private final SettableFuture future; + + ResponseMetaData(long ts, SettableFuture future) { + this.expTime = ts; + this.future = future; + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java new file mode 100644 index 0000000000..757902f174 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Properties; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +@ConditionalOnProperty(prefix = "kafka", value = "enabled", havingValue = "true", matchIfMissing = false) +@Component +public class TbKafkaSettings { + + public static final String REQUEST_ID_HEADER = "requestId"; + public static final String RESPONSE_TOPIC_HEADER = "responseTopic"; + + + @Value("${kafka.bootstrap.server}") + private String servers; + + @Value("${kafka.acks}") + private String acks; + + @Value("${kafka.retries}") + private int retries; + + @Value("${kafka.batch.size}") + private long batchSize; + + @Value("${kafka.linger.ms}") + private long lingerMs; + + @Value("${kafka.buffer.memory}") + private long bufferMemory; + + @Value("${kafka.other:null}") + private List other; + + public Properties toProps() { + Properties props = new Properties(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, servers); + props.put(ProducerConfig.ACKS_CONFIG, acks); + props.put(ProducerConfig.RETRIES_CONFIG, retries); + props.put(ProducerConfig.BATCH_SIZE_CONFIG, batchSize); + props.put(ProducerConfig.LINGER_MS_CONFIG, lingerMs); + props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, bufferMemory); + if(other != null){ + other.forEach(kv -> props.put(kv.getKey(), kv.getValue())); + } + return props; + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java new file mode 100644 index 0000000000..ecac2e6796 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java @@ -0,0 +1,114 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.LongAdder; + +/** + * Created by ashvayka on 24.09.18. + */ +@Slf4j +public class TbRuleEngineEmulator { +// +// public static void main(String[] args) throws InterruptedException, ExecutionException { +// ConcurrentMap pendingRequestsMap = new ConcurrentHashMap<>(); +// +// ExecutorService executorService = Executors.newCachedThreadPool(); +// +// String responseTopic = "server" + Math.abs((int) (5000.0 * Math.random())); +// try { +// TBKafkaAdmin admin = new TBKafkaAdmin(); +// CreateTopicsResult result = admin.createTopic(new NewTopic(responseTopic, 1, (short) 1)); +// result.all().get(); +// } catch (Exception e) { +// log.warn("Failed to create topic: {}", e.getMessage(), e); +// } +// +// List
headers = Collections.singletonList(new RecordHeader("responseTopic", responseTopic.getBytes(StandardCharsets.UTF_8))); +// +// TBKafkaConsumerTemplate responseConsumer = new TBKafkaConsumerTemplate(); +// TBKafkaProducerTemplate requestProducer = new TBKafkaProducerTemplate(); +// +// LongAdder requestCounter = new LongAdder(); +// LongAdder responseCounter = new LongAdder(); +// +// responseConsumer.subscribe(responseTopic); +// executorService.submit((Runnable) () -> { +// while (true) { +// ConsumerRecords responses = responseConsumer.poll(100); +// responses.forEach(response -> { +// String expectedResponse = pendingRequestsMap.remove(response.key()); +// if (expectedResponse == null) { +// log.error("[{}] Invalid request", response.key()); +// } else if (!expectedResponse.equals(response.value())) { +// log.error("[{}] Invalid response: {} instead of {}", response.key(), response.value(), expectedResponse); +// } +// responseCounter.add(1); +// }); +// } +// }); +// +// executorService.submit((Runnable) () -> { +// int i = 0; +// while (true) { +// String requestId = UUID.randomUUID().toString(); +// String expectedResponse = UUID.randomUUID().toString(); +// pendingRequestsMap.put(requestId, expectedResponse); +// requestProducer.send(new ProducerRecord<>("requests", null, requestId, expectedResponse, headers)); +// requestCounter.add(1); +// i++; +// if (i % 10000 == 0) { +// try { +// Thread.sleep(500L); +// } catch (InterruptedException e) { +// e.printStackTrace(); +// } +// } +// } +// }); +// +// executorService.submit((Runnable) () -> { +// while (true) { +// log.warn("Requests: [{}], Responses: [{}]", requestCounter.longValue(), responseCounter.longValue()); +// try { +// Thread.sleep(1000L); +// } catch (InterruptedException e) { +// e.printStackTrace(); +// } +// } +// }); +// +// Thread.sleep(60000); +// } + +} diff --git a/common/queue/src/main/resources/logback.xml b/common/queue/src/main/resources/logback.xml new file mode 100644 index 0000000000..dcfc9301b2 --- /dev/null +++ b/common/queue/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index aa54e72000..159dad189f 100755 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,6 @@ 4.1.22.Final 1.5.0 3.6.5 - 0.9.0.0 2.19.1 3.0.2 2.6.1 @@ -82,6 +81,7 @@ 5.0.2 0.1.14 + 2.0.0 @@ -370,6 +370,11 @@ dao ${project.version} + + org.thingsboard.common + queue + ${project.version} + org.thingsboard tools @@ -414,6 +419,11 @@ spring-boot-starter-data-jpa ${spring-boot.version} + + org.apache.kafka + kafka-clients + ${kafka.version} + org.postgresql postgresql @@ -690,21 +700,6 @@ ${lombok.version} provided - - org.apache.kafka - kafka_2.10 - ${kafka.version} - - - org.slf4j - slf4j-log4j12 - - - log4j - log4j - - - org.eclipse.paho org.eclipse.paho.client.mqttv3 diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index e52e44bb9b..39f877c8eb 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -78,7 +78,7 @@ org.apache.kafka - kafka_2.10 + kafka-clients com.amazonaws From 601efbc9317a0f65df0413bc800ad25870e3ebc3 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 26 Sep 2018 14:57:43 +0300 Subject: [PATCH 126/307] Fixed action type for alarm audit log --- .../java/org/thingsboard/server/controller/AlarmController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java index 586e8c35ef..00885e34b0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -83,7 +83,7 @@ public class AlarmController extends BaseController { Alarm savedAlarm = checkNotNull(alarmService.createOrUpdateAlarm(alarm)); logEntityAction(savedAlarm.getId(), savedAlarm, getCurrentUser().getCustomerId(), - savedAlarm.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); + alarm.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); return savedAlarm; } catch (Exception e) { logEntityAction(emptyId(EntityType.ALARM), alarm, From 7ba82aef3be961cfc3c6f0df0a9b9bb25faf32dd Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 26 Sep 2018 17:21:18 +0300 Subject: [PATCH 127/307] Fixes to configuration --- .../server/service/script/NashornJsInvokeService.java | 6 +++--- .../thingsboard/server/kafka/TbKafkaRequestTemplate.java | 8 ++++---- .../org/thingsboard/server/kafka/TbKafkaSettings.java | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java index eadbf2838c..a50ab278da 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java @@ -29,13 +29,13 @@ public class NashornJsInvokeService extends AbstractNashornJsInvokeService { @Value("${js.local.use_js_sandbox}") private boolean useJsSandbox; - @Value("${js.local.js_sandbox.monitor_thread_pool_size}") + @Value("${js.local.monitor_thread_pool_size}") private int monitorThreadPoolSize; - @Value("${js.local.js_sandbox.max_cpu_time}") + @Value("${js.local.max_cpu_time}") private long maxCpuTime; - @Value("${js.local.js_sandbox.max_errors}") + @Value("${js.local.max_errors}") private int maxErrors; @Override diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index be8c087060..dd1a47a693 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java index 757902f174..0bfa646715 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java @@ -36,7 +36,7 @@ public class TbKafkaSettings { public static final String RESPONSE_TOPIC_HEADER = "responseTopic"; - @Value("${kafka.bootstrap.server}") + @Value("${kafka.bootstrap.servers}") private String servers; @Value("${kafka.acks}") @@ -54,7 +54,7 @@ public class TbKafkaSettings { @Value("${kafka.buffer.memory}") private long bufferMemory; - @Value("${kafka.other:null}") + @Value("${kafka.other:#{null}}") private List other; public Properties toProps() { From c0668dcb5248058f8f86f40aac7fc9f940cf3757 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 26 Sep 2018 17:24:37 +0300 Subject: [PATCH 128/307] Version set to 2.2.0-SNAPSHOT --- application/pom.xml | 2 +- common/data/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/queue/pom.xml | 2 +- common/transport/pom.xml | 2 +- dao/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- ui/package.json | 2 +- ui/pom.xml | 2 +- 19 files changed, 20 insertions(+), 20 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index eadcf7ba98..67dd91d757 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard application diff --git a/common/data/pom.xml b/common/data/pom.xml index b99e6bf79f..0fef87ab56 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 6923cacdf5..f4f20b08c6 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index 9cac6a7b60..e3cdabff98 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard diff --git a/common/queue/pom.xml b/common/queue/pom.xml index eb722648d5..11bb13981f 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/pom.xml b/common/transport/pom.xml index e132ffb01e..a349012fce 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index b12ea421d5..275aee094d 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard dao diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 8dffbfdb01..80e8a5ddb8 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,12 +19,12 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard netty-mqtt - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 159dad189f..57603eedd1 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT pom Thingsboard diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 00e49807b0..15341b09b1 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 09da1cdaba..46849b0241 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 39f877c8eb..6390929911 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index a3863300c1..f61634f3f0 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 2360a7518c..a7015f0a35 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index a3117d5d7f..996470832c 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 798e7abaa2..785e9a5306 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index df2bd27e39..95b0112a2a 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard diff --git a/ui/package.json b/ui/package.json index 498de06d4f..e730c39302 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard", "private": true, - "version": "2.1.1", + "version": "2.2.0", "description": "Thingsboard UI", "licenses": [ { diff --git a/ui/pom.xml b/ui/pom.xml index 92c5a60d6a..52c309c6ef 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard From 4c4923e3f7904e1a2813c9b34a0445ba7998921a Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 27 Sep 2018 11:59:15 +0300 Subject: [PATCH 129/307] Timeout and Args support --- .../service/script/RemoteJsInvokeService.java | 18 +-- application/src/main/proto/jsinvoke.proto | 7 +- .../src/main/resources/thingsboard.yml | 6 +- .../server/kafka/TbJsEvaluator.java | 68 ----------- .../server/kafka/TbRuleEngineEmulator.java | 114 ------------------ 5 files changed, 16 insertions(+), 197 deletions(-) delete mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java delete mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index da8d62a231..3507397b7d 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -47,9 +47,6 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { @Autowired private TbKafkaSettings kafkaSettings; - @Value("${js.remote.use_js_sandbox}") - private boolean useJsSandbox; - @Value("${js.remote.request_topic}") private String requestTopic; @@ -99,8 +96,8 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { } @PreDestroy - public void destroy(){ - if(kafkaTemplate != null){ + public void destroy() { + if (kafkaTemplate != null) { kafkaTemplate.stop(); } } @@ -138,14 +135,19 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { if (scriptBody == null) { return Futures.immediateFailedFuture(new RuntimeException("No script body found for scriptId: [" + scriptId + "]!")); } - JsInvokeProtos.JsInvokeRequest jsRequest = JsInvokeProtos.JsInvokeRequest.newBuilder() + JsInvokeProtos.JsInvokeRequest.Builder jsRequestBuilder = JsInvokeProtos.JsInvokeRequest.newBuilder() .setScriptIdMSB(scriptId.getMostSignificantBits()) .setScriptIdLSB(scriptId.getLeastSignificantBits()) .setFunctionName(functionName) - .setScriptBody(scriptIdToBodysMap.get(scriptId)).build(); + .setTimeout((int) maxRequestsTimeout) + .setScriptBody(scriptIdToBodysMap.get(scriptId)); + + for (int i = 0; i < args.length; i++) { + jsRequestBuilder.setArgs(i, args[i].toString()); + } JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() - .setInvokeRequest(jsRequest) + .setInvokeRequest(jsRequestBuilder.build()) .build(); ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); diff --git a/application/src/main/proto/jsinvoke.proto b/application/src/main/proto/jsinvoke.proto index 475c7bd82c..d85c3534bb 100644 --- a/application/src/main/proto/jsinvoke.proto +++ b/application/src/main/proto/jsinvoke.proto @@ -19,10 +19,10 @@ package js; option java_package = "org.thingsboard.server.gen.js"; option java_outer_classname = "JsInvokeProtos"; -enum JsInvokeErrorCode{ +enum JsInvokeErrorCode { COMPILATION_ERROR = 0; RUNTIME_ERROR = 1; - CPU_USAGE_ERROR = 2; + TIMEOUT_ERROR = 2; } message RemoteJsRequest { @@ -69,7 +69,8 @@ message JsInvokeRequest { int64 scriptIdLSB = 2; string functionName = 3; string scriptBody = 4; - repeated string args = 5; + int32 timeout = 5; + repeated string args = 6; } message JsInvokeResponse { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index eb40ac506a..37fd6cf687 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -415,7 +415,7 @@ kafka: buffer.memory: "${TB_BUFFER_MEMORY:33554432}" js: - evaluator: "${JS_EVALUATOR:local}" # local/external + evaluator: "${JS_EVALUATOR:local}" # local/remote # Built-in JVM JavaScript environment properties local: # Use Sandboxed (secured) JVM JavaScript environment @@ -428,8 +428,6 @@ js: max_errors: "${LOCAL_JS_SANDBOX_MAX_ERRORS:3}" # Remote JavaScript environment properties remote: - # Use Sandboxed (secured) JVM JavaScript environment - use_js_sandbox: "${USE_REMOTE_JS_SANDBOX:true}" # JS Eval request topic request_topic: "${REMOTE_JS_EVAL_REQUEST_TOPIC:js.eval.requests}" # JS Eval responses topic prefix that is combined with node id @@ -437,7 +435,7 @@ js: # JS Eval max pending requests max_pending_requests: "${REMOTE_JS_MAX_PENDING_REQUESTS:10000}" # JS Eval max request timeout - max_requests_timeout: "${REMOTE_JS_MAX_REQUEST_TIMEOUT:20000}" + max_requests_timeout: "${REMOTE_JS_MAX_REQUEST_TIMEOUT:10000}" # JS response poll interval response_poll_interval: "${REMOTE_JS_RESPONSE_POLL_INTERVAL_MS:25}" # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java deleted file mode 100644 index 92e46b5459..0000000000 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbJsEvaluator.java +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.kafka; - -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecords; -import org.apache.kafka.clients.producer.ProducerRecord; -import org.apache.kafka.common.header.Header; - -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.LongAdder; - -/** - * Created by ashvayka on 24.09.18. - */ -@Slf4j -public class TbJsEvaluator { - -// public static void main(String[] args) { -// ExecutorService executorService = Executors.newCachedThreadPool(); -// -// TBKafkaConsumerTemplate requestConsumer = new TBKafkaConsumerTemplate(); -// requestConsumer.subscribe("requests"); -// -// LongAdder responseCounter = new LongAdder(); -// TBKafkaProducerTemplate responseProducer = new TBKafkaProducerTemplate(); -// executorService.submit((Runnable) () -> { -// while (true) { -// ConsumerRecords requests = requestConsumer.poll(100); -// requests.forEach(request -> { -// Header header = request.headers().lastHeader("responseTopic"); -// ProducerRecord response = new ProducerRecord<>(new String(header.value(), StandardCharsets.UTF_8), -// request.key(), request.value()); -// responseProducer.send(response); -// responseCounter.add(1); -// }); -// } -// }); -// -// executorService.submit((Runnable) () -> { -// while (true) { -// log.warn("Requests: [{}], Responses: [{}]", responseCounter.longValue(), responseCounter.longValue()); -// try { -// Thread.sleep(1000L); -// } catch (InterruptedException e) { -// e.printStackTrace(); -// } -// } -// }); -// -// } - -} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java deleted file mode 100644 index ecac2e6796..0000000000 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbRuleEngineEmulator.java +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.kafka; - -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.admin.CreateTopicsResult; -import org.apache.kafka.clients.admin.NewTopic; -import org.apache.kafka.clients.consumer.ConsumerRecords; -import org.apache.kafka.clients.producer.ProducerRecord; -import org.apache.kafka.common.header.Header; -import org.apache.kafka.common.header.internals.RecordHeader; - -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.LongAdder; - -/** - * Created by ashvayka on 24.09.18. - */ -@Slf4j -public class TbRuleEngineEmulator { -// -// public static void main(String[] args) throws InterruptedException, ExecutionException { -// ConcurrentMap pendingRequestsMap = new ConcurrentHashMap<>(); -// -// ExecutorService executorService = Executors.newCachedThreadPool(); -// -// String responseTopic = "server" + Math.abs((int) (5000.0 * Math.random())); -// try { -// TBKafkaAdmin admin = new TBKafkaAdmin(); -// CreateTopicsResult result = admin.createTopic(new NewTopic(responseTopic, 1, (short) 1)); -// result.all().get(); -// } catch (Exception e) { -// log.warn("Failed to create topic: {}", e.getMessage(), e); -// } -// -// List

headers = Collections.singletonList(new RecordHeader("responseTopic", responseTopic.getBytes(StandardCharsets.UTF_8))); -// -// TBKafkaConsumerTemplate responseConsumer = new TBKafkaConsumerTemplate(); -// TBKafkaProducerTemplate requestProducer = new TBKafkaProducerTemplate(); -// -// LongAdder requestCounter = new LongAdder(); -// LongAdder responseCounter = new LongAdder(); -// -// responseConsumer.subscribe(responseTopic); -// executorService.submit((Runnable) () -> { -// while (true) { -// ConsumerRecords responses = responseConsumer.poll(100); -// responses.forEach(response -> { -// String expectedResponse = pendingRequestsMap.remove(response.key()); -// if (expectedResponse == null) { -// log.error("[{}] Invalid request", response.key()); -// } else if (!expectedResponse.equals(response.value())) { -// log.error("[{}] Invalid response: {} instead of {}", response.key(), response.value(), expectedResponse); -// } -// responseCounter.add(1); -// }); -// } -// }); -// -// executorService.submit((Runnable) () -> { -// int i = 0; -// while (true) { -// String requestId = UUID.randomUUID().toString(); -// String expectedResponse = UUID.randomUUID().toString(); -// pendingRequestsMap.put(requestId, expectedResponse); -// requestProducer.send(new ProducerRecord<>("requests", null, requestId, expectedResponse, headers)); -// requestCounter.add(1); -// i++; -// if (i % 10000 == 0) { -// try { -// Thread.sleep(500L); -// } catch (InterruptedException e) { -// e.printStackTrace(); -// } -// } -// } -// }); -// -// executorService.submit((Runnable) () -> { -// while (true) { -// log.warn("Requests: [{}], Responses: [{}]", requestCounter.longValue(), responseCounter.longValue()); -// try { -// Thread.sleep(1000L); -// } catch (InterruptedException e) { -// e.printStackTrace(); -// } -// } -// }); -// -// Thread.sleep(60000); -// } - -} From b8c13535e45acb906216f58fbbe89c5fa17f81f3 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 27 Sep 2018 12:15:48 +0300 Subject: [PATCH 130/307] Add Microservices module. Add ThingsBoard JS Executor Microservice. --- msa/js-executor/.gitignore | 31 ++ msa/js-executor/api/jsMessageConsumer.js | 25 ++ msa/js-executor/build.gradle | 120 +++++++ .../config/custom-environment-variables.yml | 25 ++ msa/js-executor/config/default.yml | 26 ++ msa/js-executor/config/logger.js | 59 ++++ msa/js-executor/config/tb-js-executor.conf | 19 ++ msa/js-executor/install.js | 42 +++ msa/js-executor/package.json | 35 ++ msa/js-executor/pom.xml | 310 ++++++++++++++++++ msa/js-executor/server.js | 83 +++++ msa/js-executor/src/main/assembly/windows.xml | 71 ++++ .../src/main/filters/unix.properties | 1 + .../src/main/filters/windows.properties | 2 + .../src/main/scripts/control/deb/postinst | 6 + .../src/main/scripts/control/deb/postrm | 3 + .../src/main/scripts/control/deb/preinst | 18 + .../src/main/scripts/control/deb/prerm | 5 + .../src/main/scripts/control/rpm/postinst | 9 + .../src/main/scripts/control/rpm/postrm | 6 + .../src/main/scripts/control/rpm/preinst | 6 + .../src/main/scripts/control/rpm/prerm | 6 + .../scripts/control/tb-js-executor.service | 11 + .../src/main/scripts/init/tb-js-executor | 233 +++++++++++++ .../src/main/scripts/windows/install.bat | 31 ++ .../src/main/scripts/windows/service.xml | 29 ++ .../src/main/scripts/windows/uninstall.bat | 25 ++ msa/pom.xml | 41 +++ pom.xml | 1 + 29 files changed, 1279 insertions(+) create mode 100644 msa/js-executor/.gitignore create mode 100644 msa/js-executor/api/jsMessageConsumer.js create mode 100644 msa/js-executor/build.gradle create mode 100644 msa/js-executor/config/custom-environment-variables.yml create mode 100644 msa/js-executor/config/default.yml create mode 100644 msa/js-executor/config/logger.js create mode 100644 msa/js-executor/config/tb-js-executor.conf create mode 100644 msa/js-executor/install.js create mode 100644 msa/js-executor/package.json create mode 100644 msa/js-executor/pom.xml create mode 100644 msa/js-executor/server.js create mode 100644 msa/js-executor/src/main/assembly/windows.xml create mode 100644 msa/js-executor/src/main/filters/unix.properties create mode 100644 msa/js-executor/src/main/filters/windows.properties create mode 100644 msa/js-executor/src/main/scripts/control/deb/postinst create mode 100644 msa/js-executor/src/main/scripts/control/deb/postrm create mode 100644 msa/js-executor/src/main/scripts/control/deb/preinst create mode 100644 msa/js-executor/src/main/scripts/control/deb/prerm create mode 100644 msa/js-executor/src/main/scripts/control/rpm/postinst create mode 100644 msa/js-executor/src/main/scripts/control/rpm/postrm create mode 100644 msa/js-executor/src/main/scripts/control/rpm/preinst create mode 100644 msa/js-executor/src/main/scripts/control/rpm/prerm create mode 100644 msa/js-executor/src/main/scripts/control/tb-js-executor.service create mode 100644 msa/js-executor/src/main/scripts/init/tb-js-executor create mode 100644 msa/js-executor/src/main/scripts/windows/install.bat create mode 100644 msa/js-executor/src/main/scripts/windows/service.xml create mode 100644 msa/js-executor/src/main/scripts/windows/uninstall.bat create mode 100644 msa/pom.xml diff --git a/msa/js-executor/.gitignore b/msa/js-executor/.gitignore new file mode 100644 index 0000000000..c5cfd3d322 --- /dev/null +++ b/msa/js-executor/.gitignore @@ -0,0 +1,31 @@ +*.toDelete +output/** +*.class +*~ +*.iml +*/.idea/** +.idea/** +.idea +*.log +*.log.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] +*/.classpath +.classpath +*/.project +.project +.cache/** +target/ +logs/ +build/ +.settings/ +/bin +bin/ +**/dependency-reduced-pom.xml +pom.xml.versionsBackup +.DS_Store +**/.gradle +**/local.properties +**/build +**/target +**/.env +node_modules +package-lock.json diff --git a/msa/js-executor/api/jsMessageConsumer.js b/msa/js-executor/api/jsMessageConsumer.js new file mode 100644 index 0000000000..43d3c781e4 --- /dev/null +++ b/msa/js-executor/api/jsMessageConsumer.js @@ -0,0 +1,25 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var logger = require('../config/logger')('JsMessageConsumer'); + +exports.onJsInvokeMessage = function(message, producer) { + + logger.info('Received message: %s', message); + +} diff --git a/msa/js-executor/build.gradle b/msa/js-executor/build.gradle new file mode 100644 index 0000000000..f29dbeca79 --- /dev/null +++ b/msa/js-executor/build.gradle @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgUser + permissionGroup pkgUser + + // Copy the executable file + from("target/package/linux/bin/${pkgName}") { + fileMode 0500 + into "bin" + } + + // Copy the init file + from("target/package/linux/init/${pkgName}") { + fileMode 0500 + into "init" + } + + // Copy the config files + from("target/package/linux/conf") { + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = X86_64 + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgUser + permissionGroup pkgUser + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "amd64" + + archiveName = "${pkgName}.deb" + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/custom-environment-variables.yml") + configurationFile("${pkgInstallFolder}/conf/default.yml") + configurationFile("${pkgInstallFolder}/conf/logger.js") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgUser + permissionGroup pkgUser + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/init/${pkgName}") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/msa/js-executor/config/custom-environment-variables.yml b/msa/js-executor/config/custom-environment-variables.yml new file mode 100644 index 0000000000..d63a50461b --- /dev/null +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -0,0 +1,25 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +kafka: + request_topic: "REMOTE_JS_EVAL_REQUEST_TOPIC" + bootstrap: + # Kafka Bootstrap Servers + servers: "TB_KAFKA_SERVERS" +logger: + level: "LOGGER_LEVEL" + path: "LOG_FOLDER" + filename: "LOGGER_FILENAME" diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml new file mode 100644 index 0000000000..5933780bdf --- /dev/null +++ b/msa/js-executor/config/default.yml @@ -0,0 +1,26 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +kafka: + request_topic: "js.eval.requests" + bootstrap: + # Kafka Bootstrap Servers + servers: "localhost:9092" + +logger: + level: "info" + path: "logs" + filename: "tb-js-executor-%DATE%.log" diff --git a/msa/js-executor/config/logger.js b/msa/js-executor/config/logger.js new file mode 100644 index 0000000000..86df82777d --- /dev/null +++ b/msa/js-executor/config/logger.js @@ -0,0 +1,59 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var config = require('config'), + path = require('path'), + DailyRotateFile = require('winston-daily-rotate-file'); + +const { createLogger, format, transports } = require('winston'); +const { combine, timestamp, label, printf, splat } = format; + +var loggerTransports = []; + +if (process.env.NODE_ENV !== 'production') { + loggerTransports.push(new transports.Console({ + handleExceptions: true + })); +} else { + var filename = path.join(config.get('logger.path'), config.get('logger.filename')); + var transport = new (DailyRotateFile)({ + filename: filename, + datePattern: 'YYYY-MM-DD-HH', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + handleExceptions: true + }); + loggerTransports.push(transport); +} + +const tbFormat = printf(info => { + return `${info.timestamp} [${info.label}] ${info.level.toUpperCase()}: ${info.message}`; +}); + +function _logger(moduleLabel) { + return createLogger({ + level: config.get('logger.level'), + format:combine( + splat(), + label({ label: moduleLabel }), + timestamp({format: 'YYYY-MM-DD HH:mm:ss,SSS'}), + tbFormat + ), + transports: loggerTransports + }); +} + +module.exports = _logger; \ No newline at end of file diff --git a/msa/js-executor/config/tb-js-executor.conf b/msa/js-executor/config/tb-js-executor.conf new file mode 100644 index 0000000000..71f829d270 --- /dev/null +++ b/msa/js-executor/config/tb-js-executor.conf @@ -0,0 +1,19 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export NODE_CONFIG_DIR=${pkg.installFolder}/conf +export LOG_FOLDER=${pkg.logFolder} +export NODE_ENV=production diff --git a/msa/js-executor/install.js b/msa/js-executor/install.js new file mode 100644 index 0000000000..a6bf8d8ff7 --- /dev/null +++ b/msa/js-executor/install.js @@ -0,0 +1,42 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); + +let _projectRoot = null; + + +(async() => { + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-js-executor-linux'), + path.join(targetPackageDir('linux'), 'bin', 'tb-js-executor'), + {overwrite: true}); + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-js-executor-win.exe'), + path.join(targetPackageDir('windows'), 'bin', 'tb-js-executor.exe'), + {overwrite: true}); +})(); + + +function projectRoot() { + if (!_projectRoot) { + _projectRoot = __dirname; + } + return _projectRoot; +} + +function targetPackageDir(platform) { + return path.join(projectRoot(), 'target', 'package', platform); +} diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json new file mode 100644 index 0000000000..870986a24f --- /dev/null +++ b/msa/js-executor/package.json @@ -0,0 +1,35 @@ +{ + "name": "thingsboard-js-executor", + "private": true, + "version": "2.2.0", + "description": "ThingsBoard JavaScript Executor Microservice", + "main": "server.js", + "bin": "server.js", + "scripts": { + "install": "pkg -t node8-linux-x64,node8-win-x64 --out-path ./target . && node install.js", + "test": "echo \"Error: no test specified\" && exit 1", + "start": "nodemon server.js", + "start-prod": "NODE_ENV=production nodemon server.js" + }, + "dependencies": { + "config": "^1.30.0", + "js-yaml": "^3.12.0", + "kafka-node": "^3.0.1", + "winston": "^3.0.0", + "winston-daily-rotate-file": "^3.2.1" + }, + "engine": "node >= 5.9.0", + "nyc": { + "exclude": [ + "test", + "__tests__", + "node_modules", + "target" + ] + }, + "devDependencies": { + "fs-extra": "^6.0.1", + "nodemon": "^1.17.5", + "pkg": "^4.3.3" + } +} diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml new file mode 100644 index 0000000000..2dd03a4a8b --- /dev/null +++ b/msa/js-executor/pom.xml @@ -0,0 +1,310 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + js-executor + pom + + ThingsBoard JavaScript Executor Microservice + https://thingsboard.io + Service executing JavaScript functions in sandboxed environment + + + UTF-8 + ${basedir}/../.. + tb-js-executor + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/package/linux + ${project.build.directory}/package/windows + + + + + com.sun.winsw + winsw + bin + exe + provided + + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + install node and npm + + install-node-and-npm + + + v8.11.3 + 5.6.0 + + + + npm install + + npm + + + install + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-linux-conf + process-resources + + copy-resources + + + ${pkg.linux.dist}/conf + + + config + true + + + + src/main/filters/unix.properties + + + + + copy-linux-init + process-resources + + copy-resources + + + ${pkg.linux.dist}/init + + + src/main/scripts/init + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + config + + tb-js-executor.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PpkgName=${pkg.name} + -PpkgUser=${pkg.user} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.0.0 + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + + + + npm-start + + + npm-start + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + npm start + + npm + + + + start + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/js-executor/server.js b/msa/js-executor/server.js new file mode 100644 index 0000000000..2a3b24f30e --- /dev/null +++ b/msa/js-executor/server.js @@ -0,0 +1,83 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var config = require('config'), + kafka = require('kafka-node'), + Consumer = kafka.Consumer, + Producer = kafka.Producer, + JsMessageConsumer = require('./api/jsMessageConsumer'); + +var logger = require('./config/logger')('main'); + +var kafkaBootstrapServers = config.get('kafka.bootstrap.servers'); +var kafkaRequestTopic = config.get('kafka.request_topic'); + +logger.info('Kafka Bootstrap Servers: %s', kafkaBootstrapServers); +logger.info('Kafka Requests Topic: %s', kafkaRequestTopic); + +var kafkaClient; + +(async() => { + try { + logger.info('Starting ThingsBoard JavaScript Executor Microservice...'); + + kafkaClient = new kafka.KafkaClient({kafkaHost: kafkaBootstrapServers}); + + var consumer = new Consumer( + kafkaClient, + [ + { topic: kafkaRequestTopic, partition: 0 } + ], + { + autoCommit: true + } + ); + + var producer = new Producer(kafkaClient); + producer.on('error', (err) => { + logger.error('Unexpected kafka producer error'); + logger.error(err); + }); + + producer.on('ready', () => { + consumer.on('message', (message) => { + JsMessageConsumer.onJsInvokeMessage(message, producer); + }); + }); + + logger.info('Started ThingsBoard JavaScript Executor Microservice.'); + } catch (e) { + logger.error('Failed to start ThingsBoard JavaScript Executor Microservice: %s', e.message); + logger.error(e); + exit(-1); + } +})(); + +process.on('exit', function () { + exit(0); +}); + +function exit(status) { + logger.info('Exiting with status: %d ...', status); + if (kafkaClient) { + logger.info('Stopping Kafka Client...'); + kafkaClient.close(() => { + logger.info('Kafka Client stopped.'); + process.exit(status); + }); + } else { + process.exit(status); + } +} \ No newline at end of file diff --git a/msa/js-executor/src/main/assembly/windows.xml b/msa/js-executor/src/main/assembly/windows.xml new file mode 100644 index 0000000000..7d137157b4 --- /dev/null +++ b/msa/js-executor/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${pkg.win.dist}/bin/${pkg.name}.exe + bin + ${pkg.name}.exe + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/msa/js-executor/src/main/filters/unix.properties b/msa/js-executor/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/msa/js-executor/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/msa/js-executor/src/main/filters/windows.properties b/msa/js-executor/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/msa/js-executor/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/msa/js-executor/src/main/scripts/control/deb/postinst b/msa/js-executor/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..0767d3f2c7 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/msa/js-executor/src/main/scripts/control/deb/postrm b/msa/js-executor/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/msa/js-executor/src/main/scripts/control/deb/preinst b/msa/js-executor/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..d2ebea46d7 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.user} >/dev/null; then + addgroup --system ${pkg.user} +fi + +if ! getent passwd ${pkg.user} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.user} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.user} +fi diff --git a/msa/js-executor/src/main/scripts/control/deb/prerm b/msa/js-executor/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/msa/js-executor/src/main/scripts/control/rpm/postinst b/msa/js-executor/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..d8021e2dd9 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/js-executor/src/main/scripts/control/rpm/postrm b/msa/js-executor/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/js-executor/src/main/scripts/control/rpm/preinst b/msa/js-executor/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..db6306e4ac --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.user} >/dev/null || groupadd -r ${pkg.user} +getent passwd ${pkg.user} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.user} -M -r ${pkg.user} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/msa/js-executor/src/main/scripts/control/rpm/prerm b/msa/js-executor/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/msa/js-executor/src/main/scripts/control/tb-js-executor.service b/msa/js-executor/src/main/scripts/control/tb-js-executor.service new file mode 100644 index 0000000000..f542dd0f92 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/tb-js-executor.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.user} +ExecStart=${pkg.installFolder}/init/${pkg.name} +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/msa/js-executor/src/main/scripts/init/tb-js-executor b/msa/js-executor/src/main/scripts/init/tb-js-executor new file mode 100644 index 0000000000..6e8fa52cd3 --- /dev/null +++ b/msa/js-executor/src/main/scripts/init/tb-js-executor @@ -0,0 +1,233 @@ +#!/bin/bash +# + + +### BEGIN INIT INFO +# Provides: tb-js-executor +# Required-Start: $remote_fs $syslog $network +# Required-Stop: $remote_fs $syslog $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: ${project.name} +# Description: ${project.description} +# chkconfig: 2345 99 01 +### END INIT INFO + +[[ -n "$DEBUG" ]] && set -x + +# Initialize variables that cannot be provided by a .conf file +WORKING_DIR="$(pwd)" +# shellcheck disable=SC2153 + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf + +# Follow symlinks to find the real script and detect init.d script +cd "$(dirname "$0")" || exit 1 +[[ -z "$initfile" ]] && initfile=$(pwd)/$(basename "$0") +while [[ -L "$initfile" ]]; do + [[ "$initfile" =~ init\.d ]] && init_script=$(basename "$initfile") + initfile=$(readlink "$initfile") + cd "$(dirname "$initfile")" || exit 1 + initfile=$(pwd)/$(basename "$initfile") +done +initfolder="$( (cd "$(dirname "initfile")" && pwd -P) )" +cd "$WORKING_DIR" || exit 1 + +# Initialize CONF_FOLDER location +[[ -z "$CONF_FOLDER" ]] && CONF_FOLDER="${pkg.installFolder}/conf" + +# shellcheck source=/dev/null +[[ -r "${CONF_FOLDER}/${configfile}" ]] && source "${CONF_FOLDER}/${configfile}" + +# Initialize PID/LOG locations if they weren't provided by the config file +[[ -z "$PID_FOLDER" ]] && PID_FOLDER="/var/run" +[[ -z "$LOG_FOLDER" ]] && LOG_FOLDER="${pkg.unixLogFolder}" +! [[ "$PID_FOLDER" == /* ]] && PID_FOLDER="$(dirname "$mainfile")"/"$PID_FOLDER" +! [[ "$LOG_FOLDER" == /* ]] && LOG_FOLDER="$(dirname "$mainfile")"/"$LOG_FOLDER" +! [[ -x "$PID_FOLDER" ]] && PID_FOLDER="/tmp" +! [[ -x "$LOG_FOLDER" ]] && LOG_FOLDER="/tmp" + +# Set up defaults +[[ -z "$MODE" ]] && MODE="auto" # modes are "auto", "service" or "run" +[[ -z "$USE_START_STOP_DAEMON" ]] && USE_START_STOP_DAEMON="true" + +# Create an identity for log/pid files +if [[ -z "$identity" ]]; then + if [[ -n "$init_script" ]]; then + identity="${init_script}" + else + identity=$(basename "${initfile%.*}")_${initfolder//\//} + fi +fi + +# Initialize log file name if not provided by the config file +[[ -z "$LOG_FILENAME" ]] && LOG_FILENAME="${identity}.log" + +# ANSI Colors +echoRed() { echo $'\e[0;31m'"$1"$'\e[0m'; } +echoGreen() { echo $'\e[0;32m'"$1"$'\e[0m'; } +echoYellow() { echo $'\e[0;33m'"$1"$'\e[0m'; } + +# Utility functions +checkPermissions() { + touch "$pid_file" &> /dev/null || { echoRed "Operation not permitted (cannot access pid file)"; return 4; } + touch "$log_file" &> /dev/null || { echoRed "Operation not permitted (cannot access log file)"; return 4; } +} + +isRunning() { + ps -p "$1" &> /dev/null +} + +await_file() { + end=$(date +%s) + let "end+=10" + while [[ ! -s "$1" ]] + do + now=$(date +%s) + if [[ $now -ge $end ]]; then + break + fi + sleep 1 + done +} + +# Determine the script mode +action="run" +if [[ "$MODE" == "auto" && -n "$init_script" ]] || [[ "$MODE" == "service" ]]; then + action="$1" + shift +fi + +# Build the pid and log filenames +if [[ "$identity" == "$init_script" ]] || [[ "$identity" == "$APP_NAME" ]]; then + PID_FOLDER="$PID_FOLDER/${identity}" + pid_subfolder=$PID_FOLDER +fi +pid_file="$PID_FOLDER/${identity}.pid" +log_file="$LOG_FOLDER/$LOG_FILENAME" + +# Determine the user to run as if we are root +# shellcheck disable=SC2012 +[[ $(id -u) == "0" ]] && run_user=$(ls -ld "$mainfile" | awk '{print $3}') + +arguments=($RUN_ARGS "$@") + +# Action functions +start() { + if [[ -f "$pid_file" ]]; then + pid=$(cat "$pid_file") + isRunning "$pid" && { echoYellow "Already running [$pid]"; return 0; } + fi + do_start "$@" +} + +do_start() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + mkdir -p "$PID_FOLDER" &> /dev/null + if [[ -n "$run_user" ]]; then + checkPermissions || return $? + if [[ -z "$pid_subfolder" ]]; then + chown "$run_user" "$pid_subfolder" + fi + chown "$run_user" "$pid_file" + chown "$run_user" "$log_file" + if [ $USE_START_STOP_DAEMON = true ] && type start-stop-daemon > /dev/null 2>&1; then + start-stop-daemon --start --quiet \ + --chuid "$run_user" \ + --name "$identity" \ + --make-pidfile --pidfile "$pid_file" \ + --background --no-close \ + --startas "$mainfile" \ + --chdir "$working_dir" \ + -- "${arguments[@]}" \ + >> "$log_file" 2>&1 + await_file "$pid_file" + else + su -s /bin/sh -c "$mainfile $(printf "\"%s\" " "${arguments[@]}") >> \"$log_file\" 2>&1 & echo \$!" "$run_user" > "$pid_file" + fi + pid=$(cat "$pid_file") + else + checkPermissions || return $? + "$mainfile" "${arguments[@]}" >> "$log_file" 2>&1 & + pid=$! + disown $pid + echo "$pid" > "$pid_file" + fi + [[ -z $pid ]] && { echoRed "Failed to start"; return 1; } + echoGreen "Started [$pid]" +} + +stop() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoYellow "Not running (pidfile not found)"; return 0; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoYellow "Not running (process ${pid}). Removing stale pid file."; rm -f "$pid_file"; return 0; } + do_stop "$pid" "$pid_file" +} + +do_stop() { + kill -2 "$1" &> /dev/null || { echoRed "Unable to kill process $1"; return 1; } + for i in $(seq 1 60); do + isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; } + [[ $i -eq 30 ]] && kill -9 "$1" &> /dev/null + sleep 1 + done + echoRed "Unable to kill process $1"; + return 1; +} + +restart() { + stop && start +} + +orce_reload() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)"; return 7; } + pid=$(cat "$pid_file") + rm -f "$pid_file" + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 7; } + do_stop "$pid" "$pid_file" + do_start +} + +status() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f "$pid_file" ]] || { echoRed "Not running"; return 3; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 1; } + echoGreen "Running [$pid]" + return 0 +} + +run() { + pushd "$(dirname "$mainfile")" > /dev/null + "$mainfile" "${arguments[@]}" + result=$? + popd > /dev/null + return "$result" +} + +# Call the appropriate action function +case "$action" in +start) + start "$@"; exit $?;; +stop) + stop "$@"; exit $?;; +restart) + restart "$@"; exit $?;; +force-reload) + force_reload "$@"; exit $?;; +status) + status "$@"; exit $?;; +run) + run "$@"; exit $?;; +*) + echo "Usage: $0 {start|stop|restart|force-reload|status|run}"; exit 1; +esac + +exit 0 diff --git a/msa/js-executor/src/main/scripts/windows/install.bat b/msa/js-executor/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..4da55425bb --- /dev/null +++ b/msa/js-executor/src/main/scripts/windows/install.bat @@ -0,0 +1,31 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Installing ${pkg.name} ... + +SET BASE=%~dp0 + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:END diff --git a/msa/js-executor/src/main/scripts/windows/service.xml b/msa/js-executor/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..bb91111586 --- /dev/null +++ b/msa/js-executor/src/main/scripts/windows/service.xml @@ -0,0 +1,29 @@ + + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\bin + ${pkg.winWrapperLogFolder} + rotate + + + + %BASE%\bin\${pkg.name}.exe + diff --git a/msa/js-executor/src/main/scripts/windows/uninstall.bat b/msa/js-executor/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..7061d2a052 --- /dev/null +++ b/msa/js-executor/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,25 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/msa/pom.xml b/msa/pom.xml new file mode 100644 index 0000000000..85d45a1f76 --- /dev/null +++ b/msa/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + thingsboard + + org.thingsboard + msa + pom + + ThingsBoard Microservices + https://thingsboard.io + + + ${basedir}/.. + + + + js-executor + + + diff --git a/pom.xml b/pom.xml index 57603eedd1..e824177ac7 100755 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,7 @@ ui tools application + msa From 87eb21d68ff41ea15a27a9b28714e79173bb71d8 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 27 Sep 2018 13:11:06 +0300 Subject: [PATCH 131/307] Were fixed caches --- .../dao/entityview/EntityViewServiceImpl.java | 76 ++++--------------- 1 file changed, 14 insertions(+), 62 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index c1c2b6548d..a45a6419b1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.entityview; -import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; @@ -24,6 +23,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; @@ -40,7 +41,6 @@ import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; -import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; @@ -49,12 +49,10 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.tenant.TenantDao; -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; -import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -74,7 +72,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; public static final String INCORRECT_ENTITY_VIEW_ID = "Incorrect entityViewId "; - private static final int DEFAULT_LIMIT = 100; @Autowired private EntityViewDao entityViewDao; @@ -91,12 +88,15 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CacheManager cacheManager; - @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.name}") + @Caching(evict = { + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.entityId}"), + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}")}) @Override public EntityView saveEntityView(EntityView entityView) { log.trace("Executing save entity view [{}]", entityView); entityViewValidator.validate(entityView); EntityView savedEntityView = entityViewDao.save(entityView); + List>> futures = new ArrayList<>(); if (savedEntityView.getKeys() != null) { futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs())); @@ -121,6 +121,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return saveEntityView(entityView); } + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") @Override public EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId) { EntityView entityView = findEntityViewById(entityViewId); @@ -136,34 +137,11 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti new CustomerEntityViewsUnAssigner(tenantId).removeEntities(customerId); } - + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") @Override public EntityView findEntityViewById(EntityViewId entityViewId) { log.trace("Executing findEntityViewById [{}]", entityViewId); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); - List ids = Arrays.asList(entityViewId.getId()); - Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); - EntityView fromCache = cache.get(ids, EntityView.class); - if (fromCache != null) { - return fromCache; - } else { - ListenableFuture entityViewFuture - = Futures.immediateFuture(entityViewDao.findById(entityViewId.getId())); - Futures.addCallback(entityViewFuture, - new FutureCallback() { - @Override - public void onSuccess(@Nullable EntityView result) { - cache.putIfAbsent(ids, result); - } - @Override - public void onFailure(Throwable t) {} - }); - try { - return entityViewFuture.get(); - } catch (Exception e) { - log.error(e.getMessage()); - } - } return entityViewDao.findById(entityViewId.getId()); } @@ -203,7 +181,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti } return Futures.successfulAsList(futures); }); - return entityViews; } @@ -214,39 +191,24 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return entityViewDao.findByIdAsync(entityViewId.getId()); } + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #entityId}") @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId) { log.trace("Executing findEntityViewsByTenantIdAndEntityIdAsync, tenantId [{}], entityId [{}]", tenantId, entityId); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(entityId.getId(), "Incorrect entityId" + entityId); - - List tenantAndEntityIds = Arrays.asList(tenantId, entityId); - Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); - List fromCache = cache.get(tenantAndEntityIds, List.class); - if (fromCache != null) { - return Futures.immediateFuture(fromCache); - } else { - ListenableFuture> entityViewsFuture = - entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); - Futures.addCallback(entityViewsFuture, - new FutureCallback>() { - @Override - public void onSuccess(@Nullable List result) { - cache.putIfAbsent(tenantAndEntityIds, result); - } - @Override - public void onFailure(Throwable t) {} - }); - return entityViewsFuture; - } + return entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); } + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") @Override public void deleteEntityView(EntityViewId entityViewId) { log.trace("Executing deleteEntityView [{}]", entityViewId); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); deleteEntityRelations(entityViewId); - cacheEvict(entityViewId, cacheManager.getCache(ENTITY_VIEW_CACHE)); + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + EntityView entityView = entityViewDao.findById(entityViewId.getId()); + cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); entityViewDao.removeById(entityViewId.getId()); } @@ -254,19 +216,9 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti public void deleteEntityViewsByTenantId(TenantId tenantId) { log.trace("Executing deleteEntityViewsByTenantId, tenantId [{}]", tenantId); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - entityViewDao.findEntityViewsByTenantId(tenantId.getId(), new TextPageLink(DEFAULT_LIMIT)).stream() - .map(view -> view.getId()) - .collect(Collectors.toList()) - .forEach(id -> cacheEvict(id, cacheManager.getCache(ENTITY_VIEW_CACHE))); tenantEntityViewRemover.removeEntities(tenantId); } - private void cacheEvict(EntityViewId entityViewId, Cache cache) { - EntityView entityView = entityViewDao.findById(entityViewId.getId()); - cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getName())); - cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); - } - private ListenableFuture> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { if (keys != null && !keys.isEmpty()) { ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); From 029775e5576a91aaf6574ba31ccf9250fb92c230 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 27 Sep 2018 13:46:34 +0300 Subject: [PATCH 132/307] Fixes to Remote JS --- .../service/cluster/discovery/DummyDiscoveryService.java | 6 +++++- .../server/service/script/RemoteJsInvokeService.java | 9 ++++++--- application/src/main/resources/thingsboard.yml | 4 +++- .../msg/session/BasicSessionActorToAdaptorMsg.java | 2 -- .../server/common/msg/session/SessionContext.java | 7 ++++--- .../server/kafka/TBKafkaConsumerTemplate.java | 2 +- .../org/thingsboard/server/kafka/TbKafkaSettings.java | 2 +- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java index 64d1456987..859a7af4fc 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.cluster.discovery; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.DependsOn; @@ -37,14 +38,17 @@ public class DummyDiscoveryService implements DiscoveryService { @Autowired private ServerInstanceService serverInstance; + private String nodeId; + @PostConstruct public void init() { log.info("Initializing..."); + this.nodeId = RandomStringUtils.randomAlphabetic(10); } @Override public String getNodeId() { - return null; + return nodeId; } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index 3507397b7d..ffd06c0bf4 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -59,8 +59,11 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { @Value("${js.remote.max_requests_timeout}") private long maxRequestsTimeout; - @Value("${js.remote.response_poll_duration}") - private long responsePollDuration; + @Value("${js.remote.response_poll_interval}") + private int responsePollDuration; + + @Value("${js.remote.response_auto_commit_interval}") + private int autoCommitInterval; @Getter @Value("${js.remote.max_errors}") @@ -82,7 +85,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { responseBuilder.clientId(discoveryService.getNodeId()); responseBuilder.groupId("rule-engine-node"); responseBuilder.autoCommit(true); - responseBuilder.autoCommitIntervalMs(100); + responseBuilder.autoCommitIntervalMs(autoCommitInterval); responseBuilder.decoder(new RemoteJsResponseDecoder()); TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 37fd6cf687..b7d0cc15a2 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -415,7 +415,7 @@ kafka: buffer.memory: "${TB_BUFFER_MEMORY:33554432}" js: - evaluator: "${JS_EVALUATOR:local}" # local/remote + evaluator: "${JS_EVALUATOR:remote}" # local/remote # Built-in JVM JavaScript environment properties local: # Use Sandboxed (secured) JVM JavaScript environment @@ -438,5 +438,7 @@ js: max_requests_timeout: "${REMOTE_JS_MAX_REQUEST_TIMEOUT:10000}" # JS response poll interval response_poll_interval: "${REMOTE_JS_RESPONSE_POLL_INTERVAL_MS:25}" + # JS response auto commit interval + response_auto_commit_interval: "${REMOTE_JS_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted max_errors: "${REMOTE_JS_SANDBOX_MAX_ERRORS:3}" diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java index 7b234195a0..0e1a087d6e 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java @@ -15,8 +15,6 @@ */ package org.thingsboard.server.common.msg.session; -import java.util.Optional; - public class BasicSessionActorToAdaptorMsg extends BasicSessionMsg implements SessionActorToAdaptorMsg { private final ToDeviceMsg msg; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java index 73aaab4d6e..2f2d88047e 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java @@ -15,11 +15,12 @@ */ package org.thingsboard.server.common.msg.session; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; +import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.session.ex.SessionException; -public interface SessionContext extends SessionAwareMsg { +public interface SessionContext { + + SessionId getSessionId(); SessionType getSessionType(); diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java index 1b1bb5b2b7..af786f1da1 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -40,7 +40,7 @@ public class TBKafkaConsumerTemplate { @Builder private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, String clientId, String groupId, String topic, - boolean autoCommit, long autoCommitIntervalMs) { + boolean autoCommit, int autoCommitIntervalMs) { Properties props = settings.toProps(); props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java index 0bfa646715..39797fcc9b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java @@ -46,7 +46,7 @@ public class TbKafkaSettings { private int retries; @Value("${kafka.batch.size}") - private long batchSize; + private int batchSize; @Value("${kafka.linger.ms}") private long lingerMs; From 9ef768f5e6eb854bae5dfaf932c6c108abe7b4cb Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 27 Sep 2018 14:50:24 +0300 Subject: [PATCH 133/307] Fixed timeout logic --- .../server/service/script/RemoteJsInvokeService.java | 1 + .../thingsboard/server/kafka/TbKafkaRequestTemplate.java | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index ffd06c0bf4..2952cfa61b 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -96,6 +96,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { builder.maxRequestTimeout(maxRequestsTimeout); builder.pollInterval(responsePollDuration); kafkaTemplate = builder.build(); + kafkaTemplate.init(); } @PreDestroy diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index dd1a47a693..a851e6bc80 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -48,6 +48,7 @@ public class TbKafkaRequestTemplate { private final TBKafkaProducerTemplate requestTemplate; private final TBKafkaConsumerTemplate responseTemplate; private final ConcurrentMap> pendingRequests; + private final boolean internalExecutor; private final ExecutorService executor; private final long maxRequestTimeout; private final long maxPendingRequests; @@ -69,8 +70,10 @@ public class TbKafkaRequestTemplate { this.maxPendingRequests = maxPendingRequests; this.pollInterval = pollInterval; if (executor != null) { + internalExecutor = false; this.executor = executor; } else { + internalExecutor = true; this.executor = Executors.newSingleThreadExecutor(); } } @@ -126,6 +129,9 @@ public class TbKafkaRequestTemplate { public void stop() { stopped = true; + if (internalExecutor) { + executor.shutdownNow(); + } } public ListenableFuture post(String key, Request request) { From 4a4e5ebecf7370d29cf11bd5c1b53fec54d4bd99 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 27 Sep 2018 16:17:49 +0300 Subject: [PATCH 134/307] JS Executor: Protobuf --- msa/js-executor/.gitignore | 1 + msa/js-executor/api/jsMessageConsumer.js | 31 +++++++++++++++++++++++- msa/js-executor/package.json | 8 +++--- msa/js-executor/server.js | 3 ++- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/msa/js-executor/.gitignore b/msa/js-executor/.gitignore index c5cfd3d322..19c6dc921e 100644 --- a/msa/js-executor/.gitignore +++ b/msa/js-executor/.gitignore @@ -29,3 +29,4 @@ pom.xml.versionsBackup **/.env node_modules package-lock.json +api/jsinvoke.js diff --git a/msa/js-executor/api/jsMessageConsumer.js b/msa/js-executor/api/jsMessageConsumer.js index 43d3c781e4..774a205bc2 100644 --- a/msa/js-executor/api/jsMessageConsumer.js +++ b/msa/js-executor/api/jsMessageConsumer.js @@ -18,8 +18,37 @@ var logger = require('../config/logger')('JsMessageConsumer'); +var js = require('./jsinvoke').js; + + exports.onJsInvokeMessage = function(message, producer) { - logger.info('Received message: %s', message); + logger.info('Received message: %s', JSON.stringify(message)); + + var request = js.RemoteJsRequest.decode(message.value); + + logger.info('Received request: %s', JSON.stringify(request)); + + if (request.compileRequest) { + var compileResponse = js.JsCompileResponse.create( + { + errorCode: js.JsInvokeErrorCode.COMPILATION_ERROR, + success: false, + errorDetails: 'Not Implemented!', + scriptIdLSB: request.compileRequest.scriptIdLSB, + scriptIdMSB: request.compileRequest.scriptIdMSB + } + ); + var response = js.RemoteJsResponse.create( + { + compileResponse: compileResponse + } + ); + var rawResponse = js.RemoteJsResponse.encode(response).finish(); + sendMessage(producer, rawResponse); + } +} + +function sendMessage(producer, rawMessage) { } diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 870986a24f..489682ed31 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -6,15 +6,17 @@ "main": "server.js", "bin": "server.js", "scripts": { - "install": "pkg -t node8-linux-x64,node8-win-x64 --out-path ./target . && node install.js", + "build-proto": "pbjs -t static-module -w commonjs -o ./api/jsinvoke.js ../../application/src/main/proto/jsinvoke.proto", + "install": "npm run build-proto && pkg -t node8-linux-x64,node8-win-x64 --out-path ./target . && node install.js", "test": "echo \"Error: no test specified\" && exit 1", - "start": "nodemon server.js", - "start-prod": "NODE_ENV=production nodemon server.js" + "start": "npm run build-proto && nodemon server.js", + "start-prod": "npm run build-proto && NODE_ENV=production nodemon server.js" }, "dependencies": { "config": "^1.30.0", "js-yaml": "^3.12.0", "kafka-node": "^3.0.1", + "protobufjs": "^6.8.8", "winston": "^3.0.0", "winston-daily-rotate-file": "^3.2.1" }, diff --git a/msa/js-executor/server.js b/msa/js-executor/server.js index 2a3b24f30e..b8ebec3d66 100644 --- a/msa/js-executor/server.js +++ b/msa/js-executor/server.js @@ -41,7 +41,8 @@ var kafkaClient; { topic: kafkaRequestTopic, partition: 0 } ], { - autoCommit: true + autoCommit: true, + encoding: 'buffer' } ); From 5a65ec75fe6dece007277555044770ed8952efd4 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Thu, 27 Sep 2018 17:07:54 +0300 Subject: [PATCH 135/307] Reviewed --- .../dao/entityview/EntityViewServiceImpl.java | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index a45a6419b1..9fdadcd631 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -228,17 +228,13 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti filteredAttributes = attributeKvEntries.stream() .filter(attributeKvEntry -> { - if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() == 0) { - return true; - } - if (entityView.getEndTimeMs() == 0 && entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs()) { - return true; - } - if (entityView.getStartTimeMs() == 0 && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs()) { - return true; - } - return entityView.getStartTimeMs() < attributeKvEntry.getLastUpdateTs() - && entityView.getEndTimeMs() > attributeKvEntry.getLastUpdateTs(); + long startTime = entityView.getStartTimeMs(); + long endTime = entityView.getEndTimeMs(); + long lastUpdateTs = attributeKvEntry.getLastUpdateTs(); + return startTime == 0 && endTime == 0 || + (endTime == 0 && startTime < lastUpdateTs) || + (startTime == 0 && endTime > lastUpdateTs) || + (startTime < lastUpdateTs && endTime > lastUpdateTs); }).collect(Collectors.toList()); } try { From 64b50fa8bc859cc155d0f2465e4bac4fd5e71f64 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 27 Sep 2018 21:05:14 +0300 Subject: [PATCH 136/307] Remote Js Executor basic message processing. --- .../service/script/RemoteJsInvokeService.java | 21 ++++++++++- .../script/RuleNodeJsScriptEngine.java | 6 ++- application/src/main/proto/jsinvoke.proto | 17 ++++++--- .../server/kafka/TBKafkaConsumerTemplate.java | 12 +++++- .../server/kafka/TBKafkaProducerTemplate.java | 16 +++++++- .../server/kafka/TbKafkaEnricher.java | 24 ++++++++++++ .../kafka/TbKafkaRequestIdExtractor.java | 24 ++++++++++++ .../server/kafka/TbKafkaRequestTemplate.java | 35 +++++++++++++----- msa/js-executor/.gitignore | 2 +- msa/js-executor/api/Utils.js | 37 +++++++++++++++++++ msa/js-executor/api/jsMessageConsumer.js | 34 ++++++++++++++--- msa/js-executor/package.json | 4 +- msa/js-executor/server.js | 17 +++++---- pom.xml | 1 + 14 files changed, 216 insertions(+), 34 deletions(-) create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEnricher.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestIdExtractor.java create mode 100644 msa/js-executor/api/Utils.js diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index 2952cfa61b..a080c1c0bd 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -78,6 +78,22 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { requestBuilder.settings(kafkaSettings); requestBuilder.defaultTopic(requestTopic); requestBuilder.encoder(new RemoteJsRequestEncoder()); + requestBuilder.enricher((request, responseTopic, requestId) -> { + JsInvokeProtos.RemoteJsRequest.Builder remoteRequest = JsInvokeProtos.RemoteJsRequest.newBuilder(); + if (request.hasCompileRequest()) { + remoteRequest.setCompileRequest(request.getCompileRequest()); + } + if (request.hasInvokeRequest()) { + remoteRequest.setInvokeRequest(request.getInvokeRequest()); + } + if (request.hasReleaseRequest()) { + remoteRequest.setReleaseRequest(request.getReleaseRequest()); + } + remoteRequest.setResponseTopic(responseTopic); + remoteRequest.setRequestIdMSB(requestId.getMostSignificantBits()); + remoteRequest.setRequestIdLSB(requestId.getLeastSignificantBits()); + return remoteRequest.build(); + }); TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); responseBuilder.settings(kafkaSettings); @@ -87,6 +103,9 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { responseBuilder.autoCommit(true); responseBuilder.autoCommitIntervalMs(autoCommitInterval); responseBuilder.decoder(new RemoteJsResponseDecoder()); + responseBuilder.requestIdExtractor((response) -> { + return new UUID(response.getRequestIdMSB(), response.getRequestIdLSB()); + }); TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder builder = TbKafkaRequestTemplate.builder(); @@ -128,7 +147,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { return compiledScriptId; } else { log.debug("[{}] Failed to compile script due to [{}]: {}", compiledScriptId, compilationResult.getErrorCode().name(), compilationResult.getErrorDetails()); - throw new RuntimeException(compilationResult.getErrorCode().name()); + throw new RuntimeException(compilationResult.getErrorDetails()); } }); } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index 021e6da119..1cfb633ddf 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -48,7 +48,11 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S try { this.scriptId = this.sandboxService.eval(JsScriptType.RULE_NODE_SCRIPT, script, argNames).get(); } catch (Exception e) { - throw new IllegalArgumentException("Can't compile script: " + e.getMessage(), e); + Throwable t = e; + if (e instanceof ExecutionException) { + t = e.getCause(); + } + throw new IllegalArgumentException("Can't compile script: " + t.getMessage(), t); } } diff --git a/application/src/main/proto/jsinvoke.proto b/application/src/main/proto/jsinvoke.proto index d85c3534bb..a2afca2ab6 100644 --- a/application/src/main/proto/jsinvoke.proto +++ b/application/src/main/proto/jsinvoke.proto @@ -26,15 +26,20 @@ enum JsInvokeErrorCode { } message RemoteJsRequest { - JsCompileRequest compileRequest = 1; - JsInvokeRequest invokeRequest = 2; - JsReleaseRequest releaseRequest = 3; + string responseTopic = 1; + int64 requestIdMSB = 2; + int64 requestIdLSB = 3; + JsCompileRequest compileRequest = 4; + JsInvokeRequest invokeRequest = 5; + JsReleaseRequest releaseRequest = 6; } message RemoteJsResponse { - JsCompileResponse compileResponse = 1; - JsInvokeResponse invokeResponse = 2; - JsReleaseResponse releaseResponse = 3; + int64 requestIdMSB = 1; + int64 requestIdLSB = 2; + JsCompileResponse compileResponse = 3; + JsInvokeResponse invokeResponse = 4; + JsReleaseResponse releaseResponse = 5; } message JsCompileRequest { diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java index af786f1da1..397226479e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -26,6 +26,7 @@ import java.io.IOException; import java.time.Duration; import java.util.Collections; import java.util.Properties; +import java.util.UUID; /** * Created by ashvayka on 24.09.18. @@ -34,11 +35,15 @@ public class TBKafkaConsumerTemplate { private final KafkaConsumer consumer; private final TbKafkaDecoder decoder; + + @Builder.Default + private TbKafkaRequestIdExtractor requestIdExtractor = ((response) -> null); + @Getter private final String topic; @Builder - private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, + private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, TbKafkaRequestIdExtractor requestIdExtractor, String clientId, String groupId, String topic, boolean autoCommit, int autoCommitIntervalMs) { Properties props = settings.toProps(); @@ -50,6 +55,7 @@ public class TBKafkaConsumerTemplate { props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer"); this.consumer = new KafkaConsumer<>(props); this.decoder = decoder; + this.requestIdExtractor = requestIdExtractor; this.topic = topic; } @@ -68,4 +74,8 @@ public class TBKafkaConsumerTemplate { public T decode(ConsumerRecord record) throws IOException { return decoder.decode(record.value()); } + + public UUID extractRequestId(T value) { + return requestIdExtractor.extractRequestId(value); + } } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index b39e63e1ef..097dbb8e0c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -24,9 +24,13 @@ import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.PartitionInfo; import org.apache.kafka.common.header.Header; +import java.nio.ByteBuffer; +import java.util.Arrays; import java.util.List; import java.util.Properties; +import java.util.UUID; import java.util.concurrent.Future; +import java.util.function.BiConsumer; /** * Created by ashvayka on 24.09.18. @@ -35,13 +39,18 @@ public class TBKafkaProducerTemplate { private final KafkaProducer producer; private final TbKafkaEncoder encoder; + + @Builder.Default + private TbKafkaEnricher enricher = ((value, responseTopic, requestId) -> value); + private final TbKafkaPartitioner partitioner; private final List partitionInfoList; @Getter private final String defaultTopic; @Builder - private TBKafkaProducerTemplate(TbKafkaSettings settings, TbKafkaEncoder encoder, TbKafkaPartitioner partitioner, String defaultTopic) { + private TBKafkaProducerTemplate(TbKafkaSettings settings, TbKafkaEncoder encoder, TbKafkaEnricher enricher, + TbKafkaPartitioner partitioner, String defaultTopic) { Properties props = settings.toProps(); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer"); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArraySerializer"); @@ -49,10 +58,15 @@ public class TBKafkaProducerTemplate { //Maybe this should not be cached, but we don't plan to change size of partitions this.partitionInfoList = producer.partitionsFor(defaultTopic); this.encoder = encoder; + this.enricher = enricher; this.partitioner = partitioner; this.defaultTopic = defaultTopic; } + public T enrich(T value, String responseTopic, UUID requestId) { + return enricher.enrich(value, responseTopic, requestId); + } + public Future send(String key, T value) { return send(key, value, null, null); } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEnricher.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEnricher.java new file mode 100644 index 0000000000..76a6f388bc --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEnricher.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import java.util.UUID; + +public interface TbKafkaEnricher { + + T enrich(T value, String responseTopic, UUID requestId); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestIdExtractor.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestIdExtractor.java new file mode 100644 index 0000000000..4948df54dc --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestIdExtractor.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import java.util.UUID; + +public interface TbKafkaRequestIdExtractor { + + UUID extractRequestId(T value); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index a851e6bc80..38e4436a94 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -94,18 +94,34 @@ public class TbKafkaRequestTemplate { ConsumerRecords responses = responseTemplate.poll(Duration.ofMillis(pollInterval)); responses.forEach(response -> { Header requestIdHeader = response.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); + Response decocedResponse = null; + UUID requestId = null; if (requestIdHeader == null) { - log.error("[{}] Missing requestIdHeader", response); - } - UUID requestId = bytesToUuid(requestIdHeader.value()); - ResponseMetaData expectedResponse = pendingRequests.remove(requestId); - if (expectedResponse == null) { - log.trace("[{}] Invalid or stale request", requestId); - } else { try { - expectedResponse.future.set(responseTemplate.decode(response)); + decocedResponse = responseTemplate.decode(response); + requestId = responseTemplate.extractRequestId(decocedResponse); + } catch (IOException e) { - expectedResponse.future.setException(e); + log.error("Failed to decode response", e); + } + } else { + requestId = bytesToUuid(requestIdHeader.value()); + } + if (requestId == null) { + log.error("[{}] Missing requestId in header and response", response); + } else { + ResponseMetaData expectedResponse = pendingRequests.remove(requestId); + if (expectedResponse == null) { + log.trace("[{}] Invalid or stale request", requestId); + } else { + try { + if (decocedResponse == null) { + decocedResponse = responseTemplate.decode(response); + } + expectedResponse.future.set(decocedResponse); + } catch (IOException e) { + expectedResponse.future.setException(e); + } } } }); @@ -144,6 +160,7 @@ public class TbKafkaRequestTemplate { headers.add(new RecordHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER, stringToBytes(responseTemplate.getTopic()))); SettableFuture future = SettableFuture.create(); pendingRequests.putIfAbsent(requestId, new ResponseMetaData<>(tickTs + maxRequestTimeout, future)); + request = requestTemplate.enrich(request, responseTemplate.getTopic(), requestId); requestTemplate.send(key, request, headers); return future; } diff --git a/msa/js-executor/.gitignore b/msa/js-executor/.gitignore index 19c6dc921e..1aac6855f6 100644 --- a/msa/js-executor/.gitignore +++ b/msa/js-executor/.gitignore @@ -29,4 +29,4 @@ pom.xml.versionsBackup **/.env node_modules package-lock.json -api/jsinvoke.js +api/*.proto.js diff --git a/msa/js-executor/api/Utils.js b/msa/js-executor/api/Utils.js new file mode 100644 index 0000000000..16d98a596b --- /dev/null +++ b/msa/js-executor/api/Utils.js @@ -0,0 +1,37 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Long = require('long'); +const uuidParse = require('uuid-parse'); + +var logger = require('../config/logger')('Utils'); + +exports.toUUIDString = function(mostSigBits, leastSigBits) { + var msbBytes = Long.fromValue(mostSigBits, false).toBytes(false); + var lsbBytes = Long.fromValue(leastSigBits, false).toBytes(false); + var uuidBytes = msbBytes.concat(lsbBytes); + var buff = new Buffer(uuidBytes, 'utf8'); + return uuidParse.unparse(uuidBytes); +} + +exports.UUIDToBits = function(uuidString) { + const bytes = uuidParse.parse(uuidString); + var msb = Long.fromBytes(bytes.slice(0,8), false, false).toString(); + var lsb = Long.fromBytes(bytes.slice(-8), false, false).toString(); + return [msb, lsb]; +} diff --git a/msa/js-executor/api/jsMessageConsumer.js b/msa/js-executor/api/jsMessageConsumer.js index 774a205bc2..39dd43aa33 100644 --- a/msa/js-executor/api/jsMessageConsumer.js +++ b/msa/js-executor/api/jsMessageConsumer.js @@ -16,9 +16,10 @@ 'use strict'; -var logger = require('../config/logger')('JsMessageConsumer'); - -var js = require('./jsinvoke').js; +const logger = require('../config/logger')('JsMessageConsumer'); +const Utils = require('./Utils'); +const js = require('./jsinvoke.proto').js; +const KeyedMessage = require('kafka-node').KeyedMessage; exports.onJsInvokeMessage = function(message, producer) { @@ -29,7 +30,15 @@ exports.onJsInvokeMessage = function(message, producer) { logger.info('Received request: %s', JSON.stringify(request)); + var requestId = getRequestId(request); + + logger.info('Received request, responseTopic: [%s]; requestId: [%s]', request.responseTopic, requestId); + if (request.compileRequest) { + var scriptId = getScriptId(request.compileRequest); + + logger.info('Received compile request, scriptId: [%s]', scriptId); + var compileResponse = js.JsCompileResponse.create( { errorCode: js.JsInvokeErrorCode.COMPILATION_ERROR, @@ -39,16 +48,31 @@ exports.onJsInvokeMessage = function(message, producer) { scriptIdMSB: request.compileRequest.scriptIdMSB } ); + const requestIdBits = Utils.UUIDToBits(requestId); var response = js.RemoteJsResponse.create( { + requestIdMSB: requestIdBits[0], + requestIdLSB: requestIdBits[1], compileResponse: compileResponse } ); var rawResponse = js.RemoteJsResponse.encode(response).finish(); - sendMessage(producer, rawResponse); + sendMessage(producer, rawResponse, request.responseTopic, scriptId); } } -function sendMessage(producer, rawMessage) { +function sendMessage(producer, rawMessage, responseTopic, scriptId) { + const message = new KeyedMessage(scriptId, rawMessage); + const payloads = [ { topic: responseTopic, messages: rawMessage, key: scriptId } ]; + producer.send(payloads, function (err, data) { + console.log(data); + }); +} + +function getScriptId(request) { + return Utils.toUUIDString(request.scriptIdMSB, request.scriptIdLSB); +} +function getRequestId(request) { + return Utils.toUUIDString(request.requestIdMSB, request.requestIdLSB); } diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 489682ed31..fc1665bcb6 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -6,7 +6,7 @@ "main": "server.js", "bin": "server.js", "scripts": { - "build-proto": "pbjs -t static-module -w commonjs -o ./api/jsinvoke.js ../../application/src/main/proto/jsinvoke.proto", + "build-proto": "pbjs -t static-module -w commonjs -o ./api/jsinvoke.proto.js ../../application/src/main/proto/jsinvoke.proto", "install": "npm run build-proto && pkg -t node8-linux-x64,node8-win-x64 --out-path ./target . && node install.js", "test": "echo \"Error: no test specified\" && exit 1", "start": "npm run build-proto && nodemon server.js", @@ -16,7 +16,9 @@ "config": "^1.30.0", "js-yaml": "^3.12.0", "kafka-node": "^3.0.1", + "long": "^4.0.0", "protobufjs": "^6.8.8", + "uuid-parse": "^1.0.0", "winston": "^3.0.0", "winston-daily-rotate-file": "^3.2.1" }, diff --git a/msa/js-executor/server.js b/msa/js-executor/server.js index b8ebec3d66..58d721b4f9 100644 --- a/msa/js-executor/server.js +++ b/msa/js-executor/server.js @@ -13,16 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -var config = require('config'), - kafka = require('kafka-node'), - Consumer = kafka.Consumer, - Producer = kafka.Producer, - JsMessageConsumer = require('./api/jsMessageConsumer'); -var logger = require('./config/logger')('main'); +const config = require('config'), + kafka = require('kafka-node'), + Consumer = kafka.Consumer, + Producer = kafka.Producer, + JsMessageConsumer = require('./api/jsMessageConsumer'); -var kafkaBootstrapServers = config.get('kafka.bootstrap.servers'); -var kafkaRequestTopic = config.get('kafka.request_topic'); +const logger = require('./config/logger')('main'); + +const kafkaBootstrapServers = config.get('kafka.bootstrap.servers'); +const kafkaRequestTopic = config.get('kafka.request_topic'); logger.info('Kafka Bootstrap Servers: %s', kafkaBootstrapServers); logger.info('Kafka Requests Topic: %s', kafkaRequestTopic); diff --git a/pom.xml b/pom.xml index e824177ac7..327838c5a0 100755 --- a/pom.xml +++ b/pom.xml @@ -283,6 +283,7 @@ src/main/scripts/control/** src/main/scripts/windows/** src/main/resources/public/static/rulenode/** + **/*.proto.js JAVADOC_STYLE From 49a98673525407ace293bc86a14b2a44a2a88fbe Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 28 Sep 2018 17:56:32 +0300 Subject: [PATCH 137/307] After review --- .../BaseEntityViewControllerTest.java | 86 +++++++++++++------ .../dao/entityview/EntityViewServiceImpl.java | 4 +- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index 0daaeca72f..a877cbf71a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -319,12 +319,62 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes } @Test - public void testTheCopyOfAttrsThatMatchWithDeviceCriteriaForTheView() throws Exception { + public void testTheCopyOfAttrsIntoTSForTheView() throws Exception { + Set actualAttributesSet = + getAttributesByKeys("{\"caValue1\":\"value1\", \"caValue2\":true, \"caValue3\":42.0, \"caValue4\":73}"); - String viewDeviceId = testDevice.getId().getId().toString(); - DeviceCredentials deviceCredentials - = doGet("/api/device/" + viewDeviceId + "/credentials", DeviceCredentials.class); + Set expectedActualAttributesSet = + new HashSet<>(Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4")); + assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); + Thread.sleep(1000); + + EntityView savedView = getNewSavedEntityView("Test entity view"); + String urlOfTelemetryValues = "/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet); + List> values = doGetAsync(urlOfTelemetryValues, List.class); + + assertEquals("value1", getValue(values, "caValue1")); + assertEquals(true, getValue(values, "caValue2")); + assertEquals(42.0, getValue(values, "caValue3")); + assertEquals(73, getValue(values, "caValue4")); + } + + @Test + public void testTheCopyOfAttrsOutOfTSForTheView() throws Exception { + Set actualAttributesSet = + getAttributesByKeys("{\"caValue1\":\"value1\", \"caValue2\":true, \"caValue3\":42.0, \"caValue4\":73}"); + + Set expectedActualAttributesSet = new HashSet<>(Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4")); + assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); + Thread.sleep(1000); + + List> values = doGetAsync("/api/plugins/telemetry/DEVICE/" + testDevice.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); + + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(savedTenant.getId()); + view.setName("Test entity view"); + view.setKeys(telemetry); + view.setStartTimeMs((long) getValue(values, "lastUpdateTs") * 10); + view.setEndTimeMs((long) getValue(values, "lastUpdateTs") / 10); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + String urlOfTelemetryValues = "/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet); + values = doGetAsync(urlOfTelemetryValues, List.class); + + assertEquals("value1", getValue(values, "caValue1")); + assertEquals(true, getValue(values, "caValue2")); + assertEquals(42.0, getValue(values, "caValue3")); + assertEquals(73, getValue(values, "caValue4")); + } + + private Set getAttributesByKeys(String stringKV) throws Exception { + String viewDeviceId = testDevice.getId().getId().toString(); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + viewDeviceId + "/credentials", DeviceCredentials.class); assertEquals(testDevice.getId(), deviceCredentials.getDeviceId()); String accessToken = deviceCredentials.getCredentialsId(); @@ -339,31 +389,19 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Thread.sleep(3000); MqttMessage message = new MqttMessage(); - message.setPayload(("{\"caValue1\":\"value1\", \"caValue2\":true, \"caValue3\":42.0, \"caValue4\":73}").getBytes()); + message.setPayload((stringKV).getBytes()); client.publish("v1/devices/me/attributes", message); Thread.sleep(1000); - List actualAttributes = - doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class); - Set actualAttributesSet = new HashSet<>(actualAttributes); - - List expectedActualAttributes = Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4"); - Set expectedActualAttributesSet = new HashSet<>(expectedActualAttributes); - assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); - Thread.sleep(1000); - - EntityView savedView = getNewSavedEntityView("Test entity view"); - String urlOfTelemetryValues = "/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + - "/values/attributes?keys=" + String.join(",", actualAttributesSet); - List> values = doGetAsync(urlOfTelemetryValues, List.class); - - assertEquals("value1", getValueOfMap(values, "caValue1")); - assertEquals(true, getValueOfMap(values, "caValue2")); - assertEquals(42.0, getValueOfMap(values, "caValue3")); - assertEquals(73, getValueOfMap(values, "caValue4")); + return new HashSet<>(doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class)); } - private Object getValueOfMap(List> values, String stringValue) { + /*private Object getLastTs(List> values) { + return values.stream() + .filter(value -> value.get("key"); + } +*/ + private Object getValue(List> values, String stringValue) { return values.stream() .filter(value -> value.get("key").equals(stringValue)) .findFirst().get().get("value"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 9fdadcd631..6e7eba08c8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -233,8 +233,8 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti long lastUpdateTs = attributeKvEntry.getLastUpdateTs(); return startTime == 0 && endTime == 0 || (endTime == 0 && startTime < lastUpdateTs) || - (startTime == 0 && endTime > lastUpdateTs) || - (startTime < lastUpdateTs && endTime > lastUpdateTs); + (startTime == 0 && endTime > lastUpdateTs) + ? true : startTime < lastUpdateTs && endTime > lastUpdateTs; }).collect(Collectors.toList()); } try { From d32a36c312611e48a762821f9551eb8d186f4a45 Mon Sep 17 00:00:00 2001 From: viktorbasanets Date: Fri, 28 Sep 2018 20:11:45 +0300 Subject: [PATCH 138/307] added test --- .../BaseEntityViewControllerTest.java | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index a877cbf71a..ee97b97fa0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -50,6 +50,7 @@ import java.util.Set; import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; @@ -329,9 +330,8 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Thread.sleep(1000); EntityView savedView = getNewSavedEntityView("Test entity view"); - String urlOfTelemetryValues = "/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + - "/values/attributes?keys=" + String.join(",", actualAttributesSet); - List> values = doGetAsync(urlOfTelemetryValues, List.class); + List> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); assertEquals("value1", getValue(values, "caValue1")); assertEquals(true, getValue(values, "caValue2")); @@ -348,7 +348,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); Thread.sleep(1000); - List> values = doGetAsync("/api/plugins/telemetry/DEVICE/" + testDevice.getId().getId().toString() + + List> valueTelemetryOfDevices = doGetAsync("/api/plugins/telemetry/DEVICE/" + testDevice.getId().getId().toString() + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); EntityView view = new EntityView(); @@ -356,19 +356,13 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setTenantId(savedTenant.getId()); view.setName("Test entity view"); view.setKeys(telemetry); - view.setStartTimeMs((long) getValue(values, "lastUpdateTs") * 10); - view.setEndTimeMs((long) getValue(values, "lastUpdateTs") / 10); + view.setStartTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") * 10); + view.setEndTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") / 10); EntityView savedView = doPost("/api/entityView", view, EntityView.class); - String urlOfTelemetryValues = "/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + - "/values/attributes?keys=" + String.join(",", actualAttributesSet); - values = doGetAsync(urlOfTelemetryValues, List.class); - - - assertEquals("value1", getValue(values, "caValue1")); - assertEquals(true, getValue(values, "caValue2")); - assertEquals(42.0, getValue(values, "caValue3")); - assertEquals(73, getValue(values, "caValue4")); + List> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); + assertEquals(0, values.size()); } private Set getAttributesByKeys(String stringKV) throws Exception { @@ -396,15 +390,11 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes return new HashSet<>(doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class)); } - /*private Object getLastTs(List> values) { - return values.stream() - .filter(value -> value.get("key"); - } -*/ private Object getValue(List> values, String stringValue) { - return values.stream() - .filter(value -> value.get("key").equals(stringValue)) - .findFirst().get().get("value"); + return values.size() == 0 ? null : + values.stream() + .filter(value -> value.get("key").equals(stringValue)) + .findFirst().get().get("value"); } private EntityView getNewSavedEntityView(String name) throws Exception { From aa89c1a544b465f63505a766b7c0efcb77f0fd9b Mon Sep 17 00:00:00 2001 From: lonwern Date: Sat, 29 Sep 2018 09:57:40 +0800 Subject: [PATCH 139/307] Add SUB ACK on topic v1/gateway/rpc --- .../thingsboard/server/transport/mqtt/MqttTransportHandler.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 185b7a82c3..c06021b710 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -244,6 +244,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement grantedQoSList.add(getMinSupportedQos(reqQoS)); } else if (topicName.equals(GATEWAY_ATTRIBUTES_TOPIC)) { grantedQoSList.add(getMinSupportedQos(reqQoS)); + } else if (topicName.equals(GATEWAY_RPC_TOPIC)) { + grantedQoSList.add(getMinSupportedQos(reqQoS)); } else { log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topicName, reqQoS); grantedQoSList.add(FAILURE.value()); From 48bae4b2b421e4fd0ffd5a1a98fb572d415bcdc6 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Sat, 29 Sep 2018 11:30:07 +0300 Subject: [PATCH 140/307] Code review fixes --- .../server/controller/BaseController.java | 6 ++-- .../controller/EntityViewController.java | 2 +- .../controller/ControllerSqlTestSuite.java | 2 +- .../dao/entityview/EntityViewServiceImpl.java | 31 +++++++++++++++++-- .../TbCopyAttributesToEntityViewNode.java | 11 ++++--- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 84a8151316..af62e04cff 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -318,7 +318,7 @@ public abstract class BaseController { checkUserId(new UserId(entityId.getId())); return; case ENTITY_VIEW: - checkEntityViewId(entityViewService.findEntityViewById(new EntityViewId(entityId.getId()))); + checkEntityView(entityViewService.findEntityViewById(new EntityViewId(entityId.getId()))); return; default: throw new IllegalArgumentException("Unsupported entity type: " + entityId.getEntityType()); @@ -351,14 +351,14 @@ public abstract class BaseController { try { validateId(entityViewId, "Incorrect entityViewId " + entityViewId); EntityView entityView = entityViewService.findEntityViewById(entityViewId); - checkEntityViewId(entityView); + checkEntityView(entityView); return entityView; } catch (Exception e) { throw handleException(e, false); } } - protected void checkEntityViewId(EntityView entityView) throws ThingsboardException { + protected void checkEntityView(EntityView entityView) throws ThingsboardException { checkNotNull(entityView); checkTenantId(entityView.getTenantId()); if (entityView.getCustomerId() != null && !entityView.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index e6f149e05c..0ba35e85ad 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -204,7 +204,7 @@ public class EntityViewController extends BaseController { List entityViews = checkNotNull(entityViewService.findEntityViewsByQuery(query).get()); entityViews = entityViews.stream().filter(entityView -> { try { - checkEntityViewId(entityView); + checkEntityView(entityView); return true; } catch (ThingsboardException e) { return false; diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index a9e94e9184..c8a5da8151 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.controller.sql.EntityViewControllerSqlTest", + "org.thingsboard.server.controller.sql.*Test", }) public class ControllerSqlTestSuite { diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 6e7eba08c8..29ee9e46a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.entityview; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; @@ -49,6 +50,7 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.tenant.TenantDao; +import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -57,6 +59,7 @@ import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; +import static org.thingsboard.server.common.data.CacheConstants.RELATIONS_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -197,7 +200,30 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti log.trace("Executing findEntityViewsByTenantIdAndEntityIdAsync, tenantId [{}], entityId [{}]", tenantId, entityId); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(entityId.getId(), "Incorrect entityId" + entityId); - return entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); + + List tenantIdAndEntityId = new ArrayList<>(); + tenantIdAndEntityId.add(tenantId); + tenantIdAndEntityId.add(entityId); + + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + List fromCache = cache.get(tenantIdAndEntityId, List.class); + if (fromCache != null) { + return Futures.immediateFuture(fromCache); + } else { + ListenableFuture> entityViewsFuture = entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); + Futures.addCallback(entityViewsFuture, + new FutureCallback>() { + @Override + public void onSuccess(@Nullable List result) { + cache.putIfAbsent(tenantIdAndEntityId, result); + } + @Override + public void onFailure(Throwable t) { + log.error("Error while finding entity views by tenantId and entityId", t); + } + }); + return entityViewsFuture; + } } @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") @@ -206,9 +232,8 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti log.trace("Executing deleteEntityView [{}]", entityViewId); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); deleteEntityRelations(entityViewId); - Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); EntityView entityView = entityViewDao.findById(entityViewId.getId()); - cache.evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); + cacheManager.getCache(ENTITY_VIEW_CACHE).evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); entityViewDao.removeById(entityViewId.getId()); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index c5dcc0f62d..40e00ec009 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -42,6 +42,7 @@ import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; +import static org.thingsboard.rule.engine.api.TbRelationTypes.FAILURE; import static org.thingsboard.rule.engine.api.TbRelationTypes.SUCCESS; @Slf4j @@ -49,9 +50,10 @@ import static org.thingsboard.rule.engine.api.TbRelationTypes.SUCCESS; type = ComponentType.ACTION, name = "copy attributes", configClazz = EmptyNodeConfiguration.class, - nodeDescription = "Copy attributes from asset/device to entity view", + nodeDescription = "Copy attributes from asset/device to entity view and changes message originator to related entity view", nodeDetails = "Copy attributes from asset/device to related entity view according to entity view configuration. \n " + - "Copy will be done only for attributes that are between start and end dates and according to attribute keys configuration", + "Copy will be done only for attributes that are between start and end dates and according to attribute keys configuration. \n" + + "Changes message originator to related entity view and produces new messages according to count of updated entity views", uiResources = {"static/rulenode/rulenode-core-config.js"}, configDirective = "tbNodeEmptyConfig", icon = "content_copy" @@ -110,7 +112,8 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { new FutureCallback() { @Override public void onSuccess(@Nullable Void result) { - ctx.tellNext(msg, SUCCESS); + TbMsg updMsg = ctx.transformMsg(msg, msg.getType(), entityView.getId(), msg.getMetaData(), msg.getData()); + ctx.tellNext(updMsg, SUCCESS); } @Override @@ -123,7 +126,7 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { }, t -> ctx.tellFailure(msg, t)); } else { - ctx.tellNext(msg, TbRelationTypes.FAILURE); + ctx.tellNext(msg, FAILURE); } } From 0b5f61338ee7395727740ee460b50196b2646c0c Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Sat, 29 Sep 2018 11:32:02 +0300 Subject: [PATCH 141/307] Code review fixes --- .../java/org/thingsboard/server/controller/BaseController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index af62e04cff..889630b81f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -318,7 +318,7 @@ public abstract class BaseController { checkUserId(new UserId(entityId.getId())); return; case ENTITY_VIEW: - checkEntityView(entityViewService.findEntityViewById(new EntityViewId(entityId.getId()))); + checkEntityViewId(new EntityViewId(entityId.getId())); return; default: throw new IllegalArgumentException("Unsupported entity type: " + entityId.getEntityType()); From 80f0d1eb8a5f018e75ed9db6a58ed3f3728545b6 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Sat, 29 Sep 2018 11:33:24 +0300 Subject: [PATCH 142/307] Code review fixes --- .../thingsboard/server/dao/entityview/EntityViewServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 29ee9e46a0..2c5ec75485 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -194,7 +194,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return entityViewDao.findByIdAsync(entityViewId.getId()); } - @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #entityId}") @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId) { log.trace("Executing findEntityViewsByTenantIdAndEntityIdAsync, tenantId [{}], entityId [{}]", tenantId, entityId); From 02895bf31ddc9a389772f3f6b5ebfe5ea2815454 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Sat, 29 Sep 2018 22:09:18 +0300 Subject: [PATCH 143/307] Typo fixed --- application/src/main/resources/thingsboard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 4d89eafcab..0f5a7fb21b 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -160,9 +160,9 @@ quota: database: entities: - type: "${DATABASE_TS_TYPE:sql}" # cassandra OR sql + type: "${DATABASE_ENTITIES_TYPE:sql}" # cassandra OR sql ts: - type: "${DATABASE_CASSANDRA_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) + type: "${DATABASE_TS_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) # Cassandra driver configuration parameters From ac1de4d12e32c39bc6915312091d595bf00386c6 Mon Sep 17 00:00:00 2001 From: Sergey Tarnavskiy Date: Mon, 1 Oct 2018 13:48:01 +0300 Subject: [PATCH 144/307] fixed units and decimals settings in cards-bundle --- .../data/json/system/widget_bundles/cards.json | 4 ++-- ui/src/app/widget/lib/entities-table-widget.js | 16 ++++++---------- ui/src/app/widget/lib/timeseries-table-widget.js | 4 +++- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 803f1d3953..1ccfdc9071 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -15,7 +15,7 @@ "resources": [], "templateHtml": "", "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", - "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"
\" + dataKey.label +\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n console.log(self.ctx); //del\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var textValue;\n //toDo -> + IsNumber\n \n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (cellData.dataKey.decimals || cellData.dataKey.decimals === 0) {\n decimals = cellData.dataKey.decimals;\n }\n if (cellData.dataKey.units) {\n units = cellData.dataKey.units;\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCells[i].html(txtValue);\n }\n }\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasoirceTitleFontSize+'px');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size', valueFontSize+'px');\n self.ctx.valueCells[i].css('height', valueFontSize*2.5+'px');\n self.ctx.valueCells[i].css('padding', '0px ' + valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size', labelFontSize+'px');\n self.ctx.labelCells[i].css('height', labelFontSize*2.5+'px');\n self.ctx.labelCells[i].css('padding', '0px ' + labelFontSize + 'px');\n } \n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"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\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Attributes card\"}" @@ -95,7 +95,7 @@ "resources": [], "templateHtml": "", "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n\n.tbDatasource-table {\n width: 100%;\n height: 100%;\n border-collapse: collapse;\n white-space: nowrap;\n font-weight: 100;\n text-align: right;\n}\n\n.tbDatasource-table td {\n padding: 12px;\n position: relative;\n box-sizing: border-box;\n}\n\n.tbDatasource-data-key {\n opacity: 0.7;\n font-weight: 400;\n font-size: 3.500rem;\n}\n\n.tbDatasource-value {\n font-size: 5.000rem;\n}", - "controllerScript": "self.onInit = function() {\n\n self.ctx.labelPosition = self.ctx.settings.labelPosition || 'left';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = 'tbDatasource' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n \n var tableId = 'table' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === 'top') {\n table.css('text-align', 'left');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = 'labelCell' + 0;\n var cellId = 'cell' + 0;\n if (self.ctx.labelPosition === 'left') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $('#' + labelCellId, table);\n self.ctx.valueCell = $('#' + cellId, table);\n self.ctx.valueCell.html(0 + ' ' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '' + html_org + '';\n $(this).html(html_calc);\n var width = $(this).find('span:first').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n};\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n\n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n txtValue = self.ctx.utils.formatValue(value, self.ctx.decimals, self.ctx.units);\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === 'left') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css('font-size', fontSize+'px');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n};\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === 'top') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = self.ctx.height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css('font-size', labelFontSize+'px');\n self.ctx.labelCell.css('padding', self.ctx.padding+'px');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css('font-size', self.ctx.valueFontSize+'px');\n self.ctx.valueCell.css('padding', self.ctx.padding+'px');\n } \n};\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1\n };\n};\n\n\nself.onDestroy = function() {\n};\n", + "controllerScript": "self.onInit = function() {\n\n self.ctx.labelPosition = self.ctx.settings.labelPosition || 'left';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = 'tbDatasource' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n \n var tableId = 'table' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === 'top') {\n table.css('text-align', 'left');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = 'labelCell' + 0;\n var cellId = 'cell' + 0;\n if (self.ctx.labelPosition === 'left') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $('#' + labelCellId, table);\n self.ctx.valueCell = $('#' + cellId, table);\n self.ctx.valueCell.html(0 + ' ' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '' + html_org + '';\n $(this).html(html_calc);\n var width = $(this).find('span:first').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n};\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n\n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (self.ctx.datasources.length > 0 && self.ctx.datasources[0].dataKeys.length > 0) {\n dataKey = self.ctx.datasources[0].dataKeys[0];\n if (dataKey.decimals || dataKey.decimals === 0) {\n decimals = dataKey.decimals;\n }\n if (dataKey.units) {\n units = dataKey.units;\n }\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === 'left') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css('font-size', fontSize+'px');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n};\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === 'top') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = self.ctx.height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css('font-size', labelFontSize+'px');\n self.ctx.labelCell.css('padding', self.ctx.padding+'px');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css('font-size', self.ctx.valueFontSize+'px');\n self.ctx.valueCell.css('padding', self.ctx.padding+'px');\n } \n};\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1\n };\n};\n\n\nself.onDestroy = function() {\n};\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"labelPosition\": {\n \"title\": \"Label position\",\n \"type\": \"string\",\n \"default\": \"left\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"labelPosition\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"left\",\n \"label\": \"Left\"\n },\n {\n \"value\": \"top\",\n \"label\": \"Top\"\n }\n ]\n }\n ]\n}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ff5722\",\"color\":\"rgba(255, 255, 255, 0.87)\",\"padding\":\"16px\",\"settings\":{\"labelPosition\":\"top\"},\"title\":\"Simple card\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"units\":\"°C\",\"decimals\":0,\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" diff --git a/ui/src/app/widget/lib/entities-table-widget.js b/ui/src/app/widget/lib/entities-table-widget.js index d0b629d290..88ebbac4dc 100644 --- a/ui/src/app/widget/lib/entities-table-widget.js +++ b/ui/src/app/widget/lib/entities-table-widget.js @@ -367,7 +367,9 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra content = strContent; } } else { - content = defaultContent(key, value); + var decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : vm.widgetConfig.decimals; + var units = contentInfo.units || vm.widgetConfig.units; + content = vm.ctx.utils.formatValue(value, decimals, units, true); } return content; } else { @@ -375,14 +377,6 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra } } - function defaultContent(key, value) { - if (angular.isDefined(value)) { - return value; - } else { - return ''; - } - } - function defaultStyle(/*key, value*/) { return {}; } @@ -477,7 +471,9 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra vm.contentsInfo[dataKey.label] = { useCellContentFunction: useCellContentFunction, - cellContentFunction: cellContentFunction + cellContentFunction: cellContentFunction, + units: dataKey.units, + decimals: dataKey.decimals }; var columnWidth = angular.isDefined(keySettings.columnWidth) ? keySettings.columnWidth : '0px'; diff --git a/ui/src/app/widget/lib/timeseries-table-widget.js b/ui/src/app/widget/lib/timeseries-table-widget.js index bca79565af..d025c1e397 100644 --- a/ui/src/app/widget/lib/timeseries-table-widget.js +++ b/ui/src/app/widget/lib/timeseries-table-widget.js @@ -217,7 +217,9 @@ function TimeseriesTableWidgetController($element, $scope, $filter, $timeout) { content = strContent; } } else { - content = vm.ctx.utils.formatValue(value, contentInfo.decimals, contentInfo.units); + var decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : vm.widgetConfig.decimals; + var units = contentInfo.units || vm.widgetConfig.units; + content = vm.ctx.utils.formatValue(value, decimals, units, true); } return content; } From 52f04308d4e81c20ee95f03244177d1f4f5756e3 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 2 Oct 2018 11:50:55 +0300 Subject: [PATCH 145/307] Improve JS Executor. Add docker build. --- .../service/script/RemoteJsInvokeService.java | 4 +- .../script/RuleNodeJsScriptEngine.java | 2 + .../service/script/RuleNodeScriptFactory.java | 2 +- .../src/main/resources/thingsboard.yml | 2 +- .../server/kafka/TBKafkaProducerTemplate.java | 20 +- .../server/kafka/TbKafkaRequestTemplate.java | 8 +- msa/docker/docker-compose.yml | 46 ++++ msa/docker/tb-js-executor.env | 7 + msa/js-executor/api/jsExecutor.js | 48 ++++ .../api/jsInvokeMessageProcessor.js | 226 ++++++++++++++++++ msa/js-executor/api/jsMessageConsumer.js | 78 ------ msa/js-executor/api/{Utils.js => utils.js} | 4 +- msa/js-executor/config/logger.js | 2 +- msa/js-executor/docker/Dockerfile | 26 ++ msa/js-executor/docker/start-js-executor.sh | 29 +++ msa/js-executor/pom.xml | 38 +++ msa/js-executor/server.js | 48 ++-- .../src/main/scripts/control/deb/postinst | 2 +- 18 files changed, 475 insertions(+), 117 deletions(-) create mode 100644 msa/docker/docker-compose.yml create mode 100644 msa/docker/tb-js-executor.env create mode 100644 msa/js-executor/api/jsExecutor.js create mode 100644 msa/js-executor/api/jsInvokeMessageProcessor.js delete mode 100644 msa/js-executor/api/jsMessageConsumer.js rename msa/js-executor/api/{Utils.js => utils.js} (94%) create mode 100644 msa/js-executor/docker/Dockerfile create mode 100755 msa/js-executor/docker/start-js-executor.sh diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index a080c1c0bd..37a62117be 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -166,7 +166,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { .setScriptBody(scriptIdToBodysMap.get(scriptId)); for (int i = 0; i < args.length; i++) { - jsRequestBuilder.setArgs(i, args[i].toString()); + jsRequestBuilder.addArgs(args[i].toString()); } JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() @@ -180,7 +180,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { return invokeResult.getResult(); } else { log.debug("[{}] Failed to compile script due to [{}]: {}", scriptId, invokeResult.getErrorCode().name(), invokeResult.getErrorDetails()); - throw new RuntimeException(invokeResult.getErrorCode().name()); + throw new RuntimeException(invokeResult.getErrorDetails()); } }); } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index 1cfb633ddf..2a9805156f 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -174,6 +174,8 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S } catch (ExecutionException e) { if (e.getCause() instanceof ScriptException) { throw (ScriptException)e.getCause(); + } else if (e.getCause() instanceof RuntimeException) { + throw new ScriptException(e.getCause().getMessage()); } else { throw new ScriptException(e); } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java index 5cc9c55b6f..ef2b5aae15 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java @@ -28,7 +28,7 @@ public class RuleNodeScriptFactory { " var metadata = JSON.parse(metadataStr); " + " return JSON.stringify(%s(msg, metadata, msgType));" + " function %s(%s, %s, %s) {"; - private static final String JS_WRAPPER_SUFFIX = "}" + + private static final String JS_WRAPPER_SUFFIX = "\n}" + "\n}"; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index b7d0cc15a2..a6d42eb71c 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -407,7 +407,7 @@ state: kafka: enabled: true - bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + bootstrap.servers: "${TB_KAFKA_SERVERS:192.168.2.157:9092}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index 097dbb8e0c..1f0d3ad20e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -17,6 +17,9 @@ package org.thingsboard.server.kafka; import lombok.Builder; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; @@ -35,6 +38,7 @@ import java.util.function.BiConsumer; /** * Created by ashvayka on 24.09.18. */ +@Slf4j public class TBKafkaProducerTemplate { private final KafkaProducer producer; @@ -44,7 +48,7 @@ public class TBKafkaProducerTemplate { private TbKafkaEnricher enricher = ((value, responseTopic, requestId) -> value); private final TbKafkaPartitioner partitioner; - private final List partitionInfoList; + private List partitionInfoList; @Getter private final String defaultTopic; @@ -55,14 +59,24 @@ public class TBKafkaProducerTemplate { props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer"); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArraySerializer"); this.producer = new KafkaProducer<>(props); - //Maybe this should not be cached, but we don't plan to change size of partitions - this.partitionInfoList = producer.partitionsFor(defaultTopic); this.encoder = encoder; this.enricher = enricher; this.partitioner = partitioner; this.defaultTopic = defaultTopic; } + public void init() { + try { + TBKafkaAdmin admin = new TBKafkaAdmin(); + CreateTopicsResult result = admin.createTopic(new NewTopic(defaultTopic, 100, (short) 1)); + result.all().get(); + } catch (Exception e) { + log.trace("Failed to create topic: {}", e.getMessage(), e); + } + //Maybe this should not be cached, but we don't plan to change size of partitions + this.partitionInfoList = producer.partitionsFor(defaultTopic); + } + public T enrich(T value, String responseTopic, UUID requestId) { return enricher.enrich(value, responseTopic, requestId); } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index 38e4436a94..17b44597c7 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.admin.CreateTopicsResult; import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.header.Header; import org.apache.kafka.common.header.internals.RecordHeader; @@ -33,11 +34,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeoutException; +import java.util.concurrent.*; /** * Created by ashvayka on 25.09.18. @@ -86,6 +83,7 @@ public class TbKafkaRequestTemplate { } catch (Exception e) { log.trace("Failed to create topic: {}", e.getMessage(), e); } + this.requestTemplate.init(); tickTs = System.currentTimeMillis(); responseTemplate.subscribe(); executor.submit(() -> { diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml new file mode 100644 index 0000000000..a077c0ee1a --- /dev/null +++ b/msa/docker/docker-compose.yml @@ -0,0 +1,46 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +version: '2' + +services: + zookeeper: + image: "wurstmeister/zookeeper" + ports: + - "2181" + kafka: + image: "wurstmeister/kafka" + ports: + - "9092:9092" + environment: + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_LISTENERS: INSIDE://:9093,OUTSIDE://:9092 + KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://${KAFKA_HOSTNAME}:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE + KAFKA_CREATE_TOPICS: "${KAFKA_TOPICS}" + KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false' + depends_on: + - zookeeper + tb-js-executor: + image: "local-maven-build/tb-js-executor:latest" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-js-executor.env + depends_on: + - kafka diff --git a/msa/docker/tb-js-executor.env b/msa/docker/tb-js-executor.env new file mode 100644 index 0000000000..f562c3aa28 --- /dev/null +++ b/msa/docker/tb-js-executor.env @@ -0,0 +1,7 @@ + +REMOTE_JS_EVAL_REQUEST_TOPIC=js.eval.requests +TB_KAFKA_SERVERS=localhost:9092 +LOGGER_LEVEL=debug +LOG_FOLDER=logs +LOGGER_FILENAME=tb-js-executor-%DATE%.log +DOCKER_MODE=true \ No newline at end of file diff --git a/msa/js-executor/api/jsExecutor.js b/msa/js-executor/api/jsExecutor.js new file mode 100644 index 0000000000..18d3f6cf13 --- /dev/null +++ b/msa/js-executor/api/jsExecutor.js @@ -0,0 +1,48 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +const vm = require('vm'); + +function JsExecutor() { +} + +JsExecutor.prototype.compileScript = function(code) { + return new Promise(function(resolve, reject) { + try { + code = "("+code+")(...args)"; + var script = new vm.Script(code); + resolve(script); + } catch (err) { + reject(err); + } + }); +} + +JsExecutor.prototype.executeScript = function(script, args, timeout) { + return new Promise(function(resolve, reject) { + try { + var sandbox = Object.create(null); + sandbox.args = args; + var result = script.runInNewContext(sandbox, {timeout: timeout}); + resolve(result); + } catch (err) { + reject(err); + } + }); +} + +module.exports = JsExecutor; diff --git a/msa/js-executor/api/jsInvokeMessageProcessor.js b/msa/js-executor/api/jsInvokeMessageProcessor.js new file mode 100644 index 0000000000..ef81adfc97 --- /dev/null +++ b/msa/js-executor/api/jsInvokeMessageProcessor.js @@ -0,0 +1,226 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const logger = require('../config/logger')('JsInvokeMessageProcessor'), + Utils = require('./utils'), + js = require('./jsinvoke.proto').js, + KeyedMessage = require('kafka-node').KeyedMessage, + JsExecutor = require('./jsExecutor'); + +function JsInvokeMessageProcessor(producer) { + this.producer = producer; + this.executor = new JsExecutor(); + this.scriptMap = {}; +} + +JsInvokeMessageProcessor.prototype.onJsInvokeMessage = function(message) { + + var requestId; + try { + var request = js.RemoteJsRequest.decode(message.value); + requestId = getRequestId(request); + + logger.debug('[%s] Received request, responseTopic: [%s]', requestId, request.responseTopic); + + if (request.compileRequest) { + this.processCompileRequest(requestId, request.responseTopic, request.compileRequest); + } else if (request.invokeRequest) { + this.processInvokeRequest(requestId, request.responseTopic, request.invokeRequest); + } else if (request.releaseRequest) { + this.processReleaseRequest(requestId, request.responseTopic, request.releaseRequest); + } else { + logger.error('[%s] Unknown request recevied!', requestId); + } + + } catch (err) { + logger.error('[%s] Failed to process request: %s', requestId, err.message); + logger.error(err.stack); + } +} + +JsInvokeMessageProcessor.prototype.processCompileRequest = function(requestId, responseTopic, compileRequest) { + var scriptId = getScriptId(compileRequest); + logger.debug('[%s] Processing compile request, scriptId: [%s]', requestId, scriptId); + + this.executor.compileScript(compileRequest.scriptBody).then( + (script) => { + this.scriptMap[scriptId] = script; + var compileResponse = createCompileResponse(scriptId, true); + logger.debug('[%s] Sending success compile response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, compileResponse); + }, + (err) => { + var compileResponse = createCompileResponse(scriptId, false, js.JsInvokeErrorCode.COMPILATION_ERROR, err); + logger.debug('[%s] Sending failed compile response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, compileResponse); + } + ); +} + +JsInvokeMessageProcessor.prototype.processInvokeRequest = function(requestId, responseTopic, invokeRequest) { + var scriptId = getScriptId(invokeRequest); + logger.debug('[%s] Processing invoke request, scriptId: [%s]', requestId, scriptId); + this.getOrCompileScript(scriptId, invokeRequest.scriptBody).then( + (script) => { + this.executor.executeScript(script, invokeRequest.args, invokeRequest.timeout).then( + (result) => { + var invokeResponse = createInvokeResponse(result, true); + logger.debug('[%s] Sending success invoke response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, null, invokeResponse); + }, + (err) => { + var errorCode; + if (err.message.includes('Script execution timed out')) { + errorCode = js.JsInvokeErrorCode.TIMEOUT_ERROR; + } else { + errorCode = js.JsInvokeErrorCode.RUNTIME_ERROR; + } + var invokeResponse = createInvokeResponse("", false, errorCode, err); + logger.debug('[%s] Sending failed invoke response, scriptId: [%s], errorCode: [%s]', requestId, scriptId, errorCode); + this.sendResponse(requestId, responseTopic, scriptId, null, invokeResponse); + } + ) + }, + (err) => { + var invokeResponse = createInvokeResponse("", false, js.JsInvokeErrorCode.COMPILATION_ERROR, err); + logger.debug('[%s] Sending failed invoke response, scriptId: [%s], errorCode: [%s]', requestId, scriptId, js.JsInvokeErrorCode.COMPILATION_ERROR); + this.sendResponse(requestId, responseTopic, scriptId, null, invokeResponse); + } + ); +} + +JsInvokeMessageProcessor.prototype.processReleaseRequest = function(requestId, responseTopic, releaseRequest) { + var scriptId = getScriptId(releaseRequest); + logger.debug('[%s] Processing release request, scriptId: [%s]', requestId, scriptId); + if (this.scriptMap[scriptId]) { + delete this.scriptMap[scriptId]; + } + var releaseResponse = createReleaseResponse(scriptId, true); + logger.debug('[%s] Sending success release response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, null, null, releaseResponse); +} + +JsInvokeMessageProcessor.prototype.sendResponse = function (requestId, responseTopic, scriptId, compileResponse, invokeResponse, releaseResponse) { + var remoteResponse = createRemoteResponse(requestId, compileResponse, invokeResponse, releaseResponse); + var rawResponse = js.RemoteJsResponse.encode(remoteResponse).finish(); + const message = new KeyedMessage(scriptId, rawResponse); + const payloads = [ { topic: responseTopic, messages: message, key: scriptId } ]; + this.producer.send(payloads, function (err, data) { + if (err) { + logger.error('[%s] Failed to send response to kafka: %s', requestId, err.message); + logger.error(err.stack); + } + }); +} + +JsInvokeMessageProcessor.prototype.getOrCompileScript = function(scriptId, scriptBody) { + var self = this; + return new Promise(function(resolve, reject) { + if (self.scriptMap[scriptId]) { + resolve(self.scriptMap[scriptId]); + } else { + self.executor.compileScript(scriptBody).then( + (script) => { + self.scriptMap[scriptId] = script; + resolve(script); + }, + (err) => { + reject(err); + } + ); + } + }); +} + +function createRemoteResponse(requestId, compileResponse, invokeResponse, releaseResponse) { + const requestIdBits = Utils.UUIDToBits(requestId); + return js.RemoteJsResponse.create( + { + requestIdMSB: requestIdBits[0], + requestIdLSB: requestIdBits[1], + compileResponse: compileResponse, + invokeResponse: invokeResponse, + releaseResponse: releaseResponse + } + ); +} + +function createCompileResponse(scriptId, success, errorCode, err) { + const scriptIdBits = Utils.UUIDToBits(scriptId); + return js.JsCompileResponse.create( + { + errorCode: errorCode, + success: success, + errorDetails: parseJsErrorDetails(err), + scriptIdMSB: scriptIdBits[0], + scriptIdLSB: scriptIdBits[1] + } + ); +} + +function createInvokeResponse(result, success, errorCode, err) { + return js.JsInvokeResponse.create( + { + errorCode: errorCode, + success: success, + errorDetails: parseJsErrorDetails(err), + result: result + } + ); +} + +function createReleaseResponse(scriptId, success) { + const scriptIdBits = Utils.UUIDToBits(scriptId); + return js.JsReleaseResponse.create( + { + success: success, + scriptIdMSB: scriptIdBits[0], + scriptIdLSB: scriptIdBits[1] + } + ); +} + +function parseJsErrorDetails(err) { + if (!err) { + return ''; + } + var details = err.name + ': ' + err.message; + if (err.stack) { + var lines = err.stack.split('\n'); + if (lines && lines.length) { + var line = lines[0]; + var splitted = line.split(':'); + if (splitted && splitted.length === 2) { + if (!isNaN(splitted[1])) { + details += ' in at line number ' + splitted[1]; + } + } + } + } + return details; +} + +function getScriptId(request) { + return Utils.toUUIDString(request.scriptIdMSB, request.scriptIdLSB); +} + +function getRequestId(request) { + return Utils.toUUIDString(request.requestIdMSB, request.requestIdLSB); +} + +module.exports = JsInvokeMessageProcessor; \ No newline at end of file diff --git a/msa/js-executor/api/jsMessageConsumer.js b/msa/js-executor/api/jsMessageConsumer.js deleted file mode 100644 index 39dd43aa33..0000000000 --- a/msa/js-executor/api/jsMessageConsumer.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const logger = require('../config/logger')('JsMessageConsumer'); -const Utils = require('./Utils'); -const js = require('./jsinvoke.proto').js; -const KeyedMessage = require('kafka-node').KeyedMessage; - - -exports.onJsInvokeMessage = function(message, producer) { - - logger.info('Received message: %s', JSON.stringify(message)); - - var request = js.RemoteJsRequest.decode(message.value); - - logger.info('Received request: %s', JSON.stringify(request)); - - var requestId = getRequestId(request); - - logger.info('Received request, responseTopic: [%s]; requestId: [%s]', request.responseTopic, requestId); - - if (request.compileRequest) { - var scriptId = getScriptId(request.compileRequest); - - logger.info('Received compile request, scriptId: [%s]', scriptId); - - var compileResponse = js.JsCompileResponse.create( - { - errorCode: js.JsInvokeErrorCode.COMPILATION_ERROR, - success: false, - errorDetails: 'Not Implemented!', - scriptIdLSB: request.compileRequest.scriptIdLSB, - scriptIdMSB: request.compileRequest.scriptIdMSB - } - ); - const requestIdBits = Utils.UUIDToBits(requestId); - var response = js.RemoteJsResponse.create( - { - requestIdMSB: requestIdBits[0], - requestIdLSB: requestIdBits[1], - compileResponse: compileResponse - } - ); - var rawResponse = js.RemoteJsResponse.encode(response).finish(); - sendMessage(producer, rawResponse, request.responseTopic, scriptId); - } -} - -function sendMessage(producer, rawMessage, responseTopic, scriptId) { - const message = new KeyedMessage(scriptId, rawMessage); - const payloads = [ { topic: responseTopic, messages: rawMessage, key: scriptId } ]; - producer.send(payloads, function (err, data) { - console.log(data); - }); -} - -function getScriptId(request) { - return Utils.toUUIDString(request.scriptIdMSB, request.scriptIdLSB); -} - -function getRequestId(request) { - return Utils.toUUIDString(request.requestIdMSB, request.requestIdLSB); -} diff --git a/msa/js-executor/api/Utils.js b/msa/js-executor/api/utils.js similarity index 94% rename from msa/js-executor/api/Utils.js rename to msa/js-executor/api/utils.js index 16d98a596b..2bb31267f8 100644 --- a/msa/js-executor/api/Utils.js +++ b/msa/js-executor/api/utils.js @@ -16,8 +16,8 @@ 'use strict'; -const Long = require('long'); -const uuidParse = require('uuid-parse'); +const Long = require('long'), + uuidParse = require('uuid-parse'); var logger = require('../config/logger')('Utils'); diff --git a/msa/js-executor/config/logger.js b/msa/js-executor/config/logger.js index 86df82777d..695b453763 100644 --- a/msa/js-executor/config/logger.js +++ b/msa/js-executor/config/logger.js @@ -22,7 +22,7 @@ const { combine, timestamp, label, printf, splat } = format; var loggerTransports = []; -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV !== 'production' || process.env.DOCKER_MODE === 'true') { loggerTransports.push(new transports.Console({ handleExceptions: true })); diff --git a/msa/js-executor/docker/Dockerfile b/msa/js-executor/docker/Dockerfile new file mode 100644 index 0000000000..2b62eb33ae --- /dev/null +++ b/msa/js-executor/docker/Dockerfile @@ -0,0 +1,26 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM debian:stretch + +COPY start-js-executor.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-js-executor.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +CMD ["start-js-executor.sh"] diff --git a/msa/js-executor/docker/start-js-executor.sh b/msa/js-executor/docker/start-js-executor.sh new file mode 100755 index 0000000000..af7c686a11 --- /dev/null +++ b/msa/js-executor/docker/start-js-executor.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +echo "Starting '${project.name}' ..." + +CONF_FOLDER="${pkg.installFolder}/conf" + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf +identity=${pkg.name} + +source "${CONF_FOLDER}/${configfile}" + +su -s /bin/sh -c "$mainfile" diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 2dd03a4a8b..51416f5aa3 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -40,6 +40,7 @@ /usr/share/${pkg.name} ${project.build.directory}/package/linux ${project.build.directory}/package/windows + true @@ -211,6 +212,22 @@ + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + @@ -260,6 +277,27 @@ + + com.spotify + dockerfile-maven-plugin + 1.4.4 + + + build-docker-image + pre-integration-test + + build + + + + + ${dockerfile.skip} + local-maven-build/${pkg.name} + true + false + ${project.build.directory} + + diff --git a/msa/js-executor/server.js b/msa/js-executor/server.js index 58d721b4f9..03fac2ebe0 100644 --- a/msa/js-executor/server.js +++ b/msa/js-executor/server.js @@ -16,17 +16,10 @@ const config = require('config'), kafka = require('kafka-node'), - Consumer = kafka.Consumer, + ConsumerGroup = kafka.ConsumerGroup, Producer = kafka.Producer, - JsMessageConsumer = require('./api/jsMessageConsumer'); - -const logger = require('./config/logger')('main'); - -const kafkaBootstrapServers = config.get('kafka.bootstrap.servers'); -const kafkaRequestTopic = config.get('kafka.request_topic'); - -logger.info('Kafka Bootstrap Servers: %s', kafkaBootstrapServers); -logger.info('Kafka Requests Topic: %s', kafkaRequestTopic); + JsInvokeMessageProcessor = require('./api/jsInvokeMessageProcessor'), + logger = require('./config/logger')('main'); var kafkaClient; @@ -34,35 +27,42 @@ var kafkaClient; try { logger.info('Starting ThingsBoard JavaScript Executor Microservice...'); + const kafkaBootstrapServers = config.get('kafka.bootstrap.servers'); + const kafkaRequestTopic = config.get('kafka.request_topic'); + + logger.info('Kafka Bootstrap Servers: %s', kafkaBootstrapServers); + logger.info('Kafka Requests Topic: %s', kafkaRequestTopic); + kafkaClient = new kafka.KafkaClient({kafkaHost: kafkaBootstrapServers}); - var consumer = new Consumer( - kafkaClient, - [ - { topic: kafkaRequestTopic, partition: 0 } - ], + var consumer = new ConsumerGroup( { + kafkaHost: kafkaBootstrapServers, + groupId: 'js-executor-group', autoCommit: true, encoding: 'buffer' - } + }, + kafkaRequestTopic ); var producer = new Producer(kafkaClient); producer.on('error', (err) => { - logger.error('Unexpected kafka producer error'); - logger.error(err); + logger.error('Unexpected kafka producer error: %s', err.message); + logger.error(err.stack); }); + var messageProcessor = new JsInvokeMessageProcessor(producer); + producer.on('ready', () => { consumer.on('message', (message) => { - JsMessageConsumer.onJsInvokeMessage(message, producer); + messageProcessor.onJsInvokeMessage(message); }); + logger.info('Started ThingsBoard JavaScript Executor Microservice.'); }); - logger.info('Started ThingsBoard JavaScript Executor Microservice.'); } catch (e) { logger.error('Failed to start ThingsBoard JavaScript Executor Microservice: %s', e.message); - logger.error(e); + logger.error(e.stack); exit(-1); } })(); @@ -75,11 +75,13 @@ function exit(status) { logger.info('Exiting with status: %d ...', status); if (kafkaClient) { logger.info('Stopping Kafka Client...'); - kafkaClient.close(() => { + var _kafkaClient = kafkaClient; + kafkaClient = null; + _kafkaClient.close(() => { logger.info('Kafka Client stopped.'); process.exit(status); }); } else { process.exit(status); } -} \ No newline at end of file +} diff --git a/msa/js-executor/src/main/scripts/control/deb/postinst b/msa/js-executor/src/main/scripts/control/deb/postinst index 0767d3f2c7..4c48c5508a 100644 --- a/msa/js-executor/src/main/scripts/control/deb/postinst +++ b/msa/js-executor/src/main/scripts/control/deb/postinst @@ -2,5 +2,5 @@ chown -R ${pkg.user}: ${pkg.logFolder} chown -R ${pkg.user}: ${pkg.installFolder} -update-rc.d ${pkg.name} defaults +# update-rc.d ${pkg.name} defaults From 4bf57ebf20d83aa922f95aae80b5dba3e36aadfa Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 2 Oct 2018 17:42:09 +0300 Subject: [PATCH 146/307] ThingsBoard Web UI Microservice. --- msa/docker/docker-compose.yml | 13 +- msa/docker/tb-web-ui.env | 9 + msa/pom.xml | 1 + msa/web-ui/.gitignore | 31 ++ msa/web-ui/build.gradle | 125 ++++++ .../config/custom-environment-variables.yml | 30 ++ msa/web-ui/config/default.yml | 30 ++ msa/web-ui/config/logger.js | 59 +++ msa/web-ui/config/tb-web-ui.conf | 20 + msa/web-ui/docker/Dockerfile | 26 ++ msa/web-ui/docker/start-web-ui.sh | 29 ++ msa/web-ui/install.js | 42 ++ msa/web-ui/package.json | 38 ++ msa/web-ui/pom.xml | 372 ++++++++++++++++++ msa/web-ui/server.js | 129 ++++++ msa/web-ui/src/main/assembly/windows.xml | 75 ++++ msa/web-ui/src/main/filters/unix.properties | 1 + .../src/main/filters/windows.properties | 2 + .../src/main/scripts/control/deb/postinst | 6 + .../src/main/scripts/control/deb/postrm | 3 + .../src/main/scripts/control/deb/preinst | 18 + msa/web-ui/src/main/scripts/control/deb/prerm | 5 + .../src/main/scripts/control/rpm/postinst | 9 + .../src/main/scripts/control/rpm/postrm | 6 + .../src/main/scripts/control/rpm/preinst | 6 + msa/web-ui/src/main/scripts/control/rpm/prerm | 6 + .../main/scripts/control/tb-web-ui.service | 11 + msa/web-ui/src/main/scripts/init/tb-web-ui | 233 +++++++++++ .../src/main/scripts/windows/install.bat | 31 ++ .../src/main/scripts/windows/service.xml | 30 ++ .../src/main/scripts/windows/uninstall.bat | 25 ++ ui/package.json | 2 +- 32 files changed, 1421 insertions(+), 2 deletions(-) create mode 100644 msa/docker/tb-web-ui.env create mode 100644 msa/web-ui/.gitignore create mode 100644 msa/web-ui/build.gradle create mode 100644 msa/web-ui/config/custom-environment-variables.yml create mode 100644 msa/web-ui/config/default.yml create mode 100644 msa/web-ui/config/logger.js create mode 100644 msa/web-ui/config/tb-web-ui.conf create mode 100644 msa/web-ui/docker/Dockerfile create mode 100755 msa/web-ui/docker/start-web-ui.sh create mode 100644 msa/web-ui/install.js create mode 100644 msa/web-ui/package.json create mode 100644 msa/web-ui/pom.xml create mode 100644 msa/web-ui/server.js create mode 100644 msa/web-ui/src/main/assembly/windows.xml create mode 100644 msa/web-ui/src/main/filters/unix.properties create mode 100644 msa/web-ui/src/main/filters/windows.properties create mode 100644 msa/web-ui/src/main/scripts/control/deb/postinst create mode 100644 msa/web-ui/src/main/scripts/control/deb/postrm create mode 100644 msa/web-ui/src/main/scripts/control/deb/preinst create mode 100644 msa/web-ui/src/main/scripts/control/deb/prerm create mode 100644 msa/web-ui/src/main/scripts/control/rpm/postinst create mode 100644 msa/web-ui/src/main/scripts/control/rpm/postrm create mode 100644 msa/web-ui/src/main/scripts/control/rpm/preinst create mode 100644 msa/web-ui/src/main/scripts/control/rpm/prerm create mode 100644 msa/web-ui/src/main/scripts/control/tb-web-ui.service create mode 100644 msa/web-ui/src/main/scripts/init/tb-web-ui create mode 100644 msa/web-ui/src/main/scripts/windows/install.bat create mode 100644 msa/web-ui/src/main/scripts/windows/service.xml create mode 100644 msa/web-ui/src/main/scripts/windows/uninstall.bat diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index a077c0ee1a..727936c2f6 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -29,7 +29,7 @@ services: environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENERS: INSIDE://:9093,OUTSIDE://:9092 - KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://${KAFKA_HOSTNAME}:9092 + KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://${EXTERNAL_HOSTNAME}:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE KAFKA_CREATE_TOPICS: "${KAFKA_TOPICS}" @@ -44,3 +44,14 @@ services: - tb-js-executor.env depends_on: - kafka + tb-web-ui: + image: "local-maven-build/tb-web-ui:latest" + ports: + - "8090:8090" + environment: + HTTP_BIND_ADDRESS: 0.0.0.0 + HTTP_BIND_PORT: 8090 + TB_HOST: ${EXTERNAL_HOSTNAME} + TB_PORT: 8080 + env_file: + - tb-web-ui.env diff --git a/msa/docker/tb-web-ui.env b/msa/docker/tb-web-ui.env new file mode 100644 index 0000000000..8d6157bfc8 --- /dev/null +++ b/msa/docker/tb-web-ui.env @@ -0,0 +1,9 @@ + +HTTP_BIND_ADDRESS=0.0.0.0 +HTTP_BIND_PORT=8090 +TB_HOST=localhost +TB_PORT=8080 +LOGGER_LEVEL=debug +LOG_FOLDER=logs +LOGGER_FILENAME=tb-web-ui-%DATE%.log +DOCKER_MODE=true \ No newline at end of file diff --git a/msa/pom.xml b/msa/pom.xml index 85d45a1f76..3241d0cf51 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -36,6 +36,7 @@ js-executor + web-ui diff --git a/msa/web-ui/.gitignore b/msa/web-ui/.gitignore new file mode 100644 index 0000000000..c5cfd3d322 --- /dev/null +++ b/msa/web-ui/.gitignore @@ -0,0 +1,31 @@ +*.toDelete +output/** +*.class +*~ +*.iml +*/.idea/** +.idea/** +.idea +*.log +*.log.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] +*/.classpath +.classpath +*/.project +.project +.cache/** +target/ +logs/ +build/ +.settings/ +/bin +bin/ +**/dependency-reduced-pom.xml +pom.xml.versionsBackup +.DS_Store +**/.gradle +**/local.properties +**/build +**/target +**/.env +node_modules +package-lock.json diff --git a/msa/web-ui/build.gradle b/msa/web-ui/build.gradle new file mode 100644 index 0000000000..7372c0a819 --- /dev/null +++ b/msa/web-ui/build.gradle @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgUser + permissionGroup pkgUser + + // Copy the executable file + from("target/package/linux/bin/${pkgName}") { + fileMode 0500 + into "bin" + } + + // Copy the init file + from("target/package/linux/init/${pkgName}") { + fileMode 0500 + into "init" + } + + // Copy the config files + from("target/package/linux/conf") { + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + + // Copy web files + from("target/package/linux/web") { + into "web" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = X86_64 + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgUser + permissionGroup pkgUser + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "amd64" + + archiveName = "${pkgName}.deb" + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/custom-environment-variables.yml") + configurationFile("${pkgInstallFolder}/conf/default.yml") + configurationFile("${pkgInstallFolder}/conf/logger.js") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgUser + permissionGroup pkgUser + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/init/${pkgName}") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml new file mode 100644 index 0000000000..9472a50571 --- /dev/null +++ b/msa/web-ui/config/custom-environment-variables.yml @@ -0,0 +1,30 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +server: + # Server bind address + address: "HTTP_BIND_ADDRESS" + # Server bind port + port: "HTTP_BIND_PORT" +thingsboard: + # ThingsBoard node host + host: "TB_HOST" + # ThingsBoard node port + port: "TB_PORT" +logger: + level: "LOGGER_LEVEL" + path: "LOG_FOLDER" + filename: "LOGGER_FILENAME" diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml new file mode 100644 index 0000000000..cf27a14015 --- /dev/null +++ b/msa/web-ui/config/default.yml @@ -0,0 +1,30 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +server: + # Server bind address + address: "0.0.0.0" + # Server bind port + port: "8090" +thingsboard: + # ThingsBoard node host + host: "localhost" + # ThingsBoard node port + port: "8080" +logger: + level: "info" + path: "logs" + filename: "tb-web-ui-%DATE%.log" diff --git a/msa/web-ui/config/logger.js b/msa/web-ui/config/logger.js new file mode 100644 index 0000000000..695b453763 --- /dev/null +++ b/msa/web-ui/config/logger.js @@ -0,0 +1,59 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var config = require('config'), + path = require('path'), + DailyRotateFile = require('winston-daily-rotate-file'); + +const { createLogger, format, transports } = require('winston'); +const { combine, timestamp, label, printf, splat } = format; + +var loggerTransports = []; + +if (process.env.NODE_ENV !== 'production' || process.env.DOCKER_MODE === 'true') { + loggerTransports.push(new transports.Console({ + handleExceptions: true + })); +} else { + var filename = path.join(config.get('logger.path'), config.get('logger.filename')); + var transport = new (DailyRotateFile)({ + filename: filename, + datePattern: 'YYYY-MM-DD-HH', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + handleExceptions: true + }); + loggerTransports.push(transport); +} + +const tbFormat = printf(info => { + return `${info.timestamp} [${info.label}] ${info.level.toUpperCase()}: ${info.message}`; +}); + +function _logger(moduleLabel) { + return createLogger({ + level: config.get('logger.level'), + format:combine( + splat(), + label({ label: moduleLabel }), + timestamp({format: 'YYYY-MM-DD HH:mm:ss,SSS'}), + tbFormat + ), + transports: loggerTransports + }); +} + +module.exports = _logger; \ No newline at end of file diff --git a/msa/web-ui/config/tb-web-ui.conf b/msa/web-ui/config/tb-web-ui.conf new file mode 100644 index 0000000000..d002038d40 --- /dev/null +++ b/msa/web-ui/config/tb-web-ui.conf @@ -0,0 +1,20 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export NODE_CONFIG_DIR=${pkg.installFolder}/conf +export LOG_FOLDER=${pkg.logFolder} +export NODE_ENV=production +export WEB_FOLDER=${pkg.installFolder}/web \ No newline at end of file diff --git a/msa/web-ui/docker/Dockerfile b/msa/web-ui/docker/Dockerfile new file mode 100644 index 0000000000..7a75cc3f1f --- /dev/null +++ b/msa/web-ui/docker/Dockerfile @@ -0,0 +1,26 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM debian:stretch + +COPY start-web-ui.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-web-ui.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +CMD ["start-web-ui.sh"] diff --git a/msa/web-ui/docker/start-web-ui.sh b/msa/web-ui/docker/start-web-ui.sh new file mode 100755 index 0000000000..af7c686a11 --- /dev/null +++ b/msa/web-ui/docker/start-web-ui.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +echo "Starting '${project.name}' ..." + +CONF_FOLDER="${pkg.installFolder}/conf" + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf +identity=${pkg.name} + +source "${CONF_FOLDER}/${configfile}" + +su -s /bin/sh -c "$mainfile" diff --git a/msa/web-ui/install.js b/msa/web-ui/install.js new file mode 100644 index 0000000000..63e73dbe56 --- /dev/null +++ b/msa/web-ui/install.js @@ -0,0 +1,42 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); + +let _projectRoot = null; + + +(async() => { + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-web-ui-linux'), + path.join(targetPackageDir('linux'), 'bin', 'tb-web-ui'), + {overwrite: true}); + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-web-ui-win.exe'), + path.join(targetPackageDir('windows'), 'bin', 'tb-web-ui.exe'), + {overwrite: true}); +})(); + + +function projectRoot() { + if (!_projectRoot) { + _projectRoot = __dirname; + } + return _projectRoot; +} + +function targetPackageDir(platform) { + return path.join(projectRoot(), 'target', 'package', platform); +} diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json new file mode 100644 index 0000000000..f30f2fc3a6 --- /dev/null +++ b/msa/web-ui/package.json @@ -0,0 +1,38 @@ +{ + "name": "thingsboard-web-ui", + "private": true, + "version": "2.2.0", + "description": "ThingsBoard Web UI Microservice", + "main": "server.js", + "bin": "server.js", + "scripts": { + "install": "pkg -t node8-linux-x64,node8-win-x64 --out-path ./target . && node install.js", + "test": "echo \"Error: no test specified\" && exit 1", + "start": "WEB_FOLDER=./target/web nodemon server.js", + "start-prod": "NODE_ENV=production nodemon server.js" + }, + "dependencies": { + "config": "^1.30.0", + "connect-history-api-fallback": "^1.5.0", + "express": "^4.16.3", + "http": "0.0.0", + "http-proxy": "^1.17.0", + "js-yaml": "^3.12.0", + "winston": "^3.0.0", + "winston-daily-rotate-file": "^3.2.1" + }, + "engine": "node >= 5.9.0", + "nyc": { + "exclude": [ + "test", + "__tests__", + "node_modules", + "target" + ] + }, + "devDependencies": { + "fs-extra": "^6.0.1", + "nodemon": "^1.17.5", + "pkg": "^4.3.3" + } +} diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml new file mode 100644 index 0000000000..54c3bb45f5 --- /dev/null +++ b/msa/web-ui/pom.xml @@ -0,0 +1,372 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + web-ui + pom + + ThingsBoard Web UI Microservice + https://thingsboard.io + Service for hosting ThingsBoard Web UI + + + UTF-8 + ${basedir}/../.. + tb-web-ui + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/package/linux + ${project.build.directory}/package/windows + true + + + + + org.thingsboard + ui + ${project.version} + jar + provided + + + com.sun.winsw + winsw + bin + exe + provided + + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + install node and npm + + install-node-and-npm + + + v8.11.3 + 5.6.0 + + + + npm install + + npm + + + install + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + extract-web-ui + + unpack + + + + + org.thingsboard + ui + jar + false + ${project.build.directory}/web + + + + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-linux-conf + process-resources + + copy-resources + + + ${pkg.linux.dist}/conf + + + config + true + + + + src/main/filters/unix.properties + + + + + copy-linux-init + process-resources + + copy-resources + + + ${pkg.linux.dist}/init + + + src/main/scripts/init + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + config + + tb-web-ui.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PpkgName=${pkg.name} + -PpkgUser=${pkg.user} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.0.0 + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + com.spotify + dockerfile-maven-plugin + 1.4.4 + + + build-docker-image + pre-integration-test + + build + + + + + ${dockerfile.skip} + local-maven-build/${pkg.name} + true + false + ${project.build.directory} + + + + + + + npm-start + + + npm-start + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + npm start + + npm + + + + start + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/web-ui/server.js b/msa/web-ui/server.js new file mode 100644 index 0000000000..46d9bfd7a9 --- /dev/null +++ b/msa/web-ui/server.js @@ -0,0 +1,129 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const config = require('config'), + logger = require('./config/logger')('main'), + express = require('express'), + http = require('http'), + httpProxy = require('http-proxy'), + path = require('path'), + historyApiFallback = require("connect-history-api-fallback"); + +var server; + +(async() => { + try { + logger.info('Starting ThingsBoard Web UI Microservice...'); + + const bindAddress = config.get('server.address'); + const bindPort = config.get('server.port'); + + const thingsboardHost = config.get('thingsboard.host'); + const thingsboardPort = config.get('thingsboard.port'); + + logger.info('Bind address: %s', bindAddress); + logger.info('Bind port: %s', bindPort); + logger.info('ThingsBoard host: %s', thingsboardHost); + logger.info('ThingsBoard port: %s', thingsboardPort); + + var webDir = path.join(__dirname, 'web'); + + if (typeof process.env.WEB_FOLDER === 'string') { + webDir = path.resolve(process.env.WEB_FOLDER); + } + logger.info('Web folder: %s', webDir); + + const app = express(); + server = http.createServer(app); + + const apiProxy = httpProxy.createProxyServer({ + target: { + host: thingsboardHost, + port: thingsboardPort + } + }); + + apiProxy.on('error', function (err, req, res) { + logger.warn('API proxy error: %s', err.message); + res.writeHead(500); + if (err.code && err.code === 'ECONNREFUSED') { + res.end('Unable to connect to ThingsBoard server.'); + } else { + res.end('Thingsboard server connection error: ' + err.code ? err.code : ''); + } + }); + + const root = path.join(webDir, 'public'); + + const staticDir = path.join(root, 'static'); + + app.all('/api/*', (req, res) => { + logger.info(req.method + ' ' + req.originalUrl); + apiProxy.web(req, res); + }); + + app.all('/static/rulenode/*', (req, res) => { + apiProxy.web(req, res); + }); + + app.use(historyApiFallback()); + + app.use('/static', express.static(staticDir)); + + app.get('*', (req, res) => { + apiProxy.web(req, res); + }); + + server.on('upgrade', (req, socket, head) => { + apiProxy.ws(req, socket, head); + }); + + server.listen(bindPort, bindAddress, (error) => { + if (error) { + logger.error('Failed to start ThingsBoard Web UI Microservice: %s', e.message); + logger.error(error.stack); + exit(-1); + } else { + logger.info('==> 🌎 Listening on port %s.', bindPort); + logger.info('Started ThingsBoard Web UI Microservice.'); + } + }); + + } catch (e) { + logger.error('Failed to start ThingsBoard Web UI Microservice: %s', e.message); + logger.error(e.stack); + exit(-1); + } +})(); + +process.on('exit', function () { + exit(0); +}); + +function exit(status) { + logger.info('Exiting with status: %d ...', status); + if (server) { + logger.info('Stopping HTTP Server...'); + var _server = server; + server = null; + _server.close(() => { + logger.info('HTTP Server stopped.'); + process.exit(status); + }); + } else { + process.exit(status); + } +} diff --git a/msa/web-ui/src/main/assembly/windows.xml b/msa/web-ui/src/main/assembly/windows.xml new file mode 100644 index 0000000000..12119906c1 --- /dev/null +++ b/msa/web-ui/src/main/assembly/windows.xml @@ -0,0 +1,75 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + ${project.build.directory}/web + web + + + + + + ${pkg.win.dist}/bin/${pkg.name}.exe + bin + ${pkg.name}.exe + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/msa/web-ui/src/main/filters/unix.properties b/msa/web-ui/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/msa/web-ui/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/msa/web-ui/src/main/filters/windows.properties b/msa/web-ui/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/msa/web-ui/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/msa/web-ui/src/main/scripts/control/deb/postinst b/msa/web-ui/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..4c48c5508a --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} +# update-rc.d ${pkg.name} defaults + diff --git a/msa/web-ui/src/main/scripts/control/deb/postrm b/msa/web-ui/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/msa/web-ui/src/main/scripts/control/deb/preinst b/msa/web-ui/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..d2ebea46d7 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.user} >/dev/null; then + addgroup --system ${pkg.user} +fi + +if ! getent passwd ${pkg.user} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.user} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.user} +fi diff --git a/msa/web-ui/src/main/scripts/control/deb/prerm b/msa/web-ui/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/msa/web-ui/src/main/scripts/control/rpm/postinst b/msa/web-ui/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..d8021e2dd9 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/web-ui/src/main/scripts/control/rpm/postrm b/msa/web-ui/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/web-ui/src/main/scripts/control/rpm/preinst b/msa/web-ui/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..db6306e4ac --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.user} >/dev/null || groupadd -r ${pkg.user} +getent passwd ${pkg.user} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.user} -M -r ${pkg.user} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/msa/web-ui/src/main/scripts/control/rpm/prerm b/msa/web-ui/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/msa/web-ui/src/main/scripts/control/tb-web-ui.service b/msa/web-ui/src/main/scripts/control/tb-web-ui.service new file mode 100644 index 0000000000..f542dd0f92 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/tb-web-ui.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.user} +ExecStart=${pkg.installFolder}/init/${pkg.name} +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/msa/web-ui/src/main/scripts/init/tb-web-ui b/msa/web-ui/src/main/scripts/init/tb-web-ui new file mode 100644 index 0000000000..d0d3f95fc9 --- /dev/null +++ b/msa/web-ui/src/main/scripts/init/tb-web-ui @@ -0,0 +1,233 @@ +#!/bin/bash +# + + +### BEGIN INIT INFO +# Provides: tb-web-ui +# Required-Start: $remote_fs $syslog $network +# Required-Stop: $remote_fs $syslog $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: ${project.name} +# Description: ${project.description} +# chkconfig: 2345 99 01 +### END INIT INFO + +[[ -n "$DEBUG" ]] && set -x + +# Initialize variables that cannot be provided by a .conf file +WORKING_DIR="$(pwd)" +# shellcheck disable=SC2153 + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf + +# Follow symlinks to find the real script and detect init.d script +cd "$(dirname "$0")" || exit 1 +[[ -z "$initfile" ]] && initfile=$(pwd)/$(basename "$0") +while [[ -L "$initfile" ]]; do + [[ "$initfile" =~ init\.d ]] && init_script=$(basename "$initfile") + initfile=$(readlink "$initfile") + cd "$(dirname "$initfile")" || exit 1 + initfile=$(pwd)/$(basename "$initfile") +done +initfolder="$( (cd "$(dirname "initfile")" && pwd -P) )" +cd "$WORKING_DIR" || exit 1 + +# Initialize CONF_FOLDER location +[[ -z "$CONF_FOLDER" ]] && CONF_FOLDER="${pkg.installFolder}/conf" + +# shellcheck source=/dev/null +[[ -r "${CONF_FOLDER}/${configfile}" ]] && source "${CONF_FOLDER}/${configfile}" + +# Initialize PID/LOG locations if they weren't provided by the config file +[[ -z "$PID_FOLDER" ]] && PID_FOLDER="/var/run" +[[ -z "$LOG_FOLDER" ]] && LOG_FOLDER="${pkg.unixLogFolder}" +! [[ "$PID_FOLDER" == /* ]] && PID_FOLDER="$(dirname "$mainfile")"/"$PID_FOLDER" +! [[ "$LOG_FOLDER" == /* ]] && LOG_FOLDER="$(dirname "$mainfile")"/"$LOG_FOLDER" +! [[ -x "$PID_FOLDER" ]] && PID_FOLDER="/tmp" +! [[ -x "$LOG_FOLDER" ]] && LOG_FOLDER="/tmp" + +# Set up defaults +[[ -z "$MODE" ]] && MODE="auto" # modes are "auto", "service" or "run" +[[ -z "$USE_START_STOP_DAEMON" ]] && USE_START_STOP_DAEMON="true" + +# Create an identity for log/pid files +if [[ -z "$identity" ]]; then + if [[ -n "$init_script" ]]; then + identity="${init_script}" + else + identity=$(basename "${initfile%.*}")_${initfolder//\//} + fi +fi + +# Initialize log file name if not provided by the config file +[[ -z "$LOG_FILENAME" ]] && LOG_FILENAME="${identity}.log" + +# ANSI Colors +echoRed() { echo $'\e[0;31m'"$1"$'\e[0m'; } +echoGreen() { echo $'\e[0;32m'"$1"$'\e[0m'; } +echoYellow() { echo $'\e[0;33m'"$1"$'\e[0m'; } + +# Utility functions +checkPermissions() { + touch "$pid_file" &> /dev/null || { echoRed "Operation not permitted (cannot access pid file)"; return 4; } + touch "$log_file" &> /dev/null || { echoRed "Operation not permitted (cannot access log file)"; return 4; } +} + +isRunning() { + ps -p "$1" &> /dev/null +} + +await_file() { + end=$(date +%s) + let "end+=10" + while [[ ! -s "$1" ]] + do + now=$(date +%s) + if [[ $now -ge $end ]]; then + break + fi + sleep 1 + done +} + +# Determine the script mode +action="run" +if [[ "$MODE" == "auto" && -n "$init_script" ]] || [[ "$MODE" == "service" ]]; then + action="$1" + shift +fi + +# Build the pid and log filenames +if [[ "$identity" == "$init_script" ]] || [[ "$identity" == "$APP_NAME" ]]; then + PID_FOLDER="$PID_FOLDER/${identity}" + pid_subfolder=$PID_FOLDER +fi +pid_file="$PID_FOLDER/${identity}.pid" +log_file="$LOG_FOLDER/$LOG_FILENAME" + +# Determine the user to run as if we are root +# shellcheck disable=SC2012 +[[ $(id -u) == "0" ]] && run_user=$(ls -ld "$mainfile" | awk '{print $3}') + +arguments=($RUN_ARGS "$@") + +# Action functions +start() { + if [[ -f "$pid_file" ]]; then + pid=$(cat "$pid_file") + isRunning "$pid" && { echoYellow "Already running [$pid]"; return 0; } + fi + do_start "$@" +} + +do_start() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + mkdir -p "$PID_FOLDER" &> /dev/null + if [[ -n "$run_user" ]]; then + checkPermissions || return $? + if [[ -z "$pid_subfolder" ]]; then + chown "$run_user" "$pid_subfolder" + fi + chown "$run_user" "$pid_file" + chown "$run_user" "$log_file" + if [ $USE_START_STOP_DAEMON = true ] && type start-stop-daemon > /dev/null 2>&1; then + start-stop-daemon --start --quiet \ + --chuid "$run_user" \ + --name "$identity" \ + --make-pidfile --pidfile "$pid_file" \ + --background --no-close \ + --startas "$mainfile" \ + --chdir "$working_dir" \ + -- "${arguments[@]}" \ + >> "$log_file" 2>&1 + await_file "$pid_file" + else + su -s /bin/sh -c "$mainfile $(printf "\"%s\" " "${arguments[@]}") >> \"$log_file\" 2>&1 & echo \$!" "$run_user" > "$pid_file" + fi + pid=$(cat "$pid_file") + else + checkPermissions || return $? + "$mainfile" "${arguments[@]}" >> "$log_file" 2>&1 & + pid=$! + disown $pid + echo "$pid" > "$pid_file" + fi + [[ -z $pid ]] && { echoRed "Failed to start"; return 1; } + echoGreen "Started [$pid]" +} + +stop() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoYellow "Not running (pidfile not found)"; return 0; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoYellow "Not running (process ${pid}). Removing stale pid file."; rm -f "$pid_file"; return 0; } + do_stop "$pid" "$pid_file" +} + +do_stop() { + kill -2 "$1" &> /dev/null || { echoRed "Unable to kill process $1"; return 1; } + for i in $(seq 1 60); do + isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; } + [[ $i -eq 30 ]] && kill -9 "$1" &> /dev/null + sleep 1 + done + echoRed "Unable to kill process $1"; + return 1; +} + +restart() { + stop && start +} + +orce_reload() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)"; return 7; } + pid=$(cat "$pid_file") + rm -f "$pid_file" + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 7; } + do_stop "$pid" "$pid_file" + do_start +} + +status() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f "$pid_file" ]] || { echoRed "Not running"; return 3; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 1; } + echoGreen "Running [$pid]" + return 0 +} + +run() { + pushd "$(dirname "$mainfile")" > /dev/null + "$mainfile" "${arguments[@]}" + result=$? + popd > /dev/null + return "$result" +} + +# Call the appropriate action function +case "$action" in +start) + start "$@"; exit $?;; +stop) + stop "$@"; exit $?;; +restart) + restart "$@"; exit $?;; +force-reload) + force_reload "$@"; exit $?;; +status) + status "$@"; exit $?;; +run) + run "$@"; exit $?;; +*) + echo "Usage: $0 {start|stop|restart|force-reload|status|run}"; exit 1; +esac + +exit 0 diff --git a/msa/web-ui/src/main/scripts/windows/install.bat b/msa/web-ui/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..4da55425bb --- /dev/null +++ b/msa/web-ui/src/main/scripts/windows/install.bat @@ -0,0 +1,31 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Installing ${pkg.name} ... + +SET BASE=%~dp0 + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:END diff --git a/msa/web-ui/src/main/scripts/windows/service.xml b/msa/web-ui/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..e512aad163 --- /dev/null +++ b/msa/web-ui/src/main/scripts/windows/service.xml @@ -0,0 +1,30 @@ + + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\bin + ${pkg.winWrapperLogFolder} + rotate + + + + + %BASE%\bin\${pkg.name}.exe + diff --git a/msa/web-ui/src/main/scripts/windows/uninstall.bat b/msa/web-ui/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..7061d2a052 --- /dev/null +++ b/msa/web-ui/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,25 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/ui/package.json b/ui/package.json index e730c39302..417948e1c9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -2,7 +2,7 @@ "name": "thingsboard", "private": true, "version": "2.2.0", - "description": "Thingsboard UI", + "description": "ThingsBoard UI", "licenses": [ { "type": "Apache-2.0", From ca62616cccc77feccd69eb7c3526437b112dd11d Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 11:26:36 +0300 Subject: [PATCH 147/307] Fixes for cases when asset/device deleted but has entity view assigned --- .../server/dao/asset/BaseAssetService.java | 17 +++++++++++++- .../server/dao/device/DeviceServiceImpl.java | 22 +++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 6c8b60914e..f35b89069c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -30,6 +30,7 @@ import org.springframework.util.StringUtils; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -76,6 +78,9 @@ public class BaseAssetService extends AbstractEntityService implements AssetServ @Autowired private CustomerDao customerDao; + @Autowired + private EntityViewService entityViewService; + @Autowired private CacheManager cacheManager; @@ -130,11 +135,21 @@ public class BaseAssetService extends AbstractEntityService implements AssetServ validateId(assetId, INCORRECT_ASSET_ID + assetId); deleteEntityRelations(assetId); - Cache cache = cacheManager.getCache(ASSET_CACHE); Asset asset = assetDao.findById(assetId.getId()); + try { + List entityViews = entityViewService.findEntityViewsByTenantIdAndEntityIdAsync(asset.getTenantId(), assetId).get(); + if (entityViews != null && !entityViews.isEmpty()) { + throw new DataValidationException("Can't delete asset that is assigned to entity views!"); + } + } catch (Exception e) { + log.error("Exception while finding entity views for assetId [{}]", assetId, e); + throw new RuntimeException("Exception while finding entity views for assetId [" + assetId + "]", e); + } + List list = new ArrayList<>(); list.add(asset.getTenantId()); list.add(asset.getName()); + Cache cache = cacheManager.getCache(ASSET_CACHE); cache.evict(list); assetDao.removeById(assetId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 3930e3a94f..44af6e0363 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; @@ -45,6 +46,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -86,6 +88,9 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe @Autowired private DeviceCredentialsService deviceCredentialsService; + @Autowired + private EntityViewService entityViewService; + @Autowired private CacheManager cacheManager; @@ -145,18 +150,31 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe @Override public void deleteDevice(DeviceId deviceId) { log.trace("Executing deleteDevice [{}]", deviceId); - Cache cache = cacheManager.getCache(DEVICE_CACHE); validateId(deviceId, INCORRECT_DEVICE_ID + deviceId); + + Device device = deviceDao.findById(deviceId.getId()); + try { + List entityViews = entityViewService.findEntityViewsByTenantIdAndEntityIdAsync(device.getTenantId(), deviceId).get(); + if (entityViews != null && !entityViews.isEmpty()) { + throw new DataValidationException("Can't delete device that is assigned to entity views!"); + } + } catch (Exception e) { + log.error("Exception while finding entity views for deviceId [{}]", deviceId, e); + throw new RuntimeException("Exception while finding entity views for deviceId [" + deviceId + "]", e); + } + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(deviceId); if (deviceCredentials != null) { deviceCredentialsService.deleteDeviceCredentials(deviceCredentials); } deleteEntityRelations(deviceId); - Device device = deviceDao.findById(deviceId.getId()); + List list = new ArrayList<>(); list.add(device.getTenantId()); list.add(device.getName()); + Cache cache = cacheManager.getCache(DEVICE_CACHE); cache.evict(list); + deviceDao.removeById(deviceId.getId()); } From 78b40b097a8fc1ab93eef3007424b3e1e3ca4efa Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 12:42:28 +0300 Subject: [PATCH 148/307] Fixes for cases when asset/device deleted but has entity view assigned --- dao/src/test/resources/application-test.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index 20cf91c3dd..a61c285136 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -24,6 +24,9 @@ caffeine.specs.devices.maxSize=100000 caffeine.specs.assets.timeToLiveInMinutes=1440 caffeine.specs.assets.maxSize=100000 +caffeine.specs.entityViews.timeToLiveInMinutes=1440 +caffeine.specs.entityViews.maxSize=100000 + caching.specs.devices.timeToLiveInMinutes=1440 caching.specs.devices.maxSize=100000 From 321d4f1cb2680a2335aa24b0b2b4353e998ecbf8 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 13:08:50 +0300 Subject: [PATCH 149/307] Fixes for cases when asset/device deleted but has entity view assigned --- .../server/dao/entityview/CassandraEntityViewDao.java | 9 +++++---- .../thingsboard/server/dao/model/ModelConstants.java | 3 ++- dao/src/main/resources/cassandra/schema-entities.cql | 11 +++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index 395f9022b0..a2e049bfe8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -40,7 +40,8 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; import static com.datastax.driver.core.querybuilder.QueryBuilder.select; import static org.thingsboard.server.dao.model.ModelConstants.CUSTOMER_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_ID_COLUMN; -import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_NAME; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_NAME_PROPERTY; @@ -101,7 +102,7 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao entityViewEntities = findPageWithTextSearch( - ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT, + ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF, Arrays.asList(eq(CUSTOMER_ID_PROPERTY, customerId), eq(TENANT_ID_PROPERTY, tenantId)), pageLink); log.trace("Found find entity views [{}] by tenantId [{}], customerId [{}] and pageLink [{}]", @@ -112,9 +113,9 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { log.debug("Try to find entity views by tenantId [{}] and entityId [{}]", tenantId, entityId); - Select.Where query = select().from(getColumnFamilyName()).where(); + Select.Where query = select().from(ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF).where(); query.and(eq(TENANT_ID_PROPERTY, tenantId)); - query.and(eq(ENTITY_ID_COLUMN, entityId)); + query.and(eq(ENTITY_ID_COLUMN, entityId));dr return findListByStatementAsync(query); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 9890ff62d3..a487ede93a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -150,7 +150,8 @@ public class ModelConstants { public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; - public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_SEARCH_TEXT = "entity_view_by_tenant_and_customer"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF = "entity_view_by_tenant_and_customer"; + public static final String ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF = "entity_view_by_tenant_and_entity_id"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; public static final String ENTITY_VIEW_END_TS_PROPERTY = "end_ts"; diff --git a/dao/src/main/resources/cassandra/schema-entities.cql b/dao/src/main/resources/cassandra/schema-entities.cql index bd978f7960..7ccd41f03a 100644 --- a/dao/src/main/resources/cassandra/schema-entities.cql +++ b/dao/src/main/resources/cassandra/schema-entities.cql @@ -671,3 +671,14 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_cus AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_id AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id) + WITH CLUSTERING ORDER BY (entity_id DESC, customer_id DESC, search_text ASC, id DESC); \ No newline at end of file From b1f9ffb3b8ee1307ace49fa2919922f5c47a7b1a Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 13:12:23 +0300 Subject: [PATCH 150/307] Fixes for cases when asset/device deleted but has entity view assigned --- .../src/main/data/upgrade/2.1.1/schema_update.cql | 11 +++++++++++ .../server/dao/entityview/CassandraEntityViewDao.java | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index a633634f60..46a47d551f 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -67,3 +67,14 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_cus AND id IS NOT NULL PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_id AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id) + WITH CLUSTERING ORDER BY (entity_id DESC, customer_id DESC, search_text ASC, id DESC); \ No newline at end of file diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index a2e049bfe8..a03dd89815 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -115,7 +115,7 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao Date: Wed, 3 Oct 2018 16:38:18 +0300 Subject: [PATCH 151/307] Add tb-node docker image. Improve TB docker services. --- application/pom.xml | 21 +++ .../src/main/resources/thingsboard.yml | 4 +- .../server/kafka/TBKafkaAdmin.java | 11 +- .../server/kafka/TBKafkaProducerTemplate.java | 6 +- .../server/kafka/TbKafkaRequestTemplate.java | 5 +- msa/docker/.env | 7 + msa/docker/.gitignore | 4 + msa/docker/docker-compose.yml | 78 ++++++++-- msa/docker/docker-install-tb.sh | 42 ++++++ msa/docker/docker-start-services.sh | 18 +++ msa/docker/docker-stop-services.sh | 18 +++ msa/docker/docker-upgrade-tb.sh | 39 +++++ msa/docker/haproxy/certs.d/.gitignore | 4 + msa/docker/haproxy/config/haproxy.cfg | 61 ++++++++ msa/docker/haproxy/letsencrypt/.gitignore | 4 + msa/docker/tb-node.env | 23 +++ msa/docker/tb-node/conf/logback.xml | 51 +++++++ msa/docker/tb-node/conf/thingsboard.conf | 24 ++++ msa/docker/tb-node/db/.gitignore | 4 + msa/docker/tb-node/log/.gitignore | 4 + msa/docker/tb-web-ui.env | 2 +- msa/js-executor/pom.xml | 5 +- msa/pom.xml | 15 ++ msa/tb-node/docker/Dockerfile | 28 ++++ msa/tb-node/docker/start-tb-node.sh | 71 +++++++++ msa/tb-node/pom.xml | 136 ++++++++++++++++++ msa/web-ui/pom.xml | 5 +- pom.xml | 2 + 28 files changed, 663 insertions(+), 29 deletions(-) create mode 100644 msa/docker/.env create mode 100644 msa/docker/.gitignore create mode 100755 msa/docker/docker-install-tb.sh create mode 100755 msa/docker/docker-start-services.sh create mode 100755 msa/docker/docker-stop-services.sh create mode 100755 msa/docker/docker-upgrade-tb.sh create mode 100644 msa/docker/haproxy/certs.d/.gitignore create mode 100644 msa/docker/haproxy/config/haproxy.cfg create mode 100644 msa/docker/haproxy/letsencrypt/.gitignore create mode 100644 msa/docker/tb-node.env create mode 100644 msa/docker/tb-node/conf/logback.xml create mode 100644 msa/docker/tb-node/conf/thingsboard.conf create mode 100644 msa/docker/tb-node/db/.gitignore create mode 100644 msa/docker/tb-node/log/.gitignore create mode 100644 msa/tb-node/docker/Dockerfile create mode 100755 msa/tb-node/docker/start-tb-node.sh create mode 100644 msa/tb-node/pom.xml diff --git a/application/pom.xml b/application/pom.xml index 67dd91d757..c4766f2ba8 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -576,6 +576,27 @@ + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + org.xolstice.maven.plugins protobuf-maven-plugin diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index a6d42eb71c..052490939a 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -407,7 +407,7 @@ state: kafka: enabled: true - bootstrap.servers: "${TB_KAFKA_SERVERS:192.168.2.157:9092}" + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" acks: "${TB_KAFKA_ACKS:all}" retries: "${TB_KAFKA_RETRIES:1}" batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" @@ -415,7 +415,7 @@ kafka: buffer.memory: "${TB_BUFFER_MEMORY:33554432}" js: - evaluator: "${JS_EVALUATOR:remote}" # local/remote + evaluator: "${JS_EVALUATOR:local}" # local/remote # Built-in JVM JavaScript environment properties local: # Use Sandboxed (secured) JVM JavaScript environment diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java index 7ea40cd17c..f6bafef45d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java @@ -32,15 +32,8 @@ public class TBKafkaAdmin { AdminClient client; - public TBKafkaAdmin() { - Properties props = new Properties(); - props.put("bootstrap.servers", "localhost:9092"); - props.put("group.id", "test"); - props.put("enable.auto.commit", "true"); - props.put("auto.commit.interval.ms", "1000"); - props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); - props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); - client = AdminClient.create(props); + public TBKafkaAdmin(TbKafkaSettings settings) { + client = AdminClient.create(settings.toProps()); } public CreateTopicsResult createTopic(NewTopic topic){ diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index 1f0d3ad20e..7e24ad00a6 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -52,12 +52,16 @@ public class TBKafkaProducerTemplate { @Getter private final String defaultTopic; + @Getter + private final TbKafkaSettings settings; + @Builder private TBKafkaProducerTemplate(TbKafkaSettings settings, TbKafkaEncoder encoder, TbKafkaEnricher enricher, TbKafkaPartitioner partitioner, String defaultTopic) { Properties props = settings.toProps(); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer"); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArraySerializer"); + this.settings = settings; this.producer = new KafkaProducer<>(props); this.encoder = encoder; this.enricher = enricher; @@ -67,7 +71,7 @@ public class TBKafkaProducerTemplate { public void init() { try { - TBKafkaAdmin admin = new TBKafkaAdmin(); + TBKafkaAdmin admin = new TBKafkaAdmin(this.settings); CreateTopicsResult result = admin.createTopic(new NewTopic(defaultTopic, 100, (short) 1)); result.all().get(); } catch (Exception e) { diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index 17b44597c7..8a0f5293d9 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -55,7 +55,8 @@ public class TbKafkaRequestTemplate { private volatile boolean stopped = false; @Builder - public TbKafkaRequestTemplate(TBKafkaProducerTemplate requestTemplate, TBKafkaConsumerTemplate responseTemplate, + public TbKafkaRequestTemplate(TBKafkaProducerTemplate requestTemplate, + TBKafkaConsumerTemplate responseTemplate, long maxRequestTimeout, long maxPendingRequests, long pollInterval, @@ -77,7 +78,7 @@ public class TbKafkaRequestTemplate { public void init() { try { - TBKafkaAdmin admin = new TBKafkaAdmin(); + TBKafkaAdmin admin = new TBKafkaAdmin(this.requestTemplate.getSettings()); CreateTopicsResult result = admin.createTopic(new NewTopic(responseTemplate.getTopic(), 1, (short) 1)); result.all().get(); } catch (Exception e) { diff --git a/msa/docker/.env b/msa/docker/.env new file mode 100644 index 0000000000..b3c8ff66bb --- /dev/null +++ b/msa/docker/.env @@ -0,0 +1,7 @@ + +DOCKER_REPO=local-maven-build +TB_VERSION=2.2.0-SNAPSHOT + +KAFKA_TOPICS=js.eval.requests:100:1 +HTTP_PORT=80 +HTTPS_PORT=80 \ No newline at end of file diff --git a/msa/docker/.gitignore b/msa/docker/.gitignore new file mode 100644 index 0000000000..473a5a6ead --- /dev/null +++ b/msa/docker/.gitignore @@ -0,0 +1,4 @@ +haproxy/certs.d/** +haproxy/letsencrypt/** +tb-node/log/** +!.env diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 727936c2f6..876646f16d 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -19,17 +19,19 @@ version: '2' services: zookeeper: + restart: always image: "wurstmeister/zookeeper" ports: - "2181" kafka: + restart: always image: "wurstmeister/kafka" ports: - "9092:9092" environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENERS: INSIDE://:9093,OUTSIDE://:9092 - KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://${EXTERNAL_HOSTNAME}:9092 + KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://kafka:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE KAFKA_CREATE_TOPICS: "${KAFKA_TOPICS}" @@ -37,21 +39,81 @@ services: depends_on: - zookeeper tb-js-executor: - image: "local-maven-build/tb-js-executor:latest" + restart: always + image: "${DOCKER_REPO}/tb-js-executor:${TB_VERSION}" environment: TB_KAFKA_SERVERS: kafka:9092 env_file: - tb-js-executor.env depends_on: - kafka - tb-web-ui: - image: "local-maven-build/tb-web-ui:latest" + tb: + restart: always + image: "${DOCKER_REPO}/tb-node:${TB_VERSION}" ports: - - "8090:8090" + - "8080" + - "1883:1883" + - "5683:5683/udp" + env_file: + - tb-node.env + environment: + ZOOKEEPER_URL: zk:2181 + TB_KAFKA_SERVERS: kafka:9092 + JS_EVALUATOR: remote + volumes: + - ./tb-node/db:/usr/share/thingsboard/data/db" + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - kafka + tb-web-ui1: + restart: always + image: "${DOCKER_REPO}/tb-web-ui:${TB_VERSION}" + ports: + - "8080" + environment: + TB_HOST: tb + TB_PORT: 8080 + env_file: + - tb-web-ui.env + tb-web-ui2: + restart: always + image: "${DOCKER_REPO}/tb-web-ui:${TB_VERSION}" + ports: + - "8080" environment: - HTTP_BIND_ADDRESS: 0.0.0.0 - HTTP_BIND_PORT: 8090 - TB_HOST: ${EXTERNAL_HOSTNAME} + TB_HOST: tb TB_PORT: 8080 env_file: - tb-web-ui.env + tb-web-ui3: + restart: always + image: "${DOCKER_REPO}/tb-web-ui:${TB_VERSION}" + ports: + - "8080" + environment: + TB_HOST: tb + TB_PORT: 8080 + env_file: + - tb-web-ui.env + web: + restart: always + container_name: haproxy-certbot + image: nmarus/haproxy-certbot + volumes: + - ./haproxy/config:/config + - ./haproxy/letsencrypt:/etc/letsencrypt + - ./haproxy/certs.d:/usr/local/etc/haproxy/certs.d + ports: + - "80:80" + - "443:443" + - "9999:9999" + cap_add: + - NET_ADMIN + environment: + HTTP_PORT: ${HTTP_PORT} + HTTPS_PORT: ${HTTPS_PORT} + links: + - tb-web-ui1 + - tb-web-ui2 + - tb-web-ui3 diff --git a/msa/docker/docker-install-tb.sh b/msa/docker/docker-install-tb.sh new file mode 100755 index 0000000000..d17ba305d1 --- /dev/null +++ b/msa/docker/docker-install-tb.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + --loadDemo) + LOAD_DEMO=true + shift # past argument + ;; + *) + # unknown option + ;; +esac +shift # past argument or value +done + +if [ "$LOAD_DEMO" == "true" ]; then + loadDemo=true +else + loadDemo=false +fi + +docker-compose run --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb + + diff --git a/msa/docker/docker-start-services.sh b/msa/docker/docker-start-services.sh new file mode 100755 index 0000000000..3b07efb4a2 --- /dev/null +++ b/msa/docker/docker-start-services.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +docker-compose up -d diff --git a/msa/docker/docker-stop-services.sh b/msa/docker/docker-stop-services.sh new file mode 100755 index 0000000000..a388bf1391 --- /dev/null +++ b/msa/docker/docker-stop-services.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +docker-compose down diff --git a/msa/docker/docker-upgrade-tb.sh b/msa/docker/docker-upgrade-tb.sh new file mode 100755 index 0000000000..69cb4faa9e --- /dev/null +++ b/msa/docker/docker-upgrade-tb.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +for i in "$@" +do +case $i in + --fromVersion=*) + FROM_VERSION="${i#*=}" + shift + ;; + *) + # unknown option + ;; +esac +done + +if [[ -z "${FROM_VERSION// }" ]]; then + echo "--fromVersion parameter is invalid or unspecified!" + echo "Usage: docker-upgrade-tb.sh --fromVersion={VERSION}" + exit 1 +else + fromVersion="${FROM_VERSION// }" +fi + +docker-compose run --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb diff --git a/msa/docker/haproxy/certs.d/.gitignore b/msa/docker/haproxy/certs.d/.gitignore new file mode 100644 index 0000000000..86d0cb2726 --- /dev/null +++ b/msa/docker/haproxy/certs.d/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/msa/docker/haproxy/config/haproxy.cfg b/msa/docker/haproxy/config/haproxy.cfg new file mode 100644 index 0000000000..67e3f563a7 --- /dev/null +++ b/msa/docker/haproxy/config/haproxy.cfg @@ -0,0 +1,61 @@ +#HA Proxy Config +global + maxconn 4096 + + log 127.0.0.1 local0 + log 127.0.0.1 local1 notice + + ca-base /etc/ssl/certs + crt-base /etc/ssl/private + + ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS + ssl-default-bind-options no-sslv3 + +defaults + + option forwardfor + + log global + + mode http + + timeout connect 5000ms + timeout client 50000ms + timeout server 50000ms + +listen stats + bind *:9999 + stats enable + stats hide-version + stats uri /stats + stats auth admin:admin@123 + +frontend http-in + bind *:${HTTP_PORT} + + reqadd X-Forwarded-Proto:\ http + + acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ + redirect scheme https if !letsencrypt_http_acl + use_backend letsencrypt_http if letsencrypt_http_acl + + default_backend tb-web-backend + +frontend https_in + bind *:${HTTPS_PORT} ssl crt /usr/local/etc/haproxy/default.pem crt /usr/local/etc/haproxy/certs.d ciphers ECDHE-RSA-AES256-SHA:RC4-SHA:RC4:HIGH:!MD5:!aNULL:!EDH:!AESGCM + + reqadd X-Forwarded-Proto:\ https + + default_backend tb-web-backend + +backend letsencrypt_http + server letsencrypt_http_srv 127.0.0.1:8080 + +backend tb-web-backend + balance leastconn + option tcp-check + option log-health-checks + server tbWeb1 tb-web-ui1:8080 check + server tbWeb2 tb-web-ui2:8080 check + server tbWeb3 tb-web-ui3:8080 check + http-request set-header X-Forwarded-Port %[dst_port] diff --git a/msa/docker/haproxy/letsencrypt/.gitignore b/msa/docker/haproxy/letsencrypt/.gitignore new file mode 100644 index 0000000000..86d0cb2726 --- /dev/null +++ b/msa/docker/haproxy/letsencrypt/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/msa/docker/tb-node.env b/msa/docker/tb-node.env new file mode 100644 index 0000000000..8ba24620c2 --- /dev/null +++ b/msa/docker/tb-node.env @@ -0,0 +1,23 @@ +# ThingsBoard server configuration +MQTT_BIND_ADDRESS=0.0.0.0 +MQTT_BIND_PORT=1883 +COAP_BIND_ADDRESS=0.0.0.0 +COAP_BIND_PORT=5683 + +# type of database to use: sql[DEFAULT] or cassandra +DATABASE_TYPE=sql +SQL_DATA_FOLDER=/usr/share/thingsboard/data/db + +# cassandra db config +CASSANDRA_URL=cassandra:9042 +CASSANDRA_HOST=cassandra +CASSANDRA_PORT=9042 + +# postgres db config +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +# SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.PostgreSQLDialect +# SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver +# SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard +# SPRING_DATASOURCE_USERNAME=postgres +# SPRING_DATASOURCE_PASSWORD=postgres diff --git a/msa/docker/tb-node/conf/logback.xml b/msa/docker/tb-node/conf/logback.xml new file mode 100644 index 0000000000..87f9bf4c06 --- /dev/null +++ b/msa/docker/tb-node/conf/logback.xml @@ -0,0 +1,51 @@ + + + + + + + /var/log/thingsboard/thingsboard.log + + /var/log/thingsboard/thingsboard.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + \ No newline at end of file diff --git a/msa/docker/tb-node/conf/thingsboard.conf b/msa/docker/tb-node/conf/thingsboard.conf new file mode 100644 index 0000000000..1fc5ac4835 --- /dev/null +++ b/msa/docker/tb-node/conf/thingsboard.conf @@ -0,0 +1,24 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Dplatform=deb -Dinstall.data_dir=/usr/share/thingsboard/data" +export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/thingsboard/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly" +export LOG_FILENAME=thingsboard.out +export LOADER_PATH=/usr/share/thingsboard/conf,/usr/share/thingsboard/extensions diff --git a/msa/docker/tb-node/db/.gitignore b/msa/docker/tb-node/db/.gitignore new file mode 100644 index 0000000000..86d0cb2726 --- /dev/null +++ b/msa/docker/tb-node/db/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/msa/docker/tb-node/log/.gitignore b/msa/docker/tb-node/log/.gitignore new file mode 100644 index 0000000000..86d0cb2726 --- /dev/null +++ b/msa/docker/tb-node/log/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/msa/docker/tb-web-ui.env b/msa/docker/tb-web-ui.env index 8d6157bfc8..e78c80c86e 100644 --- a/msa/docker/tb-web-ui.env +++ b/msa/docker/tb-web-ui.env @@ -1,6 +1,6 @@ HTTP_BIND_ADDRESS=0.0.0.0 -HTTP_BIND_PORT=8090 +HTTP_BIND_PORT=8080 TB_HOST=localhost TB_PORT=8080 LOGGER_LEVEL=debug diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 51416f5aa3..84792f18b8 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -40,7 +40,6 @@ /usr/share/${pkg.name} ${project.build.directory}/package/linux ${project.build.directory}/package/windows - true @@ -280,7 +279,6 @@ com.spotify dockerfile-maven-plugin - 1.4.4 build-docker-image @@ -292,7 +290,8 @@ ${dockerfile.skip} - local-maven-build/${pkg.name} + ${docker.repo}/${pkg.name} + ${project.version} true false ${project.build.directory} diff --git a/msa/pom.xml b/msa/pom.xml index 3241d0cf51..21ddb4dafe 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -32,11 +32,26 @@ ${basedir}/.. + local-maven-build + true js-executor web-ui + tb-node + + + + + com.spotify + dockerfile-maven-plugin + 1.4.5 + + + + + diff --git a/msa/tb-node/docker/Dockerfile b/msa/tb-node/docker/Dockerfile new file mode 100644 index 0000000000..c86d020d20 --- /dev/null +++ b/msa/tb-node/docker/Dockerfile @@ -0,0 +1,28 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jre + +COPY start-tb-node.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-node.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +CMD ["start-tb-node.sh"] diff --git a/msa/tb-node/docker/start-tb-node.sh b/msa/tb-node/docker/start-tb-node.sh new file mode 100755 index 0000000000..7f7e8696e2 --- /dev/null +++ b/msa/tb-node/docker/start-tb-node.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="/config" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +run_user=${pkg.name} + +source "${CONF_FOLDER}/${configfile}" + +export LOADER_PATH=/config,${LOADER_PATH} + +if [ "$INSTALL_TB" == "true" ]; then + + if [ "$LOAD_DEMO" == "true" ]; then + loadDemo=true + else + loadDemo=false + fi + + echo "Starting ThingsBoard installation ..." + + exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dinstall.load_demo=${loadDemo} \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=false \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +elif [ "$UPGRADE_TB" == "true" ]; then + + echo "Starting ThingsBoard upgrade ..." + + if [[ -z "${FROM_VERSION// }" ]]; then + echo "FROM_VERSION variable is invalid or unspecified!" + exit 1 + else + fromVersion="${FROM_VERSION// }" + fi + + exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=true \ + -Dinstall.upgrade.from_version=${fromVersion} \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +else + + echo "Starting '${project.name}' ..." + + exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardServerApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=/config/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +fi diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml new file mode 100644 index 0000000000..436d88b994 --- /dev/null +++ b/msa/tb-node/pom.xml @@ -0,0 +1,136 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + tb-node + pom + + ThingsBoard Node Microservice + https://thingsboard.io + ThingsBoard Node Microservice + + + UTF-8 + ${basedir}/../.. + thingsboard + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard + application + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + + + ${dockerfile.skip} + ${docker.repo}/tb-node + ${project.version} + true + false + ${project.build.directory} + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 54c3bb45f5..32a076d183 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -40,7 +40,6 @@ /usr/share/${pkg.name} ${project.build.directory}/package/linux ${project.build.directory}/package/windows - true @@ -304,7 +303,6 @@ com.spotify dockerfile-maven-plugin - 1.4.4 build-docker-image @@ -316,7 +314,8 @@ ${dockerfile.skip} - local-maven-build/${pkg.name} + ${docker.repo}/${pkg.name} + ${project.version} true false ${project.build.directory} diff --git a/pom.xml b/pom.xml index 327838c5a0..d6db123ebe 100755 --- a/pom.xml +++ b/pom.xml @@ -284,6 +284,8 @@ src/main/scripts/windows/** src/main/resources/public/static/rulenode/** **/*.proto.js + docker/haproxy/** + docker/tb-node/** JAVADOC_STYLE From aa795578415a62b0ab129e57df0bdd09ace8e430 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 17:43:29 +0300 Subject: [PATCH 152/307] Fixes for cases when asset/device deleted but has entity view assigned --- application/src/main/data/upgrade/2.1.1/schema_update.cql | 3 ++- .../thingsboard/server/dao/customer/CustomerServiceImpl.java | 2 +- .../org/thingsboard/server/dao/device/DeviceServiceImpl.java | 4 ++-- .../org/thingsboard/server/dao/tenant/TenantServiceImpl.java | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index 46a47d551f..c477e8a02a 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -17,9 +17,10 @@ DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_name; DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_search_text; DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_customer; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_entity_id; DROP TABLE IF EXISTS thingsboard.entity_views; - +ControllerSqlTestSuite CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( id timeuuid, entity_id timeuuid, diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java index 9d96f3a97a..a9b8bfe181 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java @@ -115,9 +115,9 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom throw new IncorrectParameterException("Unable to delete non-existent customer."); } dashboardService.unassignCustomerDashboards(customerId); + entityViewService.unassignCustomerEntityViews(customer.getTenantId(), customerId); assetService.unassignCustomerAssets(customer.getTenantId(), customerId); deviceService.unassignCustomerDevices(customer.getTenantId(), customerId); - entityViewService.unassignCustomerEntityViews(customer.getTenantId(), customerId); userService.deleteCustomerUsers(customer.getTenantId(), customerId); deleteEntityRelations(customerId); customerDao.removeById(customerId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 44af6e0363..6f9ea62244 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -155,8 +155,8 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe Device device = deviceDao.findById(deviceId.getId()); try { List entityViews = entityViewService.findEntityViewsByTenantIdAndEntityIdAsync(device.getTenantId(), deviceId).get(); - if (entityViews != null && !entityViews.isEmpty()) { - throw new DataValidationException("Can't delete device that is assigned to entity views!"); + if (entityViews != null && !entityViews.isEmpty()) { + throw new DataValidationException("Can't delete device that is assigned to entity views!"); } } catch (Exception e) { log.error("Exception while finding entity views for deviceId [{}]", deviceId, e); diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index a94e715616..189c7139ac 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -105,9 +105,9 @@ public class TenantServiceImpl extends AbstractEntityService implements TenantSe customerService.deleteCustomersByTenantId(tenantId); widgetsBundleService.deleteWidgetsBundlesByTenantId(tenantId); dashboardService.deleteDashboardsByTenantId(tenantId); + entityViewService.deleteEntityViewsByTenantId(tenantId); assetService.deleteAssetsByTenantId(tenantId); deviceService.deleteDevicesByTenantId(tenantId); - entityViewService.deleteEntityViewsByTenantId(tenantId); userService.deleteTenantAdmins(tenantId); ruleChainService.deleteRuleChainsByTenantId(tenantId); tenantDao.removeById(tenantId.getId()); From ee3d75bb4f4c96b273ea8dea41e23a95bb59e70a Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 3 Oct 2018 18:58:17 +0300 Subject: [PATCH 153/307] MSA docker improvements. --- msa/docker/.env | 2 -- msa/docker/.gitignore | 1 + msa/docker/check-dirs.sh | 26 +++++++++++++++++++++++ msa/docker/docker-compose.yml | 17 +++------------ msa/docker/docker-install-tb.sh | 4 +++- msa/docker/docker-start-services.sh | 4 +++- msa/docker/docker-stop-services.sh | 2 +- msa/docker/docker-update-service.sh | 18 ++++++++++++++++ msa/docker/docker-upgrade-tb.sh | 4 +++- msa/docker/haproxy/certs.d/.gitignore | 4 ---- msa/docker/haproxy/config/haproxy.cfg | 1 - msa/docker/haproxy/letsencrypt/.gitignore | 4 ---- msa/docker/tb-js-executor.env | 2 +- msa/docker/tb-node/db/.gitignore | 4 ---- msa/docker/tb-node/log/.gitignore | 4 ---- msa/docker/tb-web-ui.env | 2 +- msa/web-ui/server.js | 2 +- 17 files changed, 61 insertions(+), 40 deletions(-) create mode 100755 msa/docker/check-dirs.sh create mode 100755 msa/docker/docker-update-service.sh delete mode 100644 msa/docker/haproxy/certs.d/.gitignore delete mode 100644 msa/docker/haproxy/letsencrypt/.gitignore delete mode 100644 msa/docker/tb-node/db/.gitignore delete mode 100644 msa/docker/tb-node/log/.gitignore diff --git a/msa/docker/.env b/msa/docker/.env index b3c8ff66bb..3b0b91d353 100644 --- a/msa/docker/.env +++ b/msa/docker/.env @@ -3,5 +3,3 @@ DOCKER_REPO=local-maven-build TB_VERSION=2.2.0-SNAPSHOT KAFKA_TOPICS=js.eval.requests:100:1 -HTTP_PORT=80 -HTTPS_PORT=80 \ No newline at end of file diff --git a/msa/docker/.gitignore b/msa/docker/.gitignore index 473a5a6ead..c9843dc5a5 100644 --- a/msa/docker/.gitignore +++ b/msa/docker/.gitignore @@ -1,4 +1,5 @@ haproxy/certs.d/** haproxy/letsencrypt/** tb-node/log/** +tb-node/db/** !.env diff --git a/msa/docker/check-dirs.sh b/msa/docker/check-dirs.sh new file mode 100755 index 0000000000..9c683e6a51 --- /dev/null +++ b/msa/docker/check-dirs.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +dirsArray=("./haproxy/certs.d" "./haproxy/letsencrypt" "./tb-node/db" "./tb-node/log") + +for dir in ${dirsArray[@]} +do + if [ ! -d "$dir" ]; then + echo creating dir $dir + mkdir -p $dir + fi +done diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 876646f16d..9ddbea3a04 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -61,7 +61,7 @@ services: TB_KAFKA_SERVERS: kafka:9092 JS_EVALUATOR: remote volumes: - - ./tb-node/db:/usr/share/thingsboard/data/db" + - ./tb-node/db:/usr/share/thingsboard/data/db - ./tb-node/conf:/config - ./tb-node/log:/var/log/thingsboard depends_on: @@ -86,16 +86,6 @@ services: TB_PORT: 8080 env_file: - tb-web-ui.env - tb-web-ui3: - restart: always - image: "${DOCKER_REPO}/tb-web-ui:${TB_VERSION}" - ports: - - "8080" - environment: - TB_HOST: tb - TB_PORT: 8080 - env_file: - - tb-web-ui.env web: restart: always container_name: haproxy-certbot @@ -111,9 +101,8 @@ services: cap_add: - NET_ADMIN environment: - HTTP_PORT: ${HTTP_PORT} - HTTPS_PORT: ${HTTPS_PORT} + HTTP_PORT: 80 + HTTPS_PORT: 443 links: - tb-web-ui1 - tb-web-ui2 - - tb-web-ui3 diff --git a/msa/docker/docker-install-tb.sh b/msa/docker/docker-install-tb.sh index d17ba305d1..5f34cc345f 100755 --- a/msa/docker/docker-install-tb.sh +++ b/msa/docker/docker-install-tb.sh @@ -15,6 +15,8 @@ # limitations under the License. # +./check-dirs.sh + while [[ $# -gt 0 ]] do key="$1" @@ -37,6 +39,6 @@ else loadDemo=false fi -docker-compose run --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb +docker-compose run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb diff --git a/msa/docker/docker-start-services.sh b/msa/docker/docker-start-services.sh index 3b07efb4a2..560c4351c0 100755 --- a/msa/docker/docker-start-services.sh +++ b/msa/docker/docker-start-services.sh @@ -15,4 +15,6 @@ # limitations under the License. # -docker-compose up -d +./check-dirs.sh + +docker-compose up -d --scale tb-js-executor=20 diff --git a/msa/docker/docker-stop-services.sh b/msa/docker/docker-stop-services.sh index a388bf1391..673a0b2dfc 100755 --- a/msa/docker/docker-stop-services.sh +++ b/msa/docker/docker-stop-services.sh @@ -15,4 +15,4 @@ # limitations under the License. # -docker-compose down +docker-compose stop diff --git a/msa/docker/docker-update-service.sh b/msa/docker/docker-update-service.sh new file mode 100755 index 0000000000..795146aa27 --- /dev/null +++ b/msa/docker/docker-update-service.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +docker-compose up -d --no-deps --build $@ diff --git a/msa/docker/docker-upgrade-tb.sh b/msa/docker/docker-upgrade-tb.sh index 69cb4faa9e..51202a6b0b 100755 --- a/msa/docker/docker-upgrade-tb.sh +++ b/msa/docker/docker-upgrade-tb.sh @@ -15,6 +15,8 @@ # limitations under the License. # +./check-dirs.sh + for i in "$@" do case $i in @@ -36,4 +38,4 @@ else fromVersion="${FROM_VERSION// }" fi -docker-compose run --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb +docker-compose run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb diff --git a/msa/docker/haproxy/certs.d/.gitignore b/msa/docker/haproxy/certs.d/.gitignore deleted file mode 100644 index 86d0cb2726..0000000000 --- a/msa/docker/haproxy/certs.d/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore \ No newline at end of file diff --git a/msa/docker/haproxy/config/haproxy.cfg b/msa/docker/haproxy/config/haproxy.cfg index 67e3f563a7..2499686b87 100644 --- a/msa/docker/haproxy/config/haproxy.cfg +++ b/msa/docker/haproxy/config/haproxy.cfg @@ -57,5 +57,4 @@ backend tb-web-backend option log-health-checks server tbWeb1 tb-web-ui1:8080 check server tbWeb2 tb-web-ui2:8080 check - server tbWeb3 tb-web-ui3:8080 check http-request set-header X-Forwarded-Port %[dst_port] diff --git a/msa/docker/haproxy/letsencrypt/.gitignore b/msa/docker/haproxy/letsencrypt/.gitignore deleted file mode 100644 index 86d0cb2726..0000000000 --- a/msa/docker/haproxy/letsencrypt/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore \ No newline at end of file diff --git a/msa/docker/tb-js-executor.env b/msa/docker/tb-js-executor.env index f562c3aa28..cf455243ee 100644 --- a/msa/docker/tb-js-executor.env +++ b/msa/docker/tb-js-executor.env @@ -1,7 +1,7 @@ REMOTE_JS_EVAL_REQUEST_TOPIC=js.eval.requests TB_KAFKA_SERVERS=localhost:9092 -LOGGER_LEVEL=debug +LOGGER_LEVEL=info LOG_FOLDER=logs LOGGER_FILENAME=tb-js-executor-%DATE%.log DOCKER_MODE=true \ No newline at end of file diff --git a/msa/docker/tb-node/db/.gitignore b/msa/docker/tb-node/db/.gitignore deleted file mode 100644 index 86d0cb2726..0000000000 --- a/msa/docker/tb-node/db/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore \ No newline at end of file diff --git a/msa/docker/tb-node/log/.gitignore b/msa/docker/tb-node/log/.gitignore deleted file mode 100644 index 86d0cb2726..0000000000 --- a/msa/docker/tb-node/log/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore \ No newline at end of file diff --git a/msa/docker/tb-web-ui.env b/msa/docker/tb-web-ui.env index e78c80c86e..5c52681798 100644 --- a/msa/docker/tb-web-ui.env +++ b/msa/docker/tb-web-ui.env @@ -3,7 +3,7 @@ HTTP_BIND_ADDRESS=0.0.0.0 HTTP_BIND_PORT=8080 TB_HOST=localhost TB_PORT=8080 -LOGGER_LEVEL=debug +LOGGER_LEVEL=info LOG_FOLDER=logs LOGGER_FILENAME=tb-web-ui-%DATE%.log DOCKER_MODE=true \ No newline at end of file diff --git a/msa/web-ui/server.js b/msa/web-ui/server.js index 46d9bfd7a9..44e175a84c 100644 --- a/msa/web-ui/server.js +++ b/msa/web-ui/server.js @@ -71,7 +71,7 @@ var server; const staticDir = path.join(root, 'static'); app.all('/api/*', (req, res) => { - logger.info(req.method + ' ' + req.originalUrl); + logger.debug(req.method + ' ' + req.originalUrl); apiProxy.web(req, res); }); From 286b296f44ed2018b700b89fa325daaed5e780c0 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 19:03:42 +0300 Subject: [PATCH 154/307] typo-fix --- application/src/main/data/upgrade/2.1.1/schema_update.cql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql index c477e8a02a..36ac8e4d38 100644 --- a/application/src/main/data/upgrade/2.1.1/schema_update.cql +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -20,7 +20,7 @@ DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_customer; DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_entity_id; DROP TABLE IF EXISTS thingsboard.entity_views; -ControllerSqlTestSuite + CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( id timeuuid, entity_id timeuuid, From f7f60458004f8cec9ba953f7ecb03e977c4c53cf Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 3 Oct 2018 19:20:47 +0300 Subject: [PATCH 155/307] Merge with master --- msa/docker/tb-node.env | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msa/docker/tb-node.env b/msa/docker/tb-node.env index 8ba24620c2..874bf75d55 100644 --- a/msa/docker/tb-node.env +++ b/msa/docker/tb-node.env @@ -5,7 +5,8 @@ COAP_BIND_ADDRESS=0.0.0.0 COAP_BIND_PORT=5683 # type of database to use: sql[DEFAULT] or cassandra -DATABASE_TYPE=sql +DATABASE_TS_TYPE=sql +DATABASE_ENTITIES_TYPE=sql SQL_DATA_FOLDER=/usr/share/thingsboard/data/db # cassandra db config From 79203bc2a9336cc0073699edff2e46e14c60399e Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 19:33:19 +0300 Subject: [PATCH 156/307] Enabled audit log for entity view --- application/src/main/resources/thingsboard.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 0f5a7fb21b..1054007c98 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -400,6 +400,7 @@ audit_log: "user": "${AUDIT_LOG_MASK_USER:W}" "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" "alarm": "${AUDIT_LOG_MASK_ALARM:W}" + "entity_view": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" From e54eb2a606c68db932b4d3b1fb2d3e3ee03f85ef Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 3 Oct 2018 21:28:37 +0300 Subject: [PATCH 157/307] Fixed exception msg --- .../org/thingsboard/server/dao/asset/BaseAssetService.java | 3 ++- .../org/thingsboard/server/dao/device/DeviceServiceImpl.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index f35b89069c..ebd056048b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -54,6 +54,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.ASSET_CACHE; @@ -141,7 +142,7 @@ public class BaseAssetService extends AbstractEntityService implements AssetServ if (entityViews != null && !entityViews.isEmpty()) { throw new DataValidationException("Can't delete asset that is assigned to entity views!"); } - } catch (Exception e) { + } catch (ExecutionException | InterruptedException e) { log.error("Exception while finding entity views for assetId [{}]", assetId, e); throw new RuntimeException("Exception while finding entity views for assetId [" + assetId + "]", e); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 6f9ea62244..2be5ba4f41 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -58,6 +58,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.DEVICE_CACHE; @@ -158,7 +159,7 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe if (entityViews != null && !entityViews.isEmpty()) { throw new DataValidationException("Can't delete device that is assigned to entity views!"); } - } catch (Exception e) { + } catch (ExecutionException | InterruptedException e) { log.error("Exception while finding entity views for deviceId [{}]", deviceId, e); throw new RuntimeException("Exception while finding entity views for deviceId [" + deviceId + "]", e); } From 132791367913f2ac4ee9582d6e2eec8025f72bc4 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 4 Oct 2018 14:18:03 +0300 Subject: [PATCH 158/307] Add default bar width option for Bars chart --- .../json/system/widget_bundles/charts.json | 8 +- ui/src/app/widget/lib/flot-widget.js | 458 ++++++++++-------- 2 files changed, 249 insertions(+), 217 deletions(-) diff --git a/application/src/main/data/json/system/widget_bundles/charts.json b/application/src/main/data/json/system/widget_bundles/charts.json index a264ba6515..71e9fa7ace 100644 --- a/application/src/main/data/json/system/widget_bundles/charts.json +++ b/application/src/main/data/json/system/widget_bundles/charts.json @@ -35,7 +35,7 @@ "resources": [], "templateHtml": "", "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", - "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema('graph');\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":false,\"tooltipIndividual\":false},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}" @@ -147,10 +147,10 @@ "resources": [], "templateHtml": "", "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", - "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, 'bar'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, 'bar'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema('bar');\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":true,\"tooltipIndividual\":false},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"stack\":true,\"tooltipIndividual\":false,\"defaultBarWidth\":600},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{}}" } }, { @@ -163,7 +163,7 @@ "resources": [], "templateHtml": "", "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", - "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, 'state'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true\n };\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, 'state'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true\n };\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema('graph');\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 1\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false,\"axisPosition\":\"left\",\"showSeparateAxis\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"return Math.random() > 0.5 ? 1 : 0;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 2\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false,\"axisPosition\":\"left\"},\"_hash\":0.12775350966079668,\"funcBody\":\"return Math.random() <= 0.5 ? 1 : 0;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\",\"ticksFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"stack\":false,\"tooltipIndividual\":false,\"tooltipValueFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\",\"smoothLines\":false},\"title\":\"State Chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{},\"legendConfig\":{\"position\":\"bottom\",\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false}}" diff --git a/ui/src/app/widget/lib/flot-widget.js b/ui/src/app/widget/lib/flot-widget.js index 1f363d627c..acaaf10791 100644 --- a/ui/src/app/widget/lib/flot-widget.js +++ b/ui/src/app/widget/lib/flot-widget.js @@ -333,6 +333,7 @@ export default class TbFlot { lineWidth: 0, fill: 0.9 } + ctx.defaultBarWidth = settings.defaultBarWidth || 600; } if (this.chartType === 'state') { @@ -476,7 +477,11 @@ export default class TbFlot { this.options.yaxes = angular.copy(this.yaxes); if (this.chartType === 'line' || this.chartType === 'bar' || this.chartType === 'state') { if (this.chartType === 'bar') { - this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + if (this.subscription.timeWindowConfig.aggregation && this.subscription.timeWindowConfig.aggregation.type === 'NONE') { + this.options.series.bars.barWidth = this.ctx.defaultBarWidth; + } else { + this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } } this.options.xaxis.min = this.subscription.timeWindow.minTime; this.options.xaxis.max = this.subscription.timeWindow.maxTime; @@ -594,7 +599,11 @@ export default class TbFlot { this.options.xaxis.min = this.subscription.timeWindow.minTime; this.options.xaxis.max = this.subscription.timeWindow.maxTime; if (this.chartType === 'bar') { - this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + if (this.subscription.timeWindowConfig.aggregation && this.subscription.timeWindowConfig.aggregation.type === 'NONE') { + this.options.series.bars.barWidth = this.ctx.defaultBarWidth; + } else { + this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } } if (axisVisibilityChanged) { @@ -603,7 +612,11 @@ export default class TbFlot { this.ctx.plot.getOptions().xaxes[0].min = this.subscription.timeWindow.minTime; this.ctx.plot.getOptions().xaxes[0].max = this.subscription.timeWindow.maxTime; if (this.chartType === 'bar') { - this.ctx.plot.getOptions().series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + if (this.subscription.timeWindowConfig.aggregation && this.subscription.timeWindowConfig.aggregation.type === 'NONE') { + this.ctx.plot.getOptions().series.bars.barWidth = this.ctx.defaultBarWidth; + } else { + this.ctx.plot.getOptions().series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } } this.updateData(); } @@ -810,238 +823,257 @@ export default class TbFlot { } } - static get settingsSchema() { - return { + static settingsSchema(chartType) { + + var schema = { "schema": { "type": "object", "title": "Settings", "properties": { - "stack": { - "title": "Stacking", - "type": "boolean", - "default": false - }, - "smoothLines": { - "title": "Display smooth (curved) lines", - "type": "boolean", - "default": false - }, - "shadowSize": { - "title": "Shadow size", - "type": "number", - "default": 4 - }, - "fontColor": { - "title": "Font color", + } + } + }; + + var properties = schema["schema"]["properties"]; + properties["stack"] = { + "title": "Stacking", + "type": "boolean", + "default": false + }; + if (chartType === 'graph') { + properties["smoothLines"] = { + "title": "Display smooth (curved) lines", + "type": "boolean", + "default": false + }; + } + if (chartType === 'bar') { + properties["defaultBarWidth"] = { + "title": "Default bar width for non-aggregated data (milliseconds)", + "type": "number", + "default": 600 + }; + } + properties["shadowSize"] = { + "title": "Shadow size", + "type": "number", + "default": 4 + }; + properties["fontColor"] = { + "title": "Font color", + "type": "string", + "default": "#545454" + }; + properties["fontSize"] = { + "title": "Font size", + "type": "number", + "default": 10 + }; + properties["tooltipIndividual"] = { + "title": "Hover individual points", + "type": "boolean", + "default": false + }; + properties["tooltipCumulative"] = { + "title": "Show cumulative values in stacking mode", + "type": "boolean", + "default": false + }; + properties["tooltipValueFormatter"] = { + "title": "Tooltip value format function, f(value)", + "type": "string", + "default": "" + }; + + properties["grid"] = { + "title": "Grid settings", + "type": "object", + "properties": { + "color": { + "title": "Primary color", "type": "string", "default": "#545454" - }, - "fontSize": { - "title": "Font size", + }, + "backgroundColor": { + "title": "Background color", + "type": "string", + "default": null + }, + "tickColor": { + "title": "Ticks color", + "type": "string", + "default": "#DDDDDD" + }, + "outlineWidth": { + "title": "Grid outline/border width (px)", "type": "number", - "default": 10 - }, - "tooltipIndividual": { - "title": "Hover individual points", + "default": 1 + }, + "verticalLines": { + "title": "Show vertical lines", "type": "boolean", - "default": false - }, - "tooltipCumulative": { - "title": "Show cumulative values in stacking mode", + "default": true + }, + "horizontalLines": { + "title": "Show horizontal lines", "type": "boolean", - "default": false - }, - "tooltipValueFormatter": { - "title": "Tooltip value format function, f(value)", - "type": "string", - "default": "" - }, - "grid": { - "title": "Grid settings", - "type": "object", - "properties": { - "color": { - "title": "Primary color", - "type": "string", - "default": "#545454" - }, - "backgroundColor": { - "title": "Background color", - "type": "string", - "default": null - }, - "tickColor": { - "title": "Ticks color", - "type": "string", - "default": "#DDDDDD" - }, - "outlineWidth": { - "title": "Grid outline/border width (px)", - "type": "number", - "default": 1 - }, - "verticalLines": { - "title": "Show vertical lines", - "type": "boolean", - "default": true - }, - "horizontalLines": { - "title": "Show horizontal lines", - "type": "boolean", - "default": true - } - } - }, - "xaxis": { - "title": "X axis settings", - "type": "object", - "properties": { - "showLabels": { - "title": "Show labels", - "type": "boolean", - "default": true - }, - "title": { - "title": "Axis title", - "type": "string", - "default": null - }, - "titleAngle": { - "title": "Axis title's angle in degrees", - "type": "number", - "default": 0 - }, - "color": { - "title": "Ticks color", - "type": "string", - "default": null - } - } - }, - "yaxis": { - "title": "Y axis settings", - "type": "object", - "properties": { - "min": { - "title": "Minimum value on the scale", - "type": "number", - "default": null - }, - "max": { - "title": "Maximum value on the scale", - "type": "number", - "default": null - }, - "showLabels": { - "title": "Show labels", - "type": "boolean", - "default": true - }, - "title": { - "title": "Axis title", - "type": "string", - "default": null - }, - "titleAngle": { - "title": "Axis title's angle in degrees", - "type": "number", - "default": 0 - }, - "color": { - "title": "Ticks color", - "type": "string", - "default": null - }, - "ticksFormatter": { - "title": "Ticks formatter function, f(value)", - "type": "string", - "default": "" - }, - "tickDecimals": { - "title": "The number of decimals to display", - "type": "number", - "default": 0 - }, - "tickSize": { - "title": "Step size between ticks", - "type": "number", - "default": null - } - } - } + "default": true + } + } + }; + + properties["xaxis"] = { + "title": "X axis settings", + "type": "object", + "properties": { + "showLabels": { + "title": "Show labels", + "type": "boolean", + "default": true }, - "required": [] - }, - "form": [ - "stack", - "smoothLines", - "shadowSize", + "title": { + "title": "Axis title", + "type": "string", + "default": null + }, + "titleAngle": { + "title": "Axis title's angle in degrees", + "type": "number", + "default": 0 + }, + "color": { + "title": "Ticks color", + "type": "string", + "default": null + } + } + }; + + properties["yaxis"] = { + "title": "Y axis settings", + "type": "object", + "properties": { + "min": { + "title": "Minimum value on the scale", + "type": "number", + "default": null + }, + "max": { + "title": "Maximum value on the scale", + "type": "number", + "default": null + }, + "showLabels": { + "title": "Show labels", + "type": "boolean", + "default": true + }, + "title": { + "title": "Axis title", + "type": "string", + "default": null + }, + "titleAngle": { + "title": "Axis title's angle in degrees", + "type": "number", + "default": 0 + }, + "color": { + "title": "Ticks color", + "type": "string", + "default": null + }, + "ticksFormatter": { + "title": "Ticks formatter function, f(value)", + "type": "string", + "default": "" + }, + "tickDecimals": { + "title": "The number of decimals to display", + "type": "number", + "default": 0 + }, + "tickSize": { + "title": "Step size between ticks", + "type": "number", + "default": null + } + } + }; + + schema["schema"]["required"] = []; + schema["form"] = ["stack"]; + if (chartType === 'graph') { + schema["form"].push("smoothLines"); + } + if (chartType === 'bar') { + schema["form"].push("defaultBarWidth"); + } + schema["form"].push("shadowSize"); + schema["form"].push({ + "key": "fontColor", + "type": "color" + }); + schema["form"].push("fontSize"); + schema["form"].push("tooltipIndividual"); + schema["form"].push("tooltipCumulative"); + schema["form"].push({ + "key": "tooltipValueFormatter", + "type": "javascript" + }); + schema["form"].push({ + "key": "grid", + "items": [ { - "key": "fontColor", + "key": "grid.color", "type": "color" }, - "fontSize", - "tooltipIndividual", - "tooltipCumulative", { - "key": "tooltipValueFormatter", - "type": "javascript" + "key": "grid.backgroundColor", + "type": "color" }, { - "key": "grid", - "items": [ - { - "key": "grid.color", - "type": "color" - }, - { - "key": "grid.backgroundColor", - "type": "color" - }, - { - "key": "grid.tickColor", - "type": "color" - }, - "grid.outlineWidth", - "grid.verticalLines", - "grid.horizontalLines" - ] + "key": "grid.tickColor", + "type": "color" }, + "grid.outlineWidth", + "grid.verticalLines", + "grid.horizontalLines" + ] + }); + schema["form"].push({ + "key": "xaxis", + "items": [ + "xaxis.showLabels", + "xaxis.title", + "xaxis.titleAngle", { - "key": "xaxis", - "items": [ - "xaxis.showLabels", - "xaxis.title", - "xaxis.titleAngle", - { - "key": "xaxis.color", - "type": "color" - } - ] + "key": "xaxis.color", + "type": "color" + } + ] + }); + schema["form"].push({ + "key": "yaxis", + "items": [ + "yaxis.min", + "yaxis.max", + "yaxis.tickDecimals", + "yaxis.tickSize", + "yaxis.showLabels", + "yaxis.title", + "yaxis.titleAngle", + { + "key": "yaxis.color", + "type": "color" }, { - "key": "yaxis", - "items": [ - "yaxis.min", - "yaxis.max", - "yaxis.tickDecimals", - "yaxis.tickSize", - "yaxis.showLabels", - "yaxis.title", - "yaxis.titleAngle", - { - "key": "yaxis.color", - "type": "color" - }, - { - "key": "yaxis.ticksFormatter", - "type": "javascript" - } - ] + "key": "yaxis.ticksFormatter", + "type": "javascript" } - ] - } + }); + return schema; } static get pieDatakeySettingsSchema() { From bd536e58810454adc22682b0ecb93d6e0877b686 Mon Sep 17 00:00:00 2001 From: Sergey Tarnavskiy Date: Thu, 4 Oct 2018 14:48:01 +0300 Subject: [PATCH 159/307] Action buttons overlay action buttons from right layout in mobile view --- ui/src/app/dashboard/dashboard.scss | 2 +- ui/src/app/dashboard/dashboard.tpl.html | 2 +- ui/src/scss/main.scss | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/app/dashboard/dashboard.scss b/ui/src/app/dashboard/dashboard.scss index 224dcf7e4a..8299ccd97e 100644 --- a/ui/src/app/dashboard/dashboard.scss +++ b/ui/src/app/dashboard/dashboard.scss @@ -122,7 +122,7 @@ section.tb-powered-by-footer { position: absolute; right: 25px; bottom: 5px; - z-index: 3; + z-index: 30; pointer-events: none; span { font-size: 12px; diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html index 829f174fe7..79653a42c5 100644 --- a/ui/src/app/dashboard/dashboard.tpl.html +++ b/ui/src/app/dashboard/dashboard.tpl.html @@ -146,7 +146,7 @@ ng-style="{minWidth: vm.rightLayoutWidth(), maxWidth: vm.rightLayoutWidth(), height: vm.rightLayoutHeight(), - zIndex: 12}" + zIndex: 25}" md-component-id="right-dashboard-layout" aria-label="Right dashboard layout" md-is-open="vm.rightLayoutOpened" diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss index 2852a7bd60..aeab9f6a5b 100644 --- a/ui/src/scss/main.scss +++ b/ui/src/scss/main.scss @@ -617,7 +617,7 @@ section.tb-footer-buttons { position: fixed; right: 20px; bottom: 20px; - z-index: 13; + z-index: 30; pointer-events: none; } From 626063dfb58e0f50bb9e7741f512c49f171d87e0 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 4 Oct 2018 16:32:37 +0300 Subject: [PATCH 160/307] Version set to 2.1.1 --- application/pom.xml | 2 +- common/data/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/transport/pom.xml | 2 +- dao/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- ui/pom.xml | 2 +- 17 files changed, 18 insertions(+), 18 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 3d7cbe1848..c46d9ce5e0 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard application diff --git a/common/data/pom.xml b/common/data/pom.xml index b99e6bf79f..2a91e1510f 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 6923cacdf5..564704be99 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index fbff206408..126b9e492f 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard org.thingsboard diff --git a/common/transport/pom.xml b/common/transport/pom.xml index e132ffb01e..a1d15fb65a 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index b12ea421d5..310a3c4515 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard dao diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 8dffbfdb01..5373c21cc0 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,12 +19,12 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard org.thingsboard netty-mqtt - 2.1.1-SNAPSHOT + 2.1.1 jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index aa54e72000..f03558cd6b 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 2.1.1-SNAPSHOT + 2.1.1 pom Thingsboard diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 00e49807b0..dd9677dd8b 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 09da1cdaba..1137f1fc66 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index e52e44bb9b..82a55078da 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index a3863300c1..f25a0a0201 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard org.thingsboard diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 2360a7518c..179969312f 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index a3117d5d7f..6ff57e74f4 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 798e7abaa2..5e27dd4c72 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index df2bd27e39..cecc2ace57 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard org.thingsboard diff --git a/ui/pom.xml b/ui/pom.xml index 92c5a60d6a..569ea27d6a 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.1.1 thingsboard org.thingsboard From bc591ce09fd718222a26ca89fc69f1304c0cada0 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 4 Oct 2018 16:36:46 +0300 Subject: [PATCH 161/307] tmp commit to merge with master --- common/transport/pom.xml | 13 +++ .../common/transport/TransportService.java | 16 ++++ .../transport/src/main/proto/transport.proto | 79 +++++++++++++++++++ .../mqtt/MqttSslHandlerProvider.java | 1 - .../transport/mqtt/MqttTransportContext.java | 38 +++++++++ .../mqtt/MqttTransportServerInitializer.java | 33 +++----- .../transport/mqtt/MqttTransportService.java | 36 ++------- 7 files changed, 161 insertions(+), 55 deletions(-) create mode 100644 common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java create mode 100644 common/transport/src/main/proto/transport.proto create mode 100644 transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java diff --git a/common/transport/pom.xml b/common/transport/pom.xml index a349012fce..b8a7ab53f0 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -86,6 +86,19 @@ org.apache.commons commons-lang3 + + com.google.protobuf + protobuf-java + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java new file mode 100644 index 0000000000..9ff19b7111 --- /dev/null +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -0,0 +1,16 @@ +package org.thingsboard.server.common.transport; + +import org.thingsboard.server.gen.transport.TransportProtos; + +/** + * Created by ashvayka on 04.10.18. + */ +public interface TransportService { + + void process(TransportProtos.SessionEventMsg msg); + + void process(TransportProtos.PostTelemetryMsg msg); + + void process(TransportProtos.PostAttributeMsg msg); + +} diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto new file mode 100644 index 0000000000..d77e79bbd0 --- /dev/null +++ b/common/transport/src/main/proto/transport.proto @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; +package transport; + +option java_package = "org.thingsboard.server.gen.transport"; +option java_outer_classname = "TransportProtos"; + +/** + * Data Structures; + */ +message SessionInfoProto { + string nodeId = 1; + int64 sessionIdMSB = 2; + int64 sessionIdLSB = 3; +} + +enum SessionEvent { + OPEN = 0; + CLOSED = 1; +} + +message KeyValueProto { + string key = 1; + bool bool_v = 2; + int64 long_v = 3; + double double_v = 4; + string string_v = 5; +} + +message TsKvListProto { + int64 ts = 1; + repeated KeyValueProto kv = 2; +} + +/** + * Messages that use Data Structures; + */ +message SessionEventMsg { + SessionInfoProto sessionInfo = 1; + int64 deviceIdMSB = 2; + int64 deviceIdLSB = 3; +} + +message PostTelemetryMsg { + SessionInfoProto sessionInfo = 1; + repeated TsKvListProto tsKvList = 2; +} + +message PostAttributeMsg { + SessionInfoProto sessionInfo = 1; + repeated TsKvListProto tsKvList = 2; +} + +message GetAttributeRequestMsg { + SessionInfoProto sessionInfo = 1; + repeated string clientAttributeNames = 2; + repeated string sharedAttributeNames = 3; +} + +message GetAttributeResponseMsg { + SessionInfoProto sessionInfo = 1; + repeated TsKvListProto clientAttributeList = 2; + repeated TsKvListProto sharedAttributeList = 3; + repeated string deletedAttributeKeys = 4; +} \ No newline at end of file diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 9c458fd39f..70ed5013e4 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -60,7 +60,6 @@ public class MqttSslHandlerProvider { @Autowired private DeviceCredentialsService deviceCredentialsService; - public SslHandler getSslHandler() { try { URL ksUrl = Resources.getResource(keyStoreFile); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java new file mode 100644 index 0000000000..43e2a3440e --- /dev/null +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -0,0 +1,38 @@ +package org.thingsboard.server.transport.mqtt; + +import io.netty.handler.ssl.SslHandler; +import lombok.Data; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; +import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; + +/** + * Created by ashvayka on 04.10.18. + */ +@Component +@Data +public class MqttTransportContext { + + @Autowired + @Lazy + private TransportService transportService; + + @Autowired(required = false) + private MqttSslHandlerProvider sslHandlerProvider; + + @Autowired(required = false) + private HostRequestsQuotaService quotaService; + + @Autowired + private MqttTransportAdaptor adaptor; + + @Value("${mqtt.netty.max_payload_size}") + private Integer maxPayloadSize; + + private SslHandler sslHandler; + +} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java index 432966eeff..102a0d9f07 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java @@ -22,6 +22,7 @@ import io.netty.handler.codec.mqtt.MqttDecoder; import io.netty.handler.codec.mqtt.MqttEncoder; import io.netty.handler.ssl.SslHandler; import org.thingsboard.server.common.transport.SessionMsgProcessor; +import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.quota.QuotaService; import org.thingsboard.server.dao.device.DeviceService; @@ -33,41 +34,25 @@ import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; */ public class MqttTransportServerInitializer extends ChannelInitializer { - private final SessionMsgProcessor processor; - private final DeviceService deviceService; - private final DeviceAuthService authService; - private final RelationService relationService; - private final MqttTransportAdaptor adaptor; - private final MqttSslHandlerProvider sslHandlerProvider; - private final QuotaService quotaService; - private final int maxPayloadSize; + private final MqttTransportContext context; - public MqttTransportServerInitializer(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, - MqttTransportAdaptor adaptor, MqttSslHandlerProvider sslHandlerProvider, - QuotaService quotaService, int maxPayloadSize) { - this.processor = processor; - this.deviceService = deviceService; - this.authService = authService; - this.relationService = relationService; - this.adaptor = adaptor; - this.sslHandlerProvider = sslHandlerProvider; - this.quotaService = quotaService; - this.maxPayloadSize = maxPayloadSize; + public MqttTransportServerInitializer(MqttTransportContext context) { + this.context = context; } @Override public void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); SslHandler sslHandler = null; - if (sslHandlerProvider != null) { - sslHandler = sslHandlerProvider.getSslHandler(); + if (context.getSslHandlerProvider() != null) { + sslHandler = context.getSslHandlerProvider().getSslHandler(); pipeline.addLast(sslHandler); + context.setSslHandler(sslHandler); } - pipeline.addLast("decoder", new MqttDecoder(maxPayloadSize)); + pipeline.addLast("decoder", new MqttDecoder(context.getMaxPayloadSize())); pipeline.addLast("encoder", MqttEncoder.INSTANCE); - MqttTransportHandler handler = new MqttTransportHandler(processor, deviceService, authService, relationService, - adaptor, sslHandler, quotaService); + MqttTransportHandler handler = new MqttTransportHandler(context); pipeline.addLast(handler); ch.closeFuture().addListener(handler); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index bb8d4ad54c..f435cc2dc5 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -23,11 +23,14 @@ import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.ResourceLeakDetector; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.SessionMsgProcessor; +import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.dao.device.DeviceService; @@ -48,27 +51,6 @@ public class MqttTransportService { private static final String V1 = "v1"; private static final String DEVICE = "device"; - @Autowired(required = false) - private ApplicationContext appContext; - - @Autowired(required = false) - private SessionMsgProcessor processor; - - @Autowired(required = false) - private DeviceService deviceService; - - @Autowired(required = false) - private DeviceAuthService authService; - - @Autowired(required = false) - private RelationService relationService; - - @Autowired(required = false) - private MqttSslHandlerProvider sslHandlerProvider; - - @Autowired(required = false) - private HostRequestsQuotaService quotaService; - @Value("${mqtt.bind_address}") private String host; @Value("${mqtt.bind_port}") @@ -82,10 +64,9 @@ public class MqttTransportService { private Integer bossGroupThreadCount; @Value("${mqtt.netty.worker_group_thread_count}") private Integer workerGroupThreadCount; - @Value("${mqtt.netty.max_payload_size}") - private Integer maxPayloadSize; - private MqttTransportAdaptor adaptor; + @Autowired + private MqttTransportContext context; private Channel serverChannel; private EventLoopGroup bossGroup; @@ -97,17 +78,12 @@ public class MqttTransportService { ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase())); log.info("Starting MQTT transport..."); - log.info("Lookup MQTT transport adaptor {}", adaptorName); - this.adaptor = (MqttTransportAdaptor) appContext.getBean(adaptorName); - - log.info("Starting MQTT transport server"); bossGroup = new NioEventLoopGroup(bossGroupThreadCount); workerGroup = new NioEventLoopGroup(workerGroupThreadCount); ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(processor, deviceService, authService, relationService, - adaptor, sslHandlerProvider, quotaService, maxPayloadSize)); + .childHandler(new MqttTransportServerInitializer(context)); serverChannel = b.bind(host, port).sync().channel(); log.info("Mqtt transport started!"); From 428ebc80ec653f81ac7b7d8233621fde67826d76 Mon Sep 17 00:00:00 2001 From: Mauro Cicolella Date: Thu, 4 Oct 2018 16:41:05 +0200 Subject: [PATCH 162/307] Update Italian translation --- ui/src/app/locale/locale.constant-it_IT.json | 86 ++++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/ui/src/app/locale/locale.constant-it_IT.json b/ui/src/app/locale/locale.constant-it_IT.json index fcdd7453ce..f69c1088b1 100644 --- a/ui/src/app/locale/locale.constant-it_IT.json +++ b/ui/src/app/locale/locale.constant-it_IT.json @@ -528,13 +528,13 @@ "decimals": "Numero cifre decimali", "data-generation-func": "Funzione generazione dati", "use-data-post-processing-func": "Use data post-processing function", - "configuration": "Data key configuration", + "configuration": "Configurazione data key", "timeseries": "Serie temporali", "attributes": "Attributi", "alarm": "Campi allarme", "timeseries-required": "Entity timeseries are required.", "timeseries-or-attributes-required": "Entity timeseries/attributes are required.", - "maximum-timeseries-or-attributes": "Maximum { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "maximum-timeseries-or-attributes": "Massimo { count, plural, 1 {1 serie temporale/attributo consentito.} other {# serie temporali/attributi consentiti.} }", "alarm-fields-required": "Campi allarme obbligatori.", "function-types": "Tipi funzione", "function-types-required": "Tipi funzione obbligatorio.", @@ -759,7 +759,7 @@ "entity": "Entità", "message-id": "Id Messaggio", "message-type": "Tipo Messaggio", - "data-type": "Data Type", + "data-type": "Tipo di dato", "relation-type": "Tipo di relazione", "metadata": "Metadati", "data": "Dati", @@ -807,7 +807,7 @@ "add-map": "Add mapping element", "timeseries": "Serie temporali", "add-timeseries": "Add timeseries", - "field-required": "Field is required", + "field-required": "Campo obbligatorio", "brokers": "Broker", "add-broker": "Aggiungi broker", "host": "Host", @@ -855,15 +855,15 @@ "add-attribute-request": "Add attribute request", "attribute-updates": "Attribute updates", "add-attribute-update": "Add attribute update", - "server-side-rpc": "Server side RPC", + "server-side-rpc": "RPC lato server", "add-server-side-rpc-request": "Add server-side RPC request", - "device-name-filter": "Device name filter", + "device-name-filter": "Filtro nome dispositivo", "attribute-filter": "Filtro attributo", "method-filter": "Filtro metodo", "request-topic-expression": "Request topic expression", "response-timeout": "Response timeout in milliseconds", "topic-expression": "Topic expression", - "client-scope": "Client scope", + "client-scope": "Visibilità client", "add-device": "Aggiungi dispositivo", "opc-server": "Server", "opc-add-server": "Aggiungi server", @@ -892,19 +892,19 @@ "modbus-baudrate": "Baud rate", "modbus-databits": "Data bits", "modbus-stopbits": "Stop bits", - "modbus-databits-range": "Data bits should be in a range from 7 to 8.", - "modbus-stopbits-range": "Stop bits should be in a range from 1 to 2.", - "modbus-unit-id": "Unit ID", - "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", + "modbus-databits-range": "Data bits deve essere compreso nell'intervallo 7-8.", + "modbus-stopbits-range": "Stop bits deve essere compreso nell'intervallo 1-2.", + "modbus-unit-id": "ID unità", + "modbus-unit-id-range": "ID unità deve essere compreso nell'intervallo 1-247.", "modbus-device-name": "Nome dispositivo", "modbus-poll-period": "Intervallo di polling (ms)", - "modbus-attributes-poll-period": "Attributes poll period (ms)", - "modbus-timeseries-poll-period": "Timeseries poll period (ms)", + "modbus-attributes-poll-period": "Intervallo di polling degli attributi (ms)", + "modbus-timeseries-poll-period": "Intervallo di polling delle serie temporali (ms)", "modbus-poll-period-range": "L'intervallo di polling deve essere un valore positivo.", "modbus-tag": "Tag", "modbus-function": "Funzione", "modbus-register-address": "Indirizzo registro", - "modbus-register-address-range": "L'indirizzo del registro deve essere compreso tra 0 e 65535.", + "modbus-register-address-range": "Indirizzo registro deve essere compreso tra 0 e 65535.", "modbus-register-bit-index": "Bit index", "modbus-register-bit-index-range": "Bit index should be in a range from 0 to 15.", "modbus-register-count": "Register count", @@ -950,7 +950,7 @@ "scroll-to-top": "Scorri verso l'alto" }, "help": { - "goto-help-page": "Go to help page" + "goto-help-page": "Vai all'help" }, "home": { "home": "Home", @@ -975,9 +975,9 @@ "key-val": { "key": "Chiave", "value": "Valore", - "remove-entry": "Remove entry", - "add-entry": "Add entry", - "no-data": "No entries" + "remove-entry": "Rimuovi voce", + "add-entry": "Aggiungi voce", + "no-data": "Nessuna voce" }, "layout": { "layout": "Layout", @@ -1002,8 +1002,8 @@ }, "login": { "login": "Login", - "request-password-reset": "Request Password Reset", - "reset-password": "Azzera Password", + "request-password-reset": "Richiesta reset password", + "reset-password": "Reset Password", "create-password": "Crea Password", "passwords-mismatch-error": "Le password inserite devono corrispondere!", "password-again": "Ripeti Password", @@ -1014,7 +1014,7 @@ "password-reset": "Password reset", "new-password": "Nuova password", "new-password-again": "Ripeti nuova password", - "password-link-sent-message": "Link azzeramento password inviato con successo!", + "password-link-sent-message": "Link reset password inviato con successo!", "email": "Email" }, "position": { @@ -1029,7 +1029,7 @@ "current-password": "Password attuale" }, "relation": { - "relations": "Relations", + "relations": "Relazioni", "direction": "Direzione", "search-direction": { "FROM": "Da", @@ -1039,9 +1039,9 @@ "FROM": "da", "TO": "a" }, - "from-relations": "Outbound relations", - "to-relations": "Inbound relations", - "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } selected", + "from-relations": "Relazioni in uscita", + "to-relations": "Relazioni in ingresso", + "selected-relations": "{ count, plural, 1 {1 relazione selezionata} other {# relazioni selezionate} }", "type": "Tipo", "to-entity-type": "A tipo entità", "to-entity-name": "A nome entità", @@ -1049,26 +1049,26 @@ "from-entity-name": "Da nome entità", "to-entity": "A entità", "from-entity": "Da entità", - "delete": "Delete relation", - "relation-type": "Relation type", - "relation-type-required": "Relation type is required.", + "delete": "Elimina relazione", + "relation-type": "Tipo di relazione", + "relation-type-required": "Tipo di relazione obbligatorio.", "any-relation-type": "Ogni tipo", - "add": "Add relation", - "edit": "Edit relation", - "delete-to-relation-title": "Are you sure you want to delete relation to the entity '{{entityName}}'?", + "add": "Aggiungi relazione", + "edit": "Modifica relazione", + "delete-to-relation-title": "Sei sicuro di voler eliminare la relazione con l'entità '{{entityName}}'?", "delete-to-relation-text": "Attenzione, dopo la conferma l'entità '{{entityName}}' sarà scollegata dall'entità corrente.", - "delete-to-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-to-relations-text": "Be careful, after the confirmation all selected relations will be removed and corresponding entities will be unrelated from the current entity.", - "delete-from-relation-title": "Are you sure you want to delete relation from the entity '{{entityName}}'?", - "delete-from-relation-text": "Be careful, after the confirmation current entity will be unrelated from the entity '{{entityName}}'.", - "delete-from-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-from-relations-text": "Be careful, after the confirmation all selected relations will be removed and current entity will be unrelated from the corresponding entities.", - "remove-relation-filter": "Remove relation filter", - "add-relation-filter": "Add relation filter", - "any-relation": "Any relation", - "relation-filters": "Relation filters", - "additional-info": "Additional info (JSON)", - "invalid-additional-info": "Unable to parse additional info json." + "delete-to-relations-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 relazione} other {# relazioni} }?", + "delete-to-relations-text": "Attenzione, dopo la conferma tutte le relazioni selezionate saranno rimosse e le corrispondenti entità scollegate da quella corrente.", + "delete-from-relation-title": "Sei sicuro di voler eliminare la relazione dall'entità '{{entityName}}'?", + "delete-from-relation-text": "Attenzione, dopo la conferma l'entità corrente sarà scollegata dall'entità '{{entityName}}'.", + "delete-from-relations-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 relazione} other {# relazioni} }?", + "delete-from-relations-text": "Attenzione, dopo la conferma tutte le relazioni selezionate saranno rimosse e l'entità corrente scollegata dalle corrispondenti entità.", + "remove-relation-filter": "Rimuovi filtro relazioni", + "add-relation-filter": "Aggiungi filtro relazioni", + "any-relation": "Qualsiasi relazione", + "relation-filters": "Filtri relazioni", + "additional-info": "Informazioni aggiuntive (JSON)", + "invalid-additional-info": "Impossibile analizzare le informazioni aggiuntive in JSON." }, "rulechain": { "rulechain": "Rule chain", From a19a86eab706a8eaee320b65e7964c98efb02234 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 4 Oct 2018 19:13:29 +0300 Subject: [PATCH 163/307] Update thingsboard yml --- application/src/main/resources/thingsboard.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index f35df67265..242a996592 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -136,8 +136,8 @@ quota: # Array of blacklist hosts blacklist: "${QUOTA_HOST_BLACKLIST:}" log: - topSize: 10 - intervalMin: 2 + topSize: "${QUOTA_HOST_LOG_TOP_SIZE:10}" + intervalMin: "${QUOTA_HOST_LOG_INTERVAL_MIN:2}" rule: tenant: # Max allowed number of API requests in interval for single tenant @@ -153,10 +153,10 @@ quota: # Array of whitelist tenants whitelist: "${QUOTA_TENANT_WHITELIST:}" # Array of blacklist tenants - blacklist: "${QUOTA_HOST_BLACKLIST:}" + blacklist: "${QUOTA_HOST_TENANT_BLACKLIST:}" log: - topSize: 10 - intervalMin: 2 + topSize: "${QUOTA_TENANT_LOG_TOP_SIZE:10}" + intervalMin: "${QUOTA_TENANT_LOG_INTERVAL_MIN:2}" database: entities: @@ -185,7 +185,8 @@ cassandra: init_timeout_ms: "${CASSANDRA_CLUSTER_INIT_TIMEOUT_MS:300000}" # Specify cassandra claster initialization retry interval (if no hosts available during startup) init_retry_interval_ms: "${CASSANDRA_CLUSTER_INIT_RETRY_INTERVAL_MS:3000}" - + max_requests_per_connection_local: "${CASSANDRA_MAX_REQUESTS_PER_CONNECTION_LOCAL:32768}" + max_requests_per_connection_remote: "${CASSANDRA_MAX_REQUESTS_PER_CONNECTION_REMOTE:32768}" # Credential parameters # credentials: "${CASSANDRA_USE_CREDENTIALS:false}" # Specify your username From fd6ad413b721fb1c8eaa1f170c92c17748abf27c Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 4 Oct 2018 19:18:26 +0300 Subject: [PATCH 164/307] Initial structure --- .../server/ThingsboardServerApplication.java | 8 +- .../common/transport/TransportService.java | 9 +- .../transport/TransportServiceCallback.java | 11 + .../session/DeviceAwareSessionContext.java | 56 +- .../transport/src/main/proto/transport.proto | 20 +- transport/mqtt-transport/pom.xml | 88 +++ .../ThingsboardMqttTransportApplication.java | 48 ++ .../src/main/resources/tb-mqtt-transport.yml | 57 ++ .../transport/mqtt/MqttTransportContext.java | 26 + .../transport/mqtt/MqttTransportHandler.java | 575 ++++++++++-------- .../mqtt/session/DeviceSessionCtx.java | 6 +- .../mqtt/session/GatewaySessionCtx.java | 4 + transport/pom.xml | 1 + 13 files changed, 603 insertions(+), 306 deletions(-) create mode 100644 common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java create mode 100644 transport/mqtt-transport/pom.xml create mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java create mode 100644 transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml diff --git a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java index 751bde6303..c072311fe2 100644 --- a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java +++ b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 9ff19b7111..ff0debcd4b 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -7,10 +7,13 @@ import org.thingsboard.server.gen.transport.TransportProtos; */ public interface TransportService { - void process(TransportProtos.SessionEventMsg msg); + void process(TransportProtos.ValidateDeviceTokenRequestMsg msg, + TransportServiceCallback callback); - void process(TransportProtos.PostTelemetryMsg msg); + void process(TransportProtos.SessionEventMsg msg, TransportServiceCallback callback); - void process(TransportProtos.PostAttributeMsg msg); + void process(TransportProtos.PostTelemetryMsg msg, TransportServiceCallback callback); + + void process(TransportProtos.PostAttributeMsg msg, TransportServiceCallback callback); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java new file mode 100644 index 0000000000..bfc7ac890e --- /dev/null +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java @@ -0,0 +1,11 @@ +package org.thingsboard.server.common.transport; + +/** + * Created by ashvayka on 04.10.18. + */ +public interface TransportServiceCallback { + + void onSuccess(T msg); + void onError(Exception e); + +} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index 2e7b412c31..28269454c7 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport.session; +import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; @@ -23,56 +24,27 @@ import org.thingsboard.server.common.msg.session.SessionContext; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.auth.DeviceAuthResult; import org.thingsboard.server.common.transport.auth.DeviceAuthService; +import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Optional; /** * @author Andrew Shvayka */ -@Slf4j +@Data public abstract class DeviceAwareSessionContext implements SessionContext { - protected final DeviceAuthService authService; - protected final SessionMsgProcessor processor; + private volatile TransportProtos.DeviceInfoProto deviceInfo; - protected volatile Device device; - - public DeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService) { - this.processor = processor; - this.authService = authService; - } - - public DeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, Device device) { - this(processor, authService); - this.device = device; - } - - - public boolean login(DeviceCredentialsFilter credentials) { - DeviceAuthResult result = authService.process(credentials); - if (result.isSuccess()) { - Optional deviceOpt = authService.findDeviceById(result.getDeviceId()); - if (deviceOpt.isPresent()) { - device = deviceOpt.get(); - } - return true; - } else { - log.debug("Can't find device using credentials [{}] due to {}", credentials, result.getErrorMsg()); - return false; - } - } - - public DeviceAuthService getAuthService() { - return authService; + public long getDeviceIdMSB() { + return deviceInfo.getDeviceIdMSB(); } - public SessionMsgProcessor getProcessor() { - return processor; + public long getDeviceIdLSB() { + return deviceInfo.getDeviceIdLSB(); } - public Device getDevice() { - return device; + public boolean isConnected() { + return deviceInfo != null; } - - } diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index d77e79bbd0..65d1468605 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -46,6 +46,14 @@ message TsKvListProto { repeated KeyValueProto kv = 2; } +message DeviceInfoProto { + int64 deviceIdMSB = 1; + int64 deviceIdLSB = 2; + string deviceName = 3; + string deviceType = 4; + string additionalInfo = 5; +} + /** * Messages that use Data Structures; */ @@ -53,6 +61,7 @@ message SessionEventMsg { SessionInfoProto sessionInfo = 1; int64 deviceIdMSB = 2; int64 deviceIdLSB = 3; + SessionEvent event = 4; } message PostTelemetryMsg { @@ -76,4 +85,13 @@ message GetAttributeResponseMsg { repeated TsKvListProto clientAttributeList = 2; repeated TsKvListProto sharedAttributeList = 3; repeated string deletedAttributeKeys = 4; -} \ No newline at end of file +} + +message ValidateDeviceTokenRequestMsg { + string token = 1; +} + +message ValidateDeviceTokenResponseMsg { + DeviceInfoProto deviceInfo = 1; +} + diff --git a/transport/mqtt-transport/pom.xml b/transport/mqtt-transport/pom.xml new file mode 100644 index 0000000000..8d8b24d6c6 --- /dev/null +++ b/transport/mqtt-transport/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + transport + + org.thingsboard.transport + mqtt-transport + jar + + Thingsboard MQTT Transport Service + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + + + + + org.thingsboard.common + transport + + + io.netty + netty-all + + + org.springframework + spring-context + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + com.google.guava + guava + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java new file mode 100644 index 0000000000..3c54938a37 --- /dev/null +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java @@ -0,0 +1,48 @@ +package org.thingsboard.server.mqtt; /** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import springfox.documentation.swagger2.annotations.EnableSwagger2; + +import java.util.Arrays; + +@SpringBootConfiguration +@EnableAsync +@EnableScheduling +@ComponentScan({"org.thingsboard.server"}) +public class ThingsboardMqttTransportApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "tb-mqtt-transport"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardMqttTransportApplication.class, updateArguments(args)); + } + + private static String[] updateArguments(String[] args) { + if (Arrays.stream(args).noneMatch(arg -> arg.startsWith(SPRING_CONFIG_NAME_KEY))) { + String[] modifiedArgs = new String[args.length + 1]; + System.arraycopy(args, 0, modifiedArgs, 0, args.length); + modifiedArgs[args.length] = DEFAULT_SPRING_CONFIG_PARAM; + return modifiedArgs; + } + return args; + } +} diff --git a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml new file mode 100644 index 0000000000..c12f25edcf --- /dev/null +++ b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml @@ -0,0 +1,57 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +spring.main.web-application-type: none + +# MQTT server parameters +mqtt: + # Enable/disable mqtt transport protocol. + enabled: "${MQTT_ENABLED:true}" + bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" + bind_port: "${MQTT_BIND_PORT:1883}" + adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" + timeout: "${MQTT_TIMEOUT:10000}" + netty: + leak_detector_level: "${NETTY_LEASK_DETECTOR_LVL:DISABLED}" + boss_group_thread_count: "${NETTY_BOSS_GROUP_THREADS:1}" + worker_group_thread_count: "${NETTY_WORKER_GROUP_THREADS:12}" + max_payload_size: "${NETTY_MAX_PAYLOAD_SIZE:65536}" + # MQTT SSL configuration + ssl: + # Enable/disable SSL support + enabled: "${MQTT_SSL_ENABLED:false}" + # SSL protocol: See http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#SSLContext + protocol: "${MQTT_SSL_PROTOCOL:TLSv1.2}" + # Path to the key store that holds the SSL certificate + key_store: "${MQTT_SSL_KEY_STORE:mqttserver.jks}" + # Password used to access the key store + key_store_password: "${MQTT_SSL_KEY_STORE_PASSWORD:server_ks_password}" + # Password used to access the key + key_password: "${MQTT_SSL_KEY_PASSWORD:server_key_password}" + # Type of the key store + key_store_type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" + +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + topic: + telemetry: "${TB_TELEMETRY_TOPIC:tb.transport.telemetry}" + requests: "${TB_TELEMETRY_TOPIC:tb.transport.requests}" \ No newline at end of file diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 43e2a3440e..cc40ee0975 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -1,22 +1,33 @@ package org.thingsboard.server.transport.mqtt; +import com.fasterxml.jackson.databind.ObjectMapper; import io.netty.handler.ssl.SslHandler; import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; +import javax.annotation.PostConstruct; +import java.net.InetAddress; +import java.net.UnknownHostException; + /** * Created by ashvayka on 04.10.18. */ +@Slf4j @Component @Data public class MqttTransportContext { + private final ObjectMapper mapper = new ObjectMapper(); + @Autowired @Lazy private TransportService transportService; @@ -33,6 +44,21 @@ public class MqttTransportContext { @Value("${mqtt.netty.max_payload_size}") private Integer maxPayloadSize; + @Value("${cluster.node_id:#{null}}") + private String nodeId; + private SslHandler sslHandler; + @PostConstruct + public void init() { + if (StringUtils.isEmpty(nodeId)) { + try { + nodeId = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + nodeId = RandomStringUtils.randomAlphabetic(10); + } + } + log.info("Current NodeId: {}", nodeId); + } + } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index ed0c8c642c..fd02d3aa39 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -18,9 +18,21 @@ package org.thingsboard.server.transport.mqtt; import com.fasterxml.jackson.databind.JsonNode; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; -import io.netty.handler.codec.mqtt.*; +import io.netty.handler.codec.mqtt.MqttConnAckMessage; +import io.netty.handler.codec.mqtt.MqttConnAckVariableHeader; +import io.netty.handler.codec.mqtt.MqttConnectMessage; import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; +import io.netty.handler.codec.mqtt.MqttPubAckMessage; +import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttSubAckMessage; +import io.netty.handler.codec.mqtt.MqttSubAckPayload; +import io.netty.handler.codec.mqtt.MqttSubscribeMessage; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage; import io.netty.handler.ssl.SslHandler; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.GenericFutureListener; @@ -34,13 +46,12 @@ import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.transport.SessionMsgProcessor; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.quota.QuotaService; import org.thingsboard.server.dao.EncryptionUtil; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; import org.thingsboard.server.transport.mqtt.session.GatewaySessionCtx; @@ -48,18 +59,51 @@ import org.thingsboard.server.transport.mqtt.util.SslUtil; import javax.net.ssl.SSLPeerUnverifiedException; import javax.security.cert.X509Certificate; +import java.io.IOException; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; -import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.*; -import static io.netty.handler.codec.mqtt.MqttMessageType.*; -import static io.netty.handler.codec.mqtt.MqttQoS.*; -import static org.thingsboard.server.common.msg.session.SessionMsgType.*; -import static org.thingsboard.server.transport.mqtt.MqttTopics.*; +import org.thingsboard.server.gen.transport.TransportProtos.*; + +import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_ACCEPTED; +import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD; +import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED; +import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.PINGRESP; +import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.SUBACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.UNSUBACK; +import static io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE; +import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE; +import static io.netty.handler.codec.mqtt.MqttQoS.FAILURE; +import static org.thingsboard.server.common.msg.session.SessionMsgType.GET_ATTRIBUTES_REQUEST; +import static org.thingsboard.server.common.msg.session.SessionMsgType.POST_ATTRIBUTES_REQUEST; +import static org.thingsboard.server.common.msg.session.SessionMsgType.POST_TELEMETRY_REQUEST; +import static org.thingsboard.server.common.msg.session.SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; +import static org.thingsboard.server.common.msg.session.SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; +import static org.thingsboard.server.common.msg.session.SessionMsgType.TO_DEVICE_RPC_RESPONSE; +import static org.thingsboard.server.common.msg.session.SessionMsgType.TO_SERVER_RPC_REQUEST; +import static org.thingsboard.server.common.msg.session.SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST; +import static org.thingsboard.server.common.msg.session.SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST; +import static org.thingsboard.server.transport.mqtt.MqttTopics.BASE_GATEWAY_API_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_ATTRIBUTES_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_REQUESTS_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_RESPONSE_SUB_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_RESPONSE_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_TELEMETRY_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_ATTRIBUTES_REQUEST_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_ATTRIBUTES_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_CONNECT_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_DISCONNECT_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_RPC_TOPIC; +import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_TELEMETRY_TOPIC; /** * @author Andrew Shvayka @@ -69,33 +113,34 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement public static final MqttQoS MAX_SUPPORTED_QOS_LVL = AT_LEAST_ONCE; - private final DeviceSessionCtx deviceSessionCtx; - private final String sessionId; + private final UUID sessionId; + private final MqttTransportContext context; private final MqttTransportAdaptor adaptor; - private final SessionMsgProcessor processor; - private final DeviceService deviceService; - private final DeviceAuthService authService; - private final RelationService relationService; + private final TransportService transportService; private final QuotaService quotaService; private final SslHandler sslHandler; private final ConcurrentMap mqttQoSMap; - private volatile boolean connected; + private final SessionInfoProto sessionInfo; + private volatile InetSocketAddress address; + private volatile DeviceSessionCtx deviceSessionCtx; private volatile GatewaySessionCtx gatewaySessionCtx; - public MqttTransportHandler(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, - MqttTransportAdaptor adaptor, SslHandler sslHandler, QuotaService quotaService) { - this.processor = processor; - this.deviceService = deviceService; - this.relationService = relationService; - this.authService = authService; - this.adaptor = adaptor; + public MqttTransportHandler(MqttTransportContext context) { + this.sessionId = UUID.randomUUID(); + this.context = context; + this.transportService = context.getTransportService(); + this.adaptor = context.getAdaptor(); + this.quotaService = context.getQuotaService(); + this.sslHandler = context.getSslHandler(); this.mqttQoSMap = new ConcurrentHashMap<>(); - this.deviceSessionCtx = new DeviceSessionCtx(processor, authService, adaptor, mqttQoSMap); - this.sessionId = deviceSessionCtx.getSessionId().toUidStr(); - this.sslHandler = sslHandler; - this.quotaService = quotaService; + this.sessionInfo = SessionInfoProto.newBuilder() + .setNodeId(context.getNodeId()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .build(); + this.deviceSessionCtx = new DeviceSessionCtx(mqttQoSMap); } @Override @@ -127,196 +172,196 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement case CONNECT: processConnect(ctx, (MqttConnectMessage) msg); break; - case PUBLISH: - processPublish(ctx, (MqttPublishMessage) msg); - break; - case SUBSCRIBE: - processSubscribe(ctx, (MqttSubscribeMessage) msg); - break; - case UNSUBSCRIBE: - processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); - break; - case PINGREQ: - if (checkConnected(ctx)) { - ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); - } - break; - case DISCONNECT: - if (checkConnected(ctx)) { - processDisconnect(ctx); - } - break; +// case PUBLISH: +// processPublish(ctx, (MqttPublishMessage) msg); +// break; +// case SUBSCRIBE: +// processSubscribe(ctx, (MqttSubscribeMessage) msg); +// break; +// case UNSUBSCRIBE: +// processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); +// break; +// case PINGREQ: +// if (checkConnected(ctx)) { +// ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); +// } +// break; +// case DISCONNECT: +// if (checkConnected(ctx)) { +// processDisconnect(ctx); +// } +// break; default: break; } } - private void processPublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg) { - if (!checkConnected(ctx)) { - return; - } - String topicName = mqttMsg.variableHeader().topicName(); - int msgId = mqttMsg.variableHeader().messageId(); - log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId); - - if (topicName.startsWith(BASE_GATEWAY_API_TOPIC)) { - if (gatewaySessionCtx != null) { - gatewaySessionCtx.setChannel(ctx); - handleMqttPublishMsg(topicName, msgId, mqttMsg); - } - } else { - processDevicePublish(ctx, mqttMsg, topicName, msgId); - } - } - - private void handleMqttPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { - try { - switch (topicName) { - case GATEWAY_TELEMETRY_TOPIC: - gatewaySessionCtx.onDeviceTelemetry(mqttMsg); - break; - case GATEWAY_ATTRIBUTES_TOPIC: - gatewaySessionCtx.onDeviceAttributes(mqttMsg); - break; - case GATEWAY_ATTRIBUTES_REQUEST_TOPIC: - gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); - break; - case GATEWAY_RPC_TOPIC: - gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); - break; - case GATEWAY_CONNECT_TOPIC: - gatewaySessionCtx.onDeviceConnect(mqttMsg); - break; - case GATEWAY_DISCONNECT_TOPIC: - gatewaySessionCtx.onDeviceDisconnect(mqttMsg); - break; - } - } catch (RuntimeException | AdaptorException e) { - log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); - } - } - - private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { - AdaptorToSessionActorMsg msg = null; - try { - if (topicName.equals(DEVICE_TELEMETRY_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_TELEMETRY_REQUEST, mqttMsg); - } else if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_ATTRIBUTES_REQUEST, mqttMsg); - } else if (topicName.startsWith(DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, GET_ATTRIBUTES_REQUEST, mqttMsg); - if (msgId >= 0) { - ctx.writeAndFlush(createMqttPubAckMsg(msgId)); - } - } else if (topicName.startsWith(DEVICE_RPC_RESPONSE_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_DEVICE_RPC_RESPONSE, mqttMsg); - if (msgId >= 0) { - ctx.writeAndFlush(createMqttPubAckMsg(msgId)); - } - } else if (topicName.startsWith(DEVICE_RPC_REQUESTS_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_SERVER_RPC_REQUEST, mqttMsg); - if (msgId >= 0) { - ctx.writeAndFlush(createMqttPubAckMsg(msgId)); - } - } - } catch (AdaptorException e) { - log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); - } - if (msg != null) { - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - } else { - log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); - ctx.close(); - } - } - - private void processSubscribe(ChannelHandlerContext ctx, MqttSubscribeMessage mqttMsg) { - if (!checkConnected(ctx)) { - return; - } - log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); - List grantedQoSList = new ArrayList<>(); - for (MqttTopicSubscription subscription : mqttMsg.payload().topicSubscriptions()) { - String topic = subscription.topicName(); - MqttQoS reqQoS = subscription.qualityOfService(); - try { - switch (topic) { - case DEVICE_ATTRIBUTES_TOPIC: { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - registerSubQoS(topic, grantedQoSList, reqQoS); - break; - } - case DEVICE_RPC_REQUESTS_SUB_TOPIC: { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - registerSubQoS(topic, grantedQoSList, reqQoS); - break; - } - case DEVICE_RPC_RESPONSE_SUB_TOPIC: - case GATEWAY_ATTRIBUTES_TOPIC: - case GATEWAY_RPC_TOPIC: - registerSubQoS(topic, grantedQoSList, reqQoS); - break; - case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: - deviceSessionCtx.setAllowAttributeResponses(); - registerSubQoS(topic, grantedQoSList, reqQoS); - break; - default: - log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); - grantedQoSList.add(FAILURE.value()); - break; - } - } catch (AdaptorException e) { - log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); - grantedQoSList.add(FAILURE.value()); - } - } - ctx.writeAndFlush(createSubAckMessage(mqttMsg.variableHeader().messageId(), grantedQoSList)); - } - - private void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { - grantedQoSList.add(getMinSupportedQos(reqQoS)); - mqttQoSMap.put(topic, getMinSupportedQos(reqQoS)); - } - - private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { - if (!checkConnected(ctx)) { - return; - } - log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); - for (String topicName : mqttMsg.payload().topics()) { - mqttQoSMap.remove(topicName); - try { - switch (topicName) { - case DEVICE_ATTRIBUTES_TOPIC: { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - break; - } - case DEVICE_RPC_REQUESTS_SUB_TOPIC: { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - break; - } - case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: - deviceSessionCtx.setDisallowAttributeResponses(); - break; - } - } catch (AdaptorException e) { - log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); - } - } - ctx.writeAndFlush(createUnSubAckMessage(mqttMsg.variableHeader().messageId())); - } - - private MqttMessage createUnSubAckMessage(int msgId) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(UNSUBACK, false, AT_LEAST_ONCE, false, 0); - MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); - return new MqttMessage(mqttFixedHeader, mqttMessageIdVariableHeader); - } +// private void processPublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg) { +// if (!checkConnected(ctx)) { +// return; +// } +// String topicName = mqttMsg.variableHeader().topicName(); +// int msgId = mqttMsg.variableHeader().packetId(); +// log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId); +// +// if (topicName.startsWith(BASE_GATEWAY_API_TOPIC)) { +// if (gatewaySessionCtx != null) { +// gatewaySessionCtx.setChannel(ctx); +// handleMqttPublishMsg(topicName, msgId, mqttMsg); +// } +// } else { +// processDevicePublish(ctx, mqttMsg, topicName, msgId); +// } +// } +// +// private void handleMqttPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { +// try { +// switch (topicName) { +// case GATEWAY_TELEMETRY_TOPIC: +// gatewaySessionCtx.onDeviceTelemetry(mqttMsg); +// break; +// case GATEWAY_ATTRIBUTES_TOPIC: +// gatewaySessionCtx.onDeviceAttributes(mqttMsg); +// break; +// case GATEWAY_ATTRIBUTES_REQUEST_TOPIC: +// gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); +// break; +// case GATEWAY_RPC_TOPIC: +// gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); +// break; +// case GATEWAY_CONNECT_TOPIC: +// gatewaySessionCtx.onDeviceConnect(mqttMsg); +// break; +// case GATEWAY_DISCONNECT_TOPIC: +// gatewaySessionCtx.onDeviceDisconnect(mqttMsg); +// break; +// } +// } catch (RuntimeException | AdaptorException e) { +// log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); +// } +// } +// +// private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { +// AdaptorToSessionActorMsg msg = null; +// try { +// if (topicName.equals(DEVICE_TELEMETRY_TOPIC)) { +// msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_TELEMETRY_REQUEST, mqttMsg); +// } else if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { +// msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_ATTRIBUTES_REQUEST, mqttMsg); +// } else if (topicName.startsWith(DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { +// msg = adaptor.convertToActorMsg(deviceSessionCtx, GET_ATTRIBUTES_REQUEST, mqttMsg); +// if (msgId >= 0) { +// ctx.writeAndFlush(createMqttPubAckMsg(msgId)); +// } +// } else if (topicName.startsWith(DEVICE_RPC_RESPONSE_TOPIC)) { +// msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_DEVICE_RPC_RESPONSE, mqttMsg); +// if (msgId >= 0) { +// ctx.writeAndFlush(createMqttPubAckMsg(msgId)); +// } +// } else if (topicName.startsWith(DEVICE_RPC_REQUESTS_TOPIC)) { +// msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_SERVER_RPC_REQUEST, mqttMsg); +// if (msgId >= 0) { +// ctx.writeAndFlush(createMqttPubAckMsg(msgId)); +// } +// } +// } catch (AdaptorException e) { +// log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); +// } +// if (msg != null) { +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); +// } else { +// log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); +// ctx.close(); +// } +// } +// +// private void processSubscribe(ChannelHandlerContext ctx, MqttSubscribeMessage mqttMsg) { +// if (!checkConnected(ctx)) { +// return; +// } +// log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); +// List grantedQoSList = new ArrayList<>(); +// for (MqttTopicSubscription subscription : mqttMsg.payload().topicSubscriptions()) { +// String topic = subscription.topicName(); +// MqttQoS reqQoS = subscription.qualityOfService(); +// try { +// switch (topic) { +// case DEVICE_ATTRIBUTES_TOPIC: { +// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); +// registerSubQoS(topic, grantedQoSList, reqQoS); +// break; +// } +// case DEVICE_RPC_REQUESTS_SUB_TOPIC: { +// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); +// registerSubQoS(topic, grantedQoSList, reqQoS); +// break; +// } +// case DEVICE_RPC_RESPONSE_SUB_TOPIC: +// case GATEWAY_ATTRIBUTES_TOPIC: +// case GATEWAY_RPC_TOPIC: +// registerSubQoS(topic, grantedQoSList, reqQoS); +// break; +// case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: +// deviceSessionCtx.setAllowAttributeResponses(); +// registerSubQoS(topic, grantedQoSList, reqQoS); +// break; +// default: +// log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); +// grantedQoSList.add(FAILURE.value()); +// break; +// } +// } catch (AdaptorException e) { +// log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); +// grantedQoSList.add(FAILURE.value()); +// } +// } +// ctx.writeAndFlush(createSubAckMessage(mqttMsg.variableHeader().messageId(), grantedQoSList)); +// } +// +// private void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { +// grantedQoSList.add(getMinSupportedQos(reqQoS)); +// mqttQoSMap.put(topic, getMinSupportedQos(reqQoS)); +// } +// +// private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { +// if (!checkConnected(ctx)) { +// return; +// } +// log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); +// for (String topicName : mqttMsg.payload().topics()) { +// mqttQoSMap.remove(topicName); +// try { +// switch (topicName) { +// case DEVICE_ATTRIBUTES_TOPIC: { +// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); +// break; +// } +// case DEVICE_RPC_REQUESTS_SUB_TOPIC: { +// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); +// break; +// } +// case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: +// deviceSessionCtx.setDisallowAttributeResponses(); +// break; +// } +// } catch (AdaptorException e) { +// log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); +// } +// } +// ctx.writeAndFlush(createUnSubAckMessage(mqttMsg.variableHeader().messageId())); +// } +// +// private MqttMessage createUnSubAckMessage(int msgId) { +// MqttFixedHeader mqttFixedHeader = +// new MqttFixedHeader(UNSUBACK, false, AT_LEAST_ONCE, false, 0); +// MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); +// return new MqttMessage(mqttFixedHeader, mqttMessageIdVariableHeader); +// } private void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) { log.info("[{}] Processing connect msg for client: {}!", sessionId, msg.payload().clientIdentifier()); @@ -333,36 +378,58 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement if (StringUtils.isEmpty(userName)) { ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD)); ctx.close(); - } else if (!deviceSessionCtx.login(new DeviceTokenCredentials(msg.payload().userName()))) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); } else { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); - connected = true; - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new SessionOpenMsg()))); - checkGatewaySession(); + transportService.process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(userName).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(ValidateDeviceTokenResponseMsg msg) { + if (!msg.hasDeviceInfo()) { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); + ctx.close(); + } else { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); + deviceSessionCtx.setDeviceInfo(deviceSessionCtx.getDeviceInfo()); + transportService.process(getSessionEventMsg(SessionEvent.OPEN), null); + checkGatewaySession(); + } + } + + @Override + public void onError(Exception e) { + log.trace("[{}] Failed to process credentials: {}", address, userName, e); + ctx.writeAndFlush(createMqttConnAckMsg(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE)); + ctx.close(); + } + }); } } + protected SessionEventMsg getSessionEventMsg(SessionEvent event) { + return SessionEventMsg.newBuilder() + .setSessionInfo(sessionInfo) + .setDeviceIdMSB(deviceSessionCtx.getDeviceIdMSB()) + .setDeviceIdLSB(deviceSessionCtx.getDeviceIdLSB()) + .setEvent(event).build(); + } + private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate cert) { - try { - String strCert = SslUtil.getX509CertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - if (deviceSessionCtx.login(new DeviceX509Credentials(sha3Hash))) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); - connected = true; - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new SessionOpenMsg()))); - checkGatewaySession(); - } else { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); - } - } catch (Exception e) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); - } +// try { +// String strCert = SslUtil.getX509CertificateString(cert); +// String sha3Hash = EncryptionUtil.getSha3Hash(strCert); +// if (deviceSessionCtx.login(new DeviceX509Credentials(sha3Hash))) { +// ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); +// connected = true; +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), +// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new SessionOpenMsg()))); +// checkGatewaySession(); +// } else { +// ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); +// ctx.close(); +// } +// } catch (Exception e) { +// ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); +// ctx.close(); +// } } private X509Certificate getX509Certificate() { @@ -380,8 +447,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void processDisconnect(ChannelHandlerContext ctx) { ctx.close(); - if (connected) { - processor.process(SessionCloseMsg.onDisconnect(deviceSessionCtx.getSessionId())); + if (deviceSessionCtx.isConnected()) { + transportService.process(getSessionEventMsg(SessionEvent.CLOSED), null); if (gatewaySessionCtx != null) { gatewaySessionCtx.onGatewayDisconnect(); } @@ -428,7 +495,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } private boolean checkConnected(ChannelHandlerContext ctx) { - if (connected) { + if (deviceSessionCtx.isConnected()) { return true; } else { log.info("[{}] Closing current session due to invalid msg order [{}][{}]", sessionId); @@ -438,18 +505,22 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } private void checkGatewaySession() { - Device device = deviceSessionCtx.getDevice(); - JsonNode infoNode = device.getAdditionalInfo(); - if (infoNode != null) { - JsonNode gatewayNode = infoNode.get("gateway"); - if (gatewayNode != null && gatewayNode.asBoolean()) { - gatewaySessionCtx = new GatewaySessionCtx(processor, deviceService, authService, relationService, deviceSessionCtx); + DeviceInfoProto device = deviceSessionCtx.getDeviceInfo(); + try { + JsonNode infoNode = context.getMapper().readTree(device.getAdditionalInfo()); + if (infoNode != null) { + JsonNode gatewayNode = infoNode.get("gateway"); + if (gatewayNode != null && gatewayNode.asBoolean()) { + gatewaySessionCtx = new GatewaySessionCtx(deviceSessionCtx); + } } + } catch (IOException e) { + log.trace("[{}][{}] Failed to fetch device additional info", sessionId, device.getDeviceName(), e); } } @Override public void operationComplete(Future future) throws Exception { - processor.process(SessionCloseMsg.onError(deviceSessionCtx.getSessionId())); + transportService.process(getSessionEventMsg(SessionEvent.CLOSED), null); } } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index 3dbb3efe0e..3f29a82fe1 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -40,15 +40,13 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { - private final MqttTransportAdaptor adaptor; private final MqttSessionId sessionId; private ChannelHandlerContext channel; private volatile boolean allowAttributeResponses; private AtomicInteger msgIdSeq = new AtomicInteger(0); - public DeviceSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, MqttTransportAdaptor adaptor, ConcurrentMap mqttQoSMap) { - super(processor, authService, mqttQoSMap); - this.adaptor = adaptor; + public DeviceSessionCtx(ConcurrentMap mqttQoSMap) { + super(null, null, null); this.sessionId = new MqttSessionId(); } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java index 98ad6d2c2c..dec86028b7 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -78,6 +78,10 @@ public class GatewaySessionCtx { this.mqttQoSMap = gatewaySessionCtx.getMqttQoSMap(); } + public GatewaySessionCtx(DeviceSessionCtx deviceSessionCtx) { + + } + public void onDeviceConnect(MqttPublishMessage msg) throws AdaptorException { JsonElement json = getJson(msg); String deviceName = checkDeviceName(getDeviceName(json)); diff --git a/transport/pom.xml b/transport/pom.xml index 95b0112a2a..a01c7ac7ba 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -38,6 +38,7 @@ http coap mqtt + mqtt-transport From f9ba02570ecd85a33bda997cb6d1700526dd7be7 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 4 Oct 2018 19:44:01 +0300 Subject: [PATCH 165/307] Variables for docker image names --- msa/docker/.env | 5 +++++ msa/docker/docker-compose.yml | 8 ++++---- msa/js-executor/pom.xml | 3 ++- msa/tb-node/pom.xml | 3 ++- msa/web-ui/pom.xml | 3 ++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/msa/docker/.env b/msa/docker/.env index 3b0b91d353..111ff1e2b4 100644 --- a/msa/docker/.env +++ b/msa/docker/.env @@ -1,5 +1,10 @@ DOCKER_REPO=local-maven-build + +JS_EXECUTOR_DOCKER_NAME=tb-js-executor +TB_NODE_DOCKER_NAME=tb-node +WEB_UI_DOCKER_NAME=tb-web-ui + TB_VERSION=2.2.0-SNAPSHOT KAFKA_TOPICS=js.eval.requests:100:1 diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 9ddbea3a04..b6b44d7401 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -40,7 +40,7 @@ services: - zookeeper tb-js-executor: restart: always - image: "${DOCKER_REPO}/tb-js-executor:${TB_VERSION}" + image: "${DOCKER_REPO}/${JS_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" environment: TB_KAFKA_SERVERS: kafka:9092 env_file: @@ -49,7 +49,7 @@ services: - kafka tb: restart: always - image: "${DOCKER_REPO}/tb-node:${TB_VERSION}" + image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" ports: - "8080" - "1883:1883" @@ -68,7 +68,7 @@ services: - kafka tb-web-ui1: restart: always - image: "${DOCKER_REPO}/tb-web-ui:${TB_VERSION}" + image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" ports: - "8080" environment: @@ -78,7 +78,7 @@ services: - tb-web-ui.env tb-web-ui2: restart: always - image: "${DOCKER_REPO}/tb-web-ui:${TB_VERSION}" + image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" ports: - "8080" environment: diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 84792f18b8..6f11df1452 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -35,6 +35,7 @@ UTF-8 ${basedir}/../.. tb-js-executor + tb-js-executor thingsboard /var/log/${pkg.name} /usr/share/${pkg.name} @@ -290,7 +291,7 @@ ${dockerfile.skip} - ${docker.repo}/${pkg.name} + ${docker.repo}/${docker.name} ${project.version} true false diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 436d88b994..18a81f3970 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -35,6 +35,7 @@ UTF-8 ${basedir}/../.. thingsboard + tb-node thingsboard /var/log/${pkg.name} /usr/share/${pkg.name} @@ -114,7 +115,7 @@ ${dockerfile.skip} - ${docker.repo}/tb-node + ${docker.repo}/${docker.name} ${project.version} true false diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 32a076d183..805bf8b821 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -35,6 +35,7 @@ UTF-8 ${basedir}/../.. tb-web-ui + tb-web-ui thingsboard /var/log/${pkg.name} /usr/share/${pkg.name} @@ -314,7 +315,7 @@ ${dockerfile.skip} - ${docker.repo}/${pkg.name} + ${docker.repo}/${docker.name} ${project.version} true false From 4238437b81d83da32de98b66d1302096d7bccb73 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 4 Oct 2018 20:00:08 +0300 Subject: [PATCH 166/307] Docker for tb-node: Stop thingsboard process on OOM --- msa/docker/tb-node/conf/thingsboard.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msa/docker/tb-node/conf/thingsboard.conf b/msa/docker/tb-node/conf/thingsboard.conf index 1fc5ac4835..85d7cfd871 100644 --- a/msa/docker/tb-node/conf/thingsboard.conf +++ b/msa/docker/tb-node/conf/thingsboard.conf @@ -19,6 +19,6 @@ export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/thingsboard/gc.log -XX:+IgnoreUnre export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" -export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" export LOG_FILENAME=thingsboard.out export LOADER_PATH=/usr/share/thingsboard/conf,/usr/share/thingsboard/extensions From af8c0bbe395be8bfc52af8bfdd68b364f4a1d87b Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 5 Oct 2018 12:54:41 +0300 Subject: [PATCH 167/307] Add missing upgrade to 2.1.1 for cassandra DB --- .../service/install/CassandraDatabaseUpgradeService.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index f4ff92cf23..eb336f9f61 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -202,6 +202,14 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { loadCql(schemaUpdateFile); log.info("Schema updated."); + break; + case "2.0.0": + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.1", SCHEMA_UPDATE_CQL); + loadCql(schemaUpdateFile); + log.info("Schema updated."); + break; default: throw new RuntimeException("Unable to upgrade Cassandra database, unsupported fromVersion: " + fromVersion); From f0d7cd6348d48a60ca41da7c61bcf150f9ddc13f Mon Sep 17 00:00:00 2001 From: Sergey Tarnavskiy Date: Fri, 5 Oct 2018 17:09:55 +0300 Subject: [PATCH 168/307] fixed issue with header actions in rpc-widgets --- ui/src/app/api/subscription.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/app/api/subscription.js b/ui/src/app/api/subscription.js index a66c4b1efa..4a38f7166c 100644 --- a/ui/src/app/api/subscription.js +++ b/ui/src/app/api/subscription.js @@ -172,9 +172,9 @@ export default class Subscription { if (this.type === this.ctx.types.widgetType.rpc.value) { if (this.targetDeviceId) { entityId = { - entityType: this.ctx.entityType.device, + entityType: this.ctx.types.entityType.device, id: this.targetDeviceId - } + }; entityName = this.targetDeviceName; } } else if (this.type == this.ctx.types.widgetType.alarm.value) { @@ -182,7 +182,7 @@ export default class Subscription { entityId = { entityType: this.alarmSource.entityType, id: this.alarmSource.entityId - } + }; entityName = this.alarmSource.entityName; } } else { @@ -192,7 +192,7 @@ export default class Subscription { entityId = { entityType: datasource.entityType, id: datasource.entityId - } + }; entityName = datasource.entityName; break; } From caf38f675594a08c66619058c4dfa9d757e7417b Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 5 Oct 2018 18:52:59 +0300 Subject: [PATCH 169/307] Some refactoring --- application/pom.xml | 2 +- .../server/kafka/TBKafkaConsumerTemplate.java | 3 +- .../server/kafka/TBKafkaProducerTemplate.java | 30 +- .../server/kafka/TbKafkaHandler.java | 12 + .../server/kafka/TbKafkaRequestTemplate.java | 15 +- .../server/kafka/TbKafkaResponseTemplate.java | 173 +++++++++++ common/transport/pom.xml | 5 + .../common/transport/TransportService.java | 15 + .../transport/TransportServiceCallback.java | 17 +- .../tenant/TenantIntervalRegistryCleaner.java | 2 + .../tenant/TenantIntervalRegistryLogger.java | 2 + .../tenant/TenantMsgsIntervalRegistry.java | 2 + .../quota/tenant/TenantQuotaService.java | 2 + .../tenant/TenantRequestLimitPolicy.java | 2 + .../session/DeviceAwareSessionContext.java | 8 +- .../transport/src/main/proto/transport.proto | 14 + .../quota/HostRequestsQuotaServiceTest.java | 6 +- pom.xml | 2 +- transport/{mqtt => mqtt-common}/pom.xml | 4 +- .../mqtt/MqttSslHandlerProvider.java | 0 .../server/transport/mqtt/MqttTopics.java | 0 .../transport/mqtt/MqttTransportContext.java | 0 .../transport/mqtt/MqttTransportHandler.java | 6 +- .../mqtt/MqttTransportServerInitializer.java | 0 .../transport/mqtt/MqttTransportService.java | 0 .../mqtt/adaptors/JsonMqttAdaptor.java | 0 .../mqtt/adaptors/MqttTransportAdaptor.java | 0 .../mqtt/session/DeviceSessionCtx.java | 12 +- .../mqtt/session/GatewayDeviceSessionCtx.java | 6 +- .../mqtt/session/GatewaySessionCtx.java | 284 ++++++++++++++++++ .../MqttDeviceAwareSessionContext.java | 4 +- .../transport/mqtt/session/MqttSessionId.java | 0 .../server/transport/mqtt/util/SslUtil.java | 0 transport/mqtt-transport/pom.xml | 28 +- .../ThingsboardMqttTransportApplication.java | 3 +- .../mqtt/service/AsyncCallbackTemplate.java | 45 +++ .../mqtt/service/MqttTransportService.java | 110 +++++++ .../service/TransportApiRequestEncoder.java | 14 + .../service/TransportApiResponseDecoder.java | 16 + .../src/main/resources/tb-mqtt-transport.yml | 34 ++- .../mqtt/session/GatewaySessionCtx.java | 280 ----------------- transport/pom.xml | 2 +- 42 files changed, 805 insertions(+), 355 deletions(-) create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java rename transport/{mqtt => mqtt-common}/pom.xml (97%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java (99%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java (93%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java (97%) create mode 100644 transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java (95%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java (100%) rename transport/{mqtt => mqtt-common}/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java (100%) create mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java create mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java create mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java create mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java delete mode 100644 transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java diff --git a/application/pom.xml b/application/pom.xml index c4766f2ba8..bff96ec6fd 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -70,7 +70,7 @@ org.thingsboard.transport - mqtt + mqtt-common org.thingsboard diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java index 397226479e..3adb1c368d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -43,7 +43,8 @@ public class TBKafkaConsumerTemplate { private final String topic; @Builder - private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, TbKafkaRequestIdExtractor requestIdExtractor, + private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, + TbKafkaRequestIdExtractor requestIdExtractor, String clientId, String groupId, String topic, boolean autoCommit, int autoCommitIntervalMs) { Properties props = settings.toProps(); diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index 7e24ad00a6..2611e9491a 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -27,13 +27,12 @@ import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.PartitionInfo; import org.apache.kafka.common.header.Header; -import java.nio.ByteBuffer; -import java.util.Arrays; import java.util.List; import java.util.Properties; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Future; -import java.util.function.BiConsumer; /** * Created by ashvayka on 24.09.18. @@ -48,7 +47,7 @@ public class TBKafkaProducerTemplate { private TbKafkaEnricher enricher = ((value, responseTopic, requestId) -> value); private final TbKafkaPartitioner partitioner; - private List partitionInfoList; + private ConcurrentMap> partitionInfoMap; @Getter private final String defaultTopic; @@ -78,11 +77,16 @@ public class TBKafkaProducerTemplate { log.trace("Failed to create topic: {}", e.getMessage(), e); } //Maybe this should not be cached, but we don't plan to change size of partitions - this.partitionInfoList = producer.partitionsFor(defaultTopic); + this.partitionInfoMap = new ConcurrentHashMap<>(); + this.partitionInfoMap.putIfAbsent(defaultTopic, producer.partitionsFor(defaultTopic)); } - public T enrich(T value, String responseTopic, UUID requestId) { - return enricher.enrich(value, responseTopic, requestId); + T enrich(T value, String responseTopic, UUID requestId) { + if (enricher != null) { + return enricher.enrich(value, responseTopic, requestId); + } else { + return value; + } } public Future send(String key, T value) { @@ -101,7 +105,7 @@ public class TBKafkaProducerTemplate { byte[] data = encoder.encode(value); ProducerRecord record; Integer partition = getPartition(topic, key, value, data); - record = new ProducerRecord<>(this.defaultTopic, partition, timestamp, key, data, headers); + record = new ProducerRecord<>(topic, partition, timestamp, key, data, headers); return producer.send(record); } @@ -109,7 +113,7 @@ public class TBKafkaProducerTemplate { if (partitioner == null) { return null; } else { - return partitioner.partition(this.defaultTopic, key, value, data, partitionInfoList); + return partitioner.partition(topic, key, value, data, partitionInfoMap.computeIfAbsent(topic, producer::partitionsFor)); } } } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java new file mode 100644 index 0000000000..66d53c3bde --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java @@ -0,0 +1,12 @@ +package org.thingsboard.server.kafka; + +import java.util.function.Consumer; + +/** + * Created by ashvayka on 05.10.18. + */ +public interface TbKafkaHandler { + + void handle(Request request, Consumer onSuccess, Consumer onFailure); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index 8a0f5293d9..30b20e709d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -93,13 +93,12 @@ public class TbKafkaRequestTemplate { ConsumerRecords responses = responseTemplate.poll(Duration.ofMillis(pollInterval)); responses.forEach(response -> { Header requestIdHeader = response.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); - Response decocedResponse = null; + Response decodedResponse = null; UUID requestId = null; if (requestIdHeader == null) { try { - decocedResponse = responseTemplate.decode(response); - requestId = responseTemplate.extractRequestId(decocedResponse); - + decodedResponse = responseTemplate.decode(response); + requestId = responseTemplate.extractRequestId(decodedResponse); } catch (IOException e) { log.error("Failed to decode response", e); } @@ -107,17 +106,17 @@ public class TbKafkaRequestTemplate { requestId = bytesToUuid(requestIdHeader.value()); } if (requestId == null) { - log.error("[{}] Missing requestId in header and response", response); + log.error("[{}] Missing requestId in header and body", response); } else { ResponseMetaData expectedResponse = pendingRequests.remove(requestId); if (expectedResponse == null) { log.trace("[{}] Invalid or stale request", requestId); } else { try { - if (decocedResponse == null) { - decocedResponse = responseTemplate.decode(response); + if (decodedResponse == null) { + decodedResponse = responseTemplate.decode(response); } - expectedResponse.future.set(decocedResponse); + expectedResponse.future.set(decodedResponse); } catch (IOException e) { expectedResponse.future.setException(e); } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java new file mode 100644 index 0000000000..536c4b93b2 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java @@ -0,0 +1,173 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +public class TbKafkaResponseTemplate { + + private final TBKafkaConsumerTemplate requestTemplate; + private final TBKafkaProducerTemplate responseTemplate; + private final TbKafkaHandler handler; + private final ConcurrentMap pendingRequests; + private final ExecutorService executor; + private final long maxPendingRequests; + + private final long pollInterval; + private volatile boolean stopped = false; + + @Builder + public TbKafkaResponseTemplate(TBKafkaConsumerTemplate requestTemplate, + TBKafkaProducerTemplate responseTemplate, + TbKafkaHandler handler, + long pollInterval, + long maxPendingRequests, + ExecutorService executor) { + this.requestTemplate = requestTemplate; + this.responseTemplate = responseTemplate; + this.handler = handler; + this.pendingRequests = new ConcurrentHashMap<>(); + this.maxPendingRequests = maxPendingRequests; + this.pollInterval = pollInterval; + this.executor = executor; + } + + public void init() { + this.responseTemplate.init(); + requestTemplate.subscribe(); + executor.submit(() -> { + long nextCleanupMs = 0L; + while (!stopped) { + ConsumerRecords requests = requestTemplate.poll(Duration.ofMillis(pollInterval)); + requests.forEach(request -> { + Header requestIdHeader = request.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); + if (requestIdHeader == null) { + log.error("[{}] Missing requestId in header", request); + return; + } + UUID requestId = bytesToUuid(requestIdHeader.value()); + if (requestId == null) { + log.error("[{}] Missing requestId in header and body", request); + return; + } + Header responseTopicHeader = request.headers().lastHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER); + if (responseTopicHeader == null) { + log.error("[{}] Missing response topic in header", request); + return; + } + String responseTopic = bytesToUuid(responseTopicHeader.value()); + if (requestId == null) { + log.error("[{}] Missing requestId in header and body", request); + return; + } + + Request decodedRequest = null; + String responseTopic = null; + + try { + if (decodedRequest == null) { + decodedRequest = requestTemplate.decode(request); + } + executor.submit(() -> { + handler.handle(decodedRequest, ); + }); + } catch (IOException e) { + expectedRequest.future.setException(e); + } + + }); + } + }); + } + + public void stop() { + stopped = true; + } + + public ListenableFuture post(String key, Request request) { + if (tickSize > maxPendingRequests) { + return Futures.immediateFailedFuture(new RuntimeException("Pending request map is full!")); + } + UUID requestId = UUID.randomUUID(); + List

headers = new ArrayList<>(2); + headers.add(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))); + headers.add(new RecordHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER, stringToBytes(responseTemplate.getTopic()))); + SettableFuture future = SettableFuture.create(); + pendingRequests.putIfAbsent(requestId, new ResponseMetaData<>(tickTs + maxRequestTimeout, future)); + request = requestTemplate.enrich(request, responseTemplate.getTopic(), requestId); + requestTemplate.send(key, request, headers); + return future; + } + + private byte[] uuidToBytes(UUID uuid) { + ByteBuffer buf = ByteBuffer.allocate(16); + buf.putLong(uuid.getMostSignificantBits()); + buf.putLong(uuid.getLeastSignificantBits()); + return buf.array(); + } + + private static UUID bytesToUuid(byte[] bytes) { + ByteBuffer bb = ByteBuffer.wrap(bytes); + long firstLong = bb.getLong(); + long secondLong = bb.getLong(); + return new UUID(firstLong, secondLong); + } + + private byte[] stringToBytes(String string) { + return string.getBytes(StandardCharsets.UTF_8); + } + + private String bytesToString(byte[] data) { + return new String(data, StandardCharsets.UTF_8); + } + + private static class ResponseMetaData { + private final long expTime; + private final SettableFuture future; + + ResponseMetaData(long ts, SettableFuture future) { + this.expTime = ts; + this.future = future; + } + } + +} diff --git a/common/transport/pom.xml b/common/transport/pom.xml index b8a7ab53f0..46efbf4dcc 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -78,6 +78,11 @@ org.springframework spring-context + + org.springframework.boot + spring-boot-starter-web + provided + com.google.guava guava diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index ff0debcd4b..e72a626ca1 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.transport; import org.thingsboard.server.gen.transport.TransportProtos; diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java index bfc7ac890e..5443f7ed29 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.transport; /** @@ -6,6 +21,6 @@ package org.thingsboard.server.common.transport; public interface TransportServiceCallback { void onSuccess(T msg); - void onError(Exception e); + void onError(Throwable e); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java index c48117069a..47b427840a 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.transport.quota.tenant; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryCleaner; @Component +@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) public class TenantIntervalRegistryCleaner extends IntervalRegistryCleaner { public TenantIntervalRegistryCleaner(TenantMsgsIntervalRegistry intervalRegistry, diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java index c56f457423..3bdcf93d07 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.transport.quota.tenant; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryLogger; @@ -25,6 +26,7 @@ import java.util.concurrent.TimeUnit; @Slf4j @Component +@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) public class TenantIntervalRegistryLogger extends IntervalRegistryLogger { private final long logIntervalMin; diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java index 6e8402c701..3402e62472 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.transport.quota.tenant; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.quota.inmemory.KeyBasedIntervalRegistry; @Component +@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) public class TenantMsgsIntervalRegistry extends KeyBasedIntervalRegistry { public TenantMsgsIntervalRegistry(@Value("${quota.rule.tenant.intervalMs}") long intervalDurationMs, diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java index a68860a2e0..875a666a28 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.transport.quota.tenant; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.quota.AbstractQuotaService; @Component +@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) public class TenantQuotaService extends AbstractQuotaService { public TenantQuotaService(TenantMsgsIntervalRegistry requestRegistry, TenantRequestLimitPolicy requestsPolicy, diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java index cc32c81ad7..c5dfc40480 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.transport.quota.tenant; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.quota.RequestLimitPolicy; @Component +@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) public class TenantRequestLimitPolicy extends RequestLimitPolicy { public TenantRequestLimitPolicy(@Value("${quota.rule.tenant.limit}") long limit) { diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index 28269454c7..7b2c05e400 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index 65d1468605..f0cc3c75ed 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -95,3 +95,17 @@ message ValidateDeviceTokenResponseMsg { DeviceInfoProto deviceInfo = 1; } +/** + * Main messages; + */ +message TransportToRuleEngineMsg { + +} + +message TransportApiRequestMsg { + ValidateDeviceTokenRequestMsg validateTokenRequestMsg = 1; +} + +message TransportApiResponseMsg { + ValidateDeviceTokenResponseMsg validateTokenResponseMsg = 1; +} \ No newline at end of file diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java index 20f8a55d0d..37441e9d41 100644 --- a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java +++ b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java @@ -17,7 +17,11 @@ package org.thingsboard.server.common.transport.quota; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.*; +import org.thingsboard.server.common.transport.quota.host.HostIntervalRegistryCleaner; +import org.thingsboard.server.common.transport.quota.host.HostIntervalRegistryLogger; +import org.thingsboard.server.common.transport.quota.host.HostRequestIntervalRegistry; +import org.thingsboard.server.common.transport.quota.host.HostRequestLimitPolicy; +import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; diff --git a/pom.xml b/pom.xml index d6db123ebe..62fda36056 100755 --- a/pom.xml +++ b/pom.xml @@ -366,7 +366,7 @@ org.thingsboard.transport - mqtt + mqtt-common ${project.version} diff --git a/transport/mqtt/pom.xml b/transport/mqtt-common/pom.xml similarity index 97% rename from transport/mqtt/pom.xml rename to transport/mqtt-common/pom.xml index 785e9a5306..6be5235a8a 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt-common/pom.xml @@ -24,10 +24,10 @@ transport org.thingsboard.transport - mqtt + mqtt-common jar - Thingsboard MQTT Transport + Thingsboard MQTT Transport Common https://thingsboard.io diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java similarity index 99% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index fd02d3aa39..8c64e7eeb2 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -395,7 +395,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } @Override - public void onError(Exception e) { + public void onError(Throwable e) { log.trace("[{}] Failed to process credentials: {}", address, userName, e); ctx.writeAndFlush(createMqttConnAckMsg(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE)); ctx.close(); @@ -521,6 +521,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement @Override public void operationComplete(Future future) throws Exception { - transportService.process(getSessionEventMsg(SessionEvent.CLOSED), null); + if (deviceSessionCtx.isConnected()) { + transportService.process(getSessionEventMsg(SessionEvent.CLOSED), null); + } } } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java similarity index 93% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index 3f29a82fe1..6661cb12f4 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -57,12 +57,12 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { @Override public void onMsg(SessionActorToAdaptorMsg msg) throws SessionException { - try { - adaptor.convertToAdaptorMsg(this, msg).ifPresent(this::pushToNetwork); - } catch (AdaptorException e) { - //TODO: close channel with disconnect; - logAndWrap(e); - } +// try { +// adaptor.convertToAdaptorMsg(this, msg).ifPresent(this::pushToNetwork); +// } catch (AdaptorException e) { +// //TODO: close channel with disconnect; +// logAndWrap(e); +// } } private void logAndWrap(AdaptorException e) throws SessionException { diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java similarity index 97% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java index e5be5c71a1..793836d793 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -135,7 +135,7 @@ public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext { private MqttMessage createMqttPublishMsg(String topic, GetAttributesResponse response) { JsonObject result = new JsonObject(); result.addProperty("id", response.getRequestId()); - result.addProperty(DEVICE_PROPERTY, device.getName()); +// result.addProperty(DEVICE_PROPERTY, device.getName()); Optional responseData = response.getData(); if (responseData.isPresent()) { AttributesKVMsg msg = responseData.get(); @@ -183,14 +183,14 @@ public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext { private MqttMessage createMqttPublishMsg(String topic, AttributesKVMsg data) { JsonObject result = new JsonObject(); - result.addProperty(DEVICE_PROPERTY, device.getName()); +// result.addProperty(DEVICE_PROPERTY, device.getName()); result.add("data", JsonConverter.toJson(data, false)); return createMqttPublishMsg(topic, result); } private MqttMessage createMqttPublishMsg(String topic, ToDeviceRpcRequestMsg data) { JsonObject result = new JsonObject(); - result.addProperty(DEVICE_PROPERTY, device.getName()); +// result.addProperty(DEVICE_PROPERTY, device.getName()); result.add("data", JsonConverter.toJson(data, true)); return createMqttPublishMsg(topic, result); } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java new file mode 100644 index 0000000000..fafbc4ec01 --- /dev/null +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -0,0 +1,284 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.session; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttPublishMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.SessionId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.msg.core.*; +import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; +import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; +import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; +import org.thingsboard.server.common.transport.SessionMsgProcessor; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.common.transport.auth.DeviceAuthService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.transport.mqtt.MqttTransportHandler; +import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; + +import java.util.*; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +import static org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor.validateJsonPayload; + +/** + * Created by ashvayka on 19.01.17. + */ +@Slf4j +public class GatewaySessionCtx { + + private static final String DEFAULT_DEVICE_TYPE = "default"; + public static final String CAN_T_PARSE_VALUE = "Can't parse value: "; + public static final String DEVICE_PROPERTY = "device"; +// private final Device gateway; +// private final SessionId gatewaySessionId; +// private final SessionMsgProcessor processor; +// private final DeviceService deviceService; +// private final DeviceAuthService authService; +// private final RelationService relationService; +// private final Map devices; +// private final ConcurrentMap mqttQoSMap; + private ChannelHandlerContext channel; + +// public GatewaySessionCtx(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, DeviceSessionCtx gatewaySessionCtx) { +// this.processor = processor; +// this.deviceService = deviceService; +// this.authService = authService; +// this.relationService = relationService; +// this.gateway = gatewaySessionCtx.getDevice(); +// this.gatewaySessionId = gatewaySessionCtx.getSessionId(); +// this.devices = new HashMap<>(); +// this.mqttQoSMap = gatewaySessionCtx.getMqttQoSMap(); +// } + + public GatewaySessionCtx(DeviceSessionCtx deviceSessionCtx) { + + } + + public void onDeviceConnect(MqttPublishMessage msg) throws AdaptorException { + JsonElement json = getJson(msg); + String deviceName = checkDeviceName(getDeviceName(json)); + String deviceType = getDeviceType(json); + onDeviceConnect(deviceName, deviceType); + ack(msg); + } + + private void onDeviceConnect(String deviceName, String deviceType) { +// if (!devices.containsKey(deviceName)) { +// Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), deviceName); +// if (device == null) { +// device = new Device(); +// device.setTenantId(gateway.getTenantId()); +// device.setName(deviceName); +// device.setType(deviceType); +// device.setCustomerId(gateway.getCustomerId()); +// device = deviceService.saveDevice(device); +// relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); +// processor.onDeviceAdded(device); +// } +// GatewayDeviceSessionCtx ctx = new GatewayDeviceSessionCtx(this, device, mqttQoSMap); +// devices.put(deviceName, ctx); +// log.debug("[{}] Added device [{}] to the gateway session", gatewaySessionId, deviceName); +// processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new AttributesSubscribeMsg()))); +// processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new RpcSubscribeMsg()))); +// } + } + + public void onDeviceDisconnect(MqttPublishMessage msg) throws AdaptorException { +// String deviceName = checkDeviceName(getDeviceName(getJson(msg))); +// GatewayDeviceSessionCtx deviceSessionCtx = devices.remove(deviceName); +// if (deviceSessionCtx != null) { +// processor.process(SessionCloseMsg.onDisconnect(deviceSessionCtx.getSessionId())); +// deviceSessionCtx.setClosed(true); +// log.debug("[{}] Removed device [{}] from the gateway session", gatewaySessionId, deviceName); +// } else { +// log.debug("[{}] Device [{}] was already removed from the gateway session", gatewaySessionId, deviceName); +// } +// ack(msg); + } + + public void onGatewayDisconnect() { +// devices.forEach((k, v) -> { +// processor.process(SessionCloseMsg.onDisconnect(v.getSessionId())); +// }); + } + + public void onDeviceTelemetry(MqttPublishMessage mqttMsg) throws AdaptorException { +// JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); +// int requestId = mqttMsg.variableHeader().messageId(); +// if (json.isJsonObject()) { +// JsonObject jsonObj = json.getAsJsonObject(); +// for (Map.Entry deviceEntry : jsonObj.entrySet()) { +// String deviceName = checkDeviceConnected(deviceEntry.getKey()); +// if (!deviceEntry.getValue().isJsonArray()) { +// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); +// } +// BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); +// JsonArray deviceData = deviceEntry.getValue().getAsJsonArray(); +// for (JsonElement element : deviceData) { +// JsonConverter.parseWithTs(request, element.getAsJsonObject()); +// } +// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), +// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); +// } +// } else { +// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); +// } + } + + public void onDeviceRpcResponse(MqttPublishMessage mqttMsg) throws AdaptorException { +// JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); +// if (json.isJsonObject()) { +// JsonObject jsonObj = json.getAsJsonObject(); +// String deviceName = checkDeviceConnected(jsonObj.get(DEVICE_PROPERTY).getAsString()); +// Integer requestId = jsonObj.get("id").getAsInt(); +// String data = jsonObj.get("data").toString(); +// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), +// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new ToDeviceRpcResponseMsg(requestId, data)))); +// ack(mqttMsg); +// } else { +// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); +// } + } + + public void onDeviceAttributes(MqttPublishMessage mqttMsg) throws AdaptorException { +// JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); +// int requestId = mqttMsg.variableHeader().messageId(); +// if (json.isJsonObject()) { +// JsonObject jsonObj = json.getAsJsonObject(); +// for (Map.Entry deviceEntry : jsonObj.entrySet()) { +// String deviceName = checkDeviceConnected(deviceEntry.getKey()); +// if (!deviceEntry.getValue().isJsonObject()) { +// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); +// } +// long ts = System.currentTimeMillis(); +// BasicAttributesUpdateRequest request = new BasicAttributesUpdateRequest(requestId); +// JsonObject deviceData = deviceEntry.getValue().getAsJsonObject(); +// request.add(JsonConverter.parseValues(deviceData).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); +// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), +// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); +// } +// } else { +// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); +// } + } + + public void onDeviceAttributesRequest(MqttPublishMessage msg) throws AdaptorException { +// JsonElement json = validateJsonPayload(gatewaySessionId, msg.payload()); +// if (json.isJsonObject()) { +// JsonObject jsonObj = json.getAsJsonObject(); +// int requestId = jsonObj.get("id").getAsInt(); +// String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); +// boolean clientScope = jsonObj.get("client").getAsBoolean(); +// Set keys; +// if (jsonObj.has("key")) { +// keys = Collections.singleton(jsonObj.get("key").getAsString()); +// } else { +// JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); +// keys = new HashSet<>(); +// for (JsonElement keyObj : keysArray) { +// keys.add(keyObj.getAsString()); +// } +// } +// +// BasicGetAttributesRequest request; +// if (clientScope) { +// request = new BasicGetAttributesRequest(requestId, keys, null); +// } else { +// request = new BasicGetAttributesRequest(requestId, null, keys); +// } +// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); +// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), +// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); +// ack(msg); +// } else { +// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); +// } + } + + private String checkDeviceConnected(String deviceName) { +// if (!devices.containsKey(deviceName)) { +// log.debug("[{}] Missing device [{}] for the gateway session", gatewaySessionId, deviceName); +// onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); +// } +// return deviceName; + return null; + } + + private String checkDeviceName(String deviceName) { + if (StringUtils.isEmpty(deviceName)) { + throw new RuntimeException("Device name is empty!"); + } else { + return deviceName; + } + } + + private String getDeviceName(JsonElement json) throws AdaptorException { + return json.getAsJsonObject().get(DEVICE_PROPERTY).getAsString(); + } + + private String getDeviceType(JsonElement json) throws AdaptorException { + JsonElement type = json.getAsJsonObject().get("type"); + return type == null || type instanceof JsonNull ? DEFAULT_DEVICE_TYPE : type.getAsString(); + } + + private JsonElement getJson(MqttPublishMessage mqttMsg) throws AdaptorException { +// return JsonMqttAdaptor.validateJsonPayload(gatewaySessionId, mqttMsg.payload()); + return null; + } + + protected SessionMsgProcessor getProcessor() { +// return processor; + return null; + } + + DeviceAuthService getAuthService() { +// return authService; + return null; + } + + public void setChannel(ChannelHandlerContext channel) { + this.channel = channel; + } + + private void ack(MqttPublishMessage msg) { + if (msg.variableHeader().messageId() > 0) { + writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msg.variableHeader().messageId())); + } + } + + void writeAndFlush(MqttMessage mqttMessage) { + channel.writeAndFlush(mqttMessage); + } + +} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java similarity index 95% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java index f085064016..c01e103889 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java @@ -32,12 +32,12 @@ public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionCo private final ConcurrentMap mqttQoSMap; public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, ConcurrentMap mqttQoSMap) { - super(processor, authService); + super(); this.mqttQoSMap = mqttQoSMap; } public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, Device device, ConcurrentMap mqttQoSMap) { - super(processor, authService, device); + super(); this.mqttQoSMap = mqttQoSMap; } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java diff --git a/transport/mqtt-transport/pom.xml b/transport/mqtt-transport/pom.xml index 8d8b24d6c6..012aabd4e9 100644 --- a/transport/mqtt-transport/pom.xml +++ b/transport/mqtt-transport/pom.xml @@ -41,32 +41,12 @@ transport - io.netty - netty-all + org.thingsboard.transport + mqtt-common - org.springframework - spring-context - - - org.slf4j - slf4j-api - - - org.slf4j - log4j-over-slf4j - - - ch.qos.logback - logback-core - - - ch.qos.logback - logback-classic - - - com.google.guava - guava + org.thingsboard.common + queue org.springframework.boot diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java index 3c54938a37..4740342692 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java @@ -19,14 +19,13 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.ComponentScan; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; -import springfox.documentation.swagger2.annotations.EnableSwagger2; import java.util.Arrays; @SpringBootConfiguration @EnableAsync @EnableScheduling -@ComponentScan({"org.thingsboard.server"}) +@ComponentScan({"org.thingsboard.server.mqtt", "org.thingsboard.server.common", "org.thingsboard.server.transport.mqtt", "org.thingsboard.server.kafka"}) public class ThingsboardMqttTransportApplication { private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java new file mode 100644 index 0000000000..493c0c8b9f --- /dev/null +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java @@ -0,0 +1,45 @@ +package org.thingsboard.server.mqtt.service; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import javax.annotation.Nullable; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 05.10.18. + */ +public class AsyncCallbackTemplate { + + public static void withCallback(ListenableFuture future, Consumer onSuccess, + Consumer onFailure) { + withCallback(future, onSuccess, onFailure, null); + } + + public static void withCallback(ListenableFuture future, Consumer onSuccess, + Consumer onFailure, Executor executor) { + FutureCallback callback = new FutureCallback() { + @Override + public void onSuccess(@Nullable T result) { + try { + onSuccess.accept(result); + } catch (Throwable th) { + onFailure(th); + } + } + + @Override + public void onFailure(Throwable t) { + onFailure.accept(t); + } + }; + if (executor != null) { + Futures.addCallback(future, callback, executor); + } else { + Futures.addCallback(future, callback); + } + } + +} diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java new file mode 100644 index 0000000000..a1641f152f --- /dev/null +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -0,0 +1,110 @@ +package org.thingsboard.server.mqtt.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaRequestTemplate; +import org.thingsboard.server.gen.transport.TransportProtos.*; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.transport.mqtt.MqttTransportContext; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by ashvayka on 05.10.18. + */ +@Service +public class MqttTransportService implements TransportService { + + @Value("${kafka.rule-engine.topic}") + private String ruleEngineTopic; + @Value("${kafka.transport-api.requests-topic}") + private String transportApiRequestsTopic; + @Value("${kafka.transport-api.responses-topic}") + private String transportApiResponsesTopic; + @Value("${kafka.transport-api.max_pending_requests}") + private long maxPendingRequests; + @Value("${kafka.transport-api.max_requests_timeout}") + private long maxRequestsTimeout; + @Value("${kafka.transport-api.response_poll_interval}") + private int responsePollDuration; + @Value("${kafka.transport-api.response_auto_commit_interval}") + private int autoCommitInterval; + + @Autowired + private TbKafkaSettings kafkaSettings; + //We use this to get the node id. We should replace this with a component that provides the node id. + @Autowired + private MqttTransportContext transportContext; + + private ExecutorService transportCallbackExecutor; + + private TbKafkaRequestTemplate transportApiTemplate; + + @PostConstruct + public void init() { + this.transportCallbackExecutor = Executors.newCachedThreadPool(); + + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder requestBuilder = TBKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.defaultTopic(transportApiRequestsTopic); + requestBuilder.encoder(new TransportApiRequestEncoder()); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); + responseBuilder.settings(kafkaSettings); + responseBuilder.topic(transportApiResponsesTopic + "." + transportContext.getNodeId()); + responseBuilder.clientId(transportContext.getNodeId()); + responseBuilder.groupId("transport-node"); + responseBuilder.autoCommit(true); + responseBuilder.autoCommitIntervalMs(autoCommitInterval); + responseBuilder.decoder(new TransportApiResponseDecoder()); + + TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder + builder = TbKafkaRequestTemplate.builder(); + builder.requestTemplate(requestBuilder.build()); + builder.responseTemplate(responseBuilder.build()); + builder.maxPendingRequests(maxPendingRequests); + builder.maxRequestTimeout(maxRequestsTimeout); + builder.pollInterval(responsePollDuration); + transportApiTemplate = builder.build(); + transportApiTemplate.init(); + } + + @PreDestroy + public void destroy() { + if (transportApiTemplate != null) { + transportApiTemplate.stop(); + } + if (transportCallbackExecutor != null) { + transportCallbackExecutor.shutdownNow(); + } + } + + @Override + public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback callback) { + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getToken(), TransportApiRequestMsg.newBuilder().setValidateTokenRequestMsg(msg).build()), + response -> callback.onSuccess(response.getValidateTokenResponseMsg()), callback::onError, transportCallbackExecutor); + } + + @Override + public void process(SessionEventMsg msg, TransportServiceCallback callback) { + + } + + @Override + public void process(PostTelemetryMsg msg, TransportServiceCallback callback) { + + } + + @Override + public void process(PostAttributeMsg msg, TransportServiceCallback callback) { + + } +} diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java new file mode 100644 index 0000000000..f931db6a2e --- /dev/null +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java @@ -0,0 +1,14 @@ +package org.thingsboard.server.mqtt.service; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.kafka.TbKafkaEncoder; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiRequestEncoder implements TbKafkaEncoder { + @Override + public byte[] encode(TransportApiRequestMsg value) { + return value.toByteArray(); + } +} diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java new file mode 100644 index 0000000000..22e16478ae --- /dev/null +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java @@ -0,0 +1,16 @@ +package org.thingsboard.server.mqtt.service; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiResponseDecoder implements TbKafkaDecoder { + @Override + public TransportApiResponseMsg decode(byte[] data) throws IOException { + return TransportApiResponseMsg.parseFrom(data); + } +} diff --git a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml index c12f25edcf..a2a0d54adc 100644 --- a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml @@ -44,6 +44,27 @@ mqtt: # Type of the key store key_store_type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" +#Quota parameters +quota: + host: + # Max allowed number of API requests in interval for single host + limit: "${QUOTA_HOST_LIMIT:10000}" + # Interval duration + intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" + # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs + ttlMs: "${QUOTA_HOST_TTL_MS:60000}" + # Interval for scheduled task that cleans expired records. TTL is used for expiring + cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" + # Enable Host API Limits + enabled: "${QUOTA_HOST_ENABLED:false}" + # Array of whitelist hosts + whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" + # Array of blacklist hosts + blacklist: "${QUOTA_HOST_BLACKLIST:}" + log: + topSize: 10 + intervalMin: 2 + kafka: enabled: true bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" @@ -52,6 +73,13 @@ kafka: batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" linger.ms: "${TB_KAFKA_LINGER_MS:1}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" - topic: - telemetry: "${TB_TELEMETRY_TOPIC:tb.transport.telemetry}" - requests: "${TB_TELEMETRY_TOPIC:tb.transport.requests}" \ No newline at end of file + transport-api: + requests-topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses-topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + max_requests_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + response_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" + # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted + rule-engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java deleted file mode 100644 index dec86028b7..0000000000 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt.session; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonSyntaxException; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.mqtt.MqttMessage; -import io.netty.handler.codec.mqtt.MqttPublishMessage; -import lombok.extern.slf4j.Slf4j; -import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.transport.mqtt.MqttTransportHandler; -import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; - -import java.util.*; -import java.util.concurrent.ConcurrentMap; -import java.util.stream.Collectors; - -import static org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor.validateJsonPayload; - -/** - * Created by ashvayka on 19.01.17. - */ -@Slf4j -public class GatewaySessionCtx { - - private static final String DEFAULT_DEVICE_TYPE = "default"; - public static final String CAN_T_PARSE_VALUE = "Can't parse value: "; - public static final String DEVICE_PROPERTY = "device"; - private final Device gateway; - private final SessionId gatewaySessionId; - private final SessionMsgProcessor processor; - private final DeviceService deviceService; - private final DeviceAuthService authService; - private final RelationService relationService; - private final Map devices; - private final ConcurrentMap mqttQoSMap; - private ChannelHandlerContext channel; - - public GatewaySessionCtx(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, DeviceSessionCtx gatewaySessionCtx) { - this.processor = processor; - this.deviceService = deviceService; - this.authService = authService; - this.relationService = relationService; - this.gateway = gatewaySessionCtx.getDevice(); - this.gatewaySessionId = gatewaySessionCtx.getSessionId(); - this.devices = new HashMap<>(); - this.mqttQoSMap = gatewaySessionCtx.getMqttQoSMap(); - } - - public GatewaySessionCtx(DeviceSessionCtx deviceSessionCtx) { - - } - - public void onDeviceConnect(MqttPublishMessage msg) throws AdaptorException { - JsonElement json = getJson(msg); - String deviceName = checkDeviceName(getDeviceName(json)); - String deviceType = getDeviceType(json); - onDeviceConnect(deviceName, deviceType); - ack(msg); - } - - private void onDeviceConnect(String deviceName, String deviceType) { - if (!devices.containsKey(deviceName)) { - Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), deviceName); - if (device == null) { - device = new Device(); - device.setTenantId(gateway.getTenantId()); - device.setName(deviceName); - device.setType(deviceType); - device.setCustomerId(gateway.getCustomerId()); - device = deviceService.saveDevice(device); - relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); - processor.onDeviceAdded(device); - } - GatewayDeviceSessionCtx ctx = new GatewayDeviceSessionCtx(this, device, mqttQoSMap); - devices.put(deviceName, ctx); - log.debug("[{}] Added device [{}] to the gateway session", gatewaySessionId, deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new AttributesSubscribeMsg()))); - processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new RpcSubscribeMsg()))); - } - } - - public void onDeviceDisconnect(MqttPublishMessage msg) throws AdaptorException { - String deviceName = checkDeviceName(getDeviceName(getJson(msg))); - GatewayDeviceSessionCtx deviceSessionCtx = devices.remove(deviceName); - if (deviceSessionCtx != null) { - processor.process(SessionCloseMsg.onDisconnect(deviceSessionCtx.getSessionId())); - deviceSessionCtx.setClosed(true); - log.debug("[{}] Removed device [{}] from the gateway session", gatewaySessionId, deviceName); - } else { - log.debug("[{}] Device [{}] was already removed from the gateway session", gatewaySessionId, deviceName); - } - ack(msg); - } - - public void onGatewayDisconnect() { - devices.forEach((k, v) -> { - processor.process(SessionCloseMsg.onDisconnect(v.getSessionId())); - }); - } - - public void onDeviceTelemetry(MqttPublishMessage mqttMsg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - int requestId = mqttMsg.variableHeader().messageId(); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - for (Map.Entry deviceEntry : jsonObj.entrySet()) { - String deviceName = checkDeviceConnected(deviceEntry.getKey()); - if (!deviceEntry.getValue().isJsonArray()) { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); - JsonArray deviceData = deviceEntry.getValue().getAsJsonArray(); - for (JsonElement element : deviceData) { - JsonConverter.parseWithTs(request, element.getAsJsonObject()); - } - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); - } - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - public void onDeviceRpcResponse(MqttPublishMessage mqttMsg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - String deviceName = checkDeviceConnected(jsonObj.get(DEVICE_PROPERTY).getAsString()); - Integer requestId = jsonObj.get("id").getAsInt(); - String data = jsonObj.get("data").toString(); - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new ToDeviceRpcResponseMsg(requestId, data)))); - ack(mqttMsg); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - public void onDeviceAttributes(MqttPublishMessage mqttMsg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - int requestId = mqttMsg.variableHeader().messageId(); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - for (Map.Entry deviceEntry : jsonObj.entrySet()) { - String deviceName = checkDeviceConnected(deviceEntry.getKey()); - if (!deviceEntry.getValue().isJsonObject()) { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - long ts = System.currentTimeMillis(); - BasicAttributesUpdateRequest request = new BasicAttributesUpdateRequest(requestId); - JsonObject deviceData = deviceEntry.getValue().getAsJsonObject(); - request.add(JsonConverter.parseValues(deviceData).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); - } - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - public void onDeviceAttributesRequest(MqttPublishMessage msg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, msg.payload()); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - int requestId = jsonObj.get("id").getAsInt(); - String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); - boolean clientScope = jsonObj.get("client").getAsBoolean(); - Set keys; - if (jsonObj.has("key")) { - keys = Collections.singleton(jsonObj.get("key").getAsString()); - } else { - JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); - keys = new HashSet<>(); - for (JsonElement keyObj : keysArray) { - keys.add(keyObj.getAsString()); - } - } - - BasicGetAttributesRequest request; - if (clientScope) { - request = new BasicGetAttributesRequest(requestId, keys, null); - } else { - request = new BasicGetAttributesRequest(requestId, null, keys); - } - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); - ack(msg); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - private String checkDeviceConnected(String deviceName) { - if (!devices.containsKey(deviceName)) { - log.debug("[{}] Missing device [{}] for the gateway session", gatewaySessionId, deviceName); - onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); - } - return deviceName; - } - - private String checkDeviceName(String deviceName) { - if (StringUtils.isEmpty(deviceName)) { - throw new RuntimeException("Device name is empty!"); - } else { - return deviceName; - } - } - - private String getDeviceName(JsonElement json) throws AdaptorException { - return json.getAsJsonObject().get(DEVICE_PROPERTY).getAsString(); - } - - private String getDeviceType(JsonElement json) throws AdaptorException { - JsonElement type = json.getAsJsonObject().get("type"); - return type == null || type instanceof JsonNull ? DEFAULT_DEVICE_TYPE : type.getAsString(); - } - - private JsonElement getJson(MqttPublishMessage mqttMsg) throws AdaptorException { - return JsonMqttAdaptor.validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - } - - protected SessionMsgProcessor getProcessor() { - return processor; - } - - DeviceAuthService getAuthService() { - return authService; - } - - public void setChannel(ChannelHandlerContext channel) { - this.channel = channel; - } - - private void ack(MqttPublishMessage msg) { - if (msg.variableHeader().messageId() > 0) { - writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msg.variableHeader().messageId())); - } - } - - void writeAndFlush(MqttMessage mqttMessage) { - channel.writeAndFlush(mqttMessage); - } - -} diff --git a/transport/pom.xml b/transport/pom.xml index a01c7ac7ba..2401d3f845 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -37,7 +37,7 @@ http coap - mqtt + mqtt-common mqtt-transport From b190a4bd409eb4943b8f774db995c0a1a3b10753 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 5 Oct 2018 19:45:10 +0300 Subject: [PATCH 170/307] Transport --- .../transport/RemoteTransportApiService.java | 14 +++ .../transport/TransportApiService.java | 7 ++ .../server/kafka/AbstractTbKafkaTemplate.java | 50 ++++++++++ .../server/kafka/TbKafkaRequestTemplate.java | 29 ++---- .../server/kafka/TbKafkaResponseTemplate.java | 98 ++++--------------- 5 files changed, 97 insertions(+), 101 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java new file mode 100644 index 0000000000..7831b340dc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -0,0 +1,14 @@ +package org.thingsboard.server.service.transport; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * Created by ashvayka on 05.10.18. + */ +@Slf4j +@Service +@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) +public class RemoteTransportApiService implements TransportApiService { +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java new file mode 100644 index 0000000000..28a769a832 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java @@ -0,0 +1,7 @@ +package org.thingsboard.server.service.transport; + +/** + * Created by ashvayka on 05.10.18. + */ +public interface TransportApiService { +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java new file mode 100644 index 0000000000..8d851bca83 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.extern.slf4j.Slf4j; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +public abstract class AbstractTbKafkaTemplate { + protected byte[] uuidToBytes(UUID uuid) { + ByteBuffer buf = ByteBuffer.allocate(16); + buf.putLong(uuid.getMostSignificantBits()); + buf.putLong(uuid.getLeastSignificantBits()); + return buf.array(); + } + + protected static UUID bytesToUuid(byte[] bytes) { + ByteBuffer bb = ByteBuffer.wrap(bytes); + long firstLong = bb.getLong(); + long secondLong = bb.getLong(); + return new UUID(firstLong, secondLong); + } + + protected byte[] stringToBytes(String string) { + return string.getBytes(StandardCharsets.UTF_8); + } + + protected String bytesToString(byte[] data) { + return new String(data, StandardCharsets.UTF_8); + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index 30b20e709d..ba08bf8946 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -23,24 +23,25 @@ import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.admin.CreateTopicsResult; import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerRecords; -import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.header.Header; import org.apache.kafka.common.header.internals.RecordHeader; import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; -import java.util.concurrent.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; /** * Created by ashvayka on 25.09.18. */ @Slf4j -public class TbKafkaRequestTemplate { +public class TbKafkaRequestTemplate extends AbstractTbKafkaTemplate { private final TBKafkaProducerTemplate requestTemplate; private final TBKafkaConsumerTemplate responseTemplate; @@ -163,24 +164,6 @@ public class TbKafkaRequestTemplate { return future; } - private byte[] uuidToBytes(UUID uuid) { - ByteBuffer buf = ByteBuffer.allocate(16); - buf.putLong(uuid.getMostSignificantBits()); - buf.putLong(uuid.getLeastSignificantBits()); - return buf.array(); - } - - private static UUID bytesToUuid(byte[] bytes) { - ByteBuffer bb = ByteBuffer.wrap(bytes); - long firstLong = bb.getLong(); - long secondLong = bb.getLong(); - return new UUID(firstLong, secondLong); - } - - private byte[] stringToBytes(String string) { - return string.getBytes(StandardCharsets.UTF_8); - } - private static class ResponseMetaData { private final long expTime; private final SettableFuture future; diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java index 536c4b93b2..1f533873cd 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java @@ -15,52 +15,45 @@ */ package org.thingsboard.server.kafka; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.SettableFuture; import lombok.Builder; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.admin.CreateTopicsResult; -import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.common.header.Header; import org.apache.kafka.common.header.internals.RecordHeader; import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.ArrayList; -import java.util.List; +import java.util.Collections; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; /** * Created by ashvayka on 25.09.18. */ @Slf4j -public class TbKafkaResponseTemplate { +public class TbKafkaResponseTemplate extends AbstractTbKafkaTemplate { private final TBKafkaConsumerTemplate requestTemplate; private final TBKafkaProducerTemplate responseTemplate; private final TbKafkaHandler handler; private final ConcurrentMap pendingRequests; private final ExecutorService executor; - private final long maxPendingRequests; + private final int maxPendingRequests; private final long pollInterval; private volatile boolean stopped = false; + //TODO: + private final AtomicInteger pendingRequestCount = new AtomicInteger(); @Builder public TbKafkaResponseTemplate(TBKafkaConsumerTemplate requestTemplate, TBKafkaProducerTemplate responseTemplate, TbKafkaHandler handler, long pollInterval, - long maxPendingRequests, + int maxPendingRequests, ExecutorService executor) { this.requestTemplate = requestTemplate; this.responseTemplate = responseTemplate; @@ -75,8 +68,11 @@ public class TbKafkaResponseTemplate { this.responseTemplate.init(); requestTemplate.subscribe(); executor.submit(() -> { - long nextCleanupMs = 0L; while (!stopped) { + if(pendingRequestCount.get() > maxPendingRequests){ + + } + //TODO: we need to protect from reading too much requests. ConsumerRecords requests = requestTemplate.poll(Duration.ofMillis(pollInterval)); requests.forEach(request -> { Header requestIdHeader = request.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); @@ -94,26 +90,15 @@ public class TbKafkaResponseTemplate { log.error("[{}] Missing response topic in header", request); return; } - String responseTopic = bytesToUuid(responseTopicHeader.value()); - if (requestId == null) { - log.error("[{}] Missing requestId in header and body", request); - return; - } - - Request decodedRequest = null; - String responseTopic = null; - + String responseTopic = bytesToString(responseTopicHeader.value()); try { - if (decodedRequest == null) { - decodedRequest = requestTemplate.decode(request); - } - executor.submit(() -> { - handler.handle(decodedRequest, ); - }); - } catch (IOException e) { - expectedRequest.future.setException(e); + Request decodedRequest = requestTemplate.decode(request); + executor.submit(() -> handler.handle(decodedRequest, + response -> reply(requestId, responseTopic, response), + e -> log.error("[{}] Failed to process the request: {}", requestId, request, e))); + } catch (Throwable e) { + log.error("[{}] Failed to process the request: {}", requestId, request, e); } - }); } }); @@ -123,51 +108,8 @@ public class TbKafkaResponseTemplate { stopped = true; } - public ListenableFuture post(String key, Request request) { - if (tickSize > maxPendingRequests) { - return Futures.immediateFailedFuture(new RuntimeException("Pending request map is full!")); - } - UUID requestId = UUID.randomUUID(); - List

headers = new ArrayList<>(2); - headers.add(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))); - headers.add(new RecordHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER, stringToBytes(responseTemplate.getTopic()))); - SettableFuture future = SettableFuture.create(); - pendingRequests.putIfAbsent(requestId, new ResponseMetaData<>(tickTs + maxRequestTimeout, future)); - request = requestTemplate.enrich(request, responseTemplate.getTopic(), requestId); - requestTemplate.send(key, request, headers); - return future; - } - - private byte[] uuidToBytes(UUID uuid) { - ByteBuffer buf = ByteBuffer.allocate(16); - buf.putLong(uuid.getMostSignificantBits()); - buf.putLong(uuid.getLeastSignificantBits()); - return buf.array(); - } - - private static UUID bytesToUuid(byte[] bytes) { - ByteBuffer bb = ByteBuffer.wrap(bytes); - long firstLong = bb.getLong(); - long secondLong = bb.getLong(); - return new UUID(firstLong, secondLong); - } - - private byte[] stringToBytes(String string) { - return string.getBytes(StandardCharsets.UTF_8); - } - - private String bytesToString(byte[] data) { - return new String(data, StandardCharsets.UTF_8); - } - - private static class ResponseMetaData { - private final long expTime; - private final SettableFuture future; - - ResponseMetaData(long ts, SettableFuture future) { - this.expTime = ts; - this.future = future; - } + private void reply(UUID requestId, String topic, Response response) { + responseTemplate.send(topic, response, Collections.singletonList(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId)))); } } From 5ffd778df110f0ec36f09f60348592c140d635e3 Mon Sep 17 00:00:00 2001 From: bombay Date: Sun, 7 Oct 2018 17:38:44 +0700 Subject: [PATCH 171/307] [WIP]Help refactor code solid patterns --- .../client/tools/MqttSslClient.java | 106 +++++++++++++----- 1 file changed, 77 insertions(+), 29 deletions(-) diff --git a/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java b/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java index dd27e44a1d..339f26ded8 100644 --- a/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java +++ b/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java @@ -50,38 +50,86 @@ public class MqttSslClient { File ksFile = new File(ksUrl.toURI()); URL tsUrl = Resources.getResource(KEY_STORE_FILE); File tsFile = new File(tsUrl.toURI()); + char[] ksPwd = setKeyPassword(); + + KeyStore trustStore = setKeyStore(tsFile, ksPwd); + + TrustManagerFactory tmf = setTrustManagerFactory(trustStore); + + KeyStore ks = setKeyStore2(ksFile, ksPwd); + + KeyManagerFactory kmf = setKeyManagerFactory(ks); + + SSLContext sslContext = setSSLContext(kmf, tmf); + + MqttConnectOptions mqttConnectOptions = setMqttConnectOptions(sslContext); + + MqttAsyncClient mqttAsyncClient = setMqttAsyncClient(mqttConnectOptions); + + setMessage(mqttAsyncClient, "v1/devices/me/telemetry", "{\"key1\":\"value1\", \"key2\":true, \"key3\": 3.0, \"key4\": 4}"); - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - - KeyStore trustStore = KeyStore.getInstance(JKS); - char[] ksPwd = new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x73, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; - trustStore.load(new FileInputStream(tsFile), ksPwd); - tmf.init(trustStore); - KeyStore ks = KeyStore.getInstance(JKS); - - ks.load(new FileInputStream(ksFile), ksPwd); - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - char[] clientPwd = new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x65, 0x79, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; - kmf.init(ks, clientPwd); - - KeyManager[] km = kmf.getKeyManagers(); - TrustManager[] tm = tmf.getTrustManagers(); - SSLContext sslContext = SSLContext.getInstance(TLS); - sslContext.init(km, tm, null); - - MqttConnectOptions options = new MqttConnectOptions(); - options.setSocketFactory(sslContext.getSocketFactory()); - MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, CLIENT_ID); - client.connect(options); - Thread.sleep(3000); - MqttMessage message = new MqttMessage(); - message.setPayload("{\"key1\":\"value1\", \"key2\":true, \"key3\": 3.0, \"key4\": 4}".getBytes()); - client.publish("v1/devices/me/telemetry", message); - client.disconnect(); - log.info("Disconnected"); - System.exit(0); } catch (Exception e) { log.error("Unexpected exception occurred in MqttSslClient", e); } } + + private static char[] setKeyPassword(){ + return new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x73, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; + } + + private static KeyStore setKeyStore(File tsFile, char[] ksPwd) throws Exception{ + KeyStore trustStore = KeyStore.getInstance(JKS); + trustStore.load(new FileInputStream(tsFile), ksPwd); + return trustStore; + } + + private static TrustManagerFactory setTrustManagerFactory(KeyStore trustStore) throws Exception { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + return tmf; + } + + private static KeyStore setKeyStore2(File ksFile, char[] ksPwd)throws Exception{ + KeyStore ks = KeyStore.getInstance(JKS); + ks.load(new FileInputStream(ksFile), ksPwd); + return ks; + } + + private static KeyManagerFactory setKeyManagerFactory(KeyStore ks) throws Exception { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + char[] clientPwd = new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x65, 0x79, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; + kmf.init(ks, clientPwd); + return kmf; + } + + private static SSLContext setSSLContext(KeyManagerFactory kmf, TrustManagerFactory tmf) throws Exception { + KeyManager[] km = kmf.getKeyManagers(); + TrustManager[] tm = tmf.getTrustManagers(); + SSLContext sslContext = SSLContext.getInstance(TLS); + sslContext.init(km, tm, null); + return sslContext; + } + + private static MqttConnectOptions setMqttConnectOptions(SSLContext sslContext){ + MqttConnectOptions options = new MqttConnectOptions(); + options.setSocketFactory(sslContext.getSocketFactory()); + return options; + } + + private static MqttAsyncClient setMqttAsyncClient(MqttConnectOptions options) throws Exception{ + MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, CLIENT_ID); + client.connect(options); + Thread.sleep(3000); + return client; + } + + private static void setMessage(MqttAsyncClient client, String topic, String payload) throws Exception { + MqttMessage message = new MqttMessage(); + message.setPayload(payload.getBytes()); + client.publish(topic, message); + client.disconnect(); + log.info("Disconnected"); + System.exit(0); + } + } \ No newline at end of file From 35e7d6c9387f6de354b3a7f2503f938511fe501d Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 8 Oct 2018 08:21:00 +0300 Subject: [PATCH 172/307] Added turkish lang --- ui/src/app/locale/locale.constant-TR.json | 1545 +++++++++ ui/src/app/locale/locale.constant-en_US.json | 3 +- ui/src/app/locale/locale.constant-es_ES.json | 3 +- ui/src/app/locale/locale.constant-fr_FR.json | 2919 +++++++++--------- ui/src/app/locale/locale.constant-it_IT.json | 3 +- ui/src/app/locale/locale.constant-ja_JA.json | 3 +- ui/src/app/locale/locale.constant-ko_KR.json | 3 +- ui/src/app/locale/locale.constant-ru_RU.json | 3 +- ui/src/app/locale/locale.constant-zh_CN.json | 3 +- 9 files changed, 3019 insertions(+), 1466 deletions(-) create mode 100644 ui/src/app/locale/locale.constant-TR.json diff --git a/ui/src/app/locale/locale.constant-TR.json b/ui/src/app/locale/locale.constant-TR.json new file mode 100644 index 0000000000..04575fbc90 --- /dev/null +++ b/ui/src/app/locale/locale.constant-TR.json @@ -0,0 +1,1545 @@ +{ + "access": { + "unauthorized": "Yetkisiz", + "unauthorized-access": "Yetkisiz Eriþim", + "unauthorized-access-text": "Bu kaynaða eriþmek için giriþ yapmalýsýnýz!", + "access-forbidden": "Eriþim Yasaklandý", + "access-forbidden-text": "Bu yere eriþim haklarýna sahip deðilsiniz!
Bu konuma eriþmek istiyorsanýz yine de farklý kullanýcýlarla giriþ yapmayý deneyin.", + "refresh-token-expired": "Oturum süresi doldu", + "refresh-token-failed": "Seans yenilenemiyor" + }, + "action": { + "activate": "Aktive Et", + "suspend": "Askýya Alma", + "save": "Kayýt etmek", + "saveAs": "Farklý kaydet", + "cancel": "Ýptal etmek", + "ok": "TAMAM", + "delete": "Sil", + "add": "Eklemek", + "yes": "Evet", + "no": "Yok hayýr", + "update": "Güncelleþtirme", + "remove": "Kaldýr", + "search": "Arama", + "clear-search": "Aramayý Temizle", + "assign": "Atamak", + "unassign": "Atamayý kaldýrma", + "share": "Pay", + "make-private": "Özel yap", + "apply": "Uygulamak", + "apply-changes": "Deðiþiklikleri uygula", + "edit-mode": "Düzenleme modu", + "enter-edit-mode": "Düzenleme moduna girin", + "decline-changes": "Deðiþiklikleri reddet", + "close": "Kapat", + "back": "Geri", + "run": "Koþmak", + "sign-in": "Oturum aç!", + "edit": "Düzenle", + "view": "Görünüm", + "create": "Yaratmak", + "drag": "Sürüklemek", + "refresh": "Yenile", + "undo": "Geri alma", + "copy": "Kopyala", + "paste": "Yapýþtýrmak", + "copy-reference": "Referansý kopyala", + "paste-reference": "Referansý yapýþtýr", + "import": "Ýthalat", + "export": "Ýhracat", + "share-via": "{{Provider}} aracýlýðýyla paylaþ" + }, + "aggregation": { + "aggregation": "Toplama", + "function": "Veri toplama iþlevi", + "limit": "Maksimum deðerler", + "group-interval": "Gruplama aralýðý", + "min": "Min", + "max": "Max", + "avg": "Ortalama", + "sum": "Sum", + "count": "Sayý", + "none": "Yok" + }, + "admin": { + "general": "Genel", + "general-settings": "Genel Ayarlar", + "outgoing-mail": "Giden posta", + "outgoing-mail-settings": "Giden Posta Ayarlarý", + "system-settings": "Sistem ayarlarý", + "test-mail-sent": "Test postasý baþarýyla gönderildi!", + "base-url": "Temel URL", + "base-url-required": "Temel URL gerekli.", + "mail-from": "Mail þu kiþiden geldi", + "mail-from-required": "Mail From gereklidir.", + "smtp-protocol": "SMTP protokolü", + "smtp-host": "SMTP ana bilgisayarý", + "smtp-host-required": "SMTP ana bilgisayarý gerekli.", + "smtp-port": "SMTP portu", + "smtp-port-required": "Bir smtp portu saðlamalýsýnýz.", + "smtp-port-invalid": "Bu geçerli bir smtp portuna benzemiyor.", + "timeout-msec": "Zaman aþýmý (msn)", + "timeout-required": "Zaman aþýmý gerekli.", + "timeout-invalid": "Bu geçerli bir zaman aþýmý gibi görünmüyor.", + "enable-tls": "TLS'yi etkinleþtir", + "send-test-mail": "Test postasý gönder" + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarmlar", + "select-alarm": "Alarmý seç", + "no-alarms-matching": " '{{Entity}}' ile eþleþen hiçbir alarm bulunamadý. ", + "alarm-required": "Alarm gerekli", + "alarm-status": "Alarm durumu", + "search-status": { + "ANY": "Herhangi biri", + "ACTIVE": "Aktif", + "CLEARED": "Temizlendi", + "ACK": "Kabul edilen", + "UNACK": "Unacknowledged" + }, + "display-status": { + "ACTIVE_UNACK": "Aktif Olmamýþ", + "ACTIVE_ACK": "Aktif Kabul", + "CLEARED_UNACK": "Onaylanmamýþ Onaylandý", + "CLEARED_ACK": "Onaylandý onaylandý" + }, + "no-alarms-prompt": "Alarm bulunamadý", + "created-time": "Zaman yaratýldý", + "type": "Tür", + "severity": "Önem", + "originator": "Originator", + "originator-type": "Gönderen türü", + "details": "Ayrýntýlar", + "status": "Durum", + "alarm-details": "Alarm detaylarý", + "start-time": "Baþlama zamaný", + "end-time": "Bitiþ zamaný", + "ack-time": "Onaylanmýþ zaman", + "clear-time": "Temizlenmiþ zaman", + "severity-critical": "Kritik", + "severity-major": "Majör", + "severity-minor": "Minör", + "severity-warning": "Uyarý", + "severity-indeterminate": "Belirsiz", + "acknowledge": "Onay", + "clear": "Açýk", + "search": "Arama alarmlarý", + "selected-alarms": "{count, çoðul, 1 {1 alarm} diðer {# alarm}} seçildi", + "no-data": "Gösterilecek bilgi yok", + "polling-interval": "Alarmlar sorgulama aralýðý (sn)", + "polling-interval-required": "Alarmlar sorgulama aralýðý gerekli.", + "min-polling-interval-message": "En az 1 san yoklama aralýðýna izin verilir.", + "aknowledge-alarms-title": "Kabul et { count, plural, 1 {1 alarm} other {# alarms}} selected ", + "aknowledge-alarms-text": "{Count, plural, 1 {1 alarm} dother {# alarm}} kimliðini kabul etmek istediðinizden emin misiniz?", + "clear-alarms-title": "Temizle {count, plural, 1 {1 alarm} other {# alarm}}", + "clear-alarms-text": "count, plural, 1 {1 alarm} diðer {# alarms}} silmek istediðinizden emin misiniz?" + }, + "alias": { + "add": "Takma ad ekle", + "edit": "Takma adý düzenle", + "name": "Takma ad", + "name-required": "Takma ad gerekli", + "duplicate-alias": "Ayný ada sahip diðer ad zaten var.", + "filter-type-single-entity": "Tek varlýk", + "filter-type-entity-list": "Varlýk listesi", + "filter-type-entity-name": "Varlýk adý", + "filter-type-state-entity": "Gösterge panosu durumundan varlýk", + "filter-type-state-entity-description": "Gösterge panosu durum parametrelerinden alýnan varlýk", + "filter-type-asset-type": "Varlýk türü", + "filter-type-asset-type-description": "Öðe varlýklarý {{assetType}} '", + "filter-type-asset-type-and-name-description": "{{AssetType}} türündeki varlýklar ve {{prefix}} ile baþlayan adla ", + "filter-type-device-type": "Cihaz tipi", + "filter-type-device-type-description": "Türlü cihazlar {{deviceType}} ", + "filter-type-device-type-and-name-description": "{{DeviceType}} türündeki cihazlar ve {{prefix}} ile baþlayan adla ", + "filter-type-relations-query": "Ýliþkiler sorgusu", + "filter-type-relations-query-description": "{{relationType}} iliþkisine sahip {{direction}} {{rootEntity}} olan {{entities}}", + "filter-type-asset-search-query": "Öðe arama sorgusu", + "filter-type-asset-search-query-description": "{{RelationType}} iliþkisine sahip {{direction}}} türlerine sahip öðeler {{direction}} {{rootEntity}} ", + "filter-type-device-search-query": "Cihaz arama sorgusu", + "filter-type-device-search-query-description": "{{RelationType}} iliþkisi olan {{direction}} {{rootEntity}} içeren {{deviceTypes}} türüne sahip cihazlar", + "entity-filter": "Varlýk filtresi", + "resolve-multiple": "Birden çok varlýk olarak çöz", + "filter-type": "Filtre tipi", + "filter-type-required": "Filtre türü gerekli.", + "entity-filter-no-entity-matched": "Belirtilen filtreyle eþleþen varlýk bulunamadý.", + "no-entity-filter-specified": "Hiçbir varlýk filtresi belirtilmemiþ", + "root-state-entity": "Gösterge panosu durum varlýðýný root olarak kullan", + "root-entity": "Kök varlýk", + "state-entity-parameter-name": "Durum öðesi parametre adý", + "default-state-entity": "Varsayýlan durum varlýðý", + "default-entity-parameter-name": "Varsayýlan olarak", + "max-relation-level": "Maksimum iliþki seviyesi", + "unlimited-level": "Sýnýrsýz seviye", + "state-entity": "Gösterge panosu durumu", + "all-entities": "Tüm varlýklar", + "any-relation": "Herhangi" + }, + "asset": { + "asset": "Varlýk", + "assets": "Varlýklar", + "management": "Varlýk Yönetimi", + "view-assets": "Varlýklarý Görüntüle", + "add": "Öðe Ekle", + "assign-to-customer": "Müþteriye atama", + "assign-asset-to-customer": "Müþteriye Varlýk (lar) Atama", + "assign-asset-to-customer-text": "Lütfen müþteriye atamak için varlýklarý seçin", + "no-assets-text": "Öðe bulunamadý", + "assign-to-customer-text": "Lütfen varlýk (lar) a atamak için müþteriyi seçin", + "public": "Halka açýk", + "assignedToCustomer": "Müþteriye atandý", + "make-public": "Varlýðý herkese açýk yap", + "make-private": "Varlýk özel yap", + "unassign-from-customer": "Müþteriden atama", + "delete": "Öðeyi sil", + "asset-public": "Varlýk halka açýk", + "asset-type": "Varlýk türü", + "asset-type-required": "Öðe türü gerekli.", + "select-asset-type": "Varlýk türünü seç", + "enter-asset-type": "Varlýk türünü girin", + "any-asset": "Herhangi bir varlýk", + "no-asset-types-matching": "{{EntitySubtype}} ile eþleþen öðe türü bulunamadý. ", + "asset-type-list-empty": "Hiçbir öðe türü seçilmedi.", + "asset-types": "Varlýk türleri", + "name": "Ad", + "name-required": "Ýsim gerekli.", + "description": "Açýklama", + "type": "Tür", + "type-required": "Tür gerekli.", + "details": "Ayrýntýlar", + "events": "Etkinlikler", + "add-asset-text": "Yeni varlýk ekle", + "asset-details": "Varlýk ayrýntýlarý", + "assign-assets": "Öðeleri atama", + "assign-assets-text": "Müþteriye {count, çoðul, 1 {1 asset}} diðer {# asset}} atayýn ", + "delete-assets": "Öðeleri sil", + "unassign-assets": "Varlýklarý atama", + "unassign-assets-action-title": "Müþteriden tekil, sayým, çoðul, 1 {1 öðe} diðer {# varlýk}} atama ", + "assign-new-asset": "Yeni varlýk atama", + "delete-asset-title": "Öðeyi {{assetName}} silmek istediðinizden emin misiniz?", + "delete-asset-text": "Dikkatli olun, onaylandýktan sonra varlýk ve ilgili tüm veriler kurtarýlamaz.", + "delete-assets-title": "{Count, çoðul, 1 {1 asset} diðer {# asset}} silmek istediðinizden emin misiniz?", + "delete-assets-action-title": "Sil {count, çoðul, 1 {1 asset} diðer {# asset}}", + "delete-assets-text": "Dikkatli olun, onaylandýktan sonra tüm seçilmiþ varlýklar kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", + "make-public-asset-title": "Varlýðý {{assetName}} herkese açýk yapmak istediðinizden emin misiniz? ", + "make-public-asset-text": "Onaydan sonra varlýk ve tüm verileri kamuya açýk ve baþkalarý tarafýndan eriþilebilir olacak.", + "make-private-asset-title": "Öðeyi {{assetName}} özel yapmak istediðinizden emin misiniz? ", + "make-private-asset-text": "Onaydan sonra varlýk ve tüm verileri gizli tutulacak ve baþkalarý tarafýndan eriþilemeyecektir.", + "unassign-asset-title": "Öðeyi {{assetName}} öðesinin atamasýný kaldýrmak istediðinizden emin misiniz?", + "unassign-asset-text": "Onaydan sonra varlýk atama yapýlmayacak ve müþteri tarafýndan eriþilemeyecektir.", + "unassign-asset": "Atama atamayý kaldýr", + "unassign-assets-title": "count, plural, 1 {1 öðe} other {# asset}} atama atamak istediðinizden emin misiniz?", + "unassign-assets-text": "Onaylandýktan sonra, seçilen tüm öðeler atamadan kaldýrýlacak ve müþteri tarafýndan eriþilemeyecektir.", + "copyId": "Öðe Kimliði Kopyala", + "idCopiedMessage": "Öðe Kimliði panoya kopyalandý", + "select-asset": "Varlýk seç", + "no-assets-matching": "{{Entity}} ile eþleþen hiçbir öðe bulunamadý. ", + "asset-required": "Varlýk gerekli", + "name-starts-with": "Öðe adý ile baþlar" + }, + "attribute": { + "attributes": "Öznitellikler", + "latest-telemetry": "Son telemetri", + "attributes-scope": "Varlýk öznitelikleri kapsamý", + "scope-latest-telemetry": "Son telemetri", + "scope-client": "Müþteri özellikleri", + "scope-server": "Sunucu öznitelikleri", + "scope-shared": "Paylaþýlan özellikler", + "add": "Özellik ekle", + "key": "Anahtar", + "last-update-time": "Son güncelleme zamaný", + "key-required": "Özellik anahtarý gerekli.", + "value": "Deðer", + "value-required": "Özellik deðeri gerekli.", + "delete-attributes-title": "{Count, çoðul, 1 {1 özellik} diðer {# attributes}} silmek istediðinizden emin misiniz?", + "delete-attributes-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen özellikler kaldýrýlacak.", + "delete-attributes": "Nitelikleri sil", + "enter-attribute-value": "Özellik deðerini girin", + "show-on-widget": "Widget’ta göster", + "widget-mode": "Widget modu", + "next-widget": "Sonraki pencere öðesi", + "prev-widget": "Önceki pencere öðesi", + "add-to-dashboard": "Gösterge tablosuna ekle", + "add-widget-to-dashboard": "Gösterge tablosuna widget ekle", + "selected-attributes": "{count, çoðul, 1 {1 özellik} diðer {# attributes}} seçildi", + "selected-telemetry": "{count, çoðul, 1 {1 telemetri birimi} diðer {# telemetri birimleri}} seçildi" + }, + "audit-log": { + "audit": "Denetim", + "audit-logs": "Denetim Kayýtlarý", + "timestamp": "Zaman Damgasý", + "entity-type": "Varlýk Türü", + "entity-name": "Varlýk adý", + "user": "Kullanýcý", + "type": "Tür", + "status": "Durum", + "details": "Ayrýntýlar", + "type-added": "Katma", + "type-deleted": "Silindi", + "type-updated": "Güncellenmiþ", + "type-attributes-updated": "Öznitelikler güncellendi", + "type-attributes-deleted": "Öznitelikler silindi", + "type-rpc-call": "RPC çaðrýsý", + "type-credentials-updated": "Kimlik bilgileri güncellendi", + "type-assigned-to-customer": "Müþteriye Atanan", + "type-unassigned-from-customer": "Müþteriden Atanmamýþ", + "type-activated": "Aktif", + "type-suspended": "Askýya alýndý", + "type-credentials-read": "Kimlik bilgileri okundu", + "type-attributes-read": "Nitelikler oku", + "type-relation-add-or-update": "Ýliþki güncellendi", + "type-relation-delete": "Ýliþki silindi", + "type-relations-delete": "Tüm iliþki silindi", + "type-alarm-ack": "Kabul edilen", + "type-alarm-clear": "Temizlendi", + "status-success": "Baþarý", + "status-failure": "Baþarýsýzlýk", + "audit-log-details": "Denetim kaydý ayrýntýlarý", + "no-audit-logs-prompt": "Kayýt bulunamadý", + "action-data": "Eylem verileri", + "failure-details": "Arýza detaylarý", + "search": "Denetim günlüklerini ara", + "clear-search": "Aramayý Temizle" + }, + "confirm-on-exit": { + "message": "Kaydedilmemiþ deðiþiklikleriniz var. Bu sayfadan ayrýlmak istediðinizden emin misiniz?", + "html-message": "Kaydedilmemiþ deðiþiklikleriniz var.
Bu sayfadan ayrýlmak istediðinizden emin misiniz?", + "title": "Kaydedilmemiþ deðiþiklikler" + }, + "contact": { + "country": "Ülke", + "city": "Kent", + "state": "Eyalet / Ýl", + "postal-code": "Posta Kodu", + "postal-code-invalid": "Geçersiz Posta Kodu / Posta Kodu biçimi.", + "address": "Adres", + "address2": "Adres 2", + "phone": "Telefon", + "email": "E-posta", + "no-address": "Adres yok" + }, + "common": { + "username": "Kullanýcý adý", + "password": "Parola", + "enter-username": "Kullanýcý adý girin", + "enter-password": "Parolaný Gir", + "enter-search": "Arama girin" + }, + "content-type": { + "json": "Json", + "text": "Metin", + "binary": "Ýkili (Base64)" + }, + "customer": { + "customer": "Müþteri", + "customers": "Müþteriler", + "management": "Müþteri yönetimi", + "dashboard": "Müþteri Kontrol Paneli", + "dashboards": "Müþteri Kontrol Panelleri", + "devices": "Müþteri Cihazlarý", + "entity-views": "Müþteri Varlýðý Görüntüleme Sayýsý", + "assets": "Müþteri Varlýklarý", + "public-dashboards": "Genel Panolar", + "public-devices": "Kamu Aygýtlarý", + "public-assets": "Kamu Varlýklarý", + "public-entity-views": "Kamu Varlýk Görüntüleme Sayýsý", + "add": "Müþteri Ekle", + "delete": "Müþteriyi sil", + "manage-customer-users": "Müþteri kullanýcýlarýný yönet", + "manage-customer-devices": "Müþteri cihazlarýný yönet", + "manage-customer-dashboards": "Müþteri panolarýný yönet", + "manage-public-devices": "Genel cihazlarý yönet", + "manage-public-dashboards": "Genel panolarý yönet", + "manage-customer-assets": "Müþteri varlýklarýný yönet", + "manage-public-assets": "Kamu varlýklarýný yönet", + "add-customer-text": "Yeni müþteri ekle", + "no-customers-text": "Müþteri bulunamadý", + "customer-details": "Müþteri detaylarý", + "delete-customer-title": "Müþteriyi silmek istediðinizden emin misiniz? {{CustomerTitle}} ? ", + "delete-customer-text": "Dikkatli olun, onaylandýktan sonra müþteri ve ilgili tüm veriler kurtarýlamaz.", + "delete-customers-title": "{Count, çoðul, 1 {1 müþteri} diðer {# customers}} silmek istediðinizden emin misiniz?", + "delete-customers-action-title": "Sil {count, çoðul, 1 {1 müþteri} diðer {# customers}}", + "delete-customers-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen müþteriler kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", + "manage-users": "Kullanýcýlarý Yönet", + "manage-assets": "Varlýklarý yönet", + "manage-devices": "Cihazlarý yönet", + "manage-dashboards": "Gösterge tablolarýný yönet", + "title": "Baþlýk", + "title-required": "Baþlýk gerekli.", + "description": "Açýklama", + "details": "Ayrýntýlar", + "events": "Etkinlikler", + "copyId": "Müþteri kimliðini kopyala", + "idCopiedMessage": "Müþteri Kimliði panoya kopyalandý", + "select-customer": "Müþteriyi seç", + "no-customers-matching": "{{Entity}} ile eþleþen hiçbir müþteri bulunamadý. ", + "customer-required": "Müþteri gerekli", + "select-default-customer": "Varsayýlan müþteriyi seç", + "default-customer": "Varsayýlan müþteri", + "default-customer-required": "Kiracý düzeyinde gösterge tablosunda hata ayýklamak için varsayýlan müþteri gerekiyor" + }, + "datetime": { + "date-from": "Tarih", + "time-from": "Zaman", + "date-to": "Tarih", + "time-to": "Zaman" + }, + "dashboard": { + "dashboard": "Pano", + "dashboards": "Gösterge tablolarý", + "management": "Gösterge tablosu yönetimi", + "view-dashboards": "Gösterge Panolarý", + "add": "Gösterge Tablosu Ekle", + "assign-dashboard-to-customer": "Gösterge Tablosunu / Müþterilerini Müþteriye Atama", + "assign-dashboard-to-customer-text": "Lütfen müþteriye atamak için kontrol panellerini seçin", + "assign-to-customer-text": "Gösterge panellerini atamak için lütfen müþteriyi seçiniz", + "assign-to-customer": "Müþteriye atama", + "unassign-from-customer": "Müþteriden atama", + "make-public": "Gösterge panosunu herkese açýk yap", + "make-private": "Gösterge panosunu özel yap", + "manage-assigned-customers": "Atanan müþterileri yönet", + "assigned-customers": "Atanan müþteriler", + "assign-to-customers": "Gösterge Tablosunu / Müþterilerini Müþterilere Atama", + "assign-to-customers-text": "Lütfen gösterge panosunu atamak için müþterileri seçin", + "unassign-from-customers": "Müþterilerden Gösterge Tablosunu (Notlarýný) Atama", + "unassign-from-customers-text": "Lütfen gösterge tablosundan atamak için müþterileri seçin", + "no-dashboards-text": "Gösterge panelleri bulunamadý", + "no-widgets": "Hiçbir widget yapýlandýrýlmamýþ", + "add-widget": "Yeni pencere öðesi ekle", + "title": "Baþlýk", + "select-widget-title": "Widget seç", + "select-widget-subtitle": "Kullanýlabilir pencere öðesi türleri", + "delete": "Gösterge tablosunu sil", + "title-required": "Baþlýk gerekli.", + "description": "Açýklama", + "details": "Ayrýntýlar", + "dashboard-details": "Gösterge tablosu ayrýntýlarý", + "add-dashboard-text": "Yeni gösterge tablosu ekle", + "assign-dashboards": "Gösterge tablolarý atama", + "assign-new-dashboard": "Yeni kontrol paneli atama", + "assign-dashboards-text": "Müþterilere {count, plural, 1 {1 gösterge tablosu} other {# dashboard}}} atayýn ", + "unassign-dashboards-action-text": "Müþterilerden atama sayýsý, çoðul, 1 {1 gösterge tablosu} diðer {# panolar}}", + "delete-dashboards": "Gösterge tablolarýný sil", + "unassign-dashboards": "Atanmamýþ gösterge panolarý", + "unassign-dashboards-action-title": "Müþteriden atama sayým, çoðul, 1 {1 gösterge tablosu} diðer {# pano}}", + "delete-dashboard-title": "'{{DashboardTitle}}' gösterge panosunu silmek istediðinizden emin misiniz?", + "delete-dashboard-text": "Doðrulamadan sonra, kontrol panelinden ve ilgili tüm veriler kurtarýlamayacak.", + "delete-dashboards-title": "{Count, çoðul, 1 {1 dashboard} diðer {# dashboards}} silmek istediðinizden emin misiniz?", + "delete-dashboards-action-title": "Sil {count, çoðul, 1 {1 gösterge tablosu} diðer {# dashboard}}", + "delete-dashboards-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen panolar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", + "unassign-dashboard-title": "{{DashboardTitle}} gösterge tablosunun atamasýný kaldýrmak istediðinizden emin misiniz? ", + "unassign-dashboard-text": "Onaydan sonra gösterge panosu atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", + "unassign-dashboard": "Atanmamýþ gösterge panosu", + "unassign-dashboards-title": "Sayým, çoðul, 1 {1 gösterge tablosu} diðer {# panolar}} atama atamak istediðinizden emin misiniz?", + "unassign-dashboards-text": "Onaydan sonra tüm seçilen gösterge tablolarý atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", + "public-dashboard-title": "Gösterge panosu artýk herkese açýk", + "public-dashboard-text": "Gösterge tablonuz {{dashboardTitle}} artýk herkese açýk ve bir sonraki herkese açýk link üzerinden eriþilebilir:", + "public-dashboard-notice": " Not: Verilerine eriþmek için ilgili cihazlarý herkese açýk yapmayý unutmayýn.", + "make-private-dashboard-title": "Gösterge panosunu {{dashboardTitle}} özel yapmak istediðinizden emin misiniz?", + "make-private-dashboard-text": "Onaydan sonra gösterge panosu özel hale getirilecek ve baþkalarý tarafýndan eriþilemeyecektir.", + "make-private-dashboard": "Gösterge panosunu özel yap", + "socialshare-text": "'{{dashboardTitle}} ThingsBoard tarafýndan desteklenmektedir ", + "socialshare-title": "'{{dashboardTitle}} ThingsBoard tarafýndan desteklenmektedir ", + "select-dashboard": "Gösterge tablosu seç", + "no-dashboards-matching": "{{Entity}} ile eþleþen hiçbir gösterge paneli bulunamadý. ", + "dashboard-required": "Gösterge tablosu gerekli.", + "select-existing": "Mevcut kontrol panelini seç", + "create-new": "Yeni gösterge tablosu oluþtur", + "new-dashboard-title": "Yeni kontrol paneli baþlýðý", + "open-dashboard": "Açýk kontrol paneli", + "set-background": "Arka planý ayarla", + "background-color": "Arka plan rengi", + "background-image": "Arka plan görüntüsü", + "background-size-mode": "Arka plan boyut modu", + "no-image": "Görüntü seçilmedi", + "drop-image": "Bir resim býrakýn veya yüklenecek bir dosya seçmek için týklayýn.", + "settings": "Ayarlar", + "columns-count": "Sütun sayýsý", + "columns-count-required": "Sütun sayýsý gerekli.", + "min-columns-count-message": "Sadece 10 minimum sütun sayýsýna izin verilir.", + "max-columns-count-message": "Sadece maksimum 1000 sütun sayýsýna izin verilir.", + "widgets-margins": "Widget arasýnda marj", + "horizontal-margin": "Yatay kenar boþluðu", + "horizontal-margin-required": "Yatay kenar boþluðu deðeri gerekiyor.", + "min-horizontal-margin-message": "Minimum yatay kenar boþluðu deðeri olarak yalnýzca 0'a izin verilir.", + "max-horizontal-margin-message": "Maksimum yatay kenar boþluðu deðeri olarak yalnýzca 50'ye izin verilir.", + "vertical-margin": "Dikey kenar boþluðu", + "vertical-margin-required": "Dikey kenar boþluðu deðeri gereklidir.", + "min-vertical-margin-message": "En az dikey kenar boþluðu deðeri olarak yalnýzca 0'a izin verilir.", + "max-vertical-margin-message": "Maksimum dikey kenar boþluðu deðeri olarak yalnýzca 50'ye izin verilir.", + "autofill-height": "Otomatik dolgu düzeni yüksekliði", + "mobile-layout": "Mobil düzen ayarlarý", + "mobile-row-height": "Mobil satýr yüksekliði, px", + "mobile-row-height-required": "Mobil satýr yüksekliði deðeri gerekli.", + "min-mobile-row-height-message": "Minimum mobil satýr yüksekliði deðeri olarak yalnýzca 5 piksele izin verilir.", + "max-mobile-row-height-message": "Maksimum mobil satýr yüksekliði deðeri olarak yalnýzca 200 piksele izin verilir.", + "display-title": "Gösterge tablosu baþlýðý", + "toolbar-always-open": "Araç çubuðunu açýk tut", + "title-color": "Baþlýk rengi", + "display-dashboards-selection": "Gösterge panolarý seçimi", + "display-entities-selection": "Öðe varlýklarý seçimi", + "display-dashboard-timewindow": "Zaman penceresi göster", + "display-dashboard-export": "Görüntülü dýþa aktarma", + "import": "Gösterge paneli", + "export": "Dýþ gösterge panosu", + "export-failed-error": "Gösterge tablosu gönderilemiyor: {{error}}", + "create-new-dashboard": "Yeni gösterge tablosu oluþtur", + "dashboard-file": "Gösterge tablosu dosyasý", + "invalid-dashboard-file-error": "Gösterge tablosu alýnamadý: Geçersiz pano veri yapýsý.", + "dashboard-import-missing-aliases-title": "Alýnan pano tarafýndan kullanýlan takma adlarý yapýlandýr", + "create-new-widget": "Yeni pencere öðesi oluþtur", + "import-widget": "Widget'ý içe aktar", + "widget-file": "Widget dosyasý", + "invalid-widget-file-error": "Widget içe aktarýlamýyor: Geçersiz pencere öðesi yapýsý.", + "widget-import-missing-aliases-title": "Ýçe aktarýlan pencere aracý tarafýndan kullanýlan takma adlarý yapýlandýr", + "open-toolbar": "Gösterge tablosu araç çubuðunu aç", + "close-toolbar": "Araç çubuðunu kapat", + "configuration-error": "Yapýlandýrma hatasý", + "alias-resolution-error-title": "Gösterge tablosu takma ad hatasý", + "invalid-aliases-config": "Diðer ad filtrelerinin biriyle eþleþen herhangi bir cihaz bulunamadý.
Lütfen bu sorunu çözmek için yöneticinize baþvurun.", + "select-devices": "Cihaz seç", + "assignedToCustomer": "Müþteriye atandý", + "assignedToCustomers": "Müþterilere atandý", + "public": "Halka açýk", + "public-link": "Genel baðlantý", + "copy-public-link": "Genel baðlantýyý kopyala", + "public-link-copied-message": "Pano genel baðlantýsý panoya kopyalandý", + "manage-states": "Gösterge panosu durumlarýný yönet", + "states": "Gösterge durumu", + "search-states": "Gösterge panosu durumlarý", + "selected-states": "{count, çoðul, 1 {1 gösterge tablosu durumu} diðer {# dashboard durumlarý}} seçildi", + "edit-state": "Gösterge panosu durumunu düzenle", + "delete-state": "Gösterge panosu durumunu sil", + "add-state": "Gösterge paneli durumu", + "state": "Gösterge paneli durumu", + "state-name": "Ad", + "state-name-required": "Gösterge panosu durum adý gerekli.", + "state-id": "Durum Kimliði", + "state-id-required": "Gösterge durumu kimliði gerekiyor.", + "state-id-exists": "Ayný kimliðe sahip gösterge tablosu zaten var.", + "is-root-state": "Kök devlet", + "delete-state-title": "Gösterge panosu durumunu sil", + "delete-state-text": "Gösterge panosu durumunu '{{stateName}}' adýyla silmek istediðinizden emin misiniz?", + "show-details": "Detaylarý göster", + "hide-details": "Detaylarý gizle", + "select-state": "Hedef durumu seç", + "state-controller": "Durum kontrolörü" + }, + "datakey": { + "settings": "Ayarlar", + "advanced": "Ýleri", + "label": "Etiket", + "color": "Renk", + "units": "Deðerin yanýnda gösterilecek özel sembol", + "decimals": "Kayan noktadan sonraki basamak sayýsý", + "data-generation-func": "Veri oluþturma iþlevi", + "use-data-post-processing-func": "Veri iþleme sonrasý iþlevini kullan", + "configuration": "Veri anahtarý yapýlandýrmasý", + "timeseries": "Zaman serisi", + "attributes": "Öznitellikler", + "alarm": "Alarm alanlarý", + "timeseries-required": "Varlýk zamanlamalarý gerekli.", + "timeseries-or-attributes-required": "Varlýk zaman çizelgeleri / öznitelikler gereklidir.", + "maximum-timeseries-or-attributes": "Maksimum {count, çoðul, 1 {1 timeseries / attribute.} Diðer {# timeseries / attributes {} izin verilir}}", + "alarm-fields-required": "Alarm alanlarý gerekli.", + "function-types": "Ýþlev türleri", + "function-types-required": "Ýþlev tipleri gereklidir.", + "maximum-function-types": "Maksimum {sayým, çoðul, 1 {1 iþlev türüne izin verilir.} Diðer {# iþlev türlerine izin verilir}}" + }, + "datasource": { + "type": "Veri kaynaðý türü", + "name": "Ad", + "add-datasource-prompt": "Lütfen veri kaynaðý ekle" + }, + "details": { + "edit-mode": "Düzenleme modu", + "toggle-edit-mode": "Düzenleme modunu deðiþtir" + }, + "device": { + "device": "Cihaz", + "device-required": "Cihaz gerekli.", + "devices": "Cihazlar", + "management": "Cihaz yönetimi", + "view-devices": "Cihazlarý Görüntüle", + "device-alias": "Cihaz takma adý", + "aliases": "Cihaz takma adlarý", + "no-alias-matching": "'{{alias}} bulunamadý. ", + "no-aliases-found": "Takma ad bulunamadý", + "no-key-matching": "'{{anahtar bulunamadý.", + "no-keys-found": "Anahtar bulunamadý.", + "create-new-alias": "Yeni bir tane oluþtur!", + "create-new-key": "Yeni bir tane oluþtur!", + "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}}..
Cihaz takma adlarý, kontrol panelinde benzersiz olmalýdýr. ", + "configure-alias": "Yapýlandýrma {{alias}} takma ad", + "no-devices-matching": "{{Entity}} ile eþleþen hiçbir cihaz bulunamadý. ", + "alias": "Alias", + "alias-required": "Cihaz takma adý gerekiyor.", + "remove-alias": "Cihaz takma adýný kaldýr", + "add-alias": "Cihaz takma adý ekle", + "name-starts-with": "Cihaz adý ile baþlýyor", + "device-list": "Aygýt listesi", + "use-device-name-filter": "Filtre kullan", + "device-list-empty": "Cihaz seçilmedi.", + "device-name-filter-required": "Cihaz adý filtresi gerekli.", + "device-name-filter-no-device-matched": "{{Device}} ile baþlayan hiçbir cihaz bulunamadý. ", + "add": "Cihaz ekle", + "assign-to-customer": "Müþteriye atama", + "assign-device-to-customer": "Aygýtý / Aygýtlarý Müþteriye Atama", + "assign-device-to-customer-text": "Lütfen müþteriye atamak istediðiniz cihazlarý seçin", + "make-public": "Cihazý herkese açýk yap", + "make-private": "Cihazý özel yap", + "no-devices-text": "Hiçbir cihaz bulunamadý", + "assign-to-customer-text": "Lütfen cihazý atamak için müþteriyi seçin", + "device-details": "Cihaz detaylarý", + "add-device-text": "Yeni cihaz ekle", + "credentials": "Kimlik bilgileri", + "manage-credentials": "Kimlik bilgilerini yönet", + "delete": "Cihazý sil", + "assign-devices": "Aygýtlarý atama", + "assign-devices-text": "Müþteriye {count, çoðul, 1 {1 cihaz} diðer {# devices}} atayýn ", + "delete-devices": "Cihazlarý sil", + "unassign-from-customer": "Müþteriden atama", + "unassign-devices": "Atanmamýþ cihazlarý", + "unassign-devices-action-title": "Müþteriden atayýn, çoðul, 1 {1 cihaz} diðer {# devices}} atama ", + "assign-new-device": "Yeni cihaz atama", + "make-public-device-title": "Cihazý {{deviceName}} herkese açýk yapmak istediðinizden emin misiniz?", + "make-public-device-text": "Onaydan sonra cihaz ve tüm verileri kamuya açýk ve baþkalarý tarafýndan eriþilebilir olacak.", + "make-private-device-title": "Cihazý {{deviceName}} özel yapmak istediðinizden emin misiniz?", + "make-private-device-text": "Onaylandýktan sonra cihaz ve tüm verileri gizli tutulacak ve baþkalarý tarafýndan eriþilemeyecektir.", + "view-credentials": "Kimlik bilgilerini görüntüle", + "delete-device-title": "Cihazý {{deviceName}} silmek istediðinizden emin misiniz? ", + "delete-device-text": "Dikkatli olun, onaylamadan sonra cihaz ve tüm ilgili veriler kurtarýlamaz.", + "delete-devices-title": "{Count, çoðul, 1 {1 cihaz} diðer {# devices}} silmek istediðinizden emin misiniz?", + "delete-devices-action-title": "Sil {count, çoðul, 1 {1 cihaz} diðer {# devices}}", + "delete-devices-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen cihazlar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", + "unassign-device-title": "Cihazýn atamasýný kaldýrmak istediðinizden emin misiniz? {{DeviceName}} ? ", + "unassign-device-text": "Onaydan sonra cihaz atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", + "unassign-device": "Atanmamýþ cihaz", + "unassign-devices-title": "Sayým, çoðul, 1 {1 cihaz} diðer {# devices}} atama atamak istediðinizden emin misiniz?", + "unassign-devices-text": "Onaylandýktan sonra tüm seçilen cihazlar atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", + "device-credentials": "Cihaz Kimlik Bilgileri", + "credentials-type": "Kimlik bilgileri türü", + "access-token": "Eriþim belirteci", + "access-token-required": "Eriþim belirteci gerekli.", + "access-token-invalid": "Eriþim belirteci uzunluðu 1 ile 20 karakter arasýnda olmalýdýr.", + "rsa-key": "RSA ortak anahtarý", + "rsa-key-required": "RSA ortak anahtarý gerekli.", + "secret": "Gizli", + "secret-required": "Gizli gerekli", + "device-type": "Cihaz tipi", + "device-type-required": "Cihaz tipi gerekli.", + "select-device-type": "Cihaz türünü seç", + "enter-device-type": "Cihaz türünü girin", + "any-device": "Herhangi bir cihaz", + "no-device-types-matching": "{{EntitySubtype}} ile eþleþen cihaz türü bulunamadý. ", + "device-type-list-empty": "Hiçbir cihaz türü seçilmedi.", + "device-types": "Cihaz türleri", + "name": "Ad", + "name-required": "Ýsim gerekli.", + "description": "Açýklama", + "events": "Etkinlikler", + "details": "Ayrýntýlar", + "copyId": "Cihaz kimliðini kopyala", + "copyAccessToken": "Eriþim belirteci kopyala", + "idCopiedMessage": "Cihaz Kimliði panoya kopyalandý", + "accessTokenCopiedMessage": "Cihaz eriþim belirteci panoya kopyalandý", + "assignedToCustomer": "Müþteriye atandý", + "unable-delete-device-alias-title": "Cihaz takma adý silinemiyor", + "unable-delete-device-alias-text": "Cihaz takma adý {{deviceAlias}} ', þu widget (lar) tarafýndan kullanýldýðý þekliyle silinemiyor:
{{widgetsList}} ", + "is-gateway": "Að geçidi", + "public": "Halka açýk", + "device-public": "Cihaz herkese açýk", + "select-device": "Cihaz seç" + }, + "dialog": { + "close": "Ýletiþim kutusunu kapat" + }, + "error": { + "unable-to-connect": "Sunucuya baðlanýlamýyor! Lütfen Ýnternet baðlantýnýzý kontrol edin.", + "unhandled-error-code": "Ýþlenmemiþ hata kodu: {{errorCode}}", + "unknown-error": "Bilinmeyen hata" + }, + "entity": { + "entity": "Varlýk", + "entities": "Varlýklarý", + "aliases": "Varlýk takma adlarý", + "entity-alias": "Varlýk takma adý", + "unable-delete-entity-alias-title": "Varlýk takma adý silinemiyor", + "unable-delete-entity-alias-text": "Varlýk takma adý {{entityAlias}} ', þu widget (lar) tarafýndan kullanýldýðý þekliyle silinemez:
{{widgetsList}} ", + "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '... Entity takma adlar, gösterge panosunda benzersiz olmalýdýr. ", + "missing-entity-filter-error": "Diðer adlar için filtre eksik {{alias}}. ", + "configure-alias": "Yapýlandýrma {{alias}} takma ad", + "alias": "Alias", + "alias-required": "Varlýk takma adý gerekiyor.", + "remove-alias": "Varlýk takma adýný kaldýrma", + "add-alias": "Varlýk takma adý ekle", + "entity-list": "Varlýk listesi", + "entity-type": "Varlýk türü", + "entity-types": "Varlýk türleri", + "entity-type-list": "Varlýk türü listesi", + "any-entity": "Herhangi bir varlýk", + "enter-entity-type": "Varlýk türü girin", + "no-entities-matching": "{{Entity}} ile eþleþen hiçbir varlýk bulunamadý. ", + "no-entity-types-matching": "{{EntityType}} ile eþleþen hiçbir varlýk türü bulunamadý. ", + "name-starts-with": "Ýsim ile baþlar", + "use-entity-name-filter": "Filtre kullan", + "entity-list-empty": "Hiçbir varlýk seçilmedi.", + "entity-type-list-empty": "Hiçbir varlýk türü seçilmedi.", + "entity-name-filter-required": "Varlýk adý filtresi gerekli.", + "entity-name-filter-no-entity-matched": "{{Entity}} ile baþlayan hiçbir varlýk bulunamadý. ", + "all-subtypes": "Herþey", + "select-entities": "Öðeleri seç", + "no-aliases-found": "Takma ad bulunamadý", + "no-alias-matching": "'{{alias}} bulunamadý. ", + "create-new-alias": "Yeni bir tane oluþtur!", + "key": "Anahtar", + "key-name": "Anahtar adý", + "no-keys-found": "Anahtar bulunamadý.", + "no-key-matching": "'{{anahtar bulunamadý.", + "create-new-key": "Yeni bir tane oluþtur!", + "type": "Tür", + "type-required": "Varlýk türü gerekli.", + "type-device": "Cihaz", + "type-devices": "Cihazlar", + "list-of-devices": "{sayým, çoðul, 1 {Bir cihaz} diðer {# cihazlarýn listesi}}", + "device-name-starts-with": "Ýsimleri '{{prefix}} ile baþlayan cihazlar ", + "type-asset": "Varlýk", + "type-assets": "Varlýklar", + "list-of-assets": "{count, plural, 1 {One asset} diðer {# asset}}", + "asset-name-starts-with": "Adlarý {{prefix}} ile baþlayan varlýklar ", + "type-entity-view": "Varlýk Görünümü", + "type-entity-views": "Varlýk Görünümleri", + "list-of-entity-views": "{count, çoðul, 1 {Bir varlýk görünümü} diðer {# varlýk görüntüleme}} listesi", + "entity-view-name-starts-with": "Adý {{önek}} ile baþlayan varlýk görünümleri", + "type-rule": "Kural", + "type-rules": "Kurallar", + "list-of-rules": "{count, çoðul, 1 {Bir kural} diðer {# kurallarýn}} listesi", + "rule-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kurallar", + "type-plugin": "Eklenti", + "type-plugins": "Eklentiler", + "list-of-plugins": "{count, çoðul, 1 {Bir eklenti} diðer {# eklenti listesi}}", + "plugin-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan eklentiler", + "type-tenant": "Kiracý", + "type-tenants": "Kiracýlar", + "list-of-tenants": "{count, çoðul, 1 {Bir kiracý} diðer {# kiracýlarýn listesi}}", + "tenant-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kiracýlar, ", + "type-customer": "Müþteri", + "type-customers": "Müþteriler", + "list-of-customers": "{count, çoðul, 1 {Bir müþteri} diðer {# müþteri}} listesi", + "customer-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan müþteriler,", + "type-user": "Kullanýcý", + "type-users": "Kullanýcýlar", + "list-of-users": "{count, çoðul, 1 {Bir kullanýcý} diðer {# user}} listesi", + "user-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kullanýcýlar", + "type-dashboard": "Pano", + "type-dashboards": "Gösterge tablolarý", + "list-of-dashboards": "{count, çoðul, 1 {Bir pano} Diðer {# panolarýn}}} listesi", + "dashboard-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan panolar", + "type-alarm": "Alarm", + "type-alarms": "Alarmlar", + "list-of-alarms": "{count, çoðul, 1 {Bir alarm} diðer {{# alarm}} listesi", + "alarm-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan alarmlar", + "type-rulechain": "Kural zinciri", + "type-rulechains": "Kural zincirleri", + "list-of-rulechains": "{count, çoðul, 1 {Bir kural zinciri} diðer {# kural zincirinin listesi}}", + "rulechain-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kural zincirleri", + "type-rulenode": "Kural düðümü", + "type-rulenodes": "Kural düðümleri", + "list-of-rulenodes": "{count, çoðul, 1 {Bir kural node} diðer {# kural düðümünün listesi}}", + "rulenode-name-starts-with": "Ýsimleri '{{prefix}} ile baþlayan kural düðümleri", + "type-current-customer": "Mevcut Müþteri", + "search": "Varlýklar ara", + "selected-entities": "{count, çoðul, 1 {1 varlýk} diðer {# entities}} seçildi", + "entity-name": "Varlýk adý", + "details": "Varlýk ayrýntýlarý", + "no-entities-prompt": "Hiçbir varlýk bulunamadý", + "no-data": "Gösterilecek bilgi yok" + }, + "entity-view": { + "entity-view": "Varlýk Görünümü", + "entity-views": "Varlýk Görünümleri", + "management": "Varlýk Görünümü yönetimi", + "view-entity-views": "Varlýk Görünümlerini Görüntüle", + "entity-view-alias": "Varlýk Görünümü takma adý", + "aliases": "Varlýk Görünümü takma adlarý", + "no-alias-matching": "'{{alias}} bulunamadý. ", + "no-aliases-found": "Takma ad bulunamadý", + "no-key-matching": "'{{anahtar bulunamadý.", + "no-keys-found": "Anahtar bulunamadý.", + "create-new-alias": "Yeni bir tane oluþtur!", + "create-new-key": "Yeni bir tane oluþtur!", + "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '.. Entity View diðer adlar, gösterge panosunda benzersiz olmalýdýr. ", + "configure-alias": "Yapýlandýrma {{alias}} takma ad", + "no-entity-views-matching": "{{Entity}} ile eþleþen hiçbir varlýk yorumu bulunamadý. ", + "alias": "Alias", + "alias-required": "Varlýk Görünümü takma adý gerekiyor.", + "remove-alias": "Varlýk görünümü takma adýný kaldýr", + "add-alias": "Varlýk görünümü takma adý ekle", + "name-starts-with": "Varlýk Görünümü adý ile baþlýyor", + "entity-view-list": "Varlýk Görünümü listesi", + "use-entity-view-name-filter": "Filtre kullan", + "entity-view-list-empty": "Hiçbir varlýk görüþü seçilmedi.", + "entity-view-name-filter-required": "Varlýk görünüm adý filtresi gerekli.", + "entity-view-name-filter-no-entity-view-matched": "{{EntityView}} ile baþlayan hiçbir varlýk sayýsý bulunamadý.", + "add": "Varlýk Görünümü Ekle", + "assign-to-customer": "Müþteriye atama", + "assign-entity-view-to-customer": "Varlýk Görünümlerini Müþteriye Atama", + "assign-entity-view-to-customer-text": "Lütfen müþteriye atamak için varlýk görünümlerini seçin", + "no-entity-views-text": "Varlýk görüþü bulunamadý", + "assign-to-customer-text": "Lütfen varlýk görünümlerini atamak için müþteriyi seçin", + "entity-view-details": "Varlýk görünümü ayrýntýlarý", + "add-entity-view-text": "Yeni varlýk görünümü ekle", + "delete": "Varlýk görünümünü sil", + "assign-entity-views": "Varlýk görünümleri atama", + "assign-entity-views-text": "Müþteriye {count, çoðul, 1 {1 entityView} diðer {# entityViews}} atayýn ", + "delete-entity-views": "Varlýk görünümlerini sil", + "unassign-from-customer": "Müþteriden atama", + "unassign-entity-views": "Varlýk görünümlerini atama", + "unassign-entity-views-action-title": "Müþteriden atama sayým, çoðul, 1 {1 entityView} diðer {# entityViews}}", + "assign-new-entity-view": "Yeni varlýk görünümü atama", + "delete-entity-view-title": "Varlýk görünümünü silmek istediðinizden emin misiniz?, {{EntityViewName}} '? ", + "delete-entity-view-text": "Dikkatli olun, onaylandýktan sonra varlýk görünümü ve ilgili tüm veriler kurtarýlamayacak.", + "delete-entity-views-title": "{Count, çoðul, 1 {1 entityView} diðer {# entityViews}} varlýk görünümüne sahip olmak istediðinizden emin misiniz?", + "delete-entity-views-action-title": "Sil {count, çoðul, 1 {1 entityView} diðer {# entityViews}}", + "delete-entity-views-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen görünümler kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", + "unassign-entity-view-title": "Varlýk görünümünün atamasýný kaldýrmak istediðinizden emin misiniz? {{EntityViewName}} '? ", + "unassign-entity-view-text": "Onaydan sonra varlýk görünümü atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", + "unassign-entity-view": "Varlýk görünümünün atamasýný kaldýr", + "unassign-entity-views-title": "Sayým, çoðul, 1 {1 entityView} diðer {# entityViews}} hesabýnýn atamasýný kaldýrmak istediðinizden emin misiniz?", + "unassign-entity-views-text": "Onaylandýktan sonra, seçilen tüm öðe görünümleri atamadan kaldýrýlacak ve müþteri tarafýndan eriþilemeyecektir.", + "entity-view-type": "Varlýk Görünümü türü", + "entity-view-type-required": "Varlýk Görünümü türü gerekli.", + "select-entity-view-type": "Varlýk görüntüleme türünü seç", + "enter-entity-view-type": "Varlýk görüntüleme türünü girin", + "any-entity-view": "Herhangi bir varlýk görünümü", + "no-entity-view-types-matching": "{{EntitySubtype}} ile eþleþen hiçbir varlýk görüntüleme türü bulunamadý. ", + "entity-view-type-list-empty": "Hiçbir varlýk görünümü türü seçilmemiþ.", + "entity-view-types": "Varlýk Görünümü türleri", + "name": "Ad", + "name-required": "Ýsim gerekli.", + "description": "Açýklama", + "events": "Etkinlikler", + "details": "Ayrýntýlar", + "copyId": "Varlýk görüntüleme kimliðini kopyala", + "assignedToCustomer": "Müþteriye atandý", + "unable-entity-view-device-alias-title": "Varlýk görünümü takma adý silinemiyor", + "unable-entity-view-device-alias-text": "Cihaz takma adý {{entityViewAlias}} ', aþaðýdaki widget (lar) tarafýndan kullanýldýðý þekliyle silinemez:
{{widgetsList}} ", + "select-entity-view": "Varlýk görünümünü seç", + "make-public": "Varlýðý herkese görünür yap", + "start-ts": "Ts", + "end-ts": "End ts" + }, + "event": { + "event-type": "Etkinlik tipi", + "type-error": "Hata", + "type-lc-event": "Yaþam döngüsü etkinliði", + "type-stats": "Ýstatistik", + "type-debug-rule-node": "Hata ayýklama", + "type-debug-rule-chain": "Hata ayýklama", + "no-events-prompt": "Etkinlik bulunamadý", + "error": "Hata", + "alarm": "Alarm", + "event-time": "Etkinlik zamaný", + "server": "Sunucu", + "body": "Vücut", + "method": "Yöntem", + "type": "Tür", + "entity": "Varlýk", + "message-id": "Mesaj Kimliði", + "message-type": "Mesaj tipi", + "data-type": "Veri tipi", + "relation-type": "Ýliþki Türü", + "metadata": "Meta veri", + "data": "Veri", + "event": "Etkinlik", + "status": "Durum", + "success": "Baþarý", + "failed": "Baþarýsýz oldu", + "messages-processed": "Mesajlar iþlendi", + "errors-occurred": "Hatalar oluþtu" + }, + "extension": { + "extensions": "Uzantýlar", + "selected-extensions": "{count, çoðul, 1 {1 uzantý} diðer {# extensions}} seçildi", + "type": "Tür", + "key": "Anahtar", + "value": "Deðer", + "id": "ÝD", + "extension-id": "Uzantý kimliði", + "extension-type": "Uzatma tipi", + "transformer-json": "JSON *", + "unique-id-required": "Mevcut uzantý kimliði zaten mevcut.", + "delete": "Uzantýyý sil", + "add": "Uzantý eklemek", + "edit": "Uzantýyý düzenle", + "delete-extension-title": "{{ExtensionId}} uzantýsýný silmek istediðinizden emin misiniz? ", + "delete-extension-text": "Dikkatli olun, onaylamadan sonra uzantý ve ilgili tüm veriler kurtarýlamaz.", + "delete-extensions-title": "{Count, çoðul, 1 {1 uzantý} diðer {# extensions}} silmek istediðinizden emin misiniz?", + "delete-extensions-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen uzantýlar kaldýrýlacak.", + "converters": "Dönüþtürücü", + "converter-id": "Dönüþtürücü kimliði", + "configuration": "Yapýlandýrma", + "converter-configurations": "Dönüþtürücü yapýlandýrmalarý", + "token": "Güvenlik belirteci", + "add-converter": "Dönüþtürücü ekle", + "add-config": "Dönüþtürücü yapýlandýrmasý ekle", + "device-name-expression": "Cihaz adý ifadesi", + "device-type-expression": "Cihaz tipi ifadesi", + "custom": "Özel", + "to-double": "Çifte", + "transformer": "Transformer", + "json-required": "Trafo jsonu gerekli.", + "json-parse": "Trafo json ayrýþtýrýlamýyor.", + "attributes": "Öznitellikler", + "add-attribute": "Özellik ekle", + "add-map": "Eþleme elemaný ekle", + "timeseries": "Zaman serisi", + "add-timeseries": "Zaman çizelgeleri ekle", + "field-required": "Alan gereklidir", + "brokers": "Komisyoncular", + "add-broker": "Broker ekle", + "host": "Host", + "port": "Liman", + "port-range": "Liman 1'den 65535'e kadar olmalýdýr.", + "ssl": "SSL", + "credentials": "Kimlik bilgileri", + "username": "Kullanýcý adý", + "password": "Parola", + "retry-interval": "Milisaniye cinsinden tekrar deneme aralýðý", + "anonymous": "Anonim", + "basic": "Temel", + "pem": "PEM", + "ca-cert": "CA sertifika dosyasý *", + "private-key": "Özel anahtar dosya *", + "cert": "Sertifika dosyasý *", + "no-file": "Dosya seçilmedi.", + "drop-file": "Bir dosya býrakýn veya yüklenecek bir dosya seçmek için týklayýn.", + "mapping": "Mapping", + "topic-filter": "Konu filtresi", + "converter-type": "Dönüþtürücü tipi", + "converter-json": "Json", + "json-name-expression": "Cihaz adý json ifadesi", + "topic-name-expression": "Cihaz adý konu ifadesi", + "json-type-expression": "Cihaz tipi json ifadesi", + "topic-type-expression": "Cihaz tipi konu ifadesi", + "attribute-key-expression": "Öznitelik anahtar ifadesi", + "attr-json-key-expression": "Öznitelik anahtar json ifadesi", + "attr-topic-key-expression": "Öznitelik anahtar konu ifadesi", + "request-id-expression": "Kimlik ifadesi iste", + "request-id-json-expression": "Kimlik json ifadesi iste", + "request-id-topic-expression": "Kimlik konu ifadesini isteyin", + "response-topic-expression": "Yanýt konusu ifadesi", + "value-expression": "Deðer ifadesi", + "topic": "Konu", + "timeout": "Zaman aþýmý milisaniye cinsinden", + "converter-json-required": "Dönüþtürücü json gerekli.", + "converter-json-parse": "Dönüþtürücü json ayrýþtýrýlamýyor.", + "filter-expression": "Filtre ifadesi", + "connect-requests": "Ýstekleri baðla", + "add-connect-request": "Baðlantý talebi ekle", + "disconnect-requests": "Ýstekleri kes", + "add-disconnect-request": "Baðlantýyý kes isteði ekle", + "attribute-requests": "Özellik istekleri", + "add-attribute-request": "Özellik isteði ekle", + "attribute-updates": "Öznitelik güncellemeleri", + "add-attribute-update": "Özellik güncellemesi ekle", + "server-side-rpc": "Sunucu tarafý RPC", + "add-server-side-rpc-request": "Sunucu tarafý RPC isteði ekle", + "device-name-filter": "Cihaz adý filtresi", + "attribute-filter": "Özellik filtresi", + "method-filter": "Yöntem filtresi", + "request-topic-expression": "Konu ifadesi iste", + "response-timeout": "Milisaniye cinsinden yanýt zaman aþýmý", + "topic-expression": "Konu ifadesi", + "client-scope": "Müþteri kapsamý", + "add-device": "Cihaz ekle", + "opc-server": "Sunucular", + "opc-add-server": "Sunucu ekle", + "opc-add-server-prompt": "Lütfen sunucu ekle", + "opc-application-name": "Uygulama Adý", + "opc-application-uri": "Uygulama uri", + "opc-scan-period-in-seconds": "Saniyeler içinde tarama süresi", + "opc-security": "Güvenlik", + "opc-identity": "Kimlik", + "opc-keystore": "Keystore", + "opc-type": "Tür", + "opc-keystore-type": "Tür", + "opc-keystore-location": "Yer *", + "opc-keystore-password": "Parola", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Anahtar þifre", + "opc-device-node-pattern": "Cihaz düðümü modeli", + "opc-device-name-pattern": "Cihaz adý deseni", + "modbus-server": "Sunucular / köle", + "modbus-add-server": "Sunucu ekle / köle", + "modbus-add-server-prompt": "Lütfen sunucu / slave ekle", + "modbus-transport": "Taþýma", + "modbus-port-name": "Seri port adý", + "modbus-encoding": "Kodlama", + "modbus-parity": "Parite", + "modbus-baudrate": "Baud hýzý", + "modbus-databits": "Veri bitleri", + "modbus-stopbits": "Bitleri durdur", + "modbus-databits-range": "Veri bitleri 7 ila 8 arasýnda olmalýdýr", + "modbus-stopbits-range": "Durma bitleri 1'den 2'ye kadar olmalýdýr.", + "modbus-unit-id": "Birim Kimliði", + "modbus-unit-id-range": "Birim numarasý 1 ile 247 arasýnda olmalýdýr.", + "modbus-device-name": "Cihaz adý", + "modbus-poll-period": "Anket dönemi (ms)", + "modbus-attributes-poll-period": "Nitelikler yoklama süresi (ms)", + "modbus-timeseries-poll-period": "Timeseries anket süresi (ms)", + "modbus-poll-period-range": "Anket dönemi pozitif deðer olmalý", + "modbus-tag": "Etiket", + "modbus-function": "Ýþlev", + "modbus-register-address": "Kayýt adresi", + "modbus-register-address-range": "Kayýt adresi 0 ile 65535 arasýnda olmalýdýr.", + "modbus-register-bit-index": "Bit endeksi", + "modbus-register-bit-index-range": "Bit endeksi 0 ile 15 arasýnda olmalýdýr", + "modbus-register-count": "Kayýt sayýsý", + "modbus-register-count-range": "Kayýt sayýsý pozitif bir deðer olmalýdýr.", + "modbus-byte-order": "Bayt sýrasý", + "sync": { + "status": "Durum", + "sync": "Senkronizasyon", + "not-sync": "Eþitleme", + "last-sync-time": "Son senkronizasyon zamaný", + "not-available": "Müsait deðil" + }, + "export-extensions-configuration": "Ýhracat uzantýlarý yapýlandýrmasý", + "import-extensions-configuration": "Uzantýlarýný içe aktarma yapýlandýrmasý", + "import-extensions": "Uzantýlarý içe aktar", + "import-extension": "Uzantý içe aktar", + "export-extension": "Ýhracat uzantýsý", + "file": "Uzantýlar dosyasý", + "invalid-file-error": "Geçersiz uzantý dosyasý" + }, + "fullscreen": { + "expand": "Tam ekrana geniþlet", + "exit": "Tam ekrandan çýk", + "toggle": "Tam ekran modunu deðiþtir", + "fullscreen": "Tam ekran" + }, + "function": { + "function": "Ýþlev" + }, + "grid": { + "delete-item-title": "Bu maddeyi silmek istediðinden emin misin?", + "delete-item-text": "Dikkatli olun, onaylandýktan sonra bu öðe ve ilgili tüm veriler kurtarýlamaz.", + "delete-items-title": "{Count, çoðul, 1 {1 item} diðer {# items}} silmek istediðinizden emin misiniz?", + "delete-items-action-title": "Sil {count, çoðul, 1 {1 öðe} diðer {# items}}", + "delete-items-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen öðeler silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", + "add-item-text": "Yeni öðe ekle", + "no-items-text": "Hiç bir öðe bulunamadý", + "item-details": "Ürün detaylarý", + "delete-item": "Öðeyi silmek", + "delete-items": "Ürünleri sil", + "scroll-to-top": "Baþa kaydýr" + }, + "help": { + "goto-help-page": "Yardým sayfasýna git" + }, + "home": { + "home": "Ev", + "profile": "Profil", + "logout": "Çýkýþ Yap", + "menu": "Menü", + "avatar": "Avatar", + "open-user-menu": "Kullanýcý menüsünü aç" + }, + "import": { + "no-file": "Dosya seçilmedi", + "drop-file": "Bir JSON dosyasýný indirin veya yüklemek için bir dosya seçmek için týklayýn." + }, + "item": { + "selected": "Seçilmiþ" + }, + "js-func": { + "no-return-error": "Ýþlev deðer döndürmeli!", + "return-type-mismatch": "Ýþlev '{{type}}' tipinin deðerini döndürmelidir!", + "tidy": "Düzenli" + }, + "key-val": { + "key": "Anahtar", + "value": "Deðer", + "remove-entry": "Giriþi kaldýr", + "add-entry": "Giriþ ekle", + "no-data": "Giriþ yok" + }, + "layout": { + "layout": "Düzen", + "manage": "Düzenleri yönet", + "settings": "Yerleþim ayarlarý", + "color": "Renk", + "main": "Ana", + "right": "Sað", + "select": "Hedef yerleþimini seç" + }, + "legend": { + "position": "Efsane pozisyonu", + "show-max": "Maksimum deðeri göster", + "show-min": "Min deðerini göster", + "show-avg": "Ortalama deðeri göster", + "show-total": "Toplam deðeri göster", + "settings": "Açýklama ayarlarý", + "min": "min", + "max": "max", + "avg": "avg", + "total": "Genel Toplam" + }, + "login": { + "login": "Oturum aç", + "request-password-reset": "Þifre sýfýrlama isteði", + "reset-password": "Þifreyi yenile", + "create-password": "Þifre oluþtur", + "passwords-mismatch-error": "Girilen þifreler ayný olmalýdýr!", + "password-again": "Þifre Tekrar", + "sign-in": "Lütfen giriþ yapýn", + "username": "Kullanýcý adý (email)", + "remember-me": "Beni hatýrla", + "forgot-password": "Parolanýzý mý unuttunuz?", + "password-reset": "Parola sýfýrlama", + "new-password": "Yeni Þifre", + "new-password-again": "Yeni Þifre Tekrar", + "password-link-sent-message": "Þifre sýfýrlama baðlantýsý baþarýyla gönderildi!", + "email": "E-posta" + }, + "position": { + "top": "Üst", + "bottom": "Alt", + "left": "Ayrýldý", + "right": "Sað" + }, + "profile": { + "profile": "Profil", + "change-password": "Þifre deðiþtir", + "current-password": "Þimdiki Þifre" + }, + "relation": { + "relations": "Ýliþkiler", + "direction": "Yön", + "search-direction": { + "FROM": "Kimden", + "TO": "Alýcý" + }, + "direction-type": { + "FROM": "den", + "TO": "to" + }, + "from-relations": "Giden iliþkiler", + "to-relations": "Gelen iliþkiler", + "selected-relations": "{count, çoðul, 1 {1 iliþki} diðer {# relations}} seçildi", + "type": "Tür", + "to-entity-type": "Varlýk türüne", + "to-entity-name": "Varlýk adýna", + "from-entity-type": "Varlýk türünden", + "from-entity-name": "Varlýk adýndan", + "to-entity": "Varlýk", + "from-entity": "Varlýktan", + "delete": "Ýliþkisi sil", + "relation-type": "Ýliþki türü", + "relation-type-required": "Ýliþki türü gerekli.", + "any-relation-type": "Her hangi bir tür", + "add": "Ýliþki ekle", + "edit": "Ýliþkisi düzenle", + "delete-to-relation-title": "{{EntityName}} varlýðýna iliþkin iliþkiyi silmek istediðinizden emin misiniz? ", + "delete-to-relation-text": "Doðrulamadan sonra, varlýðýn {{entityName}} öðesinin mevcut varlýktan alakasýz olacaðýndan emin olun. ", + "delete-to-relations-title": "{Count, çoðul, 1 {1 iliþki} diðer {# relations}} silmek istediðinizden emin misiniz?", + "delete-to-relations-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen iliþkiler kaldýrýlacak ve ilgili varlýklar mevcut varlýk ile ilgisiz olacaktýr.", + "delete-from-relation-title": "{{EntityName}} varlýðýndan iliþkiyi silmek istediðinizden emin misiniz? ", + "delete-from-relation-text": "Doðrulama varlýðýnýn, {{entityName}} kuruluþundan alakasýz olacaðýndan emin olun.", + "delete-from-relations-title": "{Count, çoðul, 1 {1 iliþki} diðer {# relations}} silmek istediðinizden emin misiniz?", + "delete-from-relations-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen iliþkiler kaldýrýlacak ve mevcut varlýk ilgili varlýklardan ilgisiz olacaktýr.", + "remove-relation-filter": "Ýliþki filtresini kaldýr", + "add-relation-filter": "Ýliþki filtresi ekle", + "any-relation": "Herhangi bir iliþki", + "relation-filters": "Ýliþki filtreleri", + "additional-info": "Ek bilgi (JSON)", + "invalid-additional-info": "Ek bilgi json ayrýþtýrýlamýyor." + }, + "rulechain": { + "rulechain": "Kural zinciri", + "rulechains": "Kural zincirleri", + "root": "Kök", + "delete": "Kural zincirini sil", + "name": "Ad", + "name-required": "Ýsim gerekli.", + "description": "Açýklama", + "add": "Kural Zinciri Ekleme", + "set-root": "Kural zincirinin kökü yap", + "set-root-rulechain-title": "Kural zincirini {{ruleChainName}} root? Yapmak istediðinizden emin misiniz?", + "set-root-rulechain-text": "Onaydan sonra kural zinciri kökleþecek ve gelen tüm iletilerle ilgilenecek.", + "delete-rulechain-title": "Kural zincirini {{ruleChainName}} silmek istediðinizden emin misiniz?", + "delete-rulechain-text": "Dikkatli olun, onaylamadan sonra kural zinciri ve ilgili tüm veriler kurtarýlamaz.", + "delete-rulechains-title": "{Count, çoðul, 1 {1 kural zinciri} diðer {# kural zincirleri}} silmek istediðinizden emin misiniz?", + "delete-rulechains-action-title": "Sil {count, çoðul, 1 {1 kural zinciri} diðer {# kural zincirleri}}", + "delete-rulechains-text": "Dikkatli olun, onaylandýktan sonra seçilen tüm kural zincirleri silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", + "add-rulechain-text": "Yeni kural zinciri ekle", + "no-rulechains-text": "Kural zinciri bulunamadý", + "rulechain-details": "Kural zinciri detaylarý", + "details": "Ayrýntýlar", + "events": "Etkinlikler", + "system": "Sistem", + "import": "Kural zincirini içe aktar", + "export": "Kural zinciri dýþa aktar", + "export-failed-error": "Kural zinciri dýþa aktarýlamadý: {{error}}", + "create-new-rulechain": "Yeni kural zinciri oluþtur", + "rulechain-file": "Kural zinciri dosyasý", + "invalid-rulechain-file-error": "Kural zinciri içe aktarýlamýyor: Geçersiz kural zinciri veri yapýsý.", + "copyId": "Kural zinciri kimliðini kopyala", + "idCopiedMessage": "Kural zinciri kimliði panoya kopyalandý", + "select-rulechain": "Kural zincirini seç", + "no-rulechains-matching": "{{Entity}} ile eþleþen kural zinciri bulunamadý. ", + "rulechain-required": "Kural zinciri gerekli", + "management": "Kural yönetimi", + "debug-mode": "Hata ayýklama modu" + }, + "rulenode": { + "details": "Ayrýntýlar", + "events": "Etkinlikler", + "search": "Arama düðümleri", + "open-node-library": "Düðüm kütüphanesini aç", + "add": "Kural düðümü ekle", + "name": "Ad", + "name-required": "Ýsim gerekli.", + "type": "Tür", + "description": "Açýklama", + "delete": "Kural düðümünü sil", + "select-all-objects": "Tüm düðümleri ve baðlantýlarý seç", + "deselect-all-objects": "Tüm düðümlerin ve baðlantýlarýn seçimini kaldýrýn", + "delete-selected-objects": "Seçilen düðümleri ve baðlantýlarý sil", + "delete-selected": "Silme seçildi", + "select-all": "Hepsini seç", + "copy-selected": "Seçilenleri kopyala", + "deselect-all": "Hiçbirini seçme", + "rulenode-details": "Kural düðümü ayrýntýlarý", + "debug-mode": "Hata ayýklama modu", + "configuration": "Yapýlandýrma", + "link": "Baðlantý", + "link-details": "Kural düðüm baðlantý detaylarý", + "add-link": "Link ekle", + "link-label": "Baðlantý etiketi", + "link-label-required": "Baðlantý etiketi gerekli.", + "custom-link-label": "Özel baðlantý etiketi", + "custom-link-label-required": "Özel baðlantý etiketi gerekli.", + "link-labels": "Link etiketleri", + "link-labels-required": "Link etiketleri gerekli.", + "no-link-labels-found": "Baðlantý etiketi bulunamadý", + "no-link-label-matching": "{{label}} bulunamadý. ", + "create-new-link-label": "Yeni bir tane oluþtur!", + "type-filter": "Filtre", + "type-filter-details": "Gelen iletileri yapýlandýrýlmýþ koþullara göre filtrele", + "type-enrichment": "Zenginleþtirme", + "type-enrichment-details": "Mesaj Meta Verilerine ek bilgi", + "type-transformation": "Dönüþüm", + "type-transformation-details": "Mesaj yükünü ve Meta Verileri Deðiþtir", + "type-action": "Aksiyon", + "type-action-details": "Özel eylem gerçekleþtir", + "type-external": "Dýþ", + "type-external-details": "Dýþ sistemle etkileþir", + "type-rule-chain": "Kural Zinciri", + "type-rule-chain-details": "Belirtilen Kural Zincirine gelen mesajlarý ilet", + "type-input": "Giriþ", + "type-input-details": "Kural Zinciri'nin mantýksal girdisi, bir sonraki ilgili Kural Düðümüne gelen iletileri iletme", + "type-unknown": "Bilinmeyen", + "type-unknown-details": "Çözümlenmemiþ Kural Düðümü", + "directive-is-not-loaded": "Tanýmlanmýþ yapýlandýrma yönergesi {{directiveName}} 'mevcut deðil. ", + "ui-resources-load-error": "Yapýlandýrma kullanýcý arayüzü kaynaklarý yüklenemedi.", + "invalid-target-rulechain": "Hedef kural zinciri çözülemiyor!", + "test-script-function": "Test komut dosyasý iþlevi", + "message": "Mesaj", + "message-type": "Mesaj tipi", + "select-message-type": "Mesaj tipini seç", + "message-type-required": "Mesaj türü gerekli", + "metadata": "Meta veri", + "metadata-required": "Meta veri giriþleri boþ býrakýlamaz.", + "output": "Çýktý", + "test": "Ölçek", + "help": "Yardým et" + }, + "tenant": { + "tenant": "Kiracý", + "tenants": "Kiracýlar", + "management": "Kiracý yönetimi", + "add": "Kiracý ekle", + "admins": "Yöneticiler", + "manage-tenant-admins": "Kiracý yöneticileri yönet", + "delete": "Kiracýyý sil", + "add-tenant-text": "Yeni kiracý ekle", + "no-tenants-text": "Kiracý bulunamadý", + "tenant-details": "Kiracý detaylarý", + "delete-tenant-title": "Kiracýyý silmek istediðinizden emin misiniz? {{TenantTitle}}? ", + "delete-tenant-text": "Dikkatli olun, onayýndan sonra kiracý ve ilgili tüm veriler kurtarýlamaz.", + "delete-tenants-title": "{Count, çoðul, 1 {1 kiracý} diðer {# kiracý}} silmek istediðinizden emin misiniz?", + "delete-tenants-action-title": "Sil {count, çoðul, 1 {1 kiracý} diðer {# kiracý}}", + "delete-tenants-text": "Dikkatli olun, onaylandýktan sonra tüm kiracýlar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", + "title": "Baþlýk", + "title-required": "Baþlýk gerekli.", + "description": "Açýklama", + "details": "Ayrýntýlar", + "events": "Etkinlikler", + "copyId": "Kiracý Kimliði Kimliði", + "idCopiedMessage": "Kiracý Kimliði panoya kopyalandý", + "select-tenant": "Kiracý seç", + "no-tenants-matching": "{{Entity}} ile eþleþen kiracý bulunamadý. ", + "tenant-required": "Kiracý gerekli" + }, + "timeinterval": { + "seconds-interval": "{saniye, çoðul, 1 {1 saniye} diðer {# seconds}}", + "minutes-interval": "{dakika, çoðul, 1 {1 dakika} diðer {# dakika}}", + "hours-interval": "{saat, çoðul, 1 {1 saat} diðer {# hours}}", + "days-interval": "{günler, çoðul, 1 {1 gün} diðer {# gün}}", + "days": "Gün", + "hours": "Saatler", + "minutes": "Dakika", + "seconds": "Saniye", + "advanced": "Ýleri" + }, + "timewindow": { + "days": "{günler, çoðul, 1 {gün} diðer {# gün}}", + "hours": "{saat, çoðul, 0 {saat} 1 {1 saat} diðer {# hours}}", + "minutes": "{dakika, çoðul, 0 {dakika} 1 {1 dakika} diðer {# dakika}}", + "seconds": "{saniye, çoðul, 0 {saniye} 1 {1 saniye} diðer {# seconds}}", + "realtime": "Gerçek zaman", + "history": "Tarihçe", + "last-prefix": "son", + "period": "{{startTime}} - {{endTime}} arasýnda ", + "edit": "Zaman penceresini düzenle", + "date-range": "Tarih aralýðý", + "last": "Son", + "time-period": "Zaman dilimi" + }, + "user": { + "user": "Kullanýcý", + "users": "Kullanýcýlar", + "customer-users": "Müþteri Kullanýcýlarý", + "tenant-admins": "Kiracý Admins", + "sys-admin": "Sistem yöneticisi", + "tenant-admin": "Kiracý yöneticisi", + "customer": "Müþteri", + "anonymous": "Anonim", + "add": "Kullanýcý Ekle", + "delete": "Kullanýcýyý sil", + "add-user-text": "Yeni kullanýcý Ekle", + "no-users-text": "Kullanýcý bulunamadý", + "user-details": "Kullanýcý detaylarý", + "delete-user-title": "Kullanýcýyý silmek istediðinizden emin misiniz? {{UserEmail}} '? ", + "delete-user-text": "Dikkatli olun, onaylandýktan sonra kullanýcý ve ilgili tüm veriler kurtarýlamayacaktýr.", + "delete-users-title": "{Count, çoðul, 1 {1 kullanýcý} diðer {# users}} silmek istediðinizden emin misiniz?", + "delete-users-action-title": "Sil {count, çoðul, 1 {1 kullanýcý} diðer {# users}}", + "delete-users-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen kullanýcýlar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", + "activation-email-sent-message": "Etkinleþtirme e-postasý baþarýyla gönderildi!", + "resend-activation": "Etkinleþtirmeyi tekrar gönder", + "email": "E-posta", + "email-required": "Email gereklidir.", + "invalid-email-format": "Geçersiz e-posta formatý.", + "first-name": "Ýsim", + "last-name": "Soyadý", + "description": "Açýklama", + "default-dashboard": "Varsayýlan gösterge paneli", + "always-fullscreen": "Her zaman tam ekran", + "select-user": "Kullanýcý seç", + "no-users-matching": "{{Entity}} ile eþleþen kullanýcý bulunamadý. ", + "user-required": "Kullanýcý gerekli", + "activation-method": "Aktivasyon yöntemi", + "display-activation-link": "Etkinleþtirme baðlantýsý göster", + "send-activation-mail": "Etkinleþtirme postasý gönder", + "activation-link": "Kullanýcý aktivasyon linki", + "activation-link-text": "Kullanýcýyý aktif hale getirmek için aþaðýdaki aktivasyon linki kullanýn.", + "copy-activation-link": "Etkinleþtirme baðlantýsýný kopyala", + "activation-link-copied-message": "Kullanýcý aktivasyon linki panoya kopyalandý", + "details": "Ayrýntýlar", + "login-as-tenant-admin": "Kiracý Yönetici Giriþi", + "login-as-customer-user": "Müþteri olarak giriþ yap" + }, + "value": { + "type": "Deðer türü", + "string": "Dize", + "string-value": "Dize deðeri", + "integer": "Integer", + "integer-value": "Tamsayý deðeri", + "invalid-integer-value": "Geçersiz tam sayý", + "double": "Çift", + "double-value": "Çift deðer", + "boolean": "Boole", + "boolean-value": "Boole deðeri", + "false": "Yanlýþ", + "true": "Doðru", + "long": "Uzun" + }, + "widget": { + "widget-library": "Widgets Kitaplýðý", + "widget-bundle": "Widget Paketi", + "select-widgets-bundle": "Widget paketini seç", + "management": "Widget yönetimi", + "editor": "Widget Düzenleyici", + "widget-type-not-found": "Sorun yükleme pencere öðesi yapýlandýrmasý.
Muhtemelen iliþkili /n pencere öðesi türü kaldýrýldý.", + "widget-type-load-error": "Widget, aþaðýdaki hatalardan dolayý yüklenmedi:", + "remove": "Widget'ý kaldýr", + "edit": "Widget düzenle", + "remove-widget-title": "Widget'ý kaldýrmak istediðinizden emin misiniz? {{WidgetTitle}}? ", + "remove-widget-text": "Onaydan sonra widget ve ilgili tüm veriler kurtarýlamaz.", + "timeseries": "Zaman serisi", + "search-data": "Arama verileri", + "no-data-found": "Veri bulunamadý", + "latest-values": "Son deðerler", + "rpc": "Kontrol aracý", + "alarm": "Alarm gereci", + "static": "Statik pencere öðesi", + "select-widget-type": "Widget tipi seç", + "missing-widget-title-error": "Widget baþlýðý belirtilmelidir!", + "widget-saved": "Widget kaydedildi", + "unable-to-save-widget-error": "Widget kaydedilemiyor! Widget'ýn hatalarý var!", + "save": "Widget kaydet", + "saveAs": "Widget olarak kaydet", + "save-widget-type-as": "Widget türünü kaydet", + "save-widget-type-as-text": "Lütfen yeni pencere öðesi baþlýðýný girin ve / veya hedef widget'larý seçin", + "toggle-fullscreen": "Tam ekrana geç", + "run": "Widget'ý çalýþtýr", + "title": "Widget Baþlýðý", + "title-required": "Widget baþlýðý gerekiyor.", + "type": "Widget türü", + "resources": "Kaynaklar", + "resource-url": "JavaScript / CSS URL", + "remove-resource": "Kaynaðý kaldýr", + "add-resource": "Kaynak ekle", + "html": "HTML", + "tidy": "Düzenli", + "css": "CSS", + "settings-schema": "Ayarlar þemasý", + "datakey-settings-schema": "Veri anahtarý ayarlarý þemasý", + "javascript": "JavaScript", + "remove-widget-type-title": "'{{WidgetName}}' widget türünü kaldýrmak istediðinizden emin misiniz?", + "remove-widget-type-text": "Onaydan sonra widget tipi ve ilgili tüm veriler kurtarýlamayacak.", + "remove-widget-type": "Widget türünü kaldýr", + "add-widget-type": "Yeni widget türü ekle", + "widget-type-load-failed-error": "Widget türü yüklenemedi!", + "widget-template-load-failed-error": "Widget þablonu yüklenemedi!", + "add": "Widget Ekle", + "undo": "Widget deðiþikliklerini geri al", + "export": "Widget'ý dýþa aktar" + }, + "widget-action": { + "header-button": "Widget baþlýk düðmesi", + "open-dashboard-state": "Yeni gösterge panosuna git", + "update-dashboard-state": "Mevcut kontrol paneli durumunu güncelle", + "open-dashboard": "Diðer kontrol paneline git", + "custom": "Özel eylem", + "target-dashboard-state": "Hedef gösterge panosu durumu", + "target-dashboard-state-required": "Hedef gösterge tablosu gerekli", + "set-entity-from-widget": "Öðeyi pencere öðesinden ayarla", + "target-dashboard": "Hedef gösterge panosu", + "open-right-layout": "Saðdaki gösterge tablosu düzeni (mobil görünüm)" + }, + "widgets-bundle": { + "current": "Mevcut paket", + "widgets-bundles": "Widget Paketleri", + "add": "Widget Paketi Ekle", + "delete": "Widget paketini sil", + "title": "Baþlýk", + "title-required": "Baþlýk gerekli.", + "add-widgets-bundle-text": "Yeni widget'lar paketi ekle", + "no-widgets-bundles-text": "Widget paketi bulunamadý", + "empty": "Widget'ler paketi boþ", + "details": "Ayrýntýlar", + "widgets-bundle-details": "Widget'lar paket ayrýntýlarýný", + "delete-widgets-bundle-title": "Widget paketini {{widgetsBundleTitle}} silmek istediðinizden emin misiniz?", + "delete-widgets-bundle-text": "Dikkatli olun, onaylandýktan sonra widget'lar paketi ve tüm ilgili veriler kurtarýlamayacak.", + "delete-widgets-bundles-title": "{Count, çoðul, 1 {1 widgets bundle} diðer {# widgets bundles}} silmek istediðinizden emin misiniz?", + "delete-widgets-bundles-action-title": "Sil {count, çoðul, 1 {1 widgets bundle} diðer {# widgets bundles}}", + "delete-widgets-bundles-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen widget'lar paketler kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", + "no-widgets-bundles-matching": "{{WidgetsBundle}} ile eþleþen hiçbir widget grubu bulunamadý. ", + "widgets-bundle-required": "Widget paketi gerekli.", + "system": "Sistem", + "import": "Widget paketlerini içe aktar", + "export": "Widget paketini dýþa aktar", + "export-failed-error": "Widget grubu dýþa aktarýlamadý: {{error}}", + "create-new-widgets-bundle": "Yeni widget'lar paketi oluþtur", + "widgets-bundle-file": "Widgets paket dosyasý", + "invalid-widgets-bundle-file-error": "Widget grubu içe aktarýlamýyor: Geçersiz widget'lar veri yapýsýný paketliyor." + }, + "widget-config": { + "data": "Veri", + "settings": "Ayarlar", + "advanced": "Ýleri", + "title": "Baþlýk", + "general-settings": "Genel Ayarlar", + "display-title": "Baþlýk", + "drop-shadow": "Düþen gölge", + "enable-fullscreen": "Tam ekraný etkinleþtir", + "background-color": "Arka plan rengi", + "text-color": "Metin rengi", + "padding": "Dolgu malzemesi", + "margin": "Kenar", + "widget-style": "Widget stili", + "title-style": "Baþlýk stili", + "mobile-mode-settings": "Mobil mod ayarlarý", + "order": "Sipariþ", + "height": "Yükseklik", + "units": "Deðerin yanýnda gösterilecek özel sembol", + "decimals": "Kayan noktadan sonraki basamak sayýsý", + "timewindow": "Timewindow", + "use-dashboard-timewindow": "Gösterge panosu zaman tüneli", + "display-legend": "Gösterge efsanesi", + "datasources": "Veri kaynaklarý", + "maximum-datasources": "Maksimum {sayým, çoðul, 1 {1 veri kaynaðý izinli.} Diðer {# veri kaynaklarýna izin verilir}}", + "datasource-type": "Tür", + "datasource-parameters": "Parametreler", + "remove-datasource": "Veri kaynaðýný kaldýr", + "add-datasource": "Veri kaynaðý ekle", + "target-device": "Hedef cihaz", + "alarm-source": "Alarm kaynaðý", + "actions": "Ýþlemler", + "action": "Aksiyon", + "add-action": "Eylem ekle", + "search-actions": "Arama iþlemleri", + "action-source": "Eylem kaynaðý", + "action-source-required": "Eylem kaynaðý gerekli.", + "action-name": "Ad", + "action-name-required": "Eylem adý gerekli.", + "action-name-not-unique": "Ayný ada sahip baþka bir eylem zaten var.
Eylem adý, ayný eylem kaynaðý içinde benzersiz olmalýdýr.", + "action-icon": "Simge", + "action-type": "Tür", + "action-type-required": "Eylem türü gerekli", + "edit-action": "Eylemi düzenle", + "delete-action": "Eylemi sil", + "delete-action-title": "Widget eylemini sil", + "delete-action-text": "Widget eylemini {{actionName}} adýyla silmek istediðinizden emin misiniz?" + }, + "widget-type": { + "import": "Widget türü içe aktar", + "export": "Widget türünü dýþa aktar", + "export-failed-error": "Widget türü verilemiyor: {{error}}", + "create-new-widget-type": "Yeni pencere öðesi türü oluþtur", + "widget-type-file": "Widget tipi dosya", + "invalid-widget-type-file-error": "Widget türü içe aktarýlamýyor: Geçersiz pencere öðesi türü yapýsý." + }, + "icon": { + "icon": "Simge", + "select-icon": "Simge seç", + "material-icons": "Malzeme simgeleri", + "show-all": "Tüm simgeleri göster" + }, + "custom": { + "widget-action": { + "action-cell-button": "Eylem hücresi düðmesi", + "row-click": "Satýr týklamasý", + "marker-click": "Ýþaretçi týklamasýnda ", + "tooltip-tag-action": "Araç ipucu etiketi iþlemi" + } + }, + "language": { + "language": "Dil", + "locales": { + "fr_FR": "Fransýzca", + "zh_CN": "Çince", + "en_US": "Ýngilizce", + "it_IT": "Ýtalyan", + "ko_KR": "Koreli", + "ru_RU": "Rusça", + "es_ES": "Ýspanyol", + "ja_JA": "Japonca", + "TR": "Türk" + } + } +} diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 481d677955..3e9ad829f8 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -1551,7 +1551,8 @@ "ko_KR": "Korean", "ru_RU": "Russian", "es_ES": "Spanish", - "ja_JA": "Japanese" + "ja_JA": "Japanese", + "TR": "Turkish" } } } diff --git a/ui/src/app/locale/locale.constant-es_ES.json b/ui/src/app/locale/locale.constant-es_ES.json index 733db14ff4..729e4ffefd 100644 --- a/ui/src/app/locale/locale.constant-es_ES.json +++ b/ui/src/app/locale/locale.constant-es_ES.json @@ -1313,7 +1313,8 @@ "ru_RU": "Ruso", "es_ES": "Español", "it_IT": "Italiano", - "ja_JA": "Japonés" + "ja_JA": "Japonés", + "TR": "Turco" } } } diff --git a/ui/src/app/locale/locale.constant-fr_FR.json b/ui/src/app/locale/locale.constant-fr_FR.json index 2c0a290e16..8b270aa7af 100644 --- a/ui/src/app/locale/locale.constant-fr_FR.json +++ b/ui/src/app/locale/locale.constant-fr_FR.json @@ -1,1461 +1,1462 @@ { -"access":{ - "access-forbidden": "Accès interdit", - "access-forbidden-text": "Vous n'avez pas accès à cet emplacement!
Essayez de vous connecter avec un autre utilisateur si vous souhaitez toujours accéder à cet emplacement.", - "refresh-token-expired": "La session a expiré", - "refresh-token-failed": "Impossible de rafraîchir la session", - "unauthorized": "non autorisé", - "unauthorized-access": "accès non autorisé", - "unauthorized-access-text": "Vous devez vous connecter pour avoir accès à cette ressource!" - }, -"action":{ - "activate": "Activer", - "add": "Ajouter", - "apply": "Appliquer", - "apply-changes": "Appliquer les modifications", - "assign": "Attribuer", - "back": "retour", - "cancel": "Annuler", - "clear-search": "Effacer la recherche", - "close": "Fermer", - "copy": "Copier", - "copy-reference": "Copier la référence", - "create": "Créer", - "decline-changes": "Refuser les modifications", - "delete": "Supprimer", - "drag": "Drag", - "edit": "Modifier", - "edit-mode": "Mode édition", - "enter-edit-mode": "Entrer en mode édition", - "export": "Exporter", - "import": "Importer", - "make-private": "Rendre privé", - "no": "Non", - "ok": "OK", - "paste": "coller", - "paste-reference": "Coller référence", - "refresh": "Rafraîchir", - "remove": "Supprimer", - "run": "Exécuter", - "save": "Enregistrer", - "saveAs": "Enregistrer sous", - "search": "Rechercher", - "share": "Partager", - "share-via": "Partager via {{provider}}", - "sign-in": "Connectez-vous!", - "suspend": "Suspendre", - "unassign": "Retirer", - "undo": "Annuler", - "update": "mise à jour", - "view": "Afficher", - "yes": "Oui" - }, -"admin":{ - "base-url": "URL de base", - "base-url-required": "L'URL de base est requise.", - "enable-tls": "Activer TLS", - "general": "Général", - "general-settings": "Paramètres généraux", - "mail-from": "Mail de", - "mail-from-required": "Mail de est requis.", - "outgoing-mail": "courrier sortant", - "outgoing-mail-settings": "Paramètres de courrier sortant", - "send-test-mail": "Envoyer un mail de test", - "smtp-host": "Hôte SMTP", - "smtp-host-required": "L'hôte SMTP est requis.", - "smtp-port": "Port SMTP", - "smtp-port-invalid": "Cela ne ressemble pas à un port smtp valide.", - "smtp-port-required": "Vous devez fournir un port smtp.", - "smtp-protocol": "Protocole SMTP", - "system-settings": "Paramètres système", - "test-mail-sent": "Le courrier de test a été envoyé avec succès!", - "timeout-invalid": "Cela ne ressemble pas à un délai d'expiration valide.", - "timeout-msec": "Délai (msec)", - "timeout-required": "Le délai est requis." - }, -"aggregation":{ - "aggregation": "agrégation", - "avg": "Moyenne", - "count": "Compte", - "function": "Fonction d'agrégation de données", - "group-interval": "Intervalle de regroupement", - "limit": "Valeurs maximales", - "max": "Max", - "min": "Min", - "none": "Aucune", - "sum": "Somme" - }, -"alarm":{ - "ack-time": "Heure d'acquittement", - "acknowledge": "Acquitter", - "aknowledge-alarms-text": "Etes-vous sûr de vouloir acquitter {count, plural, 1 {1 alarme} other {# alarmes}}?", - "aknowledge-alarms-title": "Acquitter {count, plural, 1 {1 alarme} other {# alarmes}}", - "alarm": "Alarme", - "alarm-details": "Détails de l'alarme", - "alarm-required": "Une alarme est requise", - "alarm-status": "Etat d'alarme", - "alarms": "Alarmes", - "clear": "Effacer", - "clear-alarms-text": "Êtes-vous sûr de vouloir effacer {count, plural, 1 {1 alarme} other {# alarmes}}?", - "clear-alarms-title": "Effacer {count, plural, 1 {1 alarme} other {# alarmes}}", - "clear-time": "Heure d'éffacement", - "created-time": "Heure de création", - "details": "Détails", - "display-status":{ - "ACTIVE_ACK": "Active acquittée", - "ACTIVE_UNACK": "Active non acquittée", - "CLEARED_ACK": "effacée acquittée", - "CLEARED_UNACK": "effacée non acquittée" - }, - "end-time": "Heure de fin", - "min-polling-interval-message": "Un intervalle d'interrogation d'au moins 1 seconde est autorisé.", - "no-alarms-matching": "Aucune alarme correspondant à {{entity}} n'a été trouvée. ", - "no-alarms-prompt": "Aucune alarme trouvée", - "no-data": "Aucune donnée à afficher", - "originator": "Source", - "originator-type": "Type de Source", - "polling-interval": "Intervalle d'interrogation des alarmes (sec)", - "polling-interval-required": "L'intervalle d'interrogation des alarmes est requis.", - "search": "Rechercher des alarmes", - "search-status":{ - "ACK": "acquitté", - "ACTIVE": "active", - "ANY": "Toutes", - "CLEARED": "effacée", - "UNACK": "non acquittée" - }, - "select-alarm": "Sélectionnez une alarme", - "selected-alarms": "{count, plural, 1 {1 alarme} other {# alarmes}} sélectionnées", - "severity": "Gravitée", - "severity-critical": "Critique", - "severity-indeterminate": "indéterminée", - "severity-major": "Majeure", - "severity-minor": "mineure", - "severity-warning": "Avertissement", - "start-time": "Heure de début", - "status": "Etat", - "type": "Type" - }, -"alias":{ - "add": "Ajouter un alias", - "all-entities": "Toutes les entités", - "any-relation": "toutes", - "default-entity-parameter-name": "Par défaut", - "default-state-entity": "Entité d'état par défaut", - "duplicate-alias": "Un alias portant le même nom existe déjà.", - "edit": "Modifier l'alias", - "entity-filter": "Filtre d'entité", - "entity-filter-no-entity-matched": "Aucune entité correspondant au filtre spécifié n'a été trouvée.", - "filter-type": "Type de filtre", - "filter-type-asset-search-query": "requête de recherche d'Assets", - "filter-type-asset-search-query-description": "Assets de types {{assetTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-asset-type": "type d'Asset", - "filter-type-asset-type-and-name-description": "Assets de type '{{assetType}}' et dont le nom commence par '{{prefix}}'", - "filter-type-asset-type-description": "Assets de type '{{assetType}}'", - "filter-type-device-search-query": "Requête de recherche de dispositif", - "filter-type-device-search-query-description": "Dispositifs de types {{deviceTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-device-type": "Type de dispositif", - "filter-type-device-type-and-name-description": "Dispositifs de type '{{deviceType}}' et dont le nom commence par '{{prefix}}'", - "filter-type-device-type-description": "Dispositifs de type '{{deviceType}}'", - "filter-type-entity-list": "Liste d'entités", - "filter-type-entity-name": "Nom d'entité", - "filter-type-relations-query": "Interrogation des relations", - "filter-type-relations-query-description": "{{entities}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-required": "Le type de filtre est requis.", - "filter-type-single-entity": "Entité unique", - "filter-type-state-entity": "Entité de l'état du tableau de bord", - "filter-type-state-entity-description": "Entité extraite des paramètres d'état du tableau de bord", - "max-relation-level": "Niveau de relation maximum", - "name": "Nom de l'alias", - "name-required": "Le nom d'alias est requis", - "no-entity-filter-specified": "Aucun filtre d'entité spécifié", - "resolve-multiple": "Résoudre en plusieurs entités", - "root-entity": "Entité racine", - "root-state-entity": "Utiliser l'entité d'état du tableau de bord en tant que racine", - "state-entity": "Entité d'état du tableau de bord", - "state-entity-parameter-name": "Nom du paramètre d'entité d'état", - "unlimited-level": "niveau illimité" - }, -"asset":{ - "add": "Ajouter un Asset", - "add-asset-text": "Ajouter un nouvel Asset", - "any-asset": "Tout Asset", - "asset": "Asset", - "asset-details": "Détails de l'Asset", - "asset-public": "L'Asset est public", - "asset-required": "Asset requis", - "asset-type": "Type d'Asset", - "asset-type-list-empty": "Aucun type d'Asset sélectionné.", - "asset-type-required": "Le type d'Asset est requis.", - "asset-types": "Types d'Asset", - "assets": "Assets", - "assign-asset-to-customer": "Attribuer des Assets au client", - "assign-asset-to-customer-text": "Veuillez sélectionner les Assets à attribuer au client", - "assign-assets": "Attribuer des Assets", - "assign-assets-text": "Attribuer {count, plural, 1 {1 asset} other {# assets}} au client", - "assign-new-asset": "Attribuer un nouvel Asset", - "assign-to-customer": "Attribuer au client", - "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les Assets", - "assignedToCustomer": "attribué au client", - "copyId": "Copier l'Id de l'Asset", - "delete": "Supprimer un Asset", - "delete-asset-text": "Faites attention, après la confirmation, l'Asset et toutes les données associées deviendront irrécupérables.", - "delete-asset-title": "Êtes-vous sûr de vouloir supprimer l'Asset '{{assetName}}'?", - "delete-assets": "Supprimer des Assets", - "delete-assets-action-title": "Supprimer {count, plural, 1 {1 asset} other {# assets}}", - "delete-assets-text": "Attention, après la confirmation, tous les Assets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-assets-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 asset} other {# assets}}?", - "description": "Description", - "details": "Détails", - "enter-asset-type": "Entrez le type d'Asset", - "events": "Evènements", - "idCopiedMessage": "L'Id d'asset a été copié dans le presse-papier", - "make-private": "Rendre l'Asset privé", - "make-private-asset-text": "Après la confirmation, l'Asset et toutes ses données seront rendus privés et ne seront pas accessibles par d'autres.", - "make-private-asset-title": "Etes-vous sûr de vouloir rendre l'Asset '{{assetName}}' privé '?", - "make-public": "Rendre l'Asset public", - "make-public-asset-text": "Après la confirmation, l'asset et toutes ses données seront rendus publics et accessibles aux autres.", - "make-public-asset-title": "Êtes-vous sûr de vouloir rendre l'Asset '{{assetName}}' public '?", - "management": "Gestion d'Assets", - "name": "Nom", - "name-required": "Nom est requis.", - "name-starts-with": "Le nom de l'Asset commence par", - "no-asset-types-matching": "Aucun type d'Asset correspondant à {{entitySubtype}} n'a été trouvé. ", - "no-assets-matching": "Aucun Asset correspondant à {{entity}} n'a été trouvé. ", - "no-assets-text": "Aucun Asset trouvé", - "public": "Public", - "select-asset": "Sélectionner un Asset", - "select-asset-type": "Sélectionner le type d'Asset", - "type": "Type", - "type-required": "Le type est requis.", - "unassign-asset": "Retirer l'Asset", - "unassign-asset-text": "Après la confirmation, l'Asset sera non attribué et ne sera pas accessible au client.", - "unassign-asset-title": "Êtes-vous sûr de vouloir retirer l'attribution de l'Asset '{{assetName}}'?", - "unassign-assets": "Retirer les Assets", - "unassign-assets-action-title": "Retirer {count, plural, 1 {1 asset} other {# assets}} du client", - "unassign-assets-text": "Après la confirmation, tous les Assets sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", - "unassign-assets-title": "Êtes-vous sûr de vouloir retirer l'attribution de {count, plural, 1 {1 asset} other {# assets}}?", - "unassign-from-customer": "Retirer du client", - "view-assets": "Afficher les Assets" - }, -"attribute":{ - "add": "Ajouter un attribut", - "add-to-dashboard": "Ajouter au tableau de bord", - "add-widget-to-dashboard": "Ajouter un widget au tableau de bord", - "attributes": "Attributs", - "attributes-scope": "Etendue des attributs d'entité", - "delete-attributes": "Supprimer les attributs", - "delete-attributes-text": "Attention, après la confirmation, tous les attributs sélectionnés seront supprimés.", - "delete-attributes-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 attribut} other {# attributs}}?", - "enter-attribute-value": "Entrez la valeur de l'attribut", - "key": "Clé", - "key-required": "La Clé d'attribut est requise.", - "last-update-time": "Dernière mise à jour", - "latest-telemetry": "Dernière télémétrie", - "next-widget": "Widget suivant", - "prev-widget": "Widget précédent", - "scope-client": "Attributs du client", - "scope-latest-telemetry": "Dernière télémétrie", - "scope-server": "Attributs du serveur", - "scope-shared": "Attributs partagés", - "selected-attributes": "{count, plural, 1 {1 attribut} other {# attributs}} sélectionnés", - "selected-telemetry": "{count, plural, 1 {1 unité de télémétrie} other {# unités de télémétrie}} sélectionnées", - "show-on-widget": "Afficher sur le widget", - "value": "Valeur", - "value-required": "La valeur d'attribut est obligatoire.", - "widget-mode": "Mode du widget" - }, -"audit-log":{ - "action-data": "Action data", - "audit": "Audit", - "audit-log-details": "Détails du journal d'audit", - "audit-logs": "Journaux d'audit", - "clear-search": "Effacer la recherche", - "details": "Détails", - "entity-name": "Nom de l'entité", - "entity-type": "Type d'entité", - "failure-details": "Détails de l'échec", - "no-audit-logs-prompt": "Aucun journal trouvé", - "search": "Rechercher les journaux d'audit", - "status": "Etat", - "status-failure": "Échec", - "status-success": "Succès", - "timestamp": "Horodatage", - "type": "Type", - "type-activated": "Activé", - "type-added": "Ajouté", - "type-alarm-ack": "Acquitté", - "type-alarm-clear": "Effacé", - "type-assigned-to-customer": "Attribué au client", - "type-attributes-deleted": "Attributs supprimés", - "type-attributes-read": "Attributs lus", - "type-attributes-updated": "Attributs mis à jour", - "type-credentials-read": "Lecture des informations d'identification", - "type-credentials-updated": "Informations d'identification actualisées", - "type-deleted": "Supprimé", - "type-relation-add-or-update": "Relation mise à jour", - "type-relation-delete": "Relation supprimée", - "type-relations-delete": "Toutes les relations ont été supprimées", - "type-rpc-call": "Appel RPC", - "type-suspended": "Suspendu", - "type-unassigned-from-customer": "Non attribué du client", - "type-updated": "Mise à jour", - "user": "Utilisateur" - }, -"common":{ - "enter-password": "Entrez le mot de passe", - "enter-search": "Entrez la recherche", - "enter-username": "Entrez le nom d'utilisateur", - "password": "Mot de passe", - "username": "Nom d'utilisateur" - }, -"confirm-on-exit":{ - "html-message": "Vous avez des modifications non enregistrées.
Êtes-vous sûr de vouloir quitter cette page?", - "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?", - "title": "Modifications non enregistrées" - }, -"contact":{ - "address": "Adresse", - "address2": "adresse 2", - "city": "Ville", - "country": "Pays", - "email": "Email", - "no-address": "Pas d'adresse", - "phone": "Téléphone", - "postal-code": "Code postal", - "postal-code-invalid": "Format de code postal / code postal invalide", - "state": "Etat / Province" - }, -"content-type":{ - "binary": "Binaire (Base64)", - "json": "Json", - "text": "Texte" - }, -"custom":{ - "widget-action":{ - "action-cell-button": "Action cell button", - "marker-click": "On marker click", - "row-click": "On row click", - "tooltip-tag-action": "Tooltip tag action" - } - }, -"customer":{ - "add": "Ajouter un client", - "add-customer-text": "Ajouter un nouveau client", - "assets": "Assets du client", - "copyId": "Copier l'id du client", - "customer": "Client", - "customer-details": "Détails du client", - "customer-required": "Le client est requis", - "customers": "Clients", - "dashboard": "Tableau de bord du client", - "dashboards": "tableaux de bord du client", - "default-customer": "Client par défaut", - "default-customer-required": "Le client par défaut est requis pour déboguer le tableau de bord au niveau du Tenant", - "delete": "Supprimer le client", - "delete-customer-text": "Faites attention, après la confirmation, le client et toutes les données associées deviendront irrécupérables.", - "delete-customer-title": "Êtes-vous sûr de vouloir supprimer le client '{{customerTitle}}'?", - "delete-customers-action-title": "Supprimer {count, plural, 1 {1 client} other {# clients}}", - "delete-customers-text": "Faites attention, après la confirmation, tous les clients sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-customers-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 client} other {# clients}}?", - "description": "Description", - "details": "Détails", - "devices": "Dispositifs du client", - "events": "Événements", - "idCopiedMessage": "L'Id du client a été copié dans le presse-papier", - "manage-assets": "Gérer les Assets", - "manage-customer-assets": "Gérer les Assets du client", - "manage-customer-dashboards": "Gérer les tableaux de bord du client", - "manage-customer-devices": "Gérer les dispositifs du client", - "manage-customer-users": "Gérer les utilisateurs du client", - "manage-dashboards": "Gérer les tableaux de bord", - "manage-devices": "Gérer les dispositifs", - "manage-public-assets": "Gérer les Assets publics", - "manage-public-dashboards": "Gérer les tableaux de bord publics", - "manage-public-devices": "Gérer les dispositifs publics", - "manage-users": "Gérer les utilisateurs", - "management": "Gestion des clients", - "no-customers-matching": "Aucun client correspondant à '{{entity}} n'a été trouvé.", - "no-customers-text": "Aucun client trouvé", - "public-assets": "Assets publics", - "public-dashboards": "Tableaux de bord publics", - "public-devices": "Dispositifs publics", - "select-customer": "Sélectionner un client", - "select-default-customer": "Sélectionnez le client par défaut", - "title": "Titre", - "title-required": "Le titre est requis." - }, -"dashboard":{ - "add": "Ajouter un tableau de bord", - "add-dashboard-text": "Ajouter un nouveau tableau de bord", - "add-state": "Ajouter un état du tableau de bord", - "add-widget": "Ajouter un nouveau widget", - "alias-resolution-error-title": "Erreur de configuration des alias de tableau de bord", - "assign-dashboard-to-customer": "Attribuer des tableaux de bord au client", - "assign-dashboard-to-customer-text": "Veuillez sélectionner les tableaux de bord à affecter au client", - "assign-dashboards": "Attribuer des tableaux de bord", - "assign-dashboards-text": "Attribuer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} aux clients", - "assign-new-dashboard": "Attribuer un nouveau tableau de bord", - "assign-to-customer": "Attribuer au client", - "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les tableaux de bord", - "assign-to-customers": "Attribuer des tableaux de bord aux clients", - "assign-to-customers-text": "Veuillez sélectionner les clients pour attribuer les tableaux de bord", - "assigned-customers": "clients affectés", - "assignedToCustomer": "Attribué au client", - "assignedToCustomers": "attribué aux clients", - "autofill-height": "Hauteur de remplissage automatique", - "background-color": "Couleur de fond", - "background-image": "Image d'arrière-plan", - "background-size-mode": "Mode de taille d'arrière-plan", - "close-toolbar": "Fermer la barre d'outils", - "columns-count": "Nombre de colonnes", - "columns-count-required": "Le nombre de colonnes est requis.", - "configuration-error": "Erreur de configuration", - "copy-public-link": "Copier le lien public", - "create-new": "Créer un nouveau tableau de bord", - "create-new-dashboard": "Créer un nouveau tableau de bord", - "create-new-widget": "Créer un nouveau widget", - "dashboard": "Tableau de bord", - "dashboard-details": "Détails du tableau de bord", - "dashboard-file": "Fichier du tableau de bord", - "dashboard-import-missing-aliases-title": "Configurer les alias utilisés par le tableau de bord importé", - "dashboard-required": "Le tableau de bord est requis.", - "dashboards": "Tableaux de bord", - "delete": "Supprimer le tableau de bord", - "delete-dashboard-text": "Faites attention, après la confirmation, le tableau de bord et toutes les données associées deviendront irrécupérables.", - "delete-dashboard-title": "Êtes-vous sûr de vouloir supprimer le tableau de bord '{{dashboardTitle}}'?", - "delete-dashboards": "Supprimer les tableaux de bord", - "delete-dashboards-action-title": "Supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}", - "delete-dashboards-text": "Attention, après la confirmation, tous les tableaux de bord sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-dashboards-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", - "delete-state": "Supprimer l'état du tableau de bord", - "delete-state-text": "Etes-vous sûr de vouloir supprimer l'état du tableau de bord avec le nom '{{stateName}}'?", - "delete-state-title": "Supprimer l'état du tableau de bord", - "description": "Description", - "details": "Détails", - "display-dashboard-export": "Afficher l'exportation", - "display-dashboard-timewindow": "Afficher fenêtre de temps", - "display-dashboards-selection": "Afficher la sélection des tableaux de bord", - "display-entities-selection": "Afficher la sélection des entités", - "display-title": "Afficher le titre du tableau de bord", - "drop-image": "Déposer une image ou cliquez pour sélectionner un fichier à télécharger.", - "edit-state": "Modifier l'état du tableau de bord", - "export": "Exporter le tableau de bord", - "export-failed-error": "Impossible d'exporter le tableau de bord: {{error}}", - "hide-details": "Masquer les détails", - "horizontal-margin": "Marge horizontale", - "horizontal-margin-required": "Une valeur de marge horizontale est requise.", - "import": "Importer le tableau de bord", - "import-widget": "Importer un widget", - "invalid-aliases-config": "Impossible de trouver des dispositifs correspondant à certains filtres d'alias.
Veuillez contacter votre administrateur pour résoudre ce problème.", - "invalid-dashboard-file-error": "Impossible d'importer le tableau de bord: structure de données du tableau de bord non valide", - "invalid-widget-file-error": "Impossible d'importer le widget: structure de données de widget invalide.", - "is-root-state": "Etat racine", - "make-private": "Rendre privé le tableau de bord", - "make-private-dashboard": "Rendre privé le tableau de bord", - "make-private-dashboard-text": "Après la confirmation, le tableau de bord sera rendu privé et ne sera plus accessible aux autres.", - "make-private-dashboard-title": "Etes-vous sûr de vouloir rendre le tableau de bord '{{dashboardTitle}}' privé?", - "make-public": "Rendre public le tableau de bord", - "manage-assigned-customers": "Gérer les clients affectés", - "manage-states": "Gérer les états du tableau de bord", - "management": "Gestion du tableau de bord", - "max-columns-count-message": "Seulement 1000 colonnes maximum sont autorisées.", - "max-horizontal-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge horizontale maximale.", - "max-mobile-row-height-message": "Seuls 200 pixels sont autorisés en tant que valeur maximale de hauteur de ligne mobile.", - "max-vertical-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge verticale maximale.", - "min-columns-count-message": "Seul un nombre minimum de 10 colonnes est autorisé.", - "min-horizontal-margin-message": "Seul 0 est autorisé comme valeur de marge horizontale minimale.", - "min-mobile-row-height-message": "Seuls 5 pixels sont autorisés en tant que valeur minimale de hauteur de ligne mobile.", - "min-vertical-margin-message": "Seul 0 est autorisé comme valeur de marge verticale minimale.", - "mobile-layout": "Paramètres de mise en page mobiles", - "mobile-row-height": "Hauteur de ligne mobile, px", - "mobile-row-height-required": "Une valeur de hauteur de ligne mobile est requise.", - "new-dashboard-title": "Nouveau titre du tableau de bord", - "no-dashboards-matching": "Aucun tableau de bord correspondant à {{entity}} n'a été trouvé. ", - "no-dashboards-text": "Aucun tableau de bord trouvé", - "no-image": "Aucune image sélectionnée", - "no-widgets": "Aucun widget configuré", - "open-dashboard": "Ouvrir le tableau de bord", - "open-toolbar": "Ouvrir la barre d'outils du tableau de bord", - "public": "Public", - "public-dashboard-notice": " Remarque: N'oubliez pas de rendre publics les dispositifs associés pour accéder à leurs données.", - "public-dashboard-text": "Votre tableau de bord {{dashboardTitle}} est maintenant public et accessible via le lien public : ", - "public-dashboard-title": "Le tableau de bord est maintenant public", - "public-link": "Lien public", - "public-link-copied-message": "Le lien public du tableau de bord a été copié dans le presse-papier", - "search-states": "Recherche des états du tableau de bord", - "select-dashboard": "Sélectionner le tableau de bord", - "select-devices": "Selectionner les dispositifs", - "select-existing": "Sélectionnez un tableau de bord existant", - "select-state": "Sélectionnez l'état cible", - "select-widget-subtitle": "Liste des types de widgets disponibles", - "select-widget-title": "Sélectionner un widget", - "selected-states": "{count, plural, 1 {1 état du tableau de bord} other {# états du tableau de bord}} sélectionnés", - "set-background": "Définir l'arrière-plan", - "settings": "Paramètres", - "show-details": "Afficher les détails", - "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", - "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", - "state": "Etat du tableau de bord", - "state-controller": "Contrôleur d'état", - "state-id": "ID d'état", - "state-id-exists": "L'état du tableau de bord avec le même Id existe déjà.", - "state-id-required": "L'Id d'état du tableau de bord est requis.", - "state-name": "Nom", - "state-name-required": "Le nom de l'état du tableau de bord est requis", - "states": "Etats du tableau de bord", - "title": "Titre", - "title-color": "Couleur du titre", - "title-required": "Le titre est requis.", - "toolbar-always-open": "Garder la barre d'outils ouverte", - "unassign-dashboard": "Retirer le tableau de bord", - "unassign-dashboard-text": "Après la confirmation, le tableau de bord ne sera pas attribué et ne sera pas accessible au client.", - "unassign-dashboard-title": "Êtes-vous sûr de vouloir annuler l'affectation du tableau de bord '{{dashboardTitle}}'?", - "unassign-dashboards": "Retirer les tableaux de bord", - "unassign-dashboards-action-text": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} des clients", - "unassign-dashboards-action-title": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} du client", - "unassign-dashboards-text": "Après la confirmation, tous les tableaux de bord sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", - "unassign-dashboards-title": "Etes-vous sûr de vouloir annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", - "unassign-from-customer": "Retirer du client", - "unassign-from-customers": "Retirer les tableaux de bord des clients", - "unassign-from-customers-text": "Veuillez sélectionner les clients à annuler l'affectation du ou des tableaux de bord", - "vertical-margin": "Marge verticale", - "vertical-margin-required": "Une valeur de marge verticale est requise", - "view-dashboards": "Afficher les tableaux de bord", - "widget-file": "Fichier du Widget", - "widget-import-missing-aliases-title": "Configurer les alias utilisés par le widget importé", - "widgets-margins": "Marge entre les widgets" - }, -"datakey":{ - "advanced": "Avancé", - "alarm": "Champs d'alarme", - "alarm-fields-required": "Les champs d'alarme sont obligatoires.", - "attributes": "Attributs", - "color": "Couleur", - "configuration": "Configuration de la clé de données", - "data-generation-func": "Fonction de génération de données", - "decimals": "Nombre de chiffres après virgule flottante", - "function-types": "Types de fonctions", - "function-types-required": "Les types de fonctions sont obligatoires", - "label": "Label", - "maximum-function-types": "Maximum {count, plural, 1 {1 type de fonction est autorisé.} other {# types de fonctions sont autorisés}}", - "maximum-timeseries-or-attributes": "Maximum {count, plural, 1 {1 timeseries / attribut est autorisé.} other {# timeseries / attributs sont autorisés}}", - "settings": "Paramètres", - "timeseries": "Timeseries", - "timeseries-or-attributes-required": "Les timeseries / attributs d'entité sont obligatoires.", - "timeseries-required": "Les Timeseries de l'entité sont obligatoires.", - "units": "Symbole spécial à afficher à côté de la valeur", - "use-data-post-processing-func": "Utiliser la fonction de post-traitement des données" - }, -"datasource":{ - "add-datasource-prompt": "Veuillez ajouter une source de données", - "name": "Nom", - "type": "Type de source de données" - }, -"datetime":{ - "date-from": "Date de", - "date-to": "Date à", - "time-from": "Heure de", - "time-to": "Heure à" - }, -"details":{ - "edit-mode": "Mode édition", - "toggle-edit-mode": "Activer le mode édition" - }, -"device":{ - "access-token": "Jeton d'accès", - "access-token-invalid": "La longueur du jeton d'accès doit être comprise entre 1 et 20 caractères.", - "access-token-required": "Le jeton d'accès est requis.", - "accessTokenCopiedMessage": "Le jeton d'accès au dispositif a été copié dans le presse-papier", - "add": "Ajouter un dispositif", - "add-alias": "Ajouter un alias de dispositif", - "add-device-text": "Ajouter un nouveau dispositif", - "alias": "Alias", - "alias-required": "Un alias du dispositif est requis.", - "aliases": "Alias ​​du dispositif", - "any-device": "N'importe quel dispositif", - "assign-device-to-customer": "Affecter des dispositifs au client", - "assign-device-to-customer-text": "Veuillez sélectionner les dispositif à affecter au client", - "assign-devices": "Attribuer des dispositifs", - "assign-devices-text": "Attribuer {count, plural, 1 {1 dispositif} other {# dispositifs}} au client", - "assign-new-device": "Attribuer un nouveau dispositif", - "assign-to-customer": "Attribuer au client", - "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les dispositifs", - "assignedToCustomer": "Attribué au client", - "configure-alias": "Configurer '{{alias}}' alias", - "copyAccessToken": "Copier le jeton d'accès", - "copyId": "Copier l'Id du dispositif", - "create-new-alias": "Créez un nouveau!", - "create-new-key": "Créez un nouveau!", - "credentials": "Informations d'identification", - "credentials-type": "Type d'identification", - "delete": "Supprimer le dispositif", - "delete-device-text": "Faites attention, après la confirmation, le dispositif et toutes les données associées deviendront irrécupérables.", - "delete-device-title": "Êtes-vous sûr de vouloir supprimer le dispositif '{{deviceName}}'?", - "delete-devices": "Supprimer les dispositifs", - "delete-devices-action-title": "Supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}", - "delete-devices-text": "Faites attention, après la confirmation, tous les dispositifs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-devices-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}?", - "description": "Description", - "details": "Détails", - "device": "Dispositif", - "device-alias": "Alias ​​du dispositif", - "device-credentials": "Informations d'identification du dispositif", - "device-details": "Détails du dispositif", - "device-list": "Liste des dispositifs", - "device-list-empty": "Aucun dispositif sélectionné.", - "device-name-filter-no-device-matched": "Aucun dispositif commençant par '{{device}} n'a été trouvé.", - "device-name-filter-required": "Le filtre de nom de dispositif est requis.", - "device-public": "Le dispositif est public", - "device-required": "Le dispositif est requis.", - "device-type": "Type de dispositif", - "device-type-list-empty": "Aucun type de dispositif sélectionné.", - "device-type-required": "Le type de dispositif est requis.", - "device-types": "Types de dispositif", - "devices": "Dispositifs", - "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias de dispositifs doivent être uniques dans le tableau de bord.", - "enter-device-type": "Entrez le type de dispositif", - "events": "Événements", - "idCopiedMessage": "l'Id du dispositif a été copié dans le presse-papiers", - "is-gateway": "Est une passerelle", - "make-private": "Rendre le dispositif privé", - "make-private-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendues privées et ne seront pas accessibles par d'autres.", - "make-private-device-title": "Etes-vous sûr de vouloir rendre le dispositif {{deviceName}} privé?", - "make-public": "Rendre le dispositif public", - "make-public-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendus publics et accessibles par d'autres.", - "make-public-device-title": "Êtes-vous sûr de vouloir rendre le dispositif {{deviceName}} 'public?", - "manage-credentials": "Gérer les informations d'identification", - "management": "Gestion des dispositifs", - "name": "Nom", - "name-required": "Le nom est requis.", - "name-starts-with": "Le nom du dispositif commence par", - "no-alias-matching": "'{{alias}}' introuvable.", - "no-aliases-found": "Aucun alias trouvé.", - "no-device-types-matching": "Aucun type de dispositif correspondant à {{entitySubtype}} n'a été trouvé.", - "no-devices-matching": "Aucun dispositif correspondant à '{{entity}} n'a été trouvé.", - "no-devices-text": "Aucun dispositif trouvé", - "no-key-matching": "'{{key}}' introuvable.", - "no-keys-found": "Aucune clé trouvée", - "public": "Public", - "remove-alias": "Supprimer l'alias du dispositif", - "rsa-key": "Clé publique RSA", - "rsa-key-required": "La clé publique RSA est requise.", - "secret": "Secret", - "secret-required": "Code secret est requis.", - "select-device": "Selectionner un dispositif", - "select-device-type": "Sélectionner le type d'appareil", - "unable-delete-device-alias-text": "L'alias du dispositif '{{deviceAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", - "unable-delete-device-alias-title": "Impossible de supprimer l'alias du dispositif", - "unassign-device": "Annuler l'affectation du dispositif", - "unassign-device-text": "Après la confirmation, le dispositif ne sera pas attribué et ne sera pas accessible au client.", - "unassign-device-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{deviceName}} '?", - "unassign-devices": "Annuler l'affectation des dispositifs", - "unassign-devices-action-title": "Annuler l'affectation de {count, plural, 1 {1 dispositif} other {#dispositifs}} du client", - "unassign-devices-text": "Après la confirmation, tous les dispositifs sélectionnés ne seront pas attribues et ne seront pas accessibles par le client.", - "unassign-devices-title": "Voulez-vous vraiment annuler l'affectation de {count, plural, 1 {1 dispositif} other {# dispositifs}}?", - "unassign-from-customer": "Retirer du client", - "use-device-name-filter": "Utiliser le filtre", - "view-credentials": "Afficher les informations d'identification", - "view-devices": "Afficher les dispositifs" - }, -"dialog":{ - "close": "Fermer le dialogue" - }, -"entity" : { - "add-alias": "Ajouter un alias d'entité", - "alarm-name-starts-with": "Les alarmes dont le nom commence par '{{prefix}}'", - "alias": "Alias", - "alias-required": "Un alias d'entité est requis.", - "aliases": "alias d'entité", - "all-subtypes": "Tout", - "any-entity": "Toute entité", - "asset-name-starts-with": "Les Assets dont le nom commence par '{{prefix}}'", - "configure-alias": "Configurer '{{alias}}' alias", - "create-new-alias": "Créez un nouveau!", - "create-new-key": "Créez un nouveau!", - "customer-name-starts-with": "Les clients dont les noms commencent par '{{prefix}}'", - "dashboard-name-starts-with": "Les tableaux de bord dont les noms commencent par '{{prefix}}'", - "details": "Détails de l'entité", - "device-name-starts-with": "Dispositifs dont le nom commence par '{{prefix}}'", - "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias d'entité doivent être uniques dans le tableau de bord.", - "enter-entity-type": "Entrez le type d'entité", - "entities": "Entités", - "entity": "Entité", - "entity-alias": "Alias de l'entité", - "entity-list": "Liste d'entités", - "entity-list-empty": "Aucune entité sélectionnée.", - "entity-name": "Nom de l'entité", - "entity-name-filter-no-entity-matched": "Aucune entité commençant par '{{entity}}' n'a été trouvée.", - "entity-name-filter-required": "Le filtre de nom d'entité est requis.", - "entity-type": "Type d'entité", - "entity-type-list": "Liste de types d'entités", - "entity-type-list-empty": "Aucun type d'entité sélectionné.", - "entity-types": "Types d'entité", - "key": "Clé", - "key-name": "Nom de la clé", - "list-of-alarms": "{count, plural, 1 {Une alarme} other {Liste de # alarmes}}", - "list-of-assets": "{count, plural, 1 {Un Asset} other {Liste de # Assets}}", - "list-of-customers": "{count, plural, 1 {Un client} other {Liste de # clients}}", - "list-of-dashboards": "{count, plural, 1 {Un tableau de bord} other {Liste de # tableaux de bord}}", - "list-of-devices": "{count, plural, 1 {Un dispositif} other {Liste de # dispositifs}}", - "list-of-plugins": "{count, plural, 1 {Un plugin} other {Liste de # plugins}}", - "list-of-rulechains": "{count, plural, 1 {Une chaîne de règles} other {Liste de # chaînes de règles}}", - "list-of-rulenodes": "{count, plural, 1 {Un noeud de règles} other {Liste de # noeuds de règles}}", - "list-of-rules": "{count, plural, 1 {Une règle} other {Liste de # règles}}", - "list-of-tenants": "{count, plural, 1 {Un tenant} other {Liste de # tenants}}", - "list-of-users": "{count, plural, 1 {Un utilisateur} other {Liste de # utilisateurs}}", - "missing-entity-filter-error": "Le filtre est manquant pour l'alias '{{alias}}'.", - "name-starts-with": "Nom commence par", - "no-alias-matching": "'{{alias}}' introuvable.", - "no-aliases-found": "Aucun alias trouvé.", - "no-data": "Aucune donnée à afficher", - "no-entities-matching": "Aucune entité correspondant à '{{entity}}' n'a été trouvée.", - "no-entities-prompt": "Aucune entité trouvée", - "no-entity-types-matching": "Aucun type d'entité correspondant à {{entityType}} n'a été trouvé. ", - "no-key-matching": "'{{key}}' introuvable.", - "no-keys-found": "Aucune clé trouvée", - "plugin-name-starts-with": "Plugins dont les noms commencent par '{{prefix}}'", - "remove-alias": "Supprimer l'alias d'entité", - "rule-name-starts-with": "Règles dont les noms commencent par '{{prefix}}'", - "rulechain-name-starts-with": "Chaînes de règles dont les noms commencent par '{{prefix}}'", - "rulenode-name-starts-with": "Les noeuds de règles dont le nom commence par '{{prefix}}'", - "search": "Recherche d'entités", - "select-entities": "Sélectionner des entités", - "selected-entities": "{count, plural, 1 {1 entité} other {# entités}} sélectionnées", - "tenant-name-starts-with": "Les Tenant dont le nom commence par '{{prefix}}'", - "type": "Type", - "type-alarm": "Alarme", - "type-alarms": "Alarmes", - "type-asset": "Asset", - "type-assets": "Assets", - "type-current-customer": "Client actuel", - "type-customer": "Client", - "type-customers": "Clients", - "type-dashboard": "Tableau de bord", - "type-dashboards": "Tableaux de bord", - "type-device": "Dispositif", - "type-devices": "Dispositifs", - "type-plugin": "Plugin", - "type-plugins": "Plugins", - "type-required": "Le type d'entité est obligatoire.", - "type-rule": "Règle", - "type-rulechain": "Chaîne de règles", - "type-rulechains": "Chaînes de règles", - "type-rulenode": "Noeud de règle", - "type-rulenodes": "Noeuds de règle", - "type-rules": "Règles", - "type-tenant": "Tenant", - "type-tenants": "Tenants", - "type-user": "Utilisateur", - "type-users": "Utilisateurs", - "unable-delete-entity-alias-text": "L'alias d'entité '{{entityAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", - "unable-delete-entity-alias-title": "Impossible de supprimer l'alias d'entité", - "use-entity-name-filter": "Utiliser un filtre", - "user-name-starts-with": "Utilisateurs dont les noms commencent par '{{prefix}}'" - }, -"error":{ - "unable-to-connect": "Impossible de se connecter au serveur! Veuillez vérifier votre connexion Internet.", - "unhandled-error-code": "Code d'erreur non géré: {{errorCode}}", - "unknown-error": "Erreur inconnue" - }, -"event":{ - "alarm": "Alarme", - "body": "Corps", - "data": "Données", - "data-type": "Type de données", - "entity": "Entité", - "error": "erreur", - "errors-occurred": "Des erreurs sont survenues", - "event": "événement", - "event-time": "Heure de l'événement", - "event-type": "Type d'événement", - "failed": "Échec", - "message-id": "Message Id", - "message-type": "Type de message", - "messages-processed": "Messages traités", - "metadata": "Métadonnées", - "method": "Méthode", - "no-events-prompt": "Aucun événement trouvé", - "relation-type": "Type de relation", - "server": "Serveur", - "status": "Etat", - "success": "Succès", - "type": "Type", - "type-debug-rule-chain": "Debug", - "type-debug-rule-node": "Debug", - "type-error": "Erreur", - "type-lc-event": "Evénement du cycle de vie", - "type-stats": "Statistiques" - }, -"extension":{ - "add": "Ajouter une extension", - "add-attribute": "Ajouter un attribut", - "add-attribute-request": "Ajouter une demande d'attribut", - "add-attribute-update": "Ajouter une mise à jour d'attribut", - "add-broker": "Ajouter un Broker", - "add-config": "Ajouter une configuration de convertisseur", - "add-connect-request": "Ajouter une demande de connexion", - "add-converter": "Ajouter un convertisseur", - "add-device": "Ajouter un dispositif", - "add-disconnect-request": "Ajouter une demande de déconnexion", - "add-map": "Ajouter un élément de mappage", - "add-server-side-rpc-request": "Ajouter une requête RPC côté serveur", - "add-timeseries": "Ajouter des timeseries", - "anonymous": "Anonyme", - "attr-json-key-expression": "Expression json de la clé d'attribut", - "attr-topic-key-expression": "Expression du topic de la clé d'attribut", - "attribute-filter": "Filtre d'attribut", - "attribute-key-expression": "Expression de clé d'attribut", - "attribute-requests": "Demandes d'attributs", - "attribute-updates": "Mises à jour des attributs", - "attributes": "Attributs", - "basic": "Basic", - "brokers": "Brokers", - "ca-cert": "Fichier de certificat CA", - "cert": "Fichier de certificat *", - "client-scope": "Portée client", - "configuration": "Configuration", - "connect-requests": "Demandes de connexion", - "converter-configurations": "Configurations du convertisseur", - "converter-id": "ID du convertisseur", - "converter-json": "Json", - "converter-json-parse": "Impossible d'analyser le convertisseur json.", - "converter-json-required": "Le convertisseur json est requis.", - "converter-type": "Type de convertisseur", - "converters": "Convertisseurs", - "credentials": "Informations d'identification", - "custom": "Custom", - "delete": "Supprimer l'extension", - "delete-extension-text": "Attention, après la confirmation, l'extension et toutes les données associées deviendront irrécupérables.", - "delete-extension-title": "Êtes-vous sûr de vouloir supprimer l'extension '{{extensionId}}'?", - "delete-extensions-text": "Attention, après la confirmation, toutes les extensions sélectionnées seront supprimées.", - "delete-extensions-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 extension} other {# extensions}}?", - "device-name-expression": "expression du nom du dispositif", - "device-name-filter": "Filtre de nom de dispositif", - "device-type-expression": "expression de type de dispositif", - "disconnect-requests": "Demandes de déconnection", - "drop-file": "Déposez un fichier ou cliquez pour sélectionner un fichier à télécharger.", - "edit": "Modifier l'extension", - "export-extension": "Exporter l'extension", - "export-extensions-configuration": "Exporter la configuration des extensions", - "extension-id": "Id de l'extension", - "extension-type": "Type d'extension", - "extensions": "Extensions", - "field-required": "Le champ est obligatoire", - "file": "Fichier d'extensions", - "filter-expression": "Expression du filtre", - "host": "Hôte", - "id": "Id", - "import-extension": "Importer une extension", - "import-extensions": "Importer des extensions", - "import-extensions-configuration": "Importer la configuration des extensions", - "invalid-file-error": "Fichier d'extension non valide", - "json-name-expression": "Expression json du nom du dispositif", - "json-parse": "Impossible d'analyser json transformer.", - "json-required": "Transformer json est requis.", - "json-type-expression": "Expression json du type de dispositif", - "key": "Clé", - "mapping": "Mappage", - "method-filter": "Filtre de méthode", - "modbus-add-server": "Ajouter serveur/esclave", - "modbus-add-server-prompt": "Veuillez ajouter serveur/esclave", - "modbus-attributes-poll-period": "Période d'interrogation des attributs (ms)", - "modbus-baudrate": "Débit en bauds", - "modbus-byte-order": "Ordre des octets", - "modbus-databits": "Bits de données", - "modbus-databits-range": "Les bits de données doivent être compris entre 7 et 8.", - "modbus-device-name": "Nom du dispositif", - "modbus-encoding": "Encodage", - "modbus-function": "Fonction", - "modbus-parity": "parité", - "modbus-poll-period": "Période d'interrogation (ms)", - "modbus-poll-period-range": "La période d'interrogation doit être une valeur positive.", - "modbus-port-name": "Nom du port série", - "modbus-register-address": "Adresse du registre", - "modbus-register-address-range": "L'adresse du registre doit être comprise entre 0 et 65535.", - "modbus-register-bit-index": "Bit index", - "modbus-register-bit-index-range": "L'index de bit doit être compris entre 0 et 15.", - "modbus-register-count": "Nombre de registre", - "modbus-register-count-range": "Le nombre de registres doit être une valeur positive.", - "modbus-server": "Serveurs / esclaves", - "modbus-stopbits": "Bits d'arrêt", - "modbus-stopbits-range": "Les bits d'arrêt doivent être compris entre 1 et 2.", - "modbus-tag": "Tag", - "modbus-timeseries-poll-period": "Période d'interrogation des Timeseries (ms)", - "modbus-transport": "Transport", - "modbus-unit-id": "Id de l'unité", - "modbus-unit-id-range": "L'ID de l'unité doit être compris entre 1 et 247.", - "no-file": "Aucun fichier sélectionné.", - "opc-add-server": "Ajouter un serveur", - "opc-add-server-prompt": "Veuillez ajouter un serveur", - "opc-application-name": "Nom de l'application", - "opc-application-uri": "Uri de l'application", - "opc-device-name-pattern": "modèle de nom du dispositif", - "opc-device-node-pattern": "modèle de noeud de dispositif", - "opc-identity": "Identité", - "opc-keystore": "Magasin de clés", - "opc-keystore-alias": "Alias", - "opc-keystore-key-password": "Mot de passe de la clé", - "opc-keystore-location": "Emplacement *", - "opc-keystore-password": "Mot de passe", - "opc-keystore-type": "Type", - "opc-scan-period-in-seconds": "Période d'analyse en secondes", - "opc-security": "Sécurité", - "opc-server": "Serveurs", - "opc-type": "Type", - "password": "Mot de passe", - "pem": "PEM", - "port": "Port", - "port-range": "Le port doit être compris entre 1 et 65535.", - "private-key": "Fichier de clé privée *", - "request-id-expression": "Expression de demande d'id", - "request-id-json-expression": "Expression json de la demande d'id", - "request-id-topic-expression": "Expression de la demande d'id du topic", - "request-topic-expression": "Expression de la demande du topic", - "response-timeout": "Délai de réponse en millisecondes", - "response-topic-expression": "Expression du topic de la réponse", - "retry-interval": "Intervalle de nouvelle tentative en millisecondes", - "selected-extensions": "{count, plural, 1 {1 extension} other {# extensions}} sélectionné", - "server-side-rpc": "RPC côté serveur", - "ssl": "Ssl", - "sync":{ - "last-sync-time": "Dernière heure de synchronisation", - "not-available": "Non disponible", - "not-sync": "Non sync", - "status": "Status", - "sync": "Sync" - }, - "timeout": "Délai d'attente en millisecondes", - "timeseries": "Timeseries", - "to-double": "To Double", - "token": "Jeton de sécurité", - "topic": "Topic", - "topic-expression": "Expression du topic", - "topic-filter": "Filtre du topic", - "topic-name-expression": "Expression du nom du dispositif (topic)", - "topic-type-expression": "Expression de type de dispositif (topic)", - "transformer": "Transformer", - "transformer-json": "JSON *", - "type": "Type", - "unique-id-required": "L'identifiant d'extension actuel existe déjà.", - "username": "Nom d'utilisateur", - "value": "Valeur", - "value-expression": "Expression de la valeur" - }, -"fullscreen":{ - "exit": "Quitter le plein écran", - "expand": "Afficher en plein écran", - "fullscreen": "Plein écran", - "toggle": "Activer le mode plein écran" - }, -"function":{ - "function": "Fonction" - }, -"grid":{ - "add-item-text": "Ajouter un nouvel élément", - "delete-item": "Supprimer l'élément", - "delete-item-text": "Faites attention, après la confirmation, cet élément et toutes les données associées deviendront irrécupérables.", - "delete-item-title": "Êtes-vous sûr de vouloir supprimer cet élément?", - "delete-items": "Supprimer les éléments", - "delete-items-action-title": "Supprimer {count, plural, 1 {1 élément} other {# éléments}}", - "delete-items-text": "Attention, après la confirmation, tous les éléments sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-items-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 élément} other {# éléments}}?", - "item-details": "Détails de l'élément", - "no-items-text": "Aucun élément trouvé", - "scroll-to-top": "Défiler vers le haut" - }, -"help":{ - "goto-help-page": "Aller à la page d'aide" - }, -"home":{ - "avatar": "Avatar", - "home": "Accueil", - "logout": "Déconnexion", - "menu": "Menu", - "open-user-menu": "Ouvrir le menu utilisateur", - "profile": "Profile" - }, -"icon":{ - "icon": "Icône", - "material-icons": "Material icons", - "select-icon": "Sélectionner l'icône", - "show-all": "Afficher toutes les icônes" - }, -"import":{ - "drop-file": "Déposez un fichier JSON ou cliquez pour sélectionner un fichier à télécharger.", - "no-file": "Aucun fichier sélectionné" - }, -"item":{ - "selected": "Sélectionné" - }, -"js-func":{ - "no-return-error": "La fonction doit renvoyer une valeur!", - "return-type-mismatch": "La fonction doit renvoyer une valeur de type '{{type}}' !", - "tidy": "Tidy" - }, -"key-val":{ - "add-entry": "Ajouter une entrée", - "key": "Clé", - "no-data": "Aucune entrée", - "remove-entry": "Supprimer l'entrée", - "value": "Valeur" - }, -"language":{ - "language": "Language", - "locales":{ - "en_US": "Anglais", - "fr_FR": "Français", - "es_ES": "Espagnol", - "it_IT": "Italien", - "ko_KR": "Coréen", - "ru_RU": "Russe", - "zh_CN": "Chinois" - } - }, -"layout":{ - "color": "Couleur", - "layout": "Mise en page", - "main": "Principal", - "manage": "Gérer les mises en page", - "right": "Droite", - "select": "Sélectionner la mise en page cible", - "settings": "Paramètres de mise en page" - }, -"legend":{ - "avg": "avg", - "max": "max", - "min": "min", - "position": "Position de la légende", - "settings": "Paramètres de la légende", - "show-avg": "Afficher la valeur moyenne", - "show-max": "Afficher la valeur maximale", - "show-min": "Afficher la valeur min", - "show-total": "Afficher la valeur totale", - "total": "total" - }, -"login":{ - "create-password": "Créer un mot de passe", - "email": "Email", - "forgot-password": "Mot de passe oublié?", - "login": "Login", - "new-password": "Nouveau mot de passe", - "new-password-again": "nouveau mot de passe", - "password-again": "Mot de passe à nouveau", - "password-link-sent-message": "Le lien de réinitialisation du mot de passe a été envoyé avec succès!", - "password-reset": "Mot de passe réinitialisé", - "passwords-mismatch-error": "Les mots de passe saisis doivent être identiques!", - "remember-me": "Se souvenir de moi", - "request-password-reset": "Demander la réinitialisation du mot de passe", - "reset-password": "Réinitialiser le mot de passe", - "sign-in": "Veuillez vous connecter", - "username": "Nom d'utilisateur (email)" - }, -"position":{ - "bottom": "Bas", - "left": "Gauche", - "right": "Droite", - "top": "Haut" - }, -"profile":{ - "change-password": "Modifier le mot de passe", - "current-password": "Mot de passe actuel", - "profile": "Profile" - }, -"relation":{ - "add": "Ajouter une relation", - "add-relation-filter": "Ajouter un filtre de relation", - "additional-info": "Informations supplémentaires (JSON)", - "any-relation": "toute relation", - "any-relation-type": "N'importe quel type", - "delete": "Supprimer la relation", - "delete-from-relation-text": "Attention, après la confirmation, l'entité actuelle ne sera pas liée à l'entité '{{entityName}}'.", - "delete-from-relation-title": "Etes-vous sûr de vouloir supprimer la relation de l'entité '{{entityName}}'?", - "delete-from-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et l'entité actuelle ne sera pas liée aux entités correspondantes.", - "delete-from-relations-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", - "delete-to-relation-text": "Attention, après la confirmation, l'entité '{{entityName}} ne sera plus liée à l'entité actuelle.", - "delete-to-relation-title": "Êtes-vous sûr de vouloir supprimer la relation avec l'entité '{{entityName}}'?", - "delete-to-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et les entités correspondantes ne seront pas liées à l'entité en cours.", - "delete-to-relations-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", - "direction": "Sens", - "direction-type":{ - "FROM": "de", - "TO": "à" - }, - "edit": "Modifier la relation", - "from-entity": "De l'entité", - "from-entity-name": "Du nom d'entité", - "from-entity-type": "Du type d'entité", - "from-relations": "Relations sortantes", - "invalid-additional-info": "Impossible d'analyser les informations supplémentaires json.", - "relation-filters": "Filtres de relation", - "relation-type": "Type de relation", - "relation-type-required": "Le type de relation est requis.", - "relations": "Relations", - "remove-relation-filter": "Supprimer le filtre de relation", - "search-direction":{ - "FROM": "De", - "TO": "À" - }, - "selected-relations": "{count, plural, 1 {1 relation} other {# relations}} sélectionné", - "to-entity": "À l'entité", - "to-entity-name": "vers le nom de l'entité", - "to-entity-type": "Vers le type d'entité", - "to-relations": "Relations entrantes", - "type": "Type" - }, -"rulechain":{ - "add": "Ajouter une chaîne de règles", - "add-rulechain-text": "Ajouter une nouvelle chaîne de règles", - "copyId": "Copier l'identifiant de la chaîne de règles", - "create-new-rulechain": "Créer une nouvelle chaîne de règles", - "debug-mode": "Mode de débogage", - "delete": "Supprimer la chaîne de règles", - "delete-rulechain-text": "Attention, après la confirmation, la chaîne de règles et toutes les données associées deviendront irrécupérables.", - "delete-rulechain-title": "Voulez-vous vraiment supprimer la chaîne de règles '{{ruleChainName}}'?", - "delete-rulechains-action-title": "Supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}", - "delete-rulechains-text": "Attention, après la confirmation, toutes les chaînes de règles sélectionnées seront supprimées et toutes les données associées deviendront irrécupérables.", - "delete-rulechains-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}?", - "description": "Description", - "details": "Détails", - "events": "Evénements", - "export": "Exporter la chaîne de règles", - "export-failed-error": "Impossible d'exporter la chaîne de règles: {{error}}", - "idCopiedMessage": "L'ID de la chaîne de règles a été copié dans le presse-papier", - "import": "Importer la chaîne de règles", - "invalid-rulechain-file-error": "Impossible d'importer la chaîne de règles: structure de données de la chaîne de règles non valide", - "management": "Gestion des règles", - "name": "Nom", - "name-required": "Le nom est requis.", - "no-rulechains-matching": "Aucune chaîne de règles correspondant à {{entity}} n'a été trouvée.", - "no-rulechains-text": "Aucune chaîne de règles trouvée", - "root": "Racine", - "rulechain": "Chaîne de règles", - "rulechain-details": "Détails de la chaîne de règles", - "rulechain-file": "Fichier de chaîne de règles", - "rulechain-required": "Chaîne de règles requise", - "rulechains": "Chaînes de règles", - "select-rulechain": "Sélectionner la chaîne de règles", - "set-root": "Rend la chaîne de règles racine (root) ", - "set-root-rulechain-text": "Après la confirmation, la chaîne de règles deviendra racine (root) et gérera tous les messages de transport entrants.", - "set-root-rulechain-title": "Voulez-vous vraiment que la chaîne de règles '{{ruleChainName}} soit racine (root) ?", - "system": "Système" - }, -"rulenode":{ - "add": "Ajouter un noeud de règle", - "add-link": "Ajouter un lien", - "configuration": "Configuration", - "copy-selected": "Copier les éléments sélectionnés", - "create-new-link-label": "Créez un nouveau!", - "custom-link-label": "Etiquette de lien personnalisée", - "custom-link-label-required": "Une étiquette de lien personnalisée est requise", - "debug-mode": "Mode de débogage", - "delete": "Supprimer le noeud de règle", - "delete-selected": "Supprimer les éléments sélectionnés", - "delete-selected-objects": "Supprimer les nœuds et les connexions sélectionnés", - "description": "Description", - "deselect-all": "Désélectionner tout", - "deselect-all-objects": "Désélectionnez tous les nœuds et toutes les connexions", - "details": "Détails", - "directive-is-not-loaded": "La directive de configuration définie '{{directiveName}} n'est pas disponible.", - "events": "Événements", - "help": "Aide", - "invalid-target-rulechain": "Impossible de résoudre la chaîne de règles cible!", - "link": "Lien", - "link-details": "Détails du lien du noeud de la règle", - "link-label": "Étiquette du lien", - "link-label-required": "L'étiquette du lien est obligatoire", - "link-labels": "Étiquettes de lien", - "link-labels-required": "Les étiquettes de lien sont obligatoires", - "message": "Message", - "message-type": "Type de message", - "message-type-required": "Le type de message est obligatoire", - "metadata": "Métadonnées", - "metadata-required": "Les entrées de métadonnées ne peuvent pas être vides.", - "name": "Nom", - "name-required": "Le nom est requis.", - "no-link-label-matching": "'{{label}}' introuvable.", - "no-link-labels-found": "Aucune étiquette de lien trouvée", - "open-node-library": "Ouvrir la bibliothèque de noeud", - "output": "Output", - "rulenode-details": "Détails du noeud de la règle", - "search": "Recherche de noeuds", - "select-all": "Tout sélectionner", - "select-all-objects": "Sélectionnez tous les noeuds et connexions", - "select-message-type": "Sélectionner le type de message", - "test": "Test", - "test-script-function": "Tester le script", - "type": "Type", - "type-action": "Action", - "type-action-details": "Effectuer une action spéciale", - "type-enrichment": "Enrichissement", - "type-enrichment-details": "Ajouter des informations supplémentaires dans les métadonnées de message", - "type-external": "Externe", - "type-external-details": "Interagit avec le système externe", - "type-filter": "Filtre", - "type-filter-details": "Filtrer les messages entrants avec des conditions configurées", - "type-input": "Input", - "type-input-details": "Entrée logique de la chaîne de règles, transmet les messages entrants au prochain nœud de règle associé", - "type-rule-chain": "Chaîne de règles", - "type-rule-chain-details": "Transmet les messages entrants à la chaîne de règles spécifiée", - "type-transformation": "Transformation", - "type-transformation-details": "Modifier le payload du message et les métadonnées ", - "type-unknown": "Inconnu", - "type-unknown-details": "Noeud de règle non résolu", - "ui-resources-load-error": "Impossible de charger les ressources de configuration de l'interface utilisateur." - }, -"tenant":{ - "add": "Ajouter un Tenant", - "add-tenant-text": "Ajouter un nouveau Tenant", - "admins": "Admins", - "copyId": "Copier l'Id du Tenant", - "delete": "Supprimer le Tenant", - "delete-tenant-text": "Attention, après la confirmation, le Tenant et toutes les données associées deviendront irrécupérables.", - "delete-tenant-title": "Etes-vous sûr de vouloir supprimer le tenant '{{tenantTitle}}'?", - "delete-tenants-action-title": "Supprimer {count, plural, 1 {1 tenant} other {# tenants}}", - "delete-tenants-text": "Attention, après la confirmation, tous les Tenants sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-tenants-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 tenant} other {# tenants}}?", - "description": "Description", - "details": "Détails", - "events": "Événements", - "idCopiedMessage": "L'Id du Tenant a été copié dans le Presse-papiers", - "manage-tenant-admins": "Gérer les administrateurs du Tenant", - "management": "Gestion des Tenants", - "no-tenants-matching": "Aucun Tenant correspondant à {{entity}} n'a été trouvé. ", - "no-tenants-text": "Aucun Tenant trouvé", - "select-tenant": "Sélectionner un Tenant", - "tenant": "Tenant", - "tenant-details": "Détails du Tenant", - "tenant-required": "Tenant requis", - "tenants": "Tenants", - "title": "Titre", - "title-required": "Le titre est requis." - }, -"timeinterval":{ - "advanced": "Avancé", - "days": "Jours", - "days-interval": "{days, plural, 1 {1 jour} other {# jours}}", - "hours": "Heures", - "hours-interval": "{hours, plural, 1 {1 heure} other {# heures}}", - "minutes": "Minutes", - "minutes-interval": "{minutes, plural, 1 {1 minute} other {# minutes}}", - "seconds": "Secondes", - "seconds-interval": "{seconds, plural, 1 {1 seconde} other {# secondes}}" - }, -"timewindow":{ - "date-range": "Plage de dates", - "days": "{days, plural, 1 {jour} other {# jours}}", - "edit": "Modifier timewindow", - "history": "Historique", - "hours": "{hours, plural, 0 {heure} 1 {1 heure} other {# heures}}", - "last": "Dernier", - "last-prefix": "dernier", - "minutes": "{minutes, plural, 0 {minute} 1 {1 minute} other {# minutes}}", - "period": "de {{startTime}} à {{endTime}}", - "realtime": "Temps réel", - "seconds": "{seconds, plural, 0 {second} 1 {1 second} other {# seconds}}", - "time-period": "Période" - }, -"user":{ - "activation-email-sent-message": "L'e-mail d'activation a été envoyé avec succès!", - "activation-link": "Lien d'activation utilisateur", - "activation-link-copied-message": "le lien d'activation de l'utilisateur a été copié dans le presse-papier", - "activation-link-text": "Pour activer l'utilisateur, utilisez le lien d'activation suivant: ", - "activation-method": "Méthode d'activation", - "add": "Ajouter un utilisateur", - "add-user-text": "Ajouter un nouvel utilisateur", - "always-fullscreen": "Toujours en plein écran", - "anonymous": "Anonyme", - "copy-activation-link": "Copier le lien d'activation", - "customer": "Client", - "customer-users": "Utilisateurs du client", - "default-dashboard": "Tableau de bord par défaut", - "delete": "Supprimer l'utilisateur", - "delete-user-text": "Attention, après la confirmation, l'utilisateur et toutes les données associées deviendront irrécupérables.", - "delete-user-title": "Etes-vous sûr de vouloir supprimer l'utilisateur '{{userEmail}}'?", - "delete-users-action-title": "Supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}", - "delete-users-text": "Attention, après la confirmation, tous les utilisateurs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-users-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}?", - "description": "Description", - "details": "Détails", - "display-activation-link": "Afficher le lien d'activation", - "email": "Email", - "email-required": "Email est requis.", - "first-name": "Prénom", - "invalid-email-format": "Format de courrier électronique non valide", - "last-name": "Nom de famille", - "no-users-matching": "Aucun utilisateur correspondant à '{{entity}}' n'a été trouvé.", - "no-users-text": "Aucun utilisateur trouvé", - "resend-activation": "Renvoyer l'activation", - "select-user": "Sélectionner l'utilisateur", - "send-activation-mail": "Envoyer un mail d'activation", - "sys-admin": "Administrateur du système", - "tenant-admin": "Administrateur du Tenant", - "tenant-admins": "administrateurs du Tenant", - "user": "utilisateur", - "user-details": "Détails de l'utilisateur", - "user-required": "L'utilisateur est requis", - "users": "Utilisateurs" - }, -"value":{ - "boolean": "booléen", - "boolean-value": "Valeur booléenne", - "double": "Double", - "double-value": "Valeur double", - "false": "Faux", - "integer": "Entier", - "integer-value": "Valeur entière", - "invalid-integer-value": "Valeur entière invalide", - "long": "Long", - "string": "String", - "string-value": "Valeur String", - "true": "Vrai", - "type": "Type de valeur" - }, -"widget":{ - "add": "Ajouter un widget", - "add-resource": "Ajouter une ressource", - "add-widget-type": "Ajouter un nouveau type de widget", - "alarm": "Widget d'alarme", - "css": "CSS", - "datakey-settings-schema": "Schéma des paramètres de Data key", - "edit": "Modifier le widget", - "editor": " Editeur de widget", - "export": "Exporter widget", - "html": "HTML", - "javascript": "Javascript", - "latest-values": "Dernières valeurs", - "management": "Gestion des widgets", - "missing-widget-title-error": "Le titre du widget doit être spécifié!", - "no-data-found": "Aucune donnée trouvée", - "remove": "Supprimer le widget", - "remove-resource": "Supprimer une ressource", - "remove-widget-text": "Après la confirmation, le widget et toutes les données associées deviendront irrécupérables.", - "remove-widget-title": "Êtes-vous sûr de vouloir supprimer le widget '{{widgetTitle}}'?", - "remove-widget-type": "Supprimer le type de widget", - "remove-widget-type-text": "Après la confirmation, le type de widget et toutes les données associées deviendront irrécupérables.", - "remove-widget-type-title": "Êtes-vous sûr de vouloir supprimer le type de widget '{{widgetName}}'?", - "resource-url": "URL JavaScript / CSS", - "resources": "Ressources", - "rpc": "Widget de contrôle", - "run": "Exécuter un widget", - "save": "Enregistrer le widget", - "save-widget-type-as": "Enregistrer le type de widget sous", - "save-widget-type-as-text": "Veuillez saisir un nouveau titre de widget et / ou sélectionner un ensemble de widgets cibles", - "saveAs": "Enregistrer le widget sous", - "search-data": "Rechercher des données", - "select-widget-type": "Sélectionnez le type de widget", - "select-widgets-bundle": "Sélectionner un ensemble de widgets", - "settings-schema": "Schéma des paramètres", - "static": "Widget statique", - "tidy": "Tidy", - "timeseries": "Séries chronologiques", - "title": "Titre du widget", - "title-required": "Le titre du widget est requis.", - "toggle-fullscreen": "Basculer le mode plein écran", - "type": "Type de widget", - "unable-to-save-widget-error": "Impossible de sauvegarder le widget! Le widget a des erreurs!", - "undo": "Annuler les modifications du widget", - "widget-bundle": "Ensemble de widget", - "widget-library": "Bibliothèque de widgets", - "widget-saved": "Widget enregistré", - "widget-template-load-failed-error": "Impossible de charger le modèle de widget!", - "widget-type-load-error": "Le widget n'a pas été chargé à cause des erreurs suivantes:", - "widget-type-load-failed-error": "Impossible de charger le type de widget!", - "widget-type-not-found": "Problème de chargement de la configuration du widget.
Le type de widget associé a probablement été supprimé." - }, -"widget-action":{ - "custom": "Action personnalisée", - "header-button": "Bouton d'en-tête de widget", - "open-dashboard": "Naviguer vers un autre tableau de bord", - "open-dashboard-state": "Naviguer vers un nouvel état du tableau de bord", - "open-right-layout": "Ouvrir la disposition du tableau de bord droite (vue mobile)", - "set-entity-from-widget": "Définir l'entité à partir du widget", - "target-dashboard": "Tableau de bord cible", - "target-dashboard-state": "Etat du tableau de bord cible", - "target-dashboard-state-required": "L'état du tableau de bord cible est requis", - "update-dashboard-state": "Mettre à jour l'état actuel du tableau de bord" - }, -"widget-config":{ - "action": "Action", - "action-icon": "Icône", - "action-name": "Nom", - "action-name-not-unique": "Une autre action portant le même nom existe déjà.
Le nom de l'action doit être unique dans la même source d'action.", - "action-name-required": "Le nom de l'action est requis", - "action-source": "Source de l'action", - "action-source-required": "Une source d'action est requise.", - "action-type": "Type", - "action-type-required": "Le type d'action est requis.", - "actions": "Actions", - "add-action": "Ajouter une action", - "add-datasource": "Ajouter une source de données", - "advanced": "Avancé", - "alarm-source": "Source d'alarme", - "background-color": "couleur de fond", - "data": "Données", - "datasource-parameters": "Paramètres", - "datasource-type": "Type", - "datasources": "Sources de données", - "decimals": "Nombre de chiffres après virgule flottante", - "delete-action": "Supprimer l'action", - "delete-action-text": "Etes-vous sûr de vouloir supprimer l'action du widget nommé '{{actionName}}'?", - "delete-action-title": "Supprimer l'action du widget", - "display-legend": "Afficher la légende", - "display-title": "Afficher le titre", - "drop-shadow": "Ombre portée", - "edit-action": "Modifier l'action", - "enable-fullscreen": "Activer le plein écran", - "general-settings": "Paramètres généraux", - "height": "Hauteur", - "margin": "Marge", - "maximum-datasources": "Maximum {count, plural, 1 {1 datasource est autorisé.} other {# datasources sont autorisés}}", - "mobile-mode-settings": "Paramètres du mode mobile", - "order": "Ordre", - "padding": "Padding", - "remove-datasource": "Supprimer la source de données", - "search-actions": "Recherche d'actions", - "settings": "Paramètres", - "target-device": "Dispositif cible", - "text-color": "Couleur du texte", - "timewindow": "Fenêtre de temps", - "title": "Titre", - "title-style": "Style de titre", - "units": "Symbole spécial à afficher à côté de la valeur", - "use-dashboard-timewindow": "Utiliser la fenêtre de temps du tableau de bord", - "widget-style": "Style du widget" - }, -"widget-type":{ - "create-new-widget-type": "Créer un nouveau type de widget", - "export": "Exporter le type de widget", - "export-failed-error": "Impossible d'exporter le type de widget: {{error}}", - "import": "Importer le type de widget", - "invalid-widget-type-file-error": "Impossible d'importer le type de widget: structure de données de type widget invalide.", - "widget-type-file": "Fichier de type Widget" - }, -"widgets-bundle":{ - "add": "Ajouter un groupe de widgets", - "add-widgets-bundle-text": "Ajouter un nouveau groupe de widgets", - "create-new-widgets-bundle": "Créer un nouveau groupe de widgets", - "current": "Groupe actuel", - "delete": "Supprimer le groupe de widgets", - "delete-widgets-bundle-text": "Attention, après la confirmation, le groupe de widgets et toutes les données associées deviendront irrécupérables.", - "delete-widgets-bundle-title": "Êtes-vous sûr de vouloir supprimer le groupe de widgets '{{widgetsBundleTitle}}'?", - "delete-widgets-bundles-action-title": "Supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}", - "delete-widgets-bundles-text": "Attention, après la confirmation, tous les groupes de widgets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-widgets-bundles-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}?", - "details": "Détails", - "empty": "Le groupe de widgets est vide", - "export": "Exporter le groupe de widgets", - "export-failed-error": "Impossible d'exporter le groupe de widgets: {{error}}", - "import": "Importer un groupe de widgets", - "invalid-widgets-bundle-file-error": "Impossible d'importer un groupe de widgets: structure de données du groupe de widgets non valides.", - "no-widgets-bundles-matching": "Aucun groupe de widgets correspondant à {{widgetsBundle}} n'a été trouvé.", - "no-widgets-bundles-text": "Aucun groupe de widgets trouvé", - "system": "Système", - "title": "Titre", - "title-required": "Le titre est requis.", - "widgets-bundle-details": "Détails des groupes de widgets", - "widgets-bundle-file": "Fichier de groupe de widgets", - "widgets-bundle-required": "Un groupe de widgets est requis.", - "widgets-bundles": "Groupes de widgets" - } + "access": { + "access-forbidden": "Accès interdit", + "access-forbidden-text": "Vous n'avez pas accès à cet emplacement!
Essayez de vous connecter avec un autre utilisateur si vous souhaitez toujours accéder à cet emplacement.", + "refresh-token-expired": "La session a expiré", + "refresh-token-failed": "Impossible de rafraîchir la session", + "unauthorized": "non autorisé", + "unauthorized-access": "accès non autorisé", + "unauthorized-access-text": "Vous devez vous connecter pour avoir accès à cette ressource!" + }, + "action": { + "activate": "Activer", + "add": "Ajouter", + "apply": "Appliquer", + "apply-changes": "Appliquer les modifications", + "assign": "Attribuer", + "back": "retour", + "cancel": "Annuler", + "clear-search": "Effacer la recherche", + "close": "Fermer", + "copy": "Copier", + "copy-reference": "Copier la référence", + "create": "Créer", + "decline-changes": "Refuser les modifications", + "delete": "Supprimer", + "drag": "Drag", + "edit": "Modifier", + "edit-mode": "Mode édition", + "enter-edit-mode": "Entrer en mode édition", + "export": "Exporter", + "import": "Importer", + "make-private": "Rendre privé", + "no": "Non", + "ok": "OK", + "paste": "coller", + "paste-reference": "Coller référence", + "refresh": "Rafraîchir", + "remove": "Supprimer", + "run": "Exécuter", + "save": "Enregistrer", + "saveAs": "Enregistrer sous", + "search": "Rechercher", + "share": "Partager", + "share-via": "Partager via {{provider}}", + "sign-in": "Connectez-vous!", + "suspend": "Suspendre", + "unassign": "Retirer", + "undo": "Annuler", + "update": "mise à jour", + "view": "Afficher", + "yes": "Oui" + }, + "admin": { + "base-url": "URL de base", + "base-url-required": "L'URL de base est requise.", + "enable-tls": "Activer TLS", + "general": "Général", + "general-settings": "Paramètres généraux", + "mail-from": "Mail de", + "mail-from-required": "Mail de est requis.", + "outgoing-mail": "courrier sortant", + "outgoing-mail-settings": "Paramètres de courrier sortant", + "send-test-mail": "Envoyer un mail de test", + "smtp-host": "Hôte SMTP", + "smtp-host-required": "L'hôte SMTP est requis.", + "smtp-port": "Port SMTP", + "smtp-port-invalid": "Cela ne ressemble pas à un port smtp valide.", + "smtp-port-required": "Vous devez fournir un port smtp.", + "smtp-protocol": "Protocole SMTP", + "system-settings": "Paramètres système", + "test-mail-sent": "Le courrier de test a été envoyé avec succès!", + "timeout-invalid": "Cela ne ressemble pas à un délai d'expiration valide.", + "timeout-msec": "Délai (msec)", + "timeout-required": "Le délai est requis." + }, + "aggregation": { + "aggregation": "agrégation", + "avg": "Moyenne", + "count": "Compte", + "function": "Fonction d'agrégation de données", + "group-interval": "Intervalle de regroupement", + "limit": "Valeurs maximales", + "max": "Max", + "min": "Min", + "none": "Aucune", + "sum": "Somme" + }, + "alarm": { + "ack-time": "Heure d'acquittement", + "acknowledge": "Acquitter", + "aknowledge-alarms-text": "Etes-vous sûr de vouloir acquitter {count, plural, 1 {1 alarme} other {# alarmes}}?", + "aknowledge-alarms-title": "Acquitter {count, plural, 1 {1 alarme} other {# alarmes}}", + "alarm": "Alarme", + "alarm-details": "Détails de l'alarme", + "alarm-required": "Une alarme est requise", + "alarm-status": "Etat d'alarme", + "alarms": "Alarmes", + "clear": "Effacer", + "clear-alarms-text": "Êtes-vous sûr de vouloir effacer {count, plural, 1 {1 alarme} other {# alarmes}}?", + "clear-alarms-title": "Effacer {count, plural, 1 {1 alarme} other {# alarmes}}", + "clear-time": "Heure d'éffacement", + "created-time": "Heure de création", + "details": "Détails", + "display-status": { + "ACTIVE_ACK": "Active acquittée", + "ACTIVE_UNACK": "Active non acquittée", + "CLEARED_ACK": "effacée acquittée", + "CLEARED_UNACK": "effacée non acquittée" + }, + "end-time": "Heure de fin", + "min-polling-interval-message": "Un intervalle d'interrogation d'au moins 1 seconde est autorisé.", + "no-alarms-matching": "Aucune alarme correspondant à {{entity}} n'a été trouvée. ", + "no-alarms-prompt": "Aucune alarme trouvée", + "no-data": "Aucune donnée à afficher", + "originator": "Source", + "originator-type": "Type de Source", + "polling-interval": "Intervalle d'interrogation des alarmes (sec)", + "polling-interval-required": "L'intervalle d'interrogation des alarmes est requis.", + "search": "Rechercher des alarmes", + "search-status": { + "ACK": "acquitté", + "ACTIVE": "active", + "ANY": "Toutes", + "CLEARED": "effacée", + "UNACK": "non acquittée" + }, + "select-alarm": "Sélectionnez une alarme", + "selected-alarms": "{count, plural, 1 {1 alarme} other {# alarmes}} sélectionnées", + "severity": "Gravitée", + "severity-critical": "Critique", + "severity-indeterminate": "indéterminée", + "severity-major": "Majeure", + "severity-minor": "mineure", + "severity-warning": "Avertissement", + "start-time": "Heure de début", + "status": "Etat", + "type": "Type" + }, + "alias": { + "add": "Ajouter un alias", + "all-entities": "Toutes les entités", + "any-relation": "toutes", + "default-entity-parameter-name": "Par défaut", + "default-state-entity": "Entité d'état par défaut", + "duplicate-alias": "Un alias portant le même nom existe déjà.", + "edit": "Modifier l'alias", + "entity-filter": "Filtre d'entité", + "entity-filter-no-entity-matched": "Aucune entité correspondant au filtre spécifié n'a été trouvée.", + "filter-type": "Type de filtre", + "filter-type-asset-search-query": "requête de recherche d'Assets", + "filter-type-asset-search-query-description": "Assets de types {{assetTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-asset-type": "type d'Asset", + "filter-type-asset-type-and-name-description": "Assets de type '{{assetType}}' et dont le nom commence par '{{prefix}}'", + "filter-type-asset-type-description": "Assets de type '{{assetType}}'", + "filter-type-device-search-query": "Requête de recherche de dispositif", + "filter-type-device-search-query-description": "Dispositifs de types {{deviceTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-device-type": "Type de dispositif", + "filter-type-device-type-and-name-description": "Dispositifs de type '{{deviceType}}' et dont le nom commence par '{{prefix}}'", + "filter-type-device-type-description": "Dispositifs de type '{{deviceType}}'", + "filter-type-entity-list": "Liste d'entités", + "filter-type-entity-name": "Nom d'entité", + "filter-type-relations-query": "Interrogation des relations", + "filter-type-relations-query-description": "{{entities}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-required": "Le type de filtre est requis.", + "filter-type-single-entity": "Entité unique", + "filter-type-state-entity": "Entité de l'état du tableau de bord", + "filter-type-state-entity-description": "Entité extraite des paramètres d'état du tableau de bord", + "max-relation-level": "Niveau de relation maximum", + "name": "Nom de l'alias", + "name-required": "Le nom d'alias est requis", + "no-entity-filter-specified": "Aucun filtre d'entité spécifié", + "resolve-multiple": "Résoudre en plusieurs entités", + "root-entity": "Entité racine", + "root-state-entity": "Utiliser l'entité d'état du tableau de bord en tant que racine", + "state-entity": "Entité d'état du tableau de bord", + "state-entity-parameter-name": "Nom du paramètre d'entité d'état", + "unlimited-level": "niveau illimité" + }, + "asset": { + "add": "Ajouter un Asset", + "add-asset-text": "Ajouter un nouvel Asset", + "any-asset": "Tout Asset", + "asset": "Asset", + "asset-details": "Détails de l'Asset", + "asset-public": "L'Asset est public", + "asset-required": "Asset requis", + "asset-type": "Type d'Asset", + "asset-type-list-empty": "Aucun type d'Asset sélectionné.", + "asset-type-required": "Le type d'Asset est requis.", + "asset-types": "Types d'Asset", + "assets": "Assets", + "assign-asset-to-customer": "Attribuer des Assets au client", + "assign-asset-to-customer-text": "Veuillez sélectionner les Assets à attribuer au client", + "assign-assets": "Attribuer des Assets", + "assign-assets-text": "Attribuer {count, plural, 1 {1 asset} other {# assets}} au client", + "assign-new-asset": "Attribuer un nouvel Asset", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les Assets", + "assignedToCustomer": "attribué au client", + "copyId": "Copier l'Id de l'Asset", + "delete": "Supprimer un Asset", + "delete-asset-text": "Faites attention, après la confirmation, l'Asset et toutes les données associées deviendront irrécupérables.", + "delete-asset-title": "Êtes-vous sûr de vouloir supprimer l'Asset '{{assetName}}'?", + "delete-assets": "Supprimer des Assets", + "delete-assets-action-title": "Supprimer {count, plural, 1 {1 asset} other {# assets}}", + "delete-assets-text": "Attention, après la confirmation, tous les Assets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-assets-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 asset} other {# assets}}?", + "description": "Description", + "details": "Détails", + "enter-asset-type": "Entrez le type d'Asset", + "events": "Evènements", + "idCopiedMessage": "L'Id d'asset a été copié dans le presse-papier", + "make-private": "Rendre l'Asset privé", + "make-private-asset-text": "Après la confirmation, l'Asset et toutes ses données seront rendus privés et ne seront pas accessibles par d'autres.", + "make-private-asset-title": "Etes-vous sûr de vouloir rendre l'Asset '{{assetName}}' privé '?", + "make-public": "Rendre l'Asset public", + "make-public-asset-text": "Après la confirmation, l'asset et toutes ses données seront rendus publics et accessibles aux autres.", + "make-public-asset-title": "Êtes-vous sûr de vouloir rendre l'Asset '{{assetName}}' public '?", + "management": "Gestion d'Assets", + "name": "Nom", + "name-required": "Nom est requis.", + "name-starts-with": "Le nom de l'Asset commence par", + "no-asset-types-matching": "Aucun type d'Asset correspondant à {{entitySubtype}} n'a été trouvé. ", + "no-assets-matching": "Aucun Asset correspondant à {{entity}} n'a été trouvé. ", + "no-assets-text": "Aucun Asset trouvé", + "public": "Public", + "select-asset": "Sélectionner un Asset", + "select-asset-type": "Sélectionner le type d'Asset", + "type": "Type", + "type-required": "Le type est requis.", + "unassign-asset": "Retirer l'Asset", + "unassign-asset-text": "Après la confirmation, l'Asset sera non attribué et ne sera pas accessible au client.", + "unassign-asset-title": "Êtes-vous sûr de vouloir retirer l'attribution de l'Asset '{{assetName}}'?", + "unassign-assets": "Retirer les Assets", + "unassign-assets-action-title": "Retirer {count, plural, 1 {1 asset} other {# assets}} du client", + "unassign-assets-text": "Après la confirmation, tous les Assets sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", + "unassign-assets-title": "Êtes-vous sûr de vouloir retirer l'attribution de {count, plural, 1 {1 asset} other {# assets}}?", + "unassign-from-customer": "Retirer du client", + "view-assets": "Afficher les Assets" + }, + "attribute": { + "add": "Ajouter un attribut", + "add-to-dashboard": "Ajouter au tableau de bord", + "add-widget-to-dashboard": "Ajouter un widget au tableau de bord", + "attributes": "Attributs", + "attributes-scope": "Etendue des attributs d'entité", + "delete-attributes": "Supprimer les attributs", + "delete-attributes-text": "Attention, après la confirmation, tous les attributs sélectionnés seront supprimés.", + "delete-attributes-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 attribut} other {# attributs}}?", + "enter-attribute-value": "Entrez la valeur de l'attribut", + "key": "Clé", + "key-required": "La Clé d'attribut est requise.", + "last-update-time": "Dernière mise à jour", + "latest-telemetry": "Dernière télémétrie", + "next-widget": "Widget suivant", + "prev-widget": "Widget précédent", + "scope-client": "Attributs du client", + "scope-latest-telemetry": "Dernière télémétrie", + "scope-server": "Attributs du serveur", + "scope-shared": "Attributs partagés", + "selected-attributes": "{count, plural, 1 {1 attribut} other {# attributs}} sélectionnés", + "selected-telemetry": "{count, plural, 1 {1 unité de télémétrie} other {# unités de télémétrie}} sélectionnées", + "show-on-widget": "Afficher sur le widget", + "value": "Valeur", + "value-required": "La valeur d'attribut est obligatoire.", + "widget-mode": "Mode du widget" + }, + "audit-log": { + "action-data": "Action data", + "audit": "Audit", + "audit-log-details": "Détails du journal d'audit", + "audit-logs": "Journaux d'audit", + "clear-search": "Effacer la recherche", + "details": "Détails", + "entity-name": "Nom de l'entité", + "entity-type": "Type d'entité", + "failure-details": "Détails de l'échec", + "no-audit-logs-prompt": "Aucun journal trouvé", + "search": "Rechercher les journaux d'audit", + "status": "Etat", + "status-failure": "Échec", + "status-success": "Succès", + "timestamp": "Horodatage", + "type": "Type", + "type-activated": "Activé", + "type-added": "Ajouté", + "type-alarm-ack": "Acquitté", + "type-alarm-clear": "Effacé", + "type-assigned-to-customer": "Attribué au client", + "type-attributes-deleted": "Attributs supprimés", + "type-attributes-read": "Attributs lus", + "type-attributes-updated": "Attributs mis à jour", + "type-credentials-read": "Lecture des informations d'identification", + "type-credentials-updated": "Informations d'identification actualisées", + "type-deleted": "Supprimé", + "type-relation-add-or-update": "Relation mise à jour", + "type-relation-delete": "Relation supprimée", + "type-relations-delete": "Toutes les relations ont été supprimées", + "type-rpc-call": "Appel RPC", + "type-suspended": "Suspendu", + "type-unassigned-from-customer": "Non attribué du client", + "type-updated": "Mise à jour", + "user": "Utilisateur" + }, + "common": { + "enter-password": "Entrez le mot de passe", + "enter-search": "Entrez la recherche", + "enter-username": "Entrez le nom d'utilisateur", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "confirm-on-exit": { + "html-message": "Vous avez des modifications non enregistrées.
Êtes-vous sûr de vouloir quitter cette page?", + "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?", + "title": "Modifications non enregistrées" + }, + "contact": { + "address": "Adresse", + "address2": "adresse 2", + "city": "Ville", + "country": "Pays", + "email": "Email", + "no-address": "Pas d'adresse", + "phone": "Téléphone", + "postal-code": "Code postal", + "postal-code-invalid": "Format de code postal / code postal invalide", + "state": "Etat / Province" + }, + "content-type": { + "binary": "Binaire (Base64)", + "json": "Json", + "text": "Texte" + }, + "custom": { + "widget-action": { + "action-cell-button": "Action cell button", + "marker-click": "On marker click", + "row-click": "On row click", + "tooltip-tag-action": "Tooltip tag action" + } + }, + "customer": { + "add": "Ajouter un client", + "add-customer-text": "Ajouter un nouveau client", + "assets": "Assets du client", + "copyId": "Copier l'id du client", + "customer": "Client", + "customer-details": "Détails du client", + "customer-required": "Le client est requis", + "customers": "Clients", + "dashboard": "Tableau de bord du client", + "dashboards": "tableaux de bord du client", + "default-customer": "Client par défaut", + "default-customer-required": "Le client par défaut est requis pour déboguer le tableau de bord au niveau du Tenant", + "delete": "Supprimer le client", + "delete-customer-text": "Faites attention, après la confirmation, le client et toutes les données associées deviendront irrécupérables.", + "delete-customer-title": "Êtes-vous sûr de vouloir supprimer le client '{{customerTitle}}'?", + "delete-customers-action-title": "Supprimer {count, plural, 1 {1 client} other {# clients}}", + "delete-customers-text": "Faites attention, après la confirmation, tous les clients sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-customers-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 client} other {# clients}}?", + "description": "Description", + "details": "Détails", + "devices": "Dispositifs du client", + "events": "Événements", + "idCopiedMessage": "L'Id du client a été copié dans le presse-papier", + "manage-assets": "Gérer les Assets", + "manage-customer-assets": "Gérer les Assets du client", + "manage-customer-dashboards": "Gérer les tableaux de bord du client", + "manage-customer-devices": "Gérer les dispositifs du client", + "manage-customer-users": "Gérer les utilisateurs du client", + "manage-dashboards": "Gérer les tableaux de bord", + "manage-devices": "Gérer les dispositifs", + "manage-public-assets": "Gérer les Assets publics", + "manage-public-dashboards": "Gérer les tableaux de bord publics", + "manage-public-devices": "Gérer les dispositifs publics", + "manage-users": "Gérer les utilisateurs", + "management": "Gestion des clients", + "no-customers-matching": "Aucun client correspondant à '{{entity}} n'a été trouvé.", + "no-customers-text": "Aucun client trouvé", + "public-assets": "Assets publics", + "public-dashboards": "Tableaux de bord publics", + "public-devices": "Dispositifs publics", + "select-customer": "Sélectionner un client", + "select-default-customer": "Sélectionnez le client par défaut", + "title": "Titre", + "title-required": "Le titre est requis." + }, + "dashboard": { + "add": "Ajouter un tableau de bord", + "add-dashboard-text": "Ajouter un nouveau tableau de bord", + "add-state": "Ajouter un état du tableau de bord", + "add-widget": "Ajouter un nouveau widget", + "alias-resolution-error-title": "Erreur de configuration des alias de tableau de bord", + "assign-dashboard-to-customer": "Attribuer des tableaux de bord au client", + "assign-dashboard-to-customer-text": "Veuillez sélectionner les tableaux de bord à affecter au client", + "assign-dashboards": "Attribuer des tableaux de bord", + "assign-dashboards-text": "Attribuer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} aux clients", + "assign-new-dashboard": "Attribuer un nouveau tableau de bord", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les tableaux de bord", + "assign-to-customers": "Attribuer des tableaux de bord aux clients", + "assign-to-customers-text": "Veuillez sélectionner les clients pour attribuer les tableaux de bord", + "assigned-customers": "clients affectés", + "assignedToCustomer": "Attribué au client", + "assignedToCustomers": "attribué aux clients", + "autofill-height": "Hauteur de remplissage automatique", + "background-color": "Couleur de fond", + "background-image": "Image d'arrière-plan", + "background-size-mode": "Mode de taille d'arrière-plan", + "close-toolbar": "Fermer la barre d'outils", + "columns-count": "Nombre de colonnes", + "columns-count-required": "Le nombre de colonnes est requis.", + "configuration-error": "Erreur de configuration", + "copy-public-link": "Copier le lien public", + "create-new": "Créer un nouveau tableau de bord", + "create-new-dashboard": "Créer un nouveau tableau de bord", + "create-new-widget": "Créer un nouveau widget", + "dashboard": "Tableau de bord", + "dashboard-details": "Détails du tableau de bord", + "dashboard-file": "Fichier du tableau de bord", + "dashboard-import-missing-aliases-title": "Configurer les alias utilisés par le tableau de bord importé", + "dashboard-required": "Le tableau de bord est requis.", + "dashboards": "Tableaux de bord", + "delete": "Supprimer le tableau de bord", + "delete-dashboard-text": "Faites attention, après la confirmation, le tableau de bord et toutes les données associées deviendront irrécupérables.", + "delete-dashboard-title": "Êtes-vous sûr de vouloir supprimer le tableau de bord '{{dashboardTitle}}'?", + "delete-dashboards": "Supprimer les tableaux de bord", + "delete-dashboards-action-title": "Supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}", + "delete-dashboards-text": "Attention, après la confirmation, tous les tableaux de bord sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-dashboards-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", + "delete-state": "Supprimer l'état du tableau de bord", + "delete-state-text": "Etes-vous sûr de vouloir supprimer l'état du tableau de bord avec le nom '{{stateName}}'?", + "delete-state-title": "Supprimer l'état du tableau de bord", + "description": "Description", + "details": "Détails", + "display-dashboard-export": "Afficher l'exportation", + "display-dashboard-timewindow": "Afficher fenêtre de temps", + "display-dashboards-selection": "Afficher la sélection des tableaux de bord", + "display-entities-selection": "Afficher la sélection des entités", + "display-title": "Afficher le titre du tableau de bord", + "drop-image": "Déposer une image ou cliquez pour sélectionner un fichier à télécharger.", + "edit-state": "Modifier l'état du tableau de bord", + "export": "Exporter le tableau de bord", + "export-failed-error": "Impossible d'exporter le tableau de bord: {{error}}", + "hide-details": "Masquer les détails", + "horizontal-margin": "Marge horizontale", + "horizontal-margin-required": "Une valeur de marge horizontale est requise.", + "import": "Importer le tableau de bord", + "import-widget": "Importer un widget", + "invalid-aliases-config": "Impossible de trouver des dispositifs correspondant à certains filtres d'alias.
Veuillez contacter votre administrateur pour résoudre ce problème.", + "invalid-dashboard-file-error": "Impossible d'importer le tableau de bord: structure de données du tableau de bord non valide", + "invalid-widget-file-error": "Impossible d'importer le widget: structure de données de widget invalide.", + "is-root-state": "Etat racine", + "make-private": "Rendre privé le tableau de bord", + "make-private-dashboard": "Rendre privé le tableau de bord", + "make-private-dashboard-text": "Après la confirmation, le tableau de bord sera rendu privé et ne sera plus accessible aux autres.", + "make-private-dashboard-title": "Etes-vous sûr de vouloir rendre le tableau de bord '{{dashboardTitle}}' privé?", + "make-public": "Rendre public le tableau de bord", + "manage-assigned-customers": "Gérer les clients affectés", + "manage-states": "Gérer les états du tableau de bord", + "management": "Gestion du tableau de bord", + "max-columns-count-message": "Seulement 1000 colonnes maximum sont autorisées.", + "max-horizontal-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge horizontale maximale.", + "max-mobile-row-height-message": "Seuls 200 pixels sont autorisés en tant que valeur maximale de hauteur de ligne mobile.", + "max-vertical-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge verticale maximale.", + "min-columns-count-message": "Seul un nombre minimum de 10 colonnes est autorisé.", + "min-horizontal-margin-message": "Seul 0 est autorisé comme valeur de marge horizontale minimale.", + "min-mobile-row-height-message": "Seuls 5 pixels sont autorisés en tant que valeur minimale de hauteur de ligne mobile.", + "min-vertical-margin-message": "Seul 0 est autorisé comme valeur de marge verticale minimale.", + "mobile-layout": "Paramètres de mise en page mobiles", + "mobile-row-height": "Hauteur de ligne mobile, px", + "mobile-row-height-required": "Une valeur de hauteur de ligne mobile est requise.", + "new-dashboard-title": "Nouveau titre du tableau de bord", + "no-dashboards-matching": "Aucun tableau de bord correspondant à {{entity}} n'a été trouvé. ", + "no-dashboards-text": "Aucun tableau de bord trouvé", + "no-image": "Aucune image sélectionnée", + "no-widgets": "Aucun widget configuré", + "open-dashboard": "Ouvrir le tableau de bord", + "open-toolbar": "Ouvrir la barre d'outils du tableau de bord", + "public": "Public", + "public-dashboard-notice": " Remarque: N'oubliez pas de rendre publics les dispositifs associés pour accéder à leurs données.", + "public-dashboard-text": "Votre tableau de bord {{dashboardTitle}} est maintenant public et accessible via le lien public : ", + "public-dashboard-title": "Le tableau de bord est maintenant public", + "public-link": "Lien public", + "public-link-copied-message": "Le lien public du tableau de bord a été copié dans le presse-papier", + "search-states": "Recherche des états du tableau de bord", + "select-dashboard": "Sélectionner le tableau de bord", + "select-devices": "Selectionner les dispositifs", + "select-existing": "Sélectionnez un tableau de bord existant", + "select-state": "Sélectionnez l'état cible", + "select-widget-subtitle": "Liste des types de widgets disponibles", + "select-widget-title": "Sélectionner un widget", + "selected-states": "{count, plural, 1 {1 état du tableau de bord} other {# états du tableau de bord}} sélectionnés", + "set-background": "Définir l'arrière-plan", + "settings": "Paramètres", + "show-details": "Afficher les détails", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "state": "Etat du tableau de bord", + "state-controller": "Contrôleur d'état", + "state-id": "ID d'état", + "state-id-exists": "L'état du tableau de bord avec le même Id existe déjà.", + "state-id-required": "L'Id d'état du tableau de bord est requis.", + "state-name": "Nom", + "state-name-required": "Le nom de l'état du tableau de bord est requis", + "states": "Etats du tableau de bord", + "title": "Titre", + "title-color": "Couleur du titre", + "title-required": "Le titre est requis.", + "toolbar-always-open": "Garder la barre d'outils ouverte", + "unassign-dashboard": "Retirer le tableau de bord", + "unassign-dashboard-text": "Après la confirmation, le tableau de bord ne sera pas attribué et ne sera pas accessible au client.", + "unassign-dashboard-title": "Êtes-vous sûr de vouloir annuler l'affectation du tableau de bord '{{dashboardTitle}}'?", + "unassign-dashboards": "Retirer les tableaux de bord", + "unassign-dashboards-action-text": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} des clients", + "unassign-dashboards-action-title": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} du client", + "unassign-dashboards-text": "Après la confirmation, tous les tableaux de bord sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", + "unassign-dashboards-title": "Etes-vous sûr de vouloir annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", + "unassign-from-customer": "Retirer du client", + "unassign-from-customers": "Retirer les tableaux de bord des clients", + "unassign-from-customers-text": "Veuillez sélectionner les clients à annuler l'affectation du ou des tableaux de bord", + "vertical-margin": "Marge verticale", + "vertical-margin-required": "Une valeur de marge verticale est requise", + "view-dashboards": "Afficher les tableaux de bord", + "widget-file": "Fichier du Widget", + "widget-import-missing-aliases-title": "Configurer les alias utilisés par le widget importé", + "widgets-margins": "Marge entre les widgets" + }, + "datakey": { + "advanced": "Avancé", + "alarm": "Champs d'alarme", + "alarm-fields-required": "Les champs d'alarme sont obligatoires.", + "attributes": "Attributs", + "color": "Couleur", + "configuration": "Configuration de la clé de données", + "data-generation-func": "Fonction de génération de données", + "decimals": "Nombre de chiffres après virgule flottante", + "function-types": "Types de fonctions", + "function-types-required": "Les types de fonctions sont obligatoires", + "label": "Label", + "maximum-function-types": "Maximum {count, plural, 1 {1 type de fonction est autorisé.} other {# types de fonctions sont autorisés}}", + "maximum-timeseries-or-attributes": "Maximum {count, plural, 1 {1 timeseries / attribut est autorisé.} other {# timeseries / attributs sont autorisés}}", + "settings": "Paramètres", + "timeseries": "Timeseries", + "timeseries-or-attributes-required": "Les timeseries / attributs d'entité sont obligatoires.", + "timeseries-required": "Les Timeseries de l'entité sont obligatoires.", + "units": "Symbole spécial à afficher à côté de la valeur", + "use-data-post-processing-func": "Utiliser la fonction de post-traitement des données" + }, + "datasource": { + "add-datasource-prompt": "Veuillez ajouter une source de données", + "name": "Nom", + "type": "Type de source de données" + }, + "datetime": { + "date-from": "Date de", + "date-to": "Date à", + "time-from": "Heure de", + "time-to": "Heure à" + }, + "details": { + "edit-mode": "Mode édition", + "toggle-edit-mode": "Activer le mode édition" + }, + "device": { + "access-token": "Jeton d'accès", + "access-token-invalid": "La longueur du jeton d'accès doit être comprise entre 1 et 20 caractères.", + "access-token-required": "Le jeton d'accès est requis.", + "accessTokenCopiedMessage": "Le jeton d'accès au dispositif a été copié dans le presse-papier", + "add": "Ajouter un dispositif", + "add-alias": "Ajouter un alias de dispositif", + "add-device-text": "Ajouter un nouveau dispositif", + "alias": "Alias", + "alias-required": "Un alias du dispositif est requis.", + "aliases": "Alias ​​du dispositif", + "any-device": "N'importe quel dispositif", + "assign-device-to-customer": "Affecter des dispositifs au client", + "assign-device-to-customer-text": "Veuillez sélectionner les dispositif à affecter au client", + "assign-devices": "Attribuer des dispositifs", + "assign-devices-text": "Attribuer {count, plural, 1 {1 dispositif} other {# dispositifs}} au client", + "assign-new-device": "Attribuer un nouveau dispositif", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les dispositifs", + "assignedToCustomer": "Attribué au client", + "configure-alias": "Configurer '{{alias}}' alias", + "copyAccessToken": "Copier le jeton d'accès", + "copyId": "Copier l'Id du dispositif", + "create-new-alias": "Créez un nouveau!", + "create-new-key": "Créez un nouveau!", + "credentials": "Informations d'identification", + "credentials-type": "Type d'identification", + "delete": "Supprimer le dispositif", + "delete-device-text": "Faites attention, après la confirmation, le dispositif et toutes les données associées deviendront irrécupérables.", + "delete-device-title": "Êtes-vous sûr de vouloir supprimer le dispositif '{{deviceName}}'?", + "delete-devices": "Supprimer les dispositifs", + "delete-devices-action-title": "Supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}", + "delete-devices-text": "Faites attention, après la confirmation, tous les dispositifs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-devices-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}?", + "description": "Description", + "details": "Détails", + "device": "Dispositif", + "device-alias": "Alias ​​du dispositif", + "device-credentials": "Informations d'identification du dispositif", + "device-details": "Détails du dispositif", + "device-list": "Liste des dispositifs", + "device-list-empty": "Aucun dispositif sélectionné.", + "device-name-filter-no-device-matched": "Aucun dispositif commençant par '{{device}} n'a été trouvé.", + "device-name-filter-required": "Le filtre de nom de dispositif est requis.", + "device-public": "Le dispositif est public", + "device-required": "Le dispositif est requis.", + "device-type": "Type de dispositif", + "device-type-list-empty": "Aucun type de dispositif sélectionné.", + "device-type-required": "Le type de dispositif est requis.", + "device-types": "Types de dispositif", + "devices": "Dispositifs", + "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias de dispositifs doivent être uniques dans le tableau de bord.", + "enter-device-type": "Entrez le type de dispositif", + "events": "Événements", + "idCopiedMessage": "l'Id du dispositif a été copié dans le presse-papiers", + "is-gateway": "Est une passerelle", + "make-private": "Rendre le dispositif privé", + "make-private-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendues privées et ne seront pas accessibles par d'autres.", + "make-private-device-title": "Etes-vous sûr de vouloir rendre le dispositif {{deviceName}} privé?", + "make-public": "Rendre le dispositif public", + "make-public-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendus publics et accessibles par d'autres.", + "make-public-device-title": "Êtes-vous sûr de vouloir rendre le dispositif {{deviceName}} 'public?", + "manage-credentials": "Gérer les informations d'identification", + "management": "Gestion des dispositifs", + "name": "Nom", + "name-required": "Le nom est requis.", + "name-starts-with": "Le nom du dispositif commence par", + "no-alias-matching": "'{{alias}}' introuvable.", + "no-aliases-found": "Aucun alias trouvé.", + "no-device-types-matching": "Aucun type de dispositif correspondant à {{entitySubtype}} n'a été trouvé.", + "no-devices-matching": "Aucun dispositif correspondant à '{{entity}} n'a été trouvé.", + "no-devices-text": "Aucun dispositif trouvé", + "no-key-matching": "'{{key}}' introuvable.", + "no-keys-found": "Aucune clé trouvée", + "public": "Public", + "remove-alias": "Supprimer l'alias du dispositif", + "rsa-key": "Clé publique RSA", + "rsa-key-required": "La clé publique RSA est requise.", + "secret": "Secret", + "secret-required": "Code secret est requis.", + "select-device": "Selectionner un dispositif", + "select-device-type": "Sélectionner le type d'appareil", + "unable-delete-device-alias-text": "L'alias du dispositif '{{deviceAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", + "unable-delete-device-alias-title": "Impossible de supprimer l'alias du dispositif", + "unassign-device": "Annuler l'affectation du dispositif", + "unassign-device-text": "Après la confirmation, le dispositif ne sera pas attribué et ne sera pas accessible au client.", + "unassign-device-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{deviceName}} '?", + "unassign-devices": "Annuler l'affectation des dispositifs", + "unassign-devices-action-title": "Annuler l'affectation de {count, plural, 1 {1 dispositif} other {#dispositifs}} du client", + "unassign-devices-text": "Après la confirmation, tous les dispositifs sélectionnés ne seront pas attribues et ne seront pas accessibles par le client.", + "unassign-devices-title": "Voulez-vous vraiment annuler l'affectation de {count, plural, 1 {1 dispositif} other {# dispositifs}}?", + "unassign-from-customer": "Retirer du client", + "use-device-name-filter": "Utiliser le filtre", + "view-credentials": "Afficher les informations d'identification", + "view-devices": "Afficher les dispositifs" + }, + "dialog": { + "close": "Fermer le dialogue" + }, + "entity": { + "add-alias": "Ajouter un alias d'entité", + "alarm-name-starts-with": "Les alarmes dont le nom commence par '{{prefix}}'", + "alias": "Alias", + "alias-required": "Un alias d'entité est requis.", + "aliases": "alias d'entité", + "all-subtypes": "Tout", + "any-entity": "Toute entité", + "asset-name-starts-with": "Les Assets dont le nom commence par '{{prefix}}'", + "configure-alias": "Configurer '{{alias}}' alias", + "create-new-alias": "Créez un nouveau!", + "create-new-key": "Créez un nouveau!", + "customer-name-starts-with": "Les clients dont les noms commencent par '{{prefix}}'", + "dashboard-name-starts-with": "Les tableaux de bord dont les noms commencent par '{{prefix}}'", + "details": "Détails de l'entité", + "device-name-starts-with": "Dispositifs dont le nom commence par '{{prefix}}'", + "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias d'entité doivent être uniques dans le tableau de bord.", + "enter-entity-type": "Entrez le type d'entité", + "entities": "Entités", + "entity": "Entité", + "entity-alias": "Alias de l'entité", + "entity-list": "Liste d'entités", + "entity-list-empty": "Aucune entité sélectionnée.", + "entity-name": "Nom de l'entité", + "entity-name-filter-no-entity-matched": "Aucune entité commençant par '{{entity}}' n'a été trouvée.", + "entity-name-filter-required": "Le filtre de nom d'entité est requis.", + "entity-type": "Type d'entité", + "entity-type-list": "Liste de types d'entités", + "entity-type-list-empty": "Aucun type d'entité sélectionné.", + "entity-types": "Types d'entité", + "key": "Clé", + "key-name": "Nom de la clé", + "list-of-alarms": "{count, plural, 1 {Une alarme} other {Liste de # alarmes}}", + "list-of-assets": "{count, plural, 1 {Un Asset} other {Liste de # Assets}}", + "list-of-customers": "{count, plural, 1 {Un client} other {Liste de # clients}}", + "list-of-dashboards": "{count, plural, 1 {Un tableau de bord} other {Liste de # tableaux de bord}}", + "list-of-devices": "{count, plural, 1 {Un dispositif} other {Liste de # dispositifs}}", + "list-of-plugins": "{count, plural, 1 {Un plugin} other {Liste de # plugins}}", + "list-of-rulechains": "{count, plural, 1 {Une chaîne de règles} other {Liste de # chaînes de règles}}", + "list-of-rulenodes": "{count, plural, 1 {Un noeud de règles} other {Liste de # noeuds de règles}}", + "list-of-rules": "{count, plural, 1 {Une règle} other {Liste de # règles}}", + "list-of-tenants": "{count, plural, 1 {Un tenant} other {Liste de # tenants}}", + "list-of-users": "{count, plural, 1 {Un utilisateur} other {Liste de # utilisateurs}}", + "missing-entity-filter-error": "Le filtre est manquant pour l'alias '{{alias}}'.", + "name-starts-with": "Nom commence par", + "no-alias-matching": "'{{alias}}' introuvable.", + "no-aliases-found": "Aucun alias trouvé.", + "no-data": "Aucune donnée à afficher", + "no-entities-matching": "Aucune entité correspondant à '{{entity}}' n'a été trouvée.", + "no-entities-prompt": "Aucune entité trouvée", + "no-entity-types-matching": "Aucun type d'entité correspondant à {{entityType}} n'a été trouvé. ", + "no-key-matching": "'{{key}}' introuvable.", + "no-keys-found": "Aucune clé trouvée", + "plugin-name-starts-with": "Plugins dont les noms commencent par '{{prefix}}'", + "remove-alias": "Supprimer l'alias d'entité", + "rule-name-starts-with": "Règles dont les noms commencent par '{{prefix}}'", + "rulechain-name-starts-with": "Chaînes de règles dont les noms commencent par '{{prefix}}'", + "rulenode-name-starts-with": "Les noeuds de règles dont le nom commence par '{{prefix}}'", + "search": "Recherche d'entités", + "select-entities": "Sélectionner des entités", + "selected-entities": "{count, plural, 1 {1 entité} other {# entités}} sélectionnées", + "tenant-name-starts-with": "Les Tenant dont le nom commence par '{{prefix}}'", + "type": "Type", + "type-alarm": "Alarme", + "type-alarms": "Alarmes", + "type-asset": "Asset", + "type-assets": "Assets", + "type-current-customer": "Client actuel", + "type-customer": "Client", + "type-customers": "Clients", + "type-dashboard": "Tableau de bord", + "type-dashboards": "Tableaux de bord", + "type-device": "Dispositif", + "type-devices": "Dispositifs", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "type-required": "Le type d'entité est obligatoire.", + "type-rule": "Règle", + "type-rulechain": "Chaîne de règles", + "type-rulechains": "Chaînes de règles", + "type-rulenode": "Noeud de règle", + "type-rulenodes": "Noeuds de règle", + "type-rules": "Règles", + "type-tenant": "Tenant", + "type-tenants": "Tenants", + "type-user": "Utilisateur", + "type-users": "Utilisateurs", + "unable-delete-entity-alias-text": "L'alias d'entité '{{entityAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", + "unable-delete-entity-alias-title": "Impossible de supprimer l'alias d'entité", + "use-entity-name-filter": "Utiliser un filtre", + "user-name-starts-with": "Utilisateurs dont les noms commencent par '{{prefix}}'" + }, + "error": { + "unable-to-connect": "Impossible de se connecter au serveur! Veuillez vérifier votre connexion Internet.", + "unhandled-error-code": "Code d'erreur non géré: {{errorCode}}", + "unknown-error": "Erreur inconnue" + }, + "event": { + "alarm": "Alarme", + "body": "Corps", + "data": "Données", + "data-type": "Type de données", + "entity": "Entité", + "error": "erreur", + "errors-occurred": "Des erreurs sont survenues", + "event": "événement", + "event-time": "Heure de l'événement", + "event-type": "Type d'événement", + "failed": "Échec", + "message-id": "Message Id", + "message-type": "Type de message", + "messages-processed": "Messages traités", + "metadata": "Métadonnées", + "method": "Méthode", + "no-events-prompt": "Aucun événement trouvé", + "relation-type": "Type de relation", + "server": "Serveur", + "status": "Etat", + "success": "Succès", + "type": "Type", + "type-debug-rule-chain": "Debug", + "type-debug-rule-node": "Debug", + "type-error": "Erreur", + "type-lc-event": "Evénement du cycle de vie", + "type-stats": "Statistiques" + }, + "extension": { + "add": "Ajouter une extension", + "add-attribute": "Ajouter un attribut", + "add-attribute-request": "Ajouter une demande d'attribut", + "add-attribute-update": "Ajouter une mise à jour d'attribut", + "add-broker": "Ajouter un Broker", + "add-config": "Ajouter une configuration de convertisseur", + "add-connect-request": "Ajouter une demande de connexion", + "add-converter": "Ajouter un convertisseur", + "add-device": "Ajouter un dispositif", + "add-disconnect-request": "Ajouter une demande de déconnexion", + "add-map": "Ajouter un élément de mappage", + "add-server-side-rpc-request": "Ajouter une requête RPC côté serveur", + "add-timeseries": "Ajouter des timeseries", + "anonymous": "Anonyme", + "attr-json-key-expression": "Expression json de la clé d'attribut", + "attr-topic-key-expression": "Expression du topic de la clé d'attribut", + "attribute-filter": "Filtre d'attribut", + "attribute-key-expression": "Expression de clé d'attribut", + "attribute-requests": "Demandes d'attributs", + "attribute-updates": "Mises à jour des attributs", + "attributes": "Attributs", + "basic": "Basic", + "brokers": "Brokers", + "ca-cert": "Fichier de certificat CA", + "cert": "Fichier de certificat *", + "client-scope": "Portée client", + "configuration": "Configuration", + "connect-requests": "Demandes de connexion", + "converter-configurations": "Configurations du convertisseur", + "converter-id": "ID du convertisseur", + "converter-json": "Json", + "converter-json-parse": "Impossible d'analyser le convertisseur json.", + "converter-json-required": "Le convertisseur json est requis.", + "converter-type": "Type de convertisseur", + "converters": "Convertisseurs", + "credentials": "Informations d'identification", + "custom": "Custom", + "delete": "Supprimer l'extension", + "delete-extension-text": "Attention, après la confirmation, l'extension et toutes les données associées deviendront irrécupérables.", + "delete-extension-title": "Êtes-vous sûr de vouloir supprimer l'extension '{{extensionId}}'?", + "delete-extensions-text": "Attention, après la confirmation, toutes les extensions sélectionnées seront supprimées.", + "delete-extensions-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 extension} other {# extensions}}?", + "device-name-expression": "expression du nom du dispositif", + "device-name-filter": "Filtre de nom de dispositif", + "device-type-expression": "expression de type de dispositif", + "disconnect-requests": "Demandes de déconnection", + "drop-file": "Déposez un fichier ou cliquez pour sélectionner un fichier à télécharger.", + "edit": "Modifier l'extension", + "export-extension": "Exporter l'extension", + "export-extensions-configuration": "Exporter la configuration des extensions", + "extension-id": "Id de l'extension", + "extension-type": "Type d'extension", + "extensions": "Extensions", + "field-required": "Le champ est obligatoire", + "file": "Fichier d'extensions", + "filter-expression": "Expression du filtre", + "host": "Hôte", + "id": "Id", + "import-extension": "Importer une extension", + "import-extensions": "Importer des extensions", + "import-extensions-configuration": "Importer la configuration des extensions", + "invalid-file-error": "Fichier d'extension non valide", + "json-name-expression": "Expression json du nom du dispositif", + "json-parse": "Impossible d'analyser json transformer.", + "json-required": "Transformer json est requis.", + "json-type-expression": "Expression json du type de dispositif", + "key": "Clé", + "mapping": "Mappage", + "method-filter": "Filtre de méthode", + "modbus-add-server": "Ajouter serveur/esclave", + "modbus-add-server-prompt": "Veuillez ajouter serveur/esclave", + "modbus-attributes-poll-period": "Période d'interrogation des attributs (ms)", + "modbus-baudrate": "Débit en bauds", + "modbus-byte-order": "Ordre des octets", + "modbus-databits": "Bits de données", + "modbus-databits-range": "Les bits de données doivent être compris entre 7 et 8.", + "modbus-device-name": "Nom du dispositif", + "modbus-encoding": "Encodage", + "modbus-function": "Fonction", + "modbus-parity": "parité", + "modbus-poll-period": "Période d'interrogation (ms)", + "modbus-poll-period-range": "La période d'interrogation doit être une valeur positive.", + "modbus-port-name": "Nom du port série", + "modbus-register-address": "Adresse du registre", + "modbus-register-address-range": "L'adresse du registre doit être comprise entre 0 et 65535.", + "modbus-register-bit-index": "Bit index", + "modbus-register-bit-index-range": "L'index de bit doit être compris entre 0 et 15.", + "modbus-register-count": "Nombre de registre", + "modbus-register-count-range": "Le nombre de registres doit être une valeur positive.", + "modbus-server": "Serveurs / esclaves", + "modbus-stopbits": "Bits d'arrêt", + "modbus-stopbits-range": "Les bits d'arrêt doivent être compris entre 1 et 2.", + "modbus-tag": "Tag", + "modbus-timeseries-poll-period": "Période d'interrogation des Timeseries (ms)", + "modbus-transport": "Transport", + "modbus-unit-id": "Id de l'unité", + "modbus-unit-id-range": "L'ID de l'unité doit être compris entre 1 et 247.", + "no-file": "Aucun fichier sélectionné.", + "opc-add-server": "Ajouter un serveur", + "opc-add-server-prompt": "Veuillez ajouter un serveur", + "opc-application-name": "Nom de l'application", + "opc-application-uri": "Uri de l'application", + "opc-device-name-pattern": "modèle de nom du dispositif", + "opc-device-node-pattern": "modèle de noeud de dispositif", + "opc-identity": "Identité", + "opc-keystore": "Magasin de clés", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Mot de passe de la clé", + "opc-keystore-location": "Emplacement *", + "opc-keystore-password": "Mot de passe", + "opc-keystore-type": "Type", + "opc-scan-period-in-seconds": "Période d'analyse en secondes", + "opc-security": "Sécurité", + "opc-server": "Serveurs", + "opc-type": "Type", + "password": "Mot de passe", + "pem": "PEM", + "port": "Port", + "port-range": "Le port doit être compris entre 1 et 65535.", + "private-key": "Fichier de clé privée *", + "request-id-expression": "Expression de demande d'id", + "request-id-json-expression": "Expression json de la demande d'id", + "request-id-topic-expression": "Expression de la demande d'id du topic", + "request-topic-expression": "Expression de la demande du topic", + "response-timeout": "Délai de réponse en millisecondes", + "response-topic-expression": "Expression du topic de la réponse", + "retry-interval": "Intervalle de nouvelle tentative en millisecondes", + "selected-extensions": "{count, plural, 1 {1 extension} other {# extensions}} sélectionné", + "server-side-rpc": "RPC côté serveur", + "ssl": "Ssl", + "sync": { + "last-sync-time": "Dernière heure de synchronisation", + "not-available": "Non disponible", + "not-sync": "Non sync", + "status": "Status", + "sync": "Sync" + }, + "timeout": "Délai d'attente en millisecondes", + "timeseries": "Timeseries", + "to-double": "To Double", + "token": "Jeton de sécurité", + "topic": "Topic", + "topic-expression": "Expression du topic", + "topic-filter": "Filtre du topic", + "topic-name-expression": "Expression du nom du dispositif (topic)", + "topic-type-expression": "Expression de type de dispositif (topic)", + "transformer": "Transformer", + "transformer-json": "JSON *", + "type": "Type", + "unique-id-required": "L'identifiant d'extension actuel existe déjà.", + "username": "Nom d'utilisateur", + "value": "Valeur", + "value-expression": "Expression de la valeur" + }, + "fullscreen": { + "exit": "Quitter le plein écran", + "expand": "Afficher en plein écran", + "fullscreen": "Plein écran", + "toggle": "Activer le mode plein écran" + }, + "function": { + "function": "Fonction" + }, + "grid": { + "add-item-text": "Ajouter un nouvel élément", + "delete-item": "Supprimer l'élément", + "delete-item-text": "Faites attention, après la confirmation, cet élément et toutes les données associées deviendront irrécupérables.", + "delete-item-title": "Êtes-vous sûr de vouloir supprimer cet élément?", + "delete-items": "Supprimer les éléments", + "delete-items-action-title": "Supprimer {count, plural, 1 {1 élément} other {# éléments}}", + "delete-items-text": "Attention, après la confirmation, tous les éléments sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-items-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 élément} other {# éléments}}?", + "item-details": "Détails de l'élément", + "no-items-text": "Aucun élément trouvé", + "scroll-to-top": "Défiler vers le haut" + }, + "help": { + "goto-help-page": "Aller à la page d'aide" + }, + "home": { + "avatar": "Avatar", + "home": "Accueil", + "logout": "Déconnexion", + "menu": "Menu", + "open-user-menu": "Ouvrir le menu utilisateur", + "profile": "Profile" + }, + "icon": { + "icon": "Icône", + "material-icons": "Material icons", + "select-icon": "Sélectionner l'icône", + "show-all": "Afficher toutes les icônes" + }, + "import": { + "drop-file": "Déposez un fichier JSON ou cliquez pour sélectionner un fichier à télécharger.", + "no-file": "Aucun fichier sélectionné" + }, + "item": { + "selected": "Sélectionné" + }, + "js-func": { + "no-return-error": "La fonction doit renvoyer une valeur!", + "return-type-mismatch": "La fonction doit renvoyer une valeur de type '{{type}}' !", + "tidy": "Tidy" + }, + "key-val": { + "add-entry": "Ajouter une entrée", + "key": "Clé", + "no-data": "Aucune entrée", + "remove-entry": "Supprimer l'entrée", + "value": "Valeur" + }, + "language": { + "language": "Language", + "locales": { + "en_US": "Anglais", + "fr_FR": "Français", + "es_ES": "Espagnol", + "it_IT": "Italien", + "ko_KR": "Coréen", + "ru_RU": "Russe", + "zh_CN": "Chinois", + "TR": "Turc" + } + }, + "layout": { + "color": "Couleur", + "layout": "Mise en page", + "main": "Principal", + "manage": "Gérer les mises en page", + "right": "Droite", + "select": "Sélectionner la mise en page cible", + "settings": "Paramètres de mise en page" + }, + "legend": { + "avg": "avg", + "max": "max", + "min": "min", + "position": "Position de la légende", + "settings": "Paramètres de la légende", + "show-avg": "Afficher la valeur moyenne", + "show-max": "Afficher la valeur maximale", + "show-min": "Afficher la valeur min", + "show-total": "Afficher la valeur totale", + "total": "total" + }, + "login": { + "create-password": "Créer un mot de passe", + "email": "Email", + "forgot-password": "Mot de passe oublié?", + "login": "Login", + "new-password": "Nouveau mot de passe", + "new-password-again": "nouveau mot de passe", + "password-again": "Mot de passe à nouveau", + "password-link-sent-message": "Le lien de réinitialisation du mot de passe a été envoyé avec succès!", + "password-reset": "Mot de passe réinitialisé", + "passwords-mismatch-error": "Les mots de passe saisis doivent être identiques!", + "remember-me": "Se souvenir de moi", + "request-password-reset": "Demander la réinitialisation du mot de passe", + "reset-password": "Réinitialiser le mot de passe", + "sign-in": "Veuillez vous connecter", + "username": "Nom d'utilisateur (email)" + }, + "position": { + "bottom": "Bas", + "left": "Gauche", + "right": "Droite", + "top": "Haut" + }, + "profile": { + "change-password": "Modifier le mot de passe", + "current-password": "Mot de passe actuel", + "profile": "Profile" + }, + "relation": { + "add": "Ajouter une relation", + "add-relation-filter": "Ajouter un filtre de relation", + "additional-info": "Informations supplémentaires (JSON)", + "any-relation": "toute relation", + "any-relation-type": "N'importe quel type", + "delete": "Supprimer la relation", + "delete-from-relation-text": "Attention, après la confirmation, l'entité actuelle ne sera pas liée à l'entité '{{entityName}}'.", + "delete-from-relation-title": "Etes-vous sûr de vouloir supprimer la relation de l'entité '{{entityName}}'?", + "delete-from-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et l'entité actuelle ne sera pas liée aux entités correspondantes.", + "delete-from-relations-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", + "delete-to-relation-text": "Attention, après la confirmation, l'entité '{{entityName}} ne sera plus liée à l'entité actuelle.", + "delete-to-relation-title": "Êtes-vous sûr de vouloir supprimer la relation avec l'entité '{{entityName}}'?", + "delete-to-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et les entités correspondantes ne seront pas liées à l'entité en cours.", + "delete-to-relations-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", + "direction": "Sens", + "direction-type": { + "FROM": "de", + "TO": "à" + }, + "edit": "Modifier la relation", + "from-entity": "De l'entité", + "from-entity-name": "Du nom d'entité", + "from-entity-type": "Du type d'entité", + "from-relations": "Relations sortantes", + "invalid-additional-info": "Impossible d'analyser les informations supplémentaires json.", + "relation-filters": "Filtres de relation", + "relation-type": "Type de relation", + "relation-type-required": "Le type de relation est requis.", + "relations": "Relations", + "remove-relation-filter": "Supprimer le filtre de relation", + "search-direction": { + "FROM": "De", + "TO": "À" + }, + "selected-relations": "{count, plural, 1 {1 relation} other {# relations}} sélectionné", + "to-entity": "À l'entité", + "to-entity-name": "vers le nom de l'entité", + "to-entity-type": "Vers le type d'entité", + "to-relations": "Relations entrantes", + "type": "Type" + }, + "rulechain": { + "add": "Ajouter une chaîne de règles", + "add-rulechain-text": "Ajouter une nouvelle chaîne de règles", + "copyId": "Copier l'identifiant de la chaîne de règles", + "create-new-rulechain": "Créer une nouvelle chaîne de règles", + "debug-mode": "Mode de débogage", + "delete": "Supprimer la chaîne de règles", + "delete-rulechain-text": "Attention, après la confirmation, la chaîne de règles et toutes les données associées deviendront irrécupérables.", + "delete-rulechain-title": "Voulez-vous vraiment supprimer la chaîne de règles '{{ruleChainName}}'?", + "delete-rulechains-action-title": "Supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}", + "delete-rulechains-text": "Attention, après la confirmation, toutes les chaînes de règles sélectionnées seront supprimées et toutes les données associées deviendront irrécupérables.", + "delete-rulechains-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}?", + "description": "Description", + "details": "Détails", + "events": "Evénements", + "export": "Exporter la chaîne de règles", + "export-failed-error": "Impossible d'exporter la chaîne de règles: {{error}}", + "idCopiedMessage": "L'ID de la chaîne de règles a été copié dans le presse-papier", + "import": "Importer la chaîne de règles", + "invalid-rulechain-file-error": "Impossible d'importer la chaîne de règles: structure de données de la chaîne de règles non valide", + "management": "Gestion des règles", + "name": "Nom", + "name-required": "Le nom est requis.", + "no-rulechains-matching": "Aucune chaîne de règles correspondant à {{entity}} n'a été trouvée.", + "no-rulechains-text": "Aucune chaîne de règles trouvée", + "root": "Racine", + "rulechain": "Chaîne de règles", + "rulechain-details": "Détails de la chaîne de règles", + "rulechain-file": "Fichier de chaîne de règles", + "rulechain-required": "Chaîne de règles requise", + "rulechains": "Chaînes de règles", + "select-rulechain": "Sélectionner la chaîne de règles", + "set-root": "Rend la chaîne de règles racine (root) ", + "set-root-rulechain-text": "Après la confirmation, la chaîne de règles deviendra racine (root) et gérera tous les messages de transport entrants.", + "set-root-rulechain-title": "Voulez-vous vraiment que la chaîne de règles '{{ruleChainName}} soit racine (root) ?", + "system": "Système" + }, + "rulenode": { + "add": "Ajouter un noeud de règle", + "add-link": "Ajouter un lien", + "configuration": "Configuration", + "copy-selected": "Copier les éléments sélectionnés", + "create-new-link-label": "Créez un nouveau!", + "custom-link-label": "Etiquette de lien personnalisée", + "custom-link-label-required": "Une étiquette de lien personnalisée est requise", + "debug-mode": "Mode de débogage", + "delete": "Supprimer le noeud de règle", + "delete-selected": "Supprimer les éléments sélectionnés", + "delete-selected-objects": "Supprimer les nœuds et les connexions sélectionnés", + "description": "Description", + "deselect-all": "Désélectionner tout", + "deselect-all-objects": "Désélectionnez tous les nœuds et toutes les connexions", + "details": "Détails", + "directive-is-not-loaded": "La directive de configuration définie '{{directiveName}} n'est pas disponible.", + "events": "Événements", + "help": "Aide", + "invalid-target-rulechain": "Impossible de résoudre la chaîne de règles cible!", + "link": "Lien", + "link-details": "Détails du lien du noeud de la règle", + "link-label": "Étiquette du lien", + "link-label-required": "L'étiquette du lien est obligatoire", + "link-labels": "Étiquettes de lien", + "link-labels-required": "Les étiquettes de lien sont obligatoires", + "message": "Message", + "message-type": "Type de message", + "message-type-required": "Le type de message est obligatoire", + "metadata": "Métadonnées", + "metadata-required": "Les entrées de métadonnées ne peuvent pas être vides.", + "name": "Nom", + "name-required": "Le nom est requis.", + "no-link-label-matching": "'{{label}}' introuvable.", + "no-link-labels-found": "Aucune étiquette de lien trouvée", + "open-node-library": "Ouvrir la bibliothèque de noeud", + "output": "Output", + "rulenode-details": "Détails du noeud de la règle", + "search": "Recherche de noeuds", + "select-all": "Tout sélectionner", + "select-all-objects": "Sélectionnez tous les noeuds et connexions", + "select-message-type": "Sélectionner le type de message", + "test": "Test", + "test-script-function": "Tester le script", + "type": "Type", + "type-action": "Action", + "type-action-details": "Effectuer une action spéciale", + "type-enrichment": "Enrichissement", + "type-enrichment-details": "Ajouter des informations supplémentaires dans les métadonnées de message", + "type-external": "Externe", + "type-external-details": "Interagit avec le système externe", + "type-filter": "Filtre", + "type-filter-details": "Filtrer les messages entrants avec des conditions configurées", + "type-input": "Input", + "type-input-details": "Entrée logique de la chaîne de règles, transmet les messages entrants au prochain nœud de règle associé", + "type-rule-chain": "Chaîne de règles", + "type-rule-chain-details": "Transmet les messages entrants à la chaîne de règles spécifiée", + "type-transformation": "Transformation", + "type-transformation-details": "Modifier le payload du message et les métadonnées ", + "type-unknown": "Inconnu", + "type-unknown-details": "Noeud de règle non résolu", + "ui-resources-load-error": "Impossible de charger les ressources de configuration de l'interface utilisateur." + }, + "tenant": { + "add": "Ajouter un Tenant", + "add-tenant-text": "Ajouter un nouveau Tenant", + "admins": "Admins", + "copyId": "Copier l'Id du Tenant", + "delete": "Supprimer le Tenant", + "delete-tenant-text": "Attention, après la confirmation, le Tenant et toutes les données associées deviendront irrécupérables.", + "delete-tenant-title": "Etes-vous sûr de vouloir supprimer le tenant '{{tenantTitle}}'?", + "delete-tenants-action-title": "Supprimer {count, plural, 1 {1 tenant} other {# tenants}}", + "delete-tenants-text": "Attention, après la confirmation, tous les Tenants sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-tenants-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 tenant} other {# tenants}}?", + "description": "Description", + "details": "Détails", + "events": "Événements", + "idCopiedMessage": "L'Id du Tenant a été copié dans le Presse-papiers", + "manage-tenant-admins": "Gérer les administrateurs du Tenant", + "management": "Gestion des Tenants", + "no-tenants-matching": "Aucun Tenant correspondant à {{entity}} n'a été trouvé. ", + "no-tenants-text": "Aucun Tenant trouvé", + "select-tenant": "Sélectionner un Tenant", + "tenant": "Tenant", + "tenant-details": "Détails du Tenant", + "tenant-required": "Tenant requis", + "tenants": "Tenants", + "title": "Titre", + "title-required": "Le titre est requis." + }, + "timeinterval": { + "advanced": "Avancé", + "days": "Jours", + "days-interval": "{days, plural, 1 {1 jour} other {# jours}}", + "hours": "Heures", + "hours-interval": "{hours, plural, 1 {1 heure} other {# heures}}", + "minutes": "Minutes", + "minutes-interval": "{minutes, plural, 1 {1 minute} other {# minutes}}", + "seconds": "Secondes", + "seconds-interval": "{seconds, plural, 1 {1 seconde} other {# secondes}}" + }, + "timewindow": { + "date-range": "Plage de dates", + "days": "{days, plural, 1 {jour} other {# jours}}", + "edit": "Modifier timewindow", + "history": "Historique", + "hours": "{hours, plural, 0 {heure} 1 {1 heure} other {# heures}}", + "last": "Dernier", + "last-prefix": "dernier", + "minutes": "{minutes, plural, 0 {minute} 1 {1 minute} other {# minutes}}", + "period": "de {{startTime}} à {{endTime}}", + "realtime": "Temps réel", + "seconds": "{seconds, plural, 0 {second} 1 {1 second} other {# seconds}}", + "time-period": "Période" + }, + "user": { + "activation-email-sent-message": "L'e-mail d'activation a été envoyé avec succès!", + "activation-link": "Lien d'activation utilisateur", + "activation-link-copied-message": "le lien d'activation de l'utilisateur a été copié dans le presse-papier", + "activation-link-text": "Pour activer l'utilisateur, utilisez le lien d'activation suivant: ", + "activation-method": "Méthode d'activation", + "add": "Ajouter un utilisateur", + "add-user-text": "Ajouter un nouvel utilisateur", + "always-fullscreen": "Toujours en plein écran", + "anonymous": "Anonyme", + "copy-activation-link": "Copier le lien d'activation", + "customer": "Client", + "customer-users": "Utilisateurs du client", + "default-dashboard": "Tableau de bord par défaut", + "delete": "Supprimer l'utilisateur", + "delete-user-text": "Attention, après la confirmation, l'utilisateur et toutes les données associées deviendront irrécupérables.", + "delete-user-title": "Etes-vous sûr de vouloir supprimer l'utilisateur '{{userEmail}}'?", + "delete-users-action-title": "Supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}", + "delete-users-text": "Attention, après la confirmation, tous les utilisateurs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-users-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}?", + "description": "Description", + "details": "Détails", + "display-activation-link": "Afficher le lien d'activation", + "email": "Email", + "email-required": "Email est requis.", + "first-name": "Prénom", + "invalid-email-format": "Format de courrier électronique non valide", + "last-name": "Nom de famille", + "no-users-matching": "Aucun utilisateur correspondant à '{{entity}}' n'a été trouvé.", + "no-users-text": "Aucun utilisateur trouvé", + "resend-activation": "Renvoyer l'activation", + "select-user": "Sélectionner l'utilisateur", + "send-activation-mail": "Envoyer un mail d'activation", + "sys-admin": "Administrateur du système", + "tenant-admin": "Administrateur du Tenant", + "tenant-admins": "administrateurs du Tenant", + "user": "utilisateur", + "user-details": "Détails de l'utilisateur", + "user-required": "L'utilisateur est requis", + "users": "Utilisateurs" + }, + "value": { + "boolean": "booléen", + "boolean-value": "Valeur booléenne", + "double": "Double", + "double-value": "Valeur double", + "false": "Faux", + "integer": "Entier", + "integer-value": "Valeur entière", + "invalid-integer-value": "Valeur entière invalide", + "long": "Long", + "string": "String", + "string-value": "Valeur String", + "true": "Vrai", + "type": "Type de valeur" + }, + "widget": { + "add": "Ajouter un widget", + "add-resource": "Ajouter une ressource", + "add-widget-type": "Ajouter un nouveau type de widget", + "alarm": "Widget d'alarme", + "css": "CSS", + "datakey-settings-schema": "Schéma des paramètres de Data key", + "edit": "Modifier le widget", + "editor": " Editeur de widget", + "export": "Exporter widget", + "html": "HTML", + "javascript": "Javascript", + "latest-values": "Dernières valeurs", + "management": "Gestion des widgets", + "missing-widget-title-error": "Le titre du widget doit être spécifié!", + "no-data-found": "Aucune donnée trouvée", + "remove": "Supprimer le widget", + "remove-resource": "Supprimer une ressource", + "remove-widget-text": "Après la confirmation, le widget et toutes les données associées deviendront irrécupérables.", + "remove-widget-title": "Êtes-vous sûr de vouloir supprimer le widget '{{widgetTitle}}'?", + "remove-widget-type": "Supprimer le type de widget", + "remove-widget-type-text": "Après la confirmation, le type de widget et toutes les données associées deviendront irrécupérables.", + "remove-widget-type-title": "Êtes-vous sûr de vouloir supprimer le type de widget '{{widgetName}}'?", + "resource-url": "URL JavaScript / CSS", + "resources": "Ressources", + "rpc": "Widget de contrôle", + "run": "Exécuter un widget", + "save": "Enregistrer le widget", + "save-widget-type-as": "Enregistrer le type de widget sous", + "save-widget-type-as-text": "Veuillez saisir un nouveau titre de widget et / ou sélectionner un ensemble de widgets cibles", + "saveAs": "Enregistrer le widget sous", + "search-data": "Rechercher des données", + "select-widget-type": "Sélectionnez le type de widget", + "select-widgets-bundle": "Sélectionner un ensemble de widgets", + "settings-schema": "Schéma des paramètres", + "static": "Widget statique", + "tidy": "Tidy", + "timeseries": "Séries chronologiques", + "title": "Titre du widget", + "title-required": "Le titre du widget est requis.", + "toggle-fullscreen": "Basculer le mode plein écran", + "type": "Type de widget", + "unable-to-save-widget-error": "Impossible de sauvegarder le widget! Le widget a des erreurs!", + "undo": "Annuler les modifications du widget", + "widget-bundle": "Ensemble de widget", + "widget-library": "Bibliothèque de widgets", + "widget-saved": "Widget enregistré", + "widget-template-load-failed-error": "Impossible de charger le modèle de widget!", + "widget-type-load-error": "Le widget n'a pas été chargé à cause des erreurs suivantes:", + "widget-type-load-failed-error": "Impossible de charger le type de widget!", + "widget-type-not-found": "Problème de chargement de la configuration du widget.
Le type de widget associé a probablement été supprimé." + }, + "widget-action": { + "custom": "Action personnalisée", + "header-button": "Bouton d'en-tête de widget", + "open-dashboard": "Naviguer vers un autre tableau de bord", + "open-dashboard-state": "Naviguer vers un nouvel état du tableau de bord", + "open-right-layout": "Ouvrir la disposition du tableau de bord droite (vue mobile)", + "set-entity-from-widget": "Définir l'entité à partir du widget", + "target-dashboard": "Tableau de bord cible", + "target-dashboard-state": "Etat du tableau de bord cible", + "target-dashboard-state-required": "L'état du tableau de bord cible est requis", + "update-dashboard-state": "Mettre à jour l'état actuel du tableau de bord" + }, + "widget-config": { + "action": "Action", + "action-icon": "Icône", + "action-name": "Nom", + "action-name-not-unique": "Une autre action portant le même nom existe déjà.
Le nom de l'action doit être unique dans la même source d'action.", + "action-name-required": "Le nom de l'action est requis", + "action-source": "Source de l'action", + "action-source-required": "Une source d'action est requise.", + "action-type": "Type", + "action-type-required": "Le type d'action est requis.", + "actions": "Actions", + "add-action": "Ajouter une action", + "add-datasource": "Ajouter une source de données", + "advanced": "Avancé", + "alarm-source": "Source d'alarme", + "background-color": "couleur de fond", + "data": "Données", + "datasource-parameters": "Paramètres", + "datasource-type": "Type", + "datasources": "Sources de données", + "decimals": "Nombre de chiffres après virgule flottante", + "delete-action": "Supprimer l'action", + "delete-action-text": "Etes-vous sûr de vouloir supprimer l'action du widget nommé '{{actionName}}'?", + "delete-action-title": "Supprimer l'action du widget", + "display-legend": "Afficher la légende", + "display-title": "Afficher le titre", + "drop-shadow": "Ombre portée", + "edit-action": "Modifier l'action", + "enable-fullscreen": "Activer le plein écran", + "general-settings": "Paramètres généraux", + "height": "Hauteur", + "margin": "Marge", + "maximum-datasources": "Maximum {count, plural, 1 {1 datasource est autorisé.} other {# datasources sont autorisés}}", + "mobile-mode-settings": "Paramètres du mode mobile", + "order": "Ordre", + "padding": "Padding", + "remove-datasource": "Supprimer la source de données", + "search-actions": "Recherche d'actions", + "settings": "Paramètres", + "target-device": "Dispositif cible", + "text-color": "Couleur du texte", + "timewindow": "Fenêtre de temps", + "title": "Titre", + "title-style": "Style de titre", + "units": "Symbole spécial à afficher à côté de la valeur", + "use-dashboard-timewindow": "Utiliser la fenêtre de temps du tableau de bord", + "widget-style": "Style du widget" + }, + "widget-type": { + "create-new-widget-type": "Créer un nouveau type de widget", + "export": "Exporter le type de widget", + "export-failed-error": "Impossible d'exporter le type de widget: {{error}}", + "import": "Importer le type de widget", + "invalid-widget-type-file-error": "Impossible d'importer le type de widget: structure de données de type widget invalide.", + "widget-type-file": "Fichier de type Widget" + }, + "widgets-bundle": { + "add": "Ajouter un groupe de widgets", + "add-widgets-bundle-text": "Ajouter un nouveau groupe de widgets", + "create-new-widgets-bundle": "Créer un nouveau groupe de widgets", + "current": "Groupe actuel", + "delete": "Supprimer le groupe de widgets", + "delete-widgets-bundle-text": "Attention, après la confirmation, le groupe de widgets et toutes les données associées deviendront irrécupérables.", + "delete-widgets-bundle-title": "Êtes-vous sûr de vouloir supprimer le groupe de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundles-action-title": "Supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}", + "delete-widgets-bundles-text": "Attention, après la confirmation, tous les groupes de widgets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-widgets-bundles-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}?", + "details": "Détails", + "empty": "Le groupe de widgets est vide", + "export": "Exporter le groupe de widgets", + "export-failed-error": "Impossible d'exporter le groupe de widgets: {{error}}", + "import": "Importer un groupe de widgets", + "invalid-widgets-bundle-file-error": "Impossible d'importer un groupe de widgets: structure de données du groupe de widgets non valides.", + "no-widgets-bundles-matching": "Aucun groupe de widgets correspondant à {{widgetsBundle}} n'a été trouvé.", + "no-widgets-bundles-text": "Aucun groupe de widgets trouvé", + "system": "Système", + "title": "Titre", + "title-required": "Le titre est requis.", + "widgets-bundle-details": "Détails des groupes de widgets", + "widgets-bundle-file": "Fichier de groupe de widgets", + "widgets-bundle-required": "Un groupe de widgets est requis.", + "widgets-bundles": "Groupes de widgets" + } } diff --git a/ui/src/app/locale/locale.constant-it_IT.json b/ui/src/app/locale/locale.constant-it_IT.json index f69c1088b1..79843ab09b 100644 --- a/ui/src/app/locale/locale.constant-it_IT.json +++ b/ui/src/app/locale/locale.constant-it_IT.json @@ -1441,7 +1441,8 @@ "it_IT": "Italiano", "ru_RU": "Russo", "es_ES": "Spagnolo", - "ja_JA": "Giapponese" + "ja_JA": "Giapponese", + "TR": "Turco" } } } diff --git a/ui/src/app/locale/locale.constant-ja_JA.json b/ui/src/app/locale/locale.constant-ja_JA.json index 77765efddb..6c4889460d 100644 --- a/ui/src/app/locale/locale.constant-ja_JA.json +++ b/ui/src/app/locale/locale.constant-ja_JA.json @@ -1457,7 +1457,8 @@ "zh_CN": "中国語", "ru_RU": "ロシア", "es_ES": "スペイン語", - "ja_JA": "日本語" + "ja_JA": "日本語", + "TR": "トルコ語" } } } diff --git a/ui/src/app/locale/locale.constant-ko_KR.json b/ui/src/app/locale/locale.constant-ko_KR.json index 6027ef874f..07e7dd6070 100644 --- a/ui/src/app/locale/locale.constant-ko_KR.json +++ b/ui/src/app/locale/locale.constant-ko_KR.json @@ -1335,7 +1335,8 @@ "ru_RU": "러시아어", "es_ES": "스페인어", "it_IT": "이탈리아 사람", - "ja_JA": "일본어" + "ja_JA": "일본어", + "TR": "터키어" } } } diff --git a/ui/src/app/locale/locale.constant-ru_RU.json b/ui/src/app/locale/locale.constant-ru_RU.json index 9d2c7130e1..893bbcd86c 100644 --- a/ui/src/app/locale/locale.constant-ru_RU.json +++ b/ui/src/app/locale/locale.constant-ru_RU.json @@ -1360,7 +1360,8 @@ "es_ES": "Испанский", "it_IT": "Итальянский", "ru_RU": "Русский", - "ja_JA": "Японский" + "ja_JA": "Японский", + "TR": "Турецкий" } } diff --git a/ui/src/app/locale/locale.constant-zh_CN.json b/ui/src/app/locale/locale.constant-zh_CN.json index 042d230223..c952f9cd32 100644 --- a/ui/src/app/locale/locale.constant-zh_CN.json +++ b/ui/src/app/locale/locale.constant-zh_CN.json @@ -1444,7 +1444,8 @@ "ru_RU": "俄语", "es_ES": "西班牙语", "it_IT": "意大利", - "ja_JA": "日本" + "ja_JA": "日本", + "TR": "土耳其" } } } From 287e1d7a42d7f41b143de0b382126eb03e80115f Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 8 Oct 2018 13:15:00 +0300 Subject: [PATCH 173/307] Don't automatically redirect HTTP device API to HTTPS. --- msa/docker/haproxy/config/haproxy.cfg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msa/docker/haproxy/config/haproxy.cfg b/msa/docker/haproxy/config/haproxy.cfg index 2499686b87..5f174653f9 100644 --- a/msa/docker/haproxy/config/haproxy.cfg +++ b/msa/docker/haproxy/config/haproxy.cfg @@ -35,8 +35,9 @@ frontend http-in reqadd X-Forwarded-Proto:\ http + acl transport_http_acl path_beg /api/v1/ acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ - redirect scheme https if !letsencrypt_http_acl + redirect scheme https if !letsencrypt_http_acl !transport_http_acl use_backend letsencrypt_http if letsencrypt_http_acl default_backend tb-web-backend From 99dde0cf69b7b92555e52ffa0caadbd3374e006d Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 8 Oct 2018 13:21:55 +0300 Subject: [PATCH 174/307] Fix Cassandra DB upgrade. --- .../install/CassandraDatabaseUpgradeService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index f4ff92cf23..934cf8d995 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -203,6 +203,16 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { log.info("Schema updated."); break; + + case "2.0.0": + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.1", SCHEMA_UPDATE_CQL); + loadCql(schemaUpdateFile); + log.info("Schema updated."); + + break; + default: throw new RuntimeException("Unable to upgrade Cassandra database, unsupported fromVersion: " + fromVersion); } From 1ac0ed40baa62623aef1ef2dff2c6cb83edd3cf3 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Mon, 8 Oct 2018 14:27:02 +0300 Subject: [PATCH 175/307] Handling Connect/Connack with new architecture --- .../server/ThingsboardServerApplication.java | 8 +- .../service/queue/DefaultMsgQueueService.java | 4 +- .../service/script/RemoteJsInvokeService.java | 4 +- .../transport/RemoteTransportApiService.java | 153 +++++++++++++++++- .../transport/TransportApiRequestDecoder.java | 31 ++++ .../TransportApiResponseEncoder.java | 30 ++++ .../transport/TransportApiService.java | 20 ++- .../src/main/resources/thingsboard.yml | 17 +- .../server/kafka/AbstractTbKafkaTemplate.java | 8 +- .../server/kafka/AsyncCallbackTemplate.java | 66 ++++++++ .../server/kafka/TBKafkaProducerTemplate.java | 12 +- .../server/kafka/TbKafkaHandler.java | 19 ++- .../server/kafka/TbKafkaResponseTemplate.java | 66 ++++++-- .../transport/src/main/proto/transport.proto | 12 +- .../transport/coap/CoapTransportResource.java | 10 +- .../coap/session/CoapSessionCtx.java | 4 +- .../transport/http/DeviceApiController.java | 134 +++++++-------- .../http/session/HttpSessionCtx.java | 2 +- .../transport/mqtt/MqttTransportContext.java | 15 ++ .../transport/mqtt/MqttTransportHandler.java | 10 +- .../mqtt/service/AsyncCallbackTemplate.java | 45 ------ .../mqtt/service/MqttTransportService.java | 30 +++- .../service/TransportApiRequestEncoder.java | 15 ++ .../service/TransportApiResponseDecoder.java | 15 ++ .../src/main/resources/tb-mqtt-transport.yml | 9 +- 25 files changed, 557 insertions(+), 182 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/AsyncCallbackTemplate.java delete mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java diff --git a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java index c072311fe2..751bde6303 100644 --- a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java +++ b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java index 927584789d..416a4d6b0f 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java @@ -49,7 +49,7 @@ public class DefaultMsgQueueService implements MsgQueueService { @Autowired private MsgQueue msgQueue; - @Autowired + @Autowired(required = false) private TenantQuotaService quotaService; private ScheduledExecutorService cleanupExecutor; @@ -74,7 +74,7 @@ public class DefaultMsgQueueService implements MsgQueueService { @Override public ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - if(quotaService.isQuotaExceeded(tenantId.getId().toString())) { + if(quotaService != null && quotaService.isQuotaExceeded(tenantId.getId().toString())) { log.warn("Tenant TbMsg Quota exceeded for [{}:{}] . Reject", tenantId.getId()); return Futures.immediateFailedFuture(new RuntimeException("Tenant TbMsg Quota exceeded")); } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index 37a62117be..b334ff15ae 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -103,9 +103,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { responseBuilder.autoCommit(true); responseBuilder.autoCommitIntervalMs(autoCommitInterval); responseBuilder.decoder(new RemoteJsResponseDecoder()); - responseBuilder.requestIdExtractor((response) -> { - return new UUID(response.getRequestIdMSB(), response.getRequestIdLSB()); - }); + responseBuilder.requestIdExtractor((response) -> new UUID(response.getRequestIdMSB(), response.getRequestIdLSB())); TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder builder = TbKafkaRequestTemplate.builder(); diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java index 7831b340dc..0c7b5f4327 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -1,14 +1,165 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.transport; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenResponseMsg; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaResponseTemplate; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.service.cluster.discovery.DiscoveryService; + +import javax.annotation.PostConstruct; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * Created by ashvayka on 05.10.18. */ @Slf4j @Service -@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) +@ConditionalOnProperty(prefix = "transport.remote", value = "enabled", havingValue = "true") public class RemoteTransportApiService implements TransportApiService { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Value("${transport.remote.transport_api.requests_topic}") + private String transportApiRequestsTopic; + @Value("${transport.remote.transport_api.responses_topic}") + private String transportApiResponsesTopic; + @Value("${transport.remote.transport_api.max_pending_requests}") + private int maxPendingRequests; + @Value("${transport.remote.transport_api.request_timeout}") + private long requestTimeout; + @Value("${transport.remote.transport_api.request_poll_interval}") + private int responsePollDuration; + @Value("${transport.remote.transport_api.request_auto_commit_interval}") + private int autoCommitInterval; + + @Autowired + private TbKafkaSettings kafkaSettings; + + @Autowired + private DiscoveryService discoveryService; + + @Autowired + private DeviceService deviceService; + + @Autowired + private DeviceCredentialsService deviceCredentialsService; + + private ExecutorService transportCallbackExecutor; + + private TbKafkaResponseTemplate transportApiTemplate; + + @PostConstruct + public void init() { + this.transportCallbackExecutor = Executors.newCachedThreadPool(); + + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder responseBuilder = TBKafkaProducerTemplate.builder(); + responseBuilder.settings(kafkaSettings); + responseBuilder.defaultTopic(transportApiResponsesTopic); + responseBuilder.encoder(new TransportApiResponseEncoder()); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder requestBuilder = TBKafkaConsumerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.topic(transportApiRequestsTopic); + requestBuilder.clientId(discoveryService.getNodeId()); + requestBuilder.groupId("tb-node"); + requestBuilder.autoCommit(true); + requestBuilder.autoCommitIntervalMs(autoCommitInterval); + requestBuilder.decoder(new TransportApiRequestDecoder()); + + TbKafkaResponseTemplate.TbKafkaResponseTemplateBuilder + builder = TbKafkaResponseTemplate.builder(); + builder.requestTemplate(requestBuilder.build()); + builder.responseTemplate(responseBuilder.build()); + builder.maxPendingRequests(maxPendingRequests); + builder.requestTimeout(requestTimeout); + builder.pollInterval(responsePollDuration); + builder.executor(transportCallbackExecutor); + builder.handler(this); + transportApiTemplate = builder.build(); + transportApiTemplate.init(); + } + + @Override + public ListenableFuture handle(TransportApiRequestMsg transportApiRequestMsg) throws Exception { + if (transportApiRequestMsg.hasValidateTokenRequestMsg()) { + ValidateDeviceTokenRequestMsg msg = transportApiRequestMsg.getValidateTokenRequestMsg(); + //TODO: Make async and enable caching + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(msg.getToken()); + if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.ACCESS_TOKEN) { + return getDeviceInfo(credentials.getDeviceId()); + } else { + return getEmptyTransportApiResponseFuture(); + } + } + return getEmptyTransportApiResponseFuture(); + } + + private ListenableFuture getDeviceInfo(DeviceId deviceId) { + return Futures.transform(deviceService.findDeviceByIdAsync(deviceId), device -> { + if (device == null) { + log.trace("[{}] Failed to lookup device by id", deviceId); + return getEmptyTransportApiResponse(); + } + try { + DeviceInfoProto deviceInfoProto = DeviceInfoProto.newBuilder() + .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(device.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(device.getId().getId().getMostSignificantBits()) + .setDeviceIdLSB(device.getId().getId().getLeastSignificantBits()) + .setDeviceName(device.getName()) + .setDeviceType(device.getType()) + .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())) + .build(); + return TransportApiResponseMsg.newBuilder() + .setValidateTokenResponseMsg(ValidateDeviceTokenResponseMsg.newBuilder().setDeviceInfo(deviceInfoProto).build()).build(); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to lookup device by id", deviceId, e); + return getEmptyTransportApiResponse(); + } + }); + } + + private ListenableFuture getEmptyTransportApiResponseFuture() { + return Futures.immediateFuture(getEmptyTransportApiResponse()); + } + + private TransportApiResponseMsg getEmptyTransportApiResponse() { + return TransportApiResponseMsg.newBuilder() + .setValidateTokenResponseMsg(ValidateDeviceTokenResponseMsg.getDefaultInstance()).build(); + } } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java new file mode 100644 index 0000000000..c54f1a0303 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiRequestDecoder implements TbKafkaDecoder { + @Override + public TransportApiRequestMsg decode(byte[] data) throws IOException { + return TransportApiRequestMsg.parseFrom(data); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java new file mode 100644 index 0000000000..175a92df96 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.kafka.TbKafkaEncoder; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiResponseEncoder implements TbKafkaEncoder { + @Override + public byte[] encode(TransportApiResponseMsg value) { + return value.toByteArray(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java index 28a769a832..cc6f5b9f15 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java @@ -1,7 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.transport; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.kafka.TbKafkaHandler; + /** * Created by ashvayka on 05.10.18. */ -public interface TransportApiService { +public interface TransportApiService extends TbKafkaHandler { } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index f35df67265..c58f3a1a38 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -84,7 +84,7 @@ http: # MQTT server parameters mqtt: # Enable/disable mqtt transport protocol. - enabled: "${MQTT_ENABLED:true}" + enabled: "${MQTT_ENABLED:false}" bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" bind_port: "${MQTT_BIND_PORT:1883}" adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" @@ -149,7 +149,7 @@ quota: # Interval for scheduled task that cleans expired records. TTL is used for expiring cleanPeriodMs: "${QUOTA_TENANT_CLEAN_PERIOD_MS:300000}" # Enable Host API Limits - enabled: "${QUOTA_TENANT_ENABLED:false}" + enabled: "${QUOTA_TENANT_ENABLED:true}" # Array of whitelist tenants whitelist: "${QUOTA_TENANT_WHITELIST:}" # Array of blacklist tenants @@ -450,3 +450,16 @@ js: response_auto_commit_interval: "${REMOTE_JS_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted max_errors: "${REMOTE_JS_SANDBOX_MAX_ERRORS:3}" + +transport: + remote: + enabled: "${REMOTE_TRANSPORT_ENABLED:true}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + request_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + request_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + request_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:1000}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" \ No newline at end of file diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java index 8d851bca83..386bfc574e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/AsyncCallbackTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/AsyncCallbackTemplate.java new file mode 100644 index 0000000000..b8ad758ef9 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/AsyncCallbackTemplate.java @@ -0,0 +1,66 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 05.10.18. + */ +public class AsyncCallbackTemplate { + + public static void withCallbackAndTimeout(ListenableFuture future, + Consumer onSuccess, + Consumer onFailure, + long timeoutInMs, + ScheduledExecutorService timeoutExecutor, + Executor callbackExecutor) { + future = Futures.withTimeout(future, timeoutInMs, TimeUnit.MILLISECONDS, timeoutExecutor); + withCallback(future, onSuccess, onFailure, callbackExecutor); + } + + public static void withCallback(ListenableFuture future, Consumer onSuccess, + Consumer onFailure, Executor executor) { + FutureCallback callback = new FutureCallback() { + @Override + public void onSuccess(T result) { + try { + onSuccess.accept(result); + } catch (Throwable th) { + onFailure(th); + } + } + + @Override + public void onFailure(Throwable t) { + onFailure.accept(t); + } + }; + if (executor != null) { + Futures.addCallback(future, callback, executor); + } else { + Futures.addCallback(future, callback); + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index 2611e9491a..1e109d2877 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -101,6 +101,10 @@ public class TBKafkaProducerTemplate { return send(this.defaultTopic, key, value, timestamp, headers); } + public Future send(String topic, String key, T value, Iterable

headers) { + return send(topic, key, value, null, headers); + } + public Future send(String topic, String key, T value, Long timestamp, Iterable
headers) { byte[] data = encoder.encode(value); ProducerRecord record; diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java index 66d53c3bde..a64d28e173 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java @@ -1,12 +1,27 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.kafka; -import java.util.function.Consumer; +import com.google.common.util.concurrent.ListenableFuture; /** * Created by ashvayka on 05.10.18. */ public interface TbKafkaHandler { - void handle(Request request, Consumer onSuccess, Consumer onFailure); + ListenableFuture handle(Request request) throws Exception; } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java index 1f533873cd..0dbf45db14 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -21,13 +21,15 @@ import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.common.header.Header; import org.apache.kafka.common.header.internals.RecordHeader; -import java.io.IOException; import java.time.Duration; import java.util.Collections; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; /** @@ -40,12 +42,14 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT private final TBKafkaProducerTemplate responseTemplate; private final TbKafkaHandler handler; private final ConcurrentMap pendingRequests; - private final ExecutorService executor; + private final ExecutorService loopExecutor; + private final ScheduledExecutorService timeoutExecutor; + private final ExecutorService callbackExecutor; private final int maxPendingRequests; + private final long requestTimeout; private final long pollInterval; private volatile boolean stopped = false; - //TODO: private final AtomicInteger pendingRequestCount = new AtomicInteger(); @Builder @@ -53,6 +57,7 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT TBKafkaProducerTemplate responseTemplate, TbKafkaHandler handler, long pollInterval, + long requestTimeout, int maxPendingRequests, ExecutorService executor) { this.requestTemplate = requestTemplate; @@ -61,18 +66,24 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT this.pendingRequests = new ConcurrentHashMap<>(); this.maxPendingRequests = maxPendingRequests; this.pollInterval = pollInterval; - this.executor = executor; + this.requestTimeout = requestTimeout; + this.callbackExecutor = executor; + this.timeoutExecutor = Executors.newSingleThreadScheduledExecutor(); + this.loopExecutor = Executors.newSingleThreadExecutor(); } public void init() { this.responseTemplate.init(); requestTemplate.subscribe(); - executor.submit(() -> { + loopExecutor.submit(() -> { while (!stopped) { - if(pendingRequestCount.get() > maxPendingRequests){ - + while (pendingRequestCount.get() >= maxPendingRequests) { + try { + Thread.sleep(pollInterval); + } catch (InterruptedException e) { + log.trace("Failed to wait until the server has capacity to handle new requests", e); + } } - //TODO: we need to protect from reading too much requests. ConsumerRecords requests = requestTemplate.poll(Duration.ofMillis(pollInterval)); requests.forEach(request -> { Header requestIdHeader = request.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); @@ -92,12 +103,27 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT } String responseTopic = bytesToString(responseTopicHeader.value()); try { + pendingRequestCount.getAndIncrement(); Request decodedRequest = requestTemplate.decode(request); - executor.submit(() -> handler.handle(decodedRequest, - response -> reply(requestId, responseTopic, response), - e -> log.error("[{}] Failed to process the request: {}", requestId, request, e))); + AsyncCallbackTemplate.withCallbackAndTimeout(handler.handle(decodedRequest), + response -> { + pendingRequestCount.decrementAndGet(); + reply(requestId, responseTopic, response); + }, + e -> { + pendingRequestCount.decrementAndGet(); + if (e.getCause() != null && e.getCause() instanceof TimeoutException) { + log.warn("[{}] Timedout to process the request: {}", requestId, request, e); + } else { + log.trace("[{}] Failed to process the request: {}", requestId, request, e); + } + }, + requestTimeout, + timeoutExecutor, + callbackExecutor); } catch (Throwable e) { - log.error("[{}] Failed to process the request: {}", requestId, request, e); + pendingRequestCount.decrementAndGet(); + log.warn("[{}] Failed to process the request: {}", requestId, request, e); } }); } @@ -106,10 +132,16 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT public void stop() { stopped = true; + if (timeoutExecutor != null) { + timeoutExecutor.shutdownNow(); + } + if (loopExecutor != null) { + loopExecutor.shutdownNow(); + } } private void reply(UUID requestId, String topic, Response response) { - responseTemplate.send(topic, response, Collections.singletonList(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId)))); + responseTemplate.send(topic, requestId.toString(), response, Collections.singletonList(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId)))); } } diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index f0cc3c75ed..029bb14ebf 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -47,11 +47,13 @@ message TsKvListProto { } message DeviceInfoProto { - int64 deviceIdMSB = 1; - int64 deviceIdLSB = 2; - string deviceName = 3; - string deviceType = 4; - string additionalInfo = 5; + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + string deviceName = 5; + string deviceType = 6; + string additionalInfo = 7; } /** diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java index 8a596d7119..0853124acb 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -157,10 +157,10 @@ public class CoapTransportResource extends CoapResource { CoapSessionCtx ctx = new CoapSessionCtx(exchange, adaptor, processor, authService, timeout); - if (!ctx.login(credentials.get())) { - exchange.respond(ResponseCode.UNAUTHORIZED); - return Optional.empty(); - } +// if (!ctx.login(credentials.get())) { +// exchange.respond(ResponseCode.UNAUTHORIZED); +// return Optional.empty(); +// } AdaptorToSessionActorMsg msg; try { @@ -190,7 +190,7 @@ public class CoapTransportResource extends CoapResource { throw new IllegalArgumentException("Unsupported msg type: " + type); } log.trace("Processing msg: {}", msg); - processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); +// processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); } catch (AdaptorException e) { log.debug("Failed to decode payload {}", e); exchange.respond(ResponseCode.BAD_REQUEST, e.getMessage()); diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java index 7a703b2be3..c43a4ded7a 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java @@ -49,7 +49,7 @@ public class CoapSessionCtx extends DeviceAwareSessionContext { private final AtomicInteger seqNumber = new AtomicInteger(2); public CoapSessionCtx(CoapExchange exchange, CoapTransportAdaptor adaptor, SessionMsgProcessor processor, DeviceAuthService authService, long timeout) { - super(processor, authService); + super(); Request request = exchange.advanced().getRequest(); this.token = request.getTokenString(); this.sessionId = new CoapSessionId(request.getSource().getHostAddress(), request.getSourcePort(), this.token); @@ -112,7 +112,7 @@ public class CoapSessionCtx extends DeviceAwareSessionContext { public void close() { log.info("[{}] Closing processing context. Timeout: {}", sessionId, exchange.advanced().isTimedOut()); - processor.process(exchange.advanced().isTimedOut() ? SessionCloseMsg.onTimeout(sessionId) : SessionCloseMsg.onError(sessionId)); +// processor.process(exchange.advanced().isTimedOut() ? SessionCloseMsg.onTimeout(sessionId) : SessionCloseMsg.onError(sessionId)); } @Override diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index d26d076b21..add89b1e1a 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -73,20 +73,20 @@ public class DeviceApiController { if (quotaExceeded(httpRequest, responseWriter)) { return responseWriter; } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - GetAttributesRequest request; - if (StringUtils.isEmpty(clientKeys) && StringUtils.isEmpty(sharedKeys)) { - request = new BasicGetAttributesRequest(0); - } else { - Set clientKeySet = !StringUtils.isEmpty(clientKeys) ? new HashSet<>(Arrays.asList(clientKeys.split(","))) : null; - Set sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? new HashSet<>(Arrays.asList(sharedKeys.split(","))) : null; - request = new BasicGetAttributesRequest(0, clientKeySet, sharedKeySet); - } - process(ctx, request); - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } +// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); +// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { +// GetAttributesRequest request; +// if (StringUtils.isEmpty(clientKeys) && StringUtils.isEmpty(sharedKeys)) { +// request = new BasicGetAttributesRequest(0); +// } else { +// Set clientKeySet = !StringUtils.isEmpty(clientKeys) ? new HashSet<>(Arrays.asList(clientKeys.split(","))) : null; +// Set sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? new HashSet<>(Arrays.asList(sharedKeys.split(","))) : null; +// request = new BasicGetAttributesRequest(0, clientKeySet, sharedKeySet); +// } +// process(ctx, request); +// } else { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); +// } return responseWriter; } @@ -98,16 +98,16 @@ public class DeviceApiController { if (quotaExceeded(request, responseWriter)) { return responseWriter; } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - process(ctx, JsonConverter.convertToAttributes(new JsonParser().parse(json))); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } +// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); +// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { +// try { +// process(ctx, JsonConverter.convertToAttributes(new JsonParser().parse(json))); +// } catch (IllegalStateException | JsonSyntaxException ex) { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); +// } +// } else { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); +// } return responseWriter; } @@ -119,15 +119,15 @@ public class DeviceApiController { return responseWriter; } HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - process(ctx, JsonConverter.convertToTelemetry(new JsonParser().parse(json))); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } +// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { +// try { +// process(ctx, JsonConverter.convertToTelemetry(new JsonParser().parse(json))); +// } catch (IllegalStateException | JsonSyntaxException ex) { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); +// } +// } else { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); +// } return responseWriter; } @@ -147,17 +147,17 @@ public class DeviceApiController { if (quotaExceeded(request, responseWriter)) { return responseWriter; } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - JsonObject response = new JsonParser().parse(json).getAsJsonObject(); - process(ctx, new ToDeviceRpcResponseMsg(requestId, response.toString())); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } +// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); +// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { +// try { +// JsonObject response = new JsonParser().parse(json).getAsJsonObject(); +// process(ctx, new ToDeviceRpcResponseMsg(requestId, response.toString())); +// } catch (IllegalStateException | JsonSyntaxException ex) { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); +// } +// } else { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); +// } return responseWriter; } @@ -169,18 +169,18 @@ public class DeviceApiController { return responseWriter; } HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - JsonObject request = new JsonParser().parse(json).getAsJsonObject(); - process(ctx, new ToServerRpcRequestMsg(0, - request.get("method").getAsString(), - request.get("params").toString())); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } +// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { +// try { +// JsonObject request = new JsonParser().parse(json).getAsJsonObject(); +// process(ctx, new ToServerRpcRequestMsg(0, +// request.get("method").getAsString(), +// request.get("params").toString())); +// } catch (IllegalStateException | JsonSyntaxException ex) { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); +// } +// } else { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); +// } return responseWriter; } @@ -197,16 +197,16 @@ public class DeviceApiController { if (quotaExceeded(httpRequest, responseWriter)) { return responseWriter; } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter, timeout); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - process(ctx, msg); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } +// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter, timeout); +// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { +// try { +// process(ctx, msg); +// } catch (IllegalStateException | JsonSyntaxException ex) { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); +// } +// } else { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); +// } return responseWriter; } @@ -220,7 +220,7 @@ public class DeviceApiController { private void process(HttpSessionCtx ctx, FromDeviceMsg request) { AdaptorToSessionActorMsg msg = new BasicAdaptorToSessionActorMsg(ctx, request); - processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); +// processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); } private boolean quotaExceeded(HttpServletRequest request, DeferredResult responseWriter) { diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java index 4732785de2..e503409b23 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java @@ -43,7 +43,7 @@ public class HttpSessionCtx extends DeviceAwareSessionContext { private final DeferredResult responseWriter; public HttpSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, DeferredResult responseWriter, long timeout) { - super(processor, authService); + super(); this.sessionId = new HttpSessionId(); this.responseWriter = responseWriter; this.timeout = timeout; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index cc40ee0975..b500a31e69 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.transport.mqtt; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 8c64e7eeb2..a318b30e66 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -388,7 +388,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement ctx.close(); } else { ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); - deviceSessionCtx.setDeviceInfo(deviceSessionCtx.getDeviceInfo()); + deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); transportService.process(getSessionEventMsg(SessionEvent.OPEN), null); checkGatewaySession(); } diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java deleted file mode 100644 index 493c0c8b9f..0000000000 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/AsyncCallbackTemplate.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.thingsboard.server.mqtt.service; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; - -import javax.annotation.Nullable; -import java.util.concurrent.Executor; -import java.util.function.Consumer; - -/** - * Created by ashvayka on 05.10.18. - */ -public class AsyncCallbackTemplate { - - public static void withCallback(ListenableFuture future, Consumer onSuccess, - Consumer onFailure) { - withCallback(future, onSuccess, onFailure, null); - } - - public static void withCallback(ListenableFuture future, Consumer onSuccess, - Consumer onFailure, Executor executor) { - FutureCallback callback = new FutureCallback() { - @Override - public void onSuccess(@Nullable T result) { - try { - onSuccess.accept(result); - } catch (Throwable th) { - onFailure(th); - } - } - - @Override - public void onFailure(Throwable t) { - onFailure.accept(t); - } - }; - if (executor != null) { - Futures.addCallback(future, callback, executor); - } else { - Futures.addCallback(future, callback); - } - } - -} diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index a1641f152f..f5fd690170 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.mqtt.service; import org.springframework.beans.factory.annotation.Autowired; @@ -5,6 +20,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.kafka.AsyncCallbackTemplate; import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; import org.thingsboard.server.kafka.TBKafkaProducerTemplate; import org.thingsboard.server.kafka.TbKafkaRequestTemplate; @@ -23,19 +39,19 @@ import java.util.concurrent.Executors; @Service public class MqttTransportService implements TransportService { - @Value("${kafka.rule-engine.topic}") + @Value("${kafka.rule_engine.topic}") private String ruleEngineTopic; - @Value("${kafka.transport-api.requests-topic}") + @Value("${kafka.transport_api.requests_topic}") private String transportApiRequestsTopic; - @Value("${kafka.transport-api.responses-topic}") + @Value("${kafka.transport_api.responses_topic}") private String transportApiResponsesTopic; - @Value("${kafka.transport-api.max_pending_requests}") + @Value("${kafka.transport_api.max_pending_requests}") private long maxPendingRequests; - @Value("${kafka.transport-api.max_requests_timeout}") + @Value("${kafka.transport_api.max_requests_timeout}") private long maxRequestsTimeout; - @Value("${kafka.transport-api.response_poll_interval}") + @Value("${kafka.transport_api.response_poll_interval}") private int responsePollDuration; - @Value("${kafka.transport-api.response_auto_commit_interval}") + @Value("${kafka.transport_api.response_auto_commit_interval}") private int autoCommitInterval; @Autowired diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java index f931db6a2e..8ae4d1711f 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.mqtt.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java index 22e16478ae..66e64b56e6 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.mqtt.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; diff --git a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml index a2a0d54adc..707fb4bcc1 100644 --- a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml @@ -73,13 +73,12 @@ kafka: batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" linger.ms: "${TB_KAFKA_LINGER_MS:1}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" - transport-api: - requests-topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" - responses-topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" max_requests_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" response_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" - # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted - rule-engine: + rule_engine: topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" From 6c61169452092e18611f968e0b32da68309ba7db Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Mon, 8 Oct 2018 17:33:58 +0300 Subject: [PATCH 176/307] Ability to handle X509 certificate login via MQTT --- .../transport/RemoteTransportApiService.java | 28 +++++--- .../common/transport/TransportService.java | 5 +- .../transport/src/main/proto/transport.proto | 9 ++- .../transport/mqtt/MqttTransportHandler.java | 66 +++++++++++-------- .../mqtt/service/MqttTransportService.java | 12 +++- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java index 0c7b5f4327..6e9b2efa0a 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -33,7 +33,8 @@ import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; import org.thingsboard.server.kafka.TBKafkaProducerTemplate; import org.thingsboard.server.kafka.TbKafkaResponseTemplate; @@ -118,17 +119,24 @@ public class RemoteTransportApiService implements TransportApiService { public ListenableFuture handle(TransportApiRequestMsg transportApiRequestMsg) throws Exception { if (transportApiRequestMsg.hasValidateTokenRequestMsg()) { ValidateDeviceTokenRequestMsg msg = transportApiRequestMsg.getValidateTokenRequestMsg(); - //TODO: Make async and enable caching - DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(msg.getToken()); - if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.ACCESS_TOKEN) { - return getDeviceInfo(credentials.getDeviceId()); - } else { - return getEmptyTransportApiResponseFuture(); - } + return validateCredentials(msg.getToken(), DeviceCredentialsType.ACCESS_TOKEN); + } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { + ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); + return validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); } return getEmptyTransportApiResponseFuture(); } + private ListenableFuture validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) { + //TODO: Make async and enable caching + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId); + if (credentials != null && credentials.getCredentialsType() == credentialsType) { + return getDeviceInfo(credentials.getDeviceId()); + } else { + return getEmptyTransportApiResponseFuture(); + } + } + private ListenableFuture getDeviceInfo(DeviceId deviceId) { return Futures.transform(deviceService.findDeviceByIdAsync(deviceId), device -> { if (device == null) { @@ -146,7 +154,7 @@ public class RemoteTransportApiService implements TransportApiService { .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())) .build(); return TransportApiResponseMsg.newBuilder() - .setValidateTokenResponseMsg(ValidateDeviceTokenResponseMsg.newBuilder().setDeviceInfo(deviceInfoProto).build()).build(); + .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.newBuilder().setDeviceInfo(deviceInfoProto).build()).build(); } catch (JsonProcessingException e) { log.warn("[{}] Failed to lookup device by id", deviceId, e); return getEmptyTransportApiResponse(); @@ -160,6 +168,6 @@ public class RemoteTransportApiService implements TransportApiService { private TransportApiResponseMsg getEmptyTransportApiResponse() { return TransportApiResponseMsg.newBuilder() - .setValidateTokenResponseMsg(ValidateDeviceTokenResponseMsg.getDefaultInstance()).build(); + .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.getDefaultInstance()).build(); } } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index e72a626ca1..84d34e12cb 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -23,7 +23,10 @@ import org.thingsboard.server.gen.transport.TransportProtos; public interface TransportService { void process(TransportProtos.ValidateDeviceTokenRequestMsg msg, - TransportServiceCallback callback); + TransportServiceCallback callback); + + void process(TransportProtos.ValidateDeviceX509CertRequestMsg msg, + TransportServiceCallback callback); void process(TransportProtos.SessionEventMsg msg, TransportServiceCallback callback); diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index 029bb14ebf..e78f87351e 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -93,7 +93,11 @@ message ValidateDeviceTokenRequestMsg { string token = 1; } -message ValidateDeviceTokenResponseMsg { +message ValidateDeviceX509CertRequestMsg { + string hash = 1; +} + +message ValidateDeviceCredentialsResponseMsg { DeviceInfoProto deviceInfo = 1; } @@ -106,8 +110,9 @@ message TransportToRuleEngineMsg { message TransportApiRequestMsg { ValidateDeviceTokenRequestMsg validateTokenRequestMsg = 1; + ValidateDeviceX509CertRequestMsg validateX509CertRequestMsg = 2; } message TransportApiResponseMsg { - ValidateDeviceTokenResponseMsg validateTokenResponseMsg = 1; + ValidateDeviceCredentialsResponseMsg validateTokenResponseMsg = 1; } \ No newline at end of file diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index a318b30e66..23bc4cc3bd 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -380,9 +380,9 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement ctx.close(); } else { transportService.process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(userName).build(), - new TransportServiceCallback() { + new TransportServiceCallback() { @Override - public void onSuccess(ValidateDeviceTokenResponseMsg msg) { + public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { if (!msg.hasDeviceInfo()) { ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); ctx.close(); @@ -404,32 +404,36 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - protected SessionEventMsg getSessionEventMsg(SessionEvent event) { - return SessionEventMsg.newBuilder() - .setSessionInfo(sessionInfo) - .setDeviceIdMSB(deviceSessionCtx.getDeviceIdMSB()) - .setDeviceIdLSB(deviceSessionCtx.getDeviceIdLSB()) - .setEvent(event).build(); - } - private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate cert) { -// try { -// String strCert = SslUtil.getX509CertificateString(cert); -// String sha3Hash = EncryptionUtil.getSha3Hash(strCert); -// if (deviceSessionCtx.login(new DeviceX509Credentials(sha3Hash))) { -// ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); -// connected = true; -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), -// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new SessionOpenMsg()))); -// checkGatewaySession(); -// } else { -// ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); -// ctx.close(); -// } -// } catch (Exception e) { -// ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); -// ctx.close(); -// } + try { + String strCert = SslUtil.getX509CertificateString(cert); + String sha3Hash = EncryptionUtil.getSha3Hash(strCert); + transportService.process(ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { + if (!msg.hasDeviceInfo()) { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); + ctx.close(); + } else { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); + deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); + transportService.process(getSessionEventMsg(SessionEvent.OPEN), null); + checkGatewaySession(); + } + } + + @Override + public void onError(Throwable e) { + log.trace("[{}] Failed to process credentials: {}", address, sha3Hash, e); + ctx.writeAndFlush(createMqttConnAckMsg(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE)); + ctx.close(); + } + }); + } catch (Exception e) { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); + ctx.close(); + } } private X509Certificate getX509Certificate() { @@ -519,6 +523,14 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } + private SessionEventMsg getSessionEventMsg(SessionEvent event) { + return SessionEventMsg.newBuilder() + .setSessionInfo(sessionInfo) + .setDeviceIdMSB(deviceSessionCtx.getDeviceIdMSB()) + .setDeviceIdLSB(deviceSessionCtx.getDeviceIdLSB()) + .setEvent(event).build(); + } + @Override public void operationComplete(Future future) throws Exception { if (deviceSessionCtx.isConnected()) { diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index f5fd690170..4d945de394 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -104,8 +104,16 @@ public class MqttTransportService implements TransportService { } @Override - public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback callback) { - AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getToken(), TransportApiRequestMsg.newBuilder().setValidateTokenRequestMsg(msg).build()), + public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback callback) { + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getToken(), + TransportApiRequestMsg.newBuilder().setValidateTokenRequestMsg(msg).build()), + response -> callback.onSuccess(response.getValidateTokenResponseMsg()), callback::onError, transportCallbackExecutor); + } + + @Override + public void process(ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getHash(), + TransportApiRequestMsg.newBuilder().setValidateX509CertRequestMsg(msg).build()), response -> callback.onSuccess(response.getValidateTokenResponseMsg()), callback::onError, transportCallbackExecutor); } From 2f4df2803663f01df3bd5974cd7a16d2ce4710e1 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 8 Oct 2018 20:14:49 +0300 Subject: [PATCH 177/307] Configurable max datapoints limit for Aggregation NONE --- .../controller/DashboardController.java | 14 +++++ .../src/main/resources/thingsboard.yml | 5 ++ ui/src/app/api/time.service.js | 53 ++++++++++++++----- ui/src/app/api/user.service.js | 3 +- .../components/timewindow-panel.controller.js | 10 ++++ .../app/components/timewindow-panel.tpl.html | 15 +++--- ui/src/app/components/timewindow.directive.js | 2 +- 7 files changed, 80 insertions(+), 22 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 85227e76a9..450b9761e9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.controller; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -49,6 +51,11 @@ public class DashboardController extends BaseController { public static final String DASHBOARD_ID = "dashboardId"; + @Value("${dashboard.max_datapoints_limit}") + @Getter + private long maxDatapointsLimit; + + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/dashboard/serverTime", method = RequestMethod.GET) @ResponseBody @@ -56,6 +63,13 @@ public class DashboardController extends BaseController { return System.currentTimeMillis(); } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/maxDatapointsLimit", method = RequestMethod.GET) + @ResponseBody + public long getMaxDatapointsLimit() throws ThingsboardException { + return maxDatapointsLimit; + } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/dashboard/info/{dashboardId}", method = RequestMethod.GET) @ResponseBody diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 1054007c98..5eef1bdda0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -77,6 +77,11 @@ security: # Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator user_token_access_enabled: "${SECURITY_USER_TOKEN_ACCESS_ENABLED:true}" +# Dashboard parameters +dashboard: + # Maximum allowed datapoints fetched by widgets + max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" + # Device communication protocol parameters http: request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" diff --git a/ui/src/app/api/time.service.js b/ui/src/app/api/time.service.js index b8d0c41cbc..56447aa212 100644 --- a/ui/src/app/api/time.service.js +++ b/ui/src/app/api/time.service.js @@ -26,15 +26,17 @@ const MIN_INTERVAL = SECOND; const MAX_INTERVAL = 365 * 20 * DAY; const MIN_LIMIT = 10; -const AVG_LIMIT = 200; -const MAX_LIMIT = 500; +//const AVG_LIMIT = 200; +//const MAX_LIMIT = 500; /*@ngInject*/ -function TimeService($translate, types) { +function TimeService($translate, $http, $q, types) { var predefIntervals; + var maxDatapointsLimit; var service = { + loadMaxDatapointsLimit: loadMaxDatapointsLimit, minIntervalLimit: minIntervalLimit, maxIntervalLimit: maxIntervalLimit, boundMinInterval: boundMinInterval, @@ -45,20 +47,38 @@ function TimeService($translate, types) { defaultTimewindow: defaultTimewindow, toHistoryTimewindow: toHistoryTimewindow, createSubscriptionTimewindow: createSubscriptionTimewindow, - avgAggregationLimit: function () { - return AVG_LIMIT; + getMaxDatapointsLimit: function () { + return maxDatapointsLimit; + }, + getMinDatapointsLimit: function () { + return MIN_LIMIT; } } return service; + function loadMaxDatapointsLimit() { + var deferred = $q.defer(); + var url = '/api/dashboard/maxDatapointsLimit'; + $http.get(url, {ignoreLoading: true}).then(function success(response) { + maxDatapointsLimit = response.data; + if (!maxDatapointsLimit || maxDatapointsLimit <= MIN_LIMIT) { + maxDatapointsLimit = MIN_LIMIT + 1; + } + deferred.resolve(); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + function minIntervalLimit(timewindow) { - var min = timewindow / MAX_LIMIT; + var min = timewindow / 500; return boundMinInterval(min); } function avgInterval(timewindow) { - var avg = timewindow / AVG_LIMIT; + var avg = timewindow / 200; return boundMinInterval(avg); } @@ -230,7 +250,7 @@ function TimeService($translate, types) { }, aggregation: { type: types.aggregation.avg.value, - limit: AVG_LIMIT + limit: Math.floor(maxDatapointsLimit / 2) } } return timewindow; @@ -246,22 +266,27 @@ function TimeService($translate, types) { } var aggType; + var limit; if (timewindow.aggregation) { aggType = timewindow.aggregation.type || types.aggregation.avg.value; + limit = timewindow.aggregation.limit || maxDatapointsLimit; } else { aggType = types.aggregation.avg.value; + limit = maxDatapointsLimit; } + var historyTimewindow = { history: { fixedTimewindow: { startTimeMs: startTimeMs, endTimeMs: endTimeMs }, - interval: boundIntervalToTimewindow(endTimeMs - startTimeMs, interval, aggType) + interval: boundIntervalToTimewindow(endTimeMs - startTimeMs, interval, types.aggregation.avg.value) }, aggregation: { - type: aggType + type: aggType, + limit: limit } } @@ -275,7 +300,7 @@ function TimeService($translate, types) { realtimeWindowMs: null, aggregation: { interval: SECOND, - limit: AVG_LIMIT, + limit: maxDatapointsLimit, type: types.aggregation.avg.value } }; @@ -283,14 +308,14 @@ function TimeService($translate, types) { if (stateData) { subscriptionTimewindow.aggregation = { interval: SECOND, - limit: MAX_LIMIT, + limit: maxDatapointsLimit, type: types.aggregation.none.value, stateData: true }; } else { subscriptionTimewindow.aggregation = { interval: SECOND, - limit: AVG_LIMIT, + limit: maxDatapointsLimit, type: types.aggregation.avg.value }; } @@ -298,7 +323,7 @@ function TimeService($translate, types) { if (angular.isDefined(timewindow.aggregation) && !stateData) { subscriptionTimewindow.aggregation = { type: timewindow.aggregation.type || types.aggregation.avg.value, - limit: timewindow.aggregation.limit || AVG_LIMIT + limit: timewindow.aggregation.limit || maxDatapointsLimit }; } if (angular.isDefined(timewindow.realtime)) { diff --git a/ui/src/app/api/user.service.js b/ui/src/app/api/user.service.js index fa4d63c8e2..a49c4a040a 100644 --- a/ui/src/app/api/user.service.js +++ b/ui/src/app/api/user.service.js @@ -22,7 +22,7 @@ export default angular.module('thingsboard.api.user', [thingsboardApiLogin, .name; /*@ngInject*/ -function UserService($http, $q, $rootScope, adminService, dashboardService, loginService, toast, store, jwtHelper, $translate, $state, $location) { +function UserService($http, $q, $rootScope, adminService, dashboardService, timeService, loginService, toast, store, jwtHelper, $translate, $state, $location) { var currentUser = null, currentUserDetails = null, lastPublicDashboardId = null, @@ -390,6 +390,7 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, logi function loadSystemParams() { var promises = []; promises.push(loadIsUserTokenAccessEnabled()); + promises.push(timeService.loadMaxDatapointsLimit()); return $q.all(promises); } diff --git a/ui/src/app/components/timewindow-panel.controller.js b/ui/src/app/components/timewindow-panel.controller.js index 3cf368b6db..d48355c3d4 100644 --- a/ui/src/app/components/timewindow-panel.controller.js +++ b/ui/src/app/components/timewindow-panel.controller.js @@ -31,6 +31,8 @@ export default function TimewindowPanelController(mdPanelRef, $scope, timeServic vm.maxRealtimeAggInterval = maxRealtimeAggInterval; vm.minHistoryAggInterval = minHistoryAggInterval; vm.maxHistoryAggInterval = maxHistoryAggInterval; + vm.minDatapointsLimit = minDatapointsLimit; + vm.maxDatapointsLimit = maxDatapointsLimit; if (vm.historyOnly) { vm.timewindow.selectedTab = 1; @@ -86,6 +88,14 @@ export default function TimewindowPanelController(mdPanelRef, $scope, timeServic return timeService.maxIntervalLimit(currentHistoryTimewindow()); } + function minDatapointsLimit () { + return timeService.getMinDatapointsLimit(); + } + + function maxDatapointsLimit () { + return timeService.getMaxDatapointsLimit(); + } + function currentHistoryTimewindow() { if (vm.timewindow.history.historyType === 0) { return vm.timewindow.history.timewindowMs; diff --git a/ui/src/app/components/timewindow-panel.tpl.html b/ui/src/app/components/timewindow-panel.tpl.html index 271ac4cc24..50888ac129 100644 --- a/ui/src/app/components/timewindow-panel.tpl.html +++ b/ui/src/app/components/timewindow-panel.tpl.html @@ -60,19 +60,22 @@ - + aggregation.limit - + - - + + - - diff --git a/ui/src/app/components/timewindow.directive.js b/ui/src/app/components/timewindow.directive.js index 651367179c..4a6b72e7f2 100644 --- a/ui/src/app/components/timewindow.directive.js +++ b/ui/src/app/components/timewindow.directive.js @@ -228,7 +228,7 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM if (angular.isDefined(value.aggregation.type) && value.aggregation.type.length > 0) { model.aggregation.type = value.aggregation.type; } - model.aggregation.limit = value.aggregation.limit || timeService.avgAggregationLimit(); + model.aggregation.limit = value.aggregation.limit || Math.floor(timeService.getMaxDatapointsLimit() / 2); } } scope.updateDisplayValue(); From 6b4a2ff166bbd9e44012612cc74c87efccb23848 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Oct 2018 12:18:37 +0300 Subject: [PATCH 178/307] Version set to 2.1.2-SNAPSHOT --- application/pom.xml | 2 +- common/data/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/transport/pom.xml | 2 +- dao/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- ui/pom.xml | 2 +- 17 files changed, 18 insertions(+), 18 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index c46d9ce5e0..9e448380c6 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard application diff --git a/common/data/pom.xml b/common/data/pom.xml index 2a91e1510f..b4fb1b3a31 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 564704be99..d5a218f3c1 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index 126b9e492f..d39ac085b4 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard org.thingsboard diff --git a/common/transport/pom.xml b/common/transport/pom.xml index a1d15fb65a..a19cb5cfc6 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 310a3c4515..e8dda2e810 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard dao diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 5373c21cc0..a0bfd61def 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,12 +19,12 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard org.thingsboard netty-mqtt - 2.1.1 + 2.1.2-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index f03558cd6b..9cf60b2ccc 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 2.1.1 + 2.1.2-SNAPSHOT pom Thingsboard diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index dd9677dd8b..cf0ad4700a 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 1137f1fc66..925d8e2b8c 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 82a55078da..fb8a38b61e 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index f25a0a0201..b209b970ae 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard org.thingsboard diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 179969312f..ecc8181c69 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 6ff57e74f4..3f0b5ce3c0 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 5e27dd4c72..f890777d77 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index cecc2ace57..b880a69f31 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard org.thingsboard diff --git a/ui/pom.xml b/ui/pom.xml index 569ea27d6a..087065ad77 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1 + 2.1.2-SNAPSHOT thingsboard org.thingsboard From d3ee0f921dd7d81add3c2352be7ecf1905b76949 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Oct 2018 12:46:53 +0300 Subject: [PATCH 179/307] Add tb-node docker logging options --- msa/docker/docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index b6b44d7401..cfa37d1311 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -54,6 +54,11 @@ services: - "8080" - "1883:1883" - "5683:5683/udp" + logging: + driver: "json-file" + options: + max-size: "6g" + max-file: "30" env_file: - tb-node.env environment: From 7ce51c412d86c54fc5aa604597facc7b84e8e971 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Oct 2018 13:23:21 +0300 Subject: [PATCH 180/307] Improve docker update service script. --- msa/docker/docker-update-service.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/msa/docker/docker-update-service.sh b/msa/docker/docker-update-service.sh index 795146aa27..f698f9b015 100755 --- a/msa/docker/docker-update-service.sh +++ b/msa/docker/docker-update-service.sh @@ -15,4 +15,5 @@ # limitations under the License. # +docker-compose pull $@ docker-compose up -d --no-deps --build $@ From f0bccc7cfdfd92b35c586be9e6b364a1e20678d1 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 9 Oct 2018 14:16:08 +0300 Subject: [PATCH 181/307] WIP: Implementation --- .../server/actors/ActorSystemContext.java | 7 + .../server/actors/app/AppActor.java | 13 +- .../server/actors/device/DeviceActor.java | 6 +- .../device/DeviceActorMessageProcessor.java | 220 ++++++++++-------- .../actors/device/PendingSessionMsgData.java | 3 +- .../server/actors/device/SessionInfo.java | 16 +- .../actors/session/ASyncMsgProcessor.java | 156 ------------- .../AbstractSessionActorMsgProcessor.java | 122 ---------- .../server/actors/session/SessionActor.java | 143 ------------ .../actors/session/SessionManagerActor.java | 180 -------------- .../actors/session/SyncMsgProcessor.java | 95 -------- .../server/actors/tenant/TenantActor.java | 2 +- .../RemoteRuleEngineTransportService.java | 187 +++++++++++++++ .../transport/RuleEngineTransportService.java | 32 +++ .../transport/ToRuleEngineMsgDecoder.java | 31 +++ .../transport/ToTransportMsgEncoder.java | 29 +++ .../msg/TransportToDeviceActorMsgWrapper.java | 36 +++ .../src/main/resources/thingsboard.yml | 6 +- .../server/common/msg/MsgType.java | 13 +- .../device/BasicDeviceToDeviceActorMsg.java | 107 --------- .../msg/device/DeviceToDeviceActorMsg.java | 41 ---- .../server/kafka/TBKafkaConsumerTemplate.java | 12 +- .../server/kafka/TBKafkaProducerTemplate.java | 29 +-- .../server/kafka/TbKafkaResponseTemplate.java | 109 +++++---- .../common/transport/SessionMsgListener.java | 22 +- .../common/transport/TransportService.java | 34 ++- .../session/DeviceAwareSessionContext.java | 35 +-- .../transport/src/main/proto/transport.proto | 80 +++++-- .../transport/mqtt/MqttTransportHandler.java | 67 ++---- .../mqtt/service/MqttTransportService.java | 168 ++++++++++++- .../mqtt/service/ToRuleEngineMsgEncoder.java | 29 +++ .../ToTransportMsgResponseDecoder.java | 31 +++ .../src/main/resources/tb-mqtt-transport.yml | 4 + 33 files changed, 901 insertions(+), 1164 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/actors/session/ASyncMsgProcessor.java delete mode 100644 application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java delete mode 100644 application/src/main/java/org/thingsboard/server/actors/session/SessionActor.java delete mode 100644 application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java delete mode 100644 application/src/main/java/org/thingsboard/server/actors/session/SyncMsgProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java rename application/src/main/java/org/thingsboard/server/actors/session/SessionTerminationMsg.java => common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java (59%) create mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java create mode 100644 transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToTransportMsgResponseDecoder.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 8930f8476e..8ff61aca46 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -31,6 +31,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.actors.service.ActorService; @@ -69,6 +70,7 @@ import org.thingsboard.server.service.script.JsExecutorService; import org.thingsboard.server.service.script.JsInvokeService; import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import org.thingsboard.server.service.transport.RuleEngineTransportService; import javax.annotation.Nullable; import java.io.IOException; @@ -204,6 +206,11 @@ public class ActorSystemContext { @Getter private DeviceStateService deviceStateService; + @Lazy + @Autowired + @Getter + private RuleEngineTransportService ruleEngineTransportService; + @Value("${cluster.partition_id}") @Getter private long queuePartitionId; diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 6a78f78d1b..14ca58610d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -39,7 +39,6 @@ import org.thingsboard.server.common.msg.aware.TenantAwareMsg; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.core.BasicActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.model.ModelConstants; @@ -105,7 +104,7 @@ public class AppActor extends RuleChainManagerActor { case SERVICE_TO_RULE_ENGINE_MSG: onServiceToRuleEngineMsg((ServiceToRuleEngineMsg) msg); break; - case DEVICE_SESSION_TO_DEVICE_ACTOR_MSG: + case TRANSPORT_TO_DEVICE_ACTOR_MSG: case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_NAME_OR_TYPE_UPDATE_TO_DEVICE_ACTOR_MSG: @@ -169,16 +168,6 @@ public class AppActor extends RuleChainManagerActor { getOrCreateTenantActor(msg.getTenantId()).tell(msg, ActorRef.noSender()); } - private void processDeviceMsg(DeviceToDeviceActorMsg deviceToDeviceActorMsg) { - TenantId tenantId = deviceToDeviceActorMsg.getTenantId(); - ActorRef tenantActor = getOrCreateTenantActor(tenantId); - if (deviceToDeviceActorMsg.getPayload().getMsgType().requiresRulesProcessing()) { -// tenantActor.tell(new RuleChainDeviceMsg(deviceToDeviceActorMsg, ruleManager.getRuleChain(this.context())), context().self()); - } else { - tenantActor.tell(deviceToDeviceActorMsg, context().self()); - } - } - private ActorRef getOrCreateTenantActor(TenantId tenantId) { return tenantActors.computeIfAbsent(tenantId, k -> context().actorOf(Props.create(new TenantActor.ActorCreator(systemContext, tenantId)) .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME), tenantId.toString())); diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java index 99d004574e..7fabe493dd 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -26,12 +26,12 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorQueueTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; public class DeviceActor extends ContextAwareActor { @@ -50,8 +50,8 @@ public class DeviceActor extends ContextAwareActor { case CLUSTER_EVENT_MSG: processor.processClusterEventMsg((ClusterEventMsg) msg); break; - case DEVICE_SESSION_TO_DEVICE_ACTOR_MSG: - processor.process(context(), (DeviceToDeviceActorMsg) msg); + case TRANSPORT_TO_DEVICE_ACTOR_MSG: + processor.process(context(), (TransportToDeviceActorMsgWrapper) msg); break; case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: processor.processAttributesUpdate(context(), (DeviceAttributesEventNotificationMsg) msg); diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index a2ea0487cf..1313f60be5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -61,7 +61,6 @@ import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; import org.thingsboard.server.common.msg.core.ToDeviceRpcResponseMsg; import org.thingsboard.server.common.msg.core.ToServerRpcRequestMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; import org.thingsboard.server.common.msg.kv.BasicAttributeKVMsg; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; import org.thingsboard.server.common.msg.session.FromDeviceMsg; @@ -71,9 +70,11 @@ import org.thingsboard.server.common.msg.session.ToDeviceMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorQueueTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.service.rpc.FromDeviceRpcResponse; import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; import javax.annotation.Nullable; import java.util.ArrayList; @@ -92,6 +93,8 @@ import java.util.function.Consumer; import java.util.function.Predicate; import java.util.stream.Collectors; +import org.thingsboard.server.gen.transport.TransportProtos.*; + /** * @author Andrew Shvayka */ @@ -99,12 +102,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso private final TenantId tenantId; private final DeviceId deviceId; - private final Map sessions; - private final Map attributeSubscriptions; - private final Map rpcSubscriptions; + private final Map sessions; + private final Map attributeSubscriptions; + private final Map rpcSubscriptions; private final Map toDeviceRpcPendingMap; private final Map toServerRpcPendingMap; - private final Map pendingMsgs; private final Gson gson = new Gson(); private final JsonParser jsonParser = new JsonParser(); @@ -123,7 +125,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso this.rpcSubscriptions = new HashMap<>(); this.toDeviceRpcPendingMap = new HashMap<>(); this.toServerRpcPendingMap = new HashMap<>(); - this.pendingMsgs = new HashMap<>(); initAttributes(); } @@ -154,11 +155,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso boolean sent = rpcSubscriptions.size() > 0; Set syncSessionSet = new HashSet<>(); rpcSubscriptions.entrySet().forEach(sub -> { - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sub.getKey()); - sendMsgToSessionActor(response, sub.getValue().getServer()); - if (SessionType.SYNC == sub.getValue().getType()) { - syncSessionSet.add(sub.getKey()); - } +// ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sub.getKey()); +// sendMsgToSessionActor(response, sub.getValue().getServer()); +// if (SessionType.SYNC == sub.getValue().getType()) { +// syncSessionSet.add(sub.getKey()); +// } }); syncSessionSet.forEach(rpcSubscriptions::remove); @@ -191,15 +192,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - void processQueueTimeout(ActorContext context, DeviceActorQueueTimeoutMsg msg) { - PendingSessionMsgData data = pendingMsgs.remove(msg.getId()); - if (data != null) { - logger.debug("[{}] Queue put [{}] timeout detected!", deviceId, msg.getId()); - ToDeviceMsg toDeviceMsg = new RuleEngineErrorMsg(data.getSessionMsgType(), RuleEngineError.QUEUE_PUT_TIMEOUT); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServerAddress()); - } - } - void processQueueAck(ActorContext context, RuleEngineQueuePutAckMsg msg) { PendingSessionMsgData data = pendingMsgs.remove(msg.getId()); if (data != null && data.isReplyOnQueueAck()) { @@ -252,31 +244,37 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso }; } - void process(ActorContext context, DeviceToDeviceActorMsg msg) { - processSubscriptionCommands(context, msg); - processRpcResponses(context, msg); - processSessionStateMsgs(msg); + void process(ActorContext context, TransportToDeviceActorMsgWrapper wrapper) { + TransportToDeviceActorMsg msg = wrapper.getMsg(); +// processSubscriptionCommands(context, msg); +// processRpcResponses(context, msg); + if (msg.hasSessionEvent()) { + processSessionStateMsgs(msg.getSessionInfo(), msg.getSessionEvent()); + } - SessionMsgType sessionMsgType = msg.getPayload().getMsgType(); - if (sessionMsgType.requiresRulesProcessing()) { - switch (sessionMsgType) { - case GET_ATTRIBUTES_REQUEST: - handleGetAttributesRequest(msg); - break; - case POST_ATTRIBUTES_REQUEST: - handlePostAttributesRequest(context, msg); - reportActivity(); - break; - case POST_TELEMETRY_REQUEST: - handlePostTelemetryRequest(context, msg); - reportActivity(); - break; - case TO_SERVER_RPC_REQUEST: - handleClientSideRPCRequest(context, msg); - reportActivity(); - break; - } + if (msg.hasPostAttributes()) { + handlePostAttributesRequest(context, msg.getSessionInfo(), msg.getPostAttributes()); + reportActivity(); } + if (msg.hasPostTelemetry()) { + handlePostTelemetryRequest(context, msg.getSessionInfo(), msg.getPostTelemetry()); + reportActivity(); + } + if (msg.hasGetAttributes()) { + handleGetAttributesRequest(context, msg.getSessionInfo(), msg.getGetAttributes()); + } +// SessionMsgType sessionMsgType = msg.getPayload().getMsgType(); +// if (sessionMsgType.requiresRulesProcessing()) { +// switch (sessionMsgType) { +// case GET_ATTRIBUTES_REQUEST: +// handleGetAttributesRequest(msg); +// break; +// case TO_SERVER_RPC_REQUEST: +// handleClientSideRPCRequest(context, msg); +// reportActivity(); +// break; +// } +// } } private void reportActivity() { @@ -291,6 +289,39 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso systemContext.getDeviceStateService().onDeviceDisconnect(deviceId); } + private void handleGetAttributesRequest(ActorContext context, SessionInfoProto sessionInfo, GetAttributeRequestMsg request) { + ListenableFuture> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, toOptionalSet(request.getClientAttributeNamesList())); + ListenableFuture> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, toOptionalSet(request.getSharedAttributeNamesList())); + + Futures.addCallback(Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)), new FutureCallback>>() { + @Override + public void onSuccess(@Nullable List> result) { + systemContext.getRuleEngineTransportService().process(); + BasicGetAttributesResponse response = BasicGetAttributesResponse.onSuccess(request.getMsgType(), + request.getRequestId(), BasicAttributeKVMsg.from(result.get(0), result.get(1))); + sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, src.getSessionId()), src.getServerAddress()); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof Exception) { + ToDeviceMsg toDeviceMsg = BasicStatusCodeResponse.onError(SessionMsgType.GET_ATTRIBUTES_REQUEST, request.getRequestId(), (Exception) t); + sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, src.getSessionId()), src.getServerAddress()); + } else { + logger.error("[{}] Failed to process attributes request", deviceId, t); + } + } + }); + } + + private Optional> toOptionalSet(List strings) { + if (strings == null || strings.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(new HashSet<>(strings)); + } + } + private void handleGetAttributesRequest(DeviceToDeviceActorMsg src) { GetAttributesRequest request = (GetAttributesRequest) src.getPayload(); ListenableFuture> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, request.getClientAttributeNames()); @@ -328,43 +359,20 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void handlePostAttributesRequest(ActorContext context, DeviceToDeviceActorMsg src) { - AttributesUpdateRequest request = (AttributesUpdateRequest) src.getPayload(); - - JsonObject json = new JsonObject(); - for (AttributeKvEntry kv : request.getAttributes()) { - kv.getBooleanValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getLongValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getDoubleValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getStrValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - } - - TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.POST_ATTRIBUTES_REQUEST.name(), deviceId, defaultMetaData.copy(), TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); - PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), - SessionMsgType.POST_ATTRIBUTES_REQUEST, request.getRequestId(), true, 1); - pushToRuleEngineWithTimeout(context, tbMsg, msgData); + private void handlePostAttributesRequest(ActorContext context, SessionInfoProto sessionInfo, PostAttributeMsg postAttributes) { + JsonObject json = getJsonObject(postAttributes.getKvList()); + TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.POST_ATTRIBUTES_REQUEST.name(), deviceId, defaultMetaData.copy(), + TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); + pushToRuleEngine(context, tbMsg); } - private void handlePostTelemetryRequest(ActorContext context, DeviceToDeviceActorMsg src) { - TelemetryUploadRequest request = (TelemetryUploadRequest) src.getPayload(); - - Map> tsData = request.getData(); - - PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), - SessionMsgType.POST_TELEMETRY_REQUEST, request.getRequestId(), true, tsData.size()); - - for (Map.Entry> entry : tsData.entrySet()) { - JsonObject json = new JsonObject(); - for (KvEntry kv : entry.getValue()) { - kv.getBooleanValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getLongValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getDoubleValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getStrValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - } + private void handlePostTelemetryRequest(ActorContext context, SessionInfoProto sessionInfo, PostTelemetryMsg postTelemetry) { + for (TsKvListProto tsKv : postTelemetry.getTsKvListList()) { + JsonObject json = getJsonObject(tsKv.getKvList()); TbMsgMetaData metaData = defaultMetaData.copy(); - metaData.putValue("ts", entry.getKey() + ""); + metaData.putValue("ts", tsKv.getTs() + ""); TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.POST_TELEMETRY_REQUEST.name(), deviceId, metaData, TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); - pushToRuleEngineWithTimeout(context, tbMsg, msgData); + pushToRuleEngine(context, tbMsg); } } @@ -401,16 +409,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void pushToRuleEngineWithTimeout(ActorContext context, TbMsg tbMsg, PendingSessionMsgData pendingMsgData) { - SessionMsgType sessionMsgType = pendingMsgData.getSessionMsgType(); - int requestId = pendingMsgData.getRequestId(); - if (systemContext.isQueuePersistenceEnabled()) { - pendingMsgs.put(tbMsg.getId(), pendingMsgData); - scheduleMsgWithDelay(context, new DeviceActorQueueTimeoutMsg(tbMsg.getId(), systemContext.getQueuePersistenceTimeout()), systemContext.getQueuePersistenceTimeout()); - } else { - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(BasicStatusCodeResponse.onSuccess(sessionMsgType, requestId), pendingMsgData.getSessionId()); - sendMsgToSessionActor(response, pendingMsgData.getServerAddress()); - } + private void pushToRuleEngine(ActorContext context, TbMsg tbMsg) { context.parent().tell(new DeviceActorToRuleEngineMsg(context.self(), tbMsg), context.self()); } @@ -497,13 +496,12 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void processSessionStateMsgs(DeviceToDeviceActorMsg msg) { - SessionId sessionId = msg.getSessionId(); - FromDeviceMsg inMsg = msg.getPayload(); - if (inMsg instanceof SessionOpenMsg) { + private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { + UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + if (msg.getEvent() == SessionEvent.OPEN) { logger.debug("[{}] Processing new session [{}]", deviceId, sessionId); if (sessions.size() >= systemContext.getMaxConcurrentSessionsPerDevice()) { - SessionId sessionIdToRemove = sessions.keySet().stream().findFirst().orElse(null); + UUID sessionIdToRemove = sessions.keySet().stream().findFirst().orElse(null); if (sessionIdToRemove != null) { closeSession(sessionIdToRemove, sessions.remove(sessionIdToRemove)); } @@ -512,6 +510,10 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso if (sessions.size() == 1) { reportSessionOpen(); } + } + FromDeviceMsg inMsg = msg.getPayload(); + if (inMsg instanceof SessionOpenMsg) { + logger.debug("[{}] Processing new session [{}]", deviceId, sessionId); } else if (inMsg instanceof SessionCloseMsg) { logger.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId); sessions.remove(sessionId); @@ -540,8 +542,12 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso rpcSubscriptions.clear(); } - private void closeSession(SessionId sessionId, SessionInfo sessionInfo) { - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(new SessionCloseNotification(), sessionId), sessionInfo.getServer()); + private void closeSession(UUID sessionId, SessionInfo sessionInfo) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setSessionCloseNotification(SessionCloseNotificationProto.getDefaultInstance()).build(); + systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); } void processNameOrTypeUpdate(DeviceNameOrTypeUpdateMsg msg) { @@ -552,4 +558,24 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso this.defaultMetaData.putValue("deviceType", deviceType); } + private JsonObject getJsonObject(List tsKv) { + JsonObject json = new JsonObject(); + for (KeyValueProto kv : tsKv) { + switch (kv.getType()) { + case BOOLEAN_V: + json.addProperty(kv.getKey(), kv.getBoolV()); + break; + case LONG_V: + json.addProperty(kv.getKey(), kv.getLongV()); + break; + case DOUBLE_V: + json.addProperty(kv.getKey(), kv.getDoubleV()); + break; + case STRING_V: + json.addProperty(kv.getKey(), kv.getStringV()); + break; + } + } + return json; + } } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java b/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java index 23ad966049..dfa07cf065 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.session.SessionMsgType; import java.util.Optional; +import java.util.UUID; /** * Created by ashvayka on 17.04.18. @@ -30,7 +31,7 @@ import java.util.Optional; @AllArgsConstructor public final class PendingSessionMsgData { - private final SessionId sessionId; + private final UUID sessionId; private final Optional serverAddress; private final SessionMsgType sessionMsgType; private final int requestId; diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java index 04c457c92e..9faaade208 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,10 +16,7 @@ package org.thingsboard.server.actors.device; import lombok.Data; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.SessionType; - -import java.util.Optional; +import org.thingsboard.server.gen.transport.TransportProtos.SessionType; /** * @author Andrew Shvayka @@ -27,5 +24,6 @@ import java.util.Optional; @Data public class SessionInfo { private final SessionType type; - private final Optional server; + private final String nodeId; + } diff --git a/application/src/main/java/org/thingsboard/server/actors/session/ASyncMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/session/ASyncMsgProcessor.java deleted file mode 100644 index a8f14fedf5..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/ASyncMsgProcessor.java +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorContext; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.core.AttributesSubscribeMsg; -import org.thingsboard.server.common.msg.core.ResponseMsg; -import org.thingsboard.server.common.msg.core.RpcSubscribeMsg; -import org.thingsboard.server.common.msg.core.SessionCloseMsg; -import org.thingsboard.server.common.msg.core.SessionOpenMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.session.BasicSessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ex.SessionException; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -class ASyncMsgProcessor extends AbstractSessionActorMsgProcessor { - - private boolean firstMsg = true; - private Map pendingMap = new HashMap<>(); - private Optional currentTargetServer; - private boolean subscribedToAttributeUpdates; - private boolean subscribedToRpcCommands; - - public ASyncMsgProcessor(ActorSystemContext ctx, LoggingAdapter logger, SessionId sessionId) { - super(ctx, logger, sessionId); - } - - @Override - protected void processToDeviceActorMsg(ActorContext ctx, TransportToDeviceSessionActorMsg msg) { - updateSessionCtx(msg, SessionType.ASYNC); - DeviceToDeviceActorMsg pendingMsg = toDeviceMsg(msg); - FromDeviceMsg fromDeviceMsg = pendingMsg.getPayload(); - if (firstMsg) { - if (fromDeviceMsg.getMsgType() != SessionMsgType.SESSION_OPEN) { - toDeviceMsg(new SessionOpenMsg()).ifPresent(m -> forwardToAppActor(ctx, m)); - } - firstMsg = false; - } - switch (fromDeviceMsg.getMsgType()) { - case POST_TELEMETRY_REQUEST: - case POST_ATTRIBUTES_REQUEST: - FromDeviceRequestMsg requestMsg = (FromDeviceRequestMsg) fromDeviceMsg; - if (requestMsg.getRequestId() >= 0) { - logger.debug("[{}] Pending request {} registered", requestMsg.getRequestId(), requestMsg.getMsgType()); - //TODO: handle duplicates. - pendingMap.put(requestMsg.getRequestId(), pendingMsg); - } - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - subscribedToAttributeUpdates = true; - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - subscribedToAttributeUpdates = false; - break; - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - subscribedToRpcCommands = true; - break; - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - subscribedToRpcCommands = false; - break; - default: - break; - } - currentTargetServer = forwardToAppActor(ctx, pendingMsg); - } - - @Override - public void processToDeviceMsg(ActorContext context, ToDeviceMsg msg) { - try { - if (msg.getSessionMsgType() != SessionMsgType.SESSION_CLOSE) { - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - case GET_ATTRIBUTES_RESPONSE: - ResponseMsg responseMsg = (ResponseMsg) msg; - if (responseMsg.getRequestId() >= 0) { - logger.debug("[{}] Pending request processed: {}", responseMsg.getRequestId(), responseMsg); - pendingMap.remove(responseMsg.getRequestId()); - } - break; - default: - break; - } - sessionCtx.onMsg(new BasicSessionActorToAdaptorMsg(this.sessionCtx, msg)); - } else { - sessionCtx.onMsg(org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg.onCredentialsRevoked(sessionCtx.getSessionId())); - } - } catch (SessionException e) { - logger.warning("Failed to push session response msg", e); - } - } - - @Override - public void processTimeoutMsg(ActorContext context, SessionTimeoutMsg msg) { - // TODO Auto-generated method stub - } - - @Override - protected void cleanupSession(ActorContext ctx) { - toDeviceMsg(new SessionCloseMsg()).ifPresent(m -> forwardToAppActor(ctx, m)); - } - - @Override - public void processClusterEvent(ActorContext context, ClusterEventMsg msg) { - if (pendingMap.size() > 0 || subscribedToAttributeUpdates || subscribedToRpcCommands) { - Optional newTargetServer = systemContext.getRoutingService().resolveById(getDeviceId()); - if (!newTargetServer.equals(currentTargetServer)) { - firstMsg = true; - currentTargetServer = newTargetServer; - pendingMap.values().forEach(v -> { - forwardToAppActor(context, v, currentTargetServer); - if (currentTargetServer.isPresent()) { - logger.debug("[{}] Forwarded msg to new server: {}", sessionId, currentTargetServer.get()); - } else { - logger.debug("[{}] Forwarded msg to local server.", sessionId); - } - }); - if (subscribedToAttributeUpdates) { - toDeviceMsg(new AttributesSubscribeMsg()).ifPresent(m -> forwardToAppActor(context, m, currentTargetServer)); - logger.debug("[{}] Forwarded attributes subscription.", sessionId); - } - if (subscribedToRpcCommands) { - toDeviceMsg(new RpcSubscribeMsg()).ifPresent(m -> forwardToAppActor(context, m, currentTargetServer)); - logger.debug("[{}] Forwarded rpc commands subscription.", sessionId); - } - } - } - } -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java deleted file mode 100644 index 469cda9c5f..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorContext; -import akka.actor.ActorRef; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.device.BasicDeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; - -import java.util.Optional; - -abstract class AbstractSessionActorMsgProcessor extends AbstractContextAwareMsgProcessor { - - protected final SessionId sessionId; - protected SessionContext sessionCtx; - protected DeviceToDeviceActorMsg deviceToDeviceActorMsgPrototype; - - protected AbstractSessionActorMsgProcessor(ActorSystemContext ctx, LoggingAdapter logger, SessionId sessionId) { - super(ctx, logger); - this.sessionId = sessionId; - } - - protected abstract void processToDeviceActorMsg(ActorContext ctx, TransportToDeviceSessionActorMsg msg); - - protected abstract void processTimeoutMsg(ActorContext context, SessionTimeoutMsg msg); - - protected abstract void processToDeviceMsg(ActorContext context, ToDeviceMsg msg); - - public abstract void processClusterEvent(ActorContext context, ClusterEventMsg msg); - - protected void processSessionCtrlMsg(ActorContext ctx, SessionCtrlMsg msg) { - if (msg instanceof SessionCloseMsg) { - cleanupSession(ctx); - terminateSession(ctx, sessionId); - } - } - - protected void cleanupSession(ActorContext ctx) { - } - - protected void updateSessionCtx(TransportToDeviceSessionActorMsg msg, SessionType type) { - sessionCtx = msg.getSessionMsg().getSessionContext(); - deviceToDeviceActorMsgPrototype = new BasicDeviceToDeviceActorMsg(msg, type); - } - - protected DeviceToDeviceActorMsg toDeviceMsg(TransportToDeviceSessionActorMsg msg) { - AdaptorToSessionActorMsg adaptorMsg = msg.getSessionMsg(); - return new BasicDeviceToDeviceActorMsg(deviceToDeviceActorMsgPrototype, adaptorMsg.getMsg()); - } - - protected Optional toDeviceMsg(FromDeviceMsg msg) { - if (deviceToDeviceActorMsgPrototype != null) { - return Optional.of(new BasicDeviceToDeviceActorMsg(deviceToDeviceActorMsgPrototype, msg)); - } else { - return Optional.empty(); - } - } - - protected Optional forwardToAppActor(ActorContext ctx, DeviceToDeviceActorMsg toForward) { - Optional address = systemContext.getRoutingService().resolveById(toForward.getDeviceId()); - forwardToAppActor(ctx, toForward, address); - return address; - } - - protected Optional forwardToAppActorIfAddressChanged(ActorContext ctx, DeviceToDeviceActorMsg toForward, Optional oldAddress) { - - Optional newAddress = systemContext.getRoutingService().resolveById(toForward.getDeviceId()); - if (!newAddress.equals(oldAddress)) { - getAppActor().tell(new SendToClusterMsg(toForward.getDeviceId(), toForward - .toOtherAddress(systemContext.getRoutingService().getCurrentServer())), ctx.self()); - } - return newAddress; - } - - protected void forwardToAppActor(ActorContext ctx, DeviceToDeviceActorMsg toForward, Optional address) { - if (address.isPresent()) { - systemContext.getRpcService().tell(systemContext.getEncodingService().convertToProtoDataMessage(address.get(), - toForward.toOtherAddress(systemContext.getRoutingService().getCurrentServer()))); - } else { - getAppActor().tell(toForward, ctx.self()); - } - } - - public static void terminateSession(ActorContext ctx, SessionId sessionId) { - ctx.parent().tell(new SessionTerminationMsg(sessionId), ActorRef.noSender()); - ctx.stop(ctx.self()); - } - - public DeviceId getDeviceId() { - return deviceToDeviceActorMsgPrototype.getDeviceId(); - } -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SessionActor.java b/application/src/main/java/org/thingsboard/server/actors/session/SessionActor.java deleted file mode 100644 index 05926c1825..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/SessionActor.java +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.OneForOneStrategy; -import akka.actor.SupervisorStrategy; -import akka.event.Logging; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.service.ContextAwareActor; -import org.thingsboard.server.actors.service.ContextBasedCreator; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import scala.concurrent.duration.Duration; - -public class SessionActor extends ContextAwareActor { - - private final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - - private final SessionId sessionId; - private AbstractSessionActorMsgProcessor processor; - - private SessionActor(ActorSystemContext systemContext, SessionId sessionId) { - super(systemContext); - this.sessionId = sessionId; - } - - @Override - public SupervisorStrategy supervisorStrategy() { - return new OneForOneStrategy(-1, Duration.Inf(), - throwable -> { - logger.error(throwable, "Unknown session error"); - if (throwable instanceof Error) { - return OneForOneStrategy.escalate(); - } else { - return OneForOneStrategy.resume(); - } - }); - } - - @Override - protected boolean process(TbActorMsg msg) { - switch (msg.getMsgType()) { - case TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG: - processTransportToSessionMsg((TransportToDeviceSessionActorMsg) msg); - break; - case ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG: - processActorsToSessionMsg((ActorSystemToDeviceSessionActorMsg) msg); - break; - case SESSION_TIMEOUT_MSG: - processTimeoutMsg((SessionTimeoutMsg) msg); - break; - case SESSION_CTRL_MSG: - processSessionCloseMsg((SessionCtrlMsg) msg); - break; - case CLUSTER_EVENT_MSG: - processClusterEvent((ClusterEventMsg) msg); - break; - default: return false; - } - return true; - } - - private void processClusterEvent(ClusterEventMsg msg) { - processor.processClusterEvent(context(), msg); - } - - private void processTransportToSessionMsg(TransportToDeviceSessionActorMsg msg) { - initProcessor(msg); - processor.processToDeviceActorMsg(context(), msg); - } - - private void processActorsToSessionMsg(ActorSystemToDeviceSessionActorMsg msg) { - processor.processToDeviceMsg(context(), msg.getMsg()); - } - - private void processTimeoutMsg(SessionTimeoutMsg msg) { - if (processor != null) { - processor.processTimeoutMsg(context(), msg); - } else { - logger.warning("[{}] Can't process timeout msg: {} without processor", sessionId, msg); - } - } - - private void processSessionCloseMsg(SessionCtrlMsg msg) { - if (processor != null) { - processor.processSessionCtrlMsg(context(), msg); - } else if (msg instanceof SessionCloseMsg) { - AbstractSessionActorMsgProcessor.terminateSession(context(), sessionId); - } else { - logger.warning("[{}] Can't process session ctrl msg: {} without processor", sessionId, msg); - } - } - - private void initProcessor(TransportToDeviceSessionActorMsg msg) { - if (processor == null) { - SessionMsg sessionMsg = (SessionMsg) msg.getSessionMsg(); - if (sessionMsg.getSessionContext().getSessionType() == SessionType.SYNC) { - processor = new SyncMsgProcessor(systemContext, logger, sessionId); - } else { - processor = new ASyncMsgProcessor(systemContext, logger, sessionId); - } - } - } - - public static class ActorCreator extends ContextBasedCreator { - private static final long serialVersionUID = 1L; - - private final SessionId sessionId; - - public ActorCreator(ActorSystemContext context, SessionId sessionId) { - super(context); - this.sessionId = sessionId; - } - - @Override - public SessionActor create() throws Exception { - return new SessionActor(context, sessionId); - } - } - -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java deleted file mode 100644 index 9039d302e3..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorInitializationException; -import akka.actor.ActorRef; -import akka.actor.InvalidActorNameException; -import akka.actor.LocalActorRef; -import akka.actor.OneForOneStrategy; -import akka.actor.Props; -import akka.actor.SupervisorStrategy; -import akka.actor.Terminated; -import akka.event.Logging; -import akka.event.LoggingAdapter; -import akka.japi.Function; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.service.ContextAwareActor; -import org.thingsboard.server.actors.service.ContextBasedCreator; -import org.thingsboard.server.actors.service.DefaultActorService; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.core.SessionCloseMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import scala.concurrent.duration.Duration; - -import java.util.HashMap; -import java.util.Map; - -public class SessionManagerActor extends ContextAwareActor { - - private static final int INITIAL_SESSION_MAP_SIZE = 1024; - - private final LoggingAdapter log = Logging.getLogger(getContext().system(), this); - - private final Map sessionActors; - - SessionManagerActor(ActorSystemContext systemContext) { - super(systemContext); - this.sessionActors = new HashMap<>(INITIAL_SESSION_MAP_SIZE); - } - - @Override - public SupervisorStrategy supervisorStrategy() { - return strategy; - } - - @Override - protected boolean process(TbActorMsg msg) { - //TODO Move everything here, to work with TbActorMsg - return false; - } - - @Override - public void onReceive(Object msg) throws Exception { - if (msg instanceof SessionCtrlMsg) { - onSessionCtrlMsg((SessionCtrlMsg) msg); - } else if (msg instanceof SessionAwareMsg) { - forwardToSessionActor((SessionAwareMsg) msg); - } else if (msg instanceof SessionTerminationMsg) { - onSessionTermination((SessionTerminationMsg) msg); - } else if (msg instanceof Terminated) { - onTermination((Terminated) msg); - } else if (msg instanceof SessionTimeoutMsg) { - onSessionTimeout((SessionTimeoutMsg) msg); - } else if (msg instanceof ClusterEventMsg) { - broadcast(msg); - } - } - - private void broadcast(Object msg) { - sessionActors.values().forEach(actorRef -> actorRef.tell(msg, ActorRef.noSender())); - } - - private void onSessionTimeout(SessionTimeoutMsg msg) { - String sessionIdStr = msg.getSessionId().toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor != null) { - sessionActor.tell(msg, ActorRef.noSender()); - } - } - - private void onSessionCtrlMsg(SessionCtrlMsg msg) { - String sessionIdStr = msg.getSessionId().toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor != null) { - sessionActor.tell(msg, ActorRef.noSender()); - } - } - - private void onSessionTermination(SessionTerminationMsg msg) { - String sessionIdStr = msg.getId().toUidStr(); - ActorRef sessionActor = sessionActors.remove(sessionIdStr); - if (sessionActor != null) { - log.debug("[{}] Removed session actor.", sessionIdStr); - //TODO: onSubscriptionUpdate device actor about session close; - } else { - log.debug("[{}] Session actor was already removed.", sessionIdStr); - } - } - - private void forwardToSessionActor(SessionAwareMsg msg) { - if (msg instanceof ActorSystemToDeviceSessionActorMsg || msg instanceof SessionCloseMsg) { - String sessionIdStr = msg.getSessionId().toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor != null) { - sessionActor.tell(msg, ActorRef.noSender()); - } else { - log.debug("[{}] Session actor was already removed.", sessionIdStr); - } - } else { - try { - getOrCreateSessionActor(msg.getSessionId()).tell(msg, self()); - } catch (InvalidActorNameException e) { - log.info("Invalid msg : {}", msg); - } - } - } - - private ActorRef getOrCreateSessionActor(SessionId sessionId) { - String sessionIdStr = sessionId.toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor == null) { - log.debug("[{}] Creating session actor.", sessionIdStr); - sessionActor = context().actorOf( - Props.create(new SessionActor.ActorCreator(systemContext, sessionId)).withDispatcher(DefaultActorService.SESSION_DISPATCHER_NAME), - sessionIdStr); - sessionActors.put(sessionIdStr, sessionActor); - log.debug("[{}] Created session actor.", sessionIdStr); - } - return sessionActor; - } - - private void onTermination(Terminated message) { - ActorRef terminated = message.actor(); - if (terminated instanceof LocalActorRef) { - log.info("Removed actor: {}.", terminated); - //TODO: cleanup session actors map - } else { - throw new IllegalStateException("Remote actors are not supported!"); - } - } - - public static class ActorCreator extends ContextBasedCreator { - private static final long serialVersionUID = 1L; - - public ActorCreator(ActorSystemContext context) { - super(context); - } - - @Override - public SessionManagerActor create() throws Exception { - return new SessionManagerActor(context); - } - } - - private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), new Function() { - @Override - public SupervisorStrategy.Directive apply(Throwable t) { - logger.error(t, "Unknown failure"); - return SupervisorStrategy.stop(); - } - }); -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SyncMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/session/SyncMsgProcessor.java deleted file mode 100644 index cf8df1385f..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/SyncMsgProcessor.java +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorContext; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.session.BasicSessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.msg.session.ex.SessionException; - -import java.util.Optional; - -class SyncMsgProcessor extends AbstractSessionActorMsgProcessor { - private DeviceToDeviceActorMsg pendingMsg; - private Optional currentTargetServer; - private boolean pendingResponse; - - public SyncMsgProcessor(ActorSystemContext ctx, LoggingAdapter logger, SessionId sessionId) { - super(ctx, logger, sessionId); - } - - @Override - protected void processToDeviceActorMsg(ActorContext ctx, TransportToDeviceSessionActorMsg msg) { - updateSessionCtx(msg, SessionType.SYNC); - pendingMsg = toDeviceMsg(msg); - pendingResponse = true; - currentTargetServer = forwardToAppActor(ctx, pendingMsg); - scheduleMsgWithDelay(ctx, new SessionTimeoutMsg(sessionId), getTimeout(systemContext, msg.getSessionMsg().getSessionContext()), ctx.parent()); - } - - public void processTimeoutMsg(ActorContext context, SessionTimeoutMsg msg) { - if (pendingResponse) { - try { - sessionCtx.onMsg(SessionCloseMsg.onTimeout(sessionId)); - } catch (SessionException e) { - logger.warning("Failed to push session close msg", e); - } - terminateSession(context, this.sessionId); - } - } - - public void processToDeviceMsg(ActorContext context, ToDeviceMsg msg) { - try { - sessionCtx.onMsg(new BasicSessionActorToAdaptorMsg(this.sessionCtx, msg)); - pendingResponse = false; - } catch (SessionException e) { - logger.warning("Failed to push session response msg", e); - } - terminateSession(context, this.sessionId); - } - - @Override - public void processClusterEvent(ActorContext context, ClusterEventMsg msg) { - if (pendingResponse) { - Optional newTargetServer = forwardToAppActorIfAddressChanged(context, pendingMsg, currentTargetServer); - if (logger.isDebugEnabled()) { - if (!newTargetServer.equals(currentTargetServer)) { - if (newTargetServer.isPresent()) { - logger.debug("[{}] Forwarded msg to new server: {}", sessionId, newTargetServer.get()); - } else { - logger.debug("[{}] Forwarded msg to local server.", sessionId); - } - } - } - currentTargetServer = newTargetServer; - } - } - - private long getTimeout(ActorSystemContext ctx, SessionContext sessionCtx) { - return sessionCtx.getTimeout() > 0 ? sessionCtx.getTimeout() : ctx.getSyncSessionTimeout(); - } -} diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index 460b64c772..347483a185 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -87,7 +87,7 @@ public class TenantActor extends RuleChainManagerActor { case DEVICE_ACTOR_TO_RULE_ENGINE_MSG: onDeviceActorToRuleEngineMsg((DeviceActorToRuleEngineMsg) msg); break; - case DEVICE_SESSION_TO_DEVICE_ACTOR_MSG: + case TRANSPORT_TO_DEVICE_ACTOR_MSG: case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_NAME_OR_TYPE_UPDATE_TO_DEVICE_ACTOR_MSG: diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java new file mode 100644 index 0000000000..6b827921cd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -0,0 +1,187 @@ +package org.thingsboard.server.service.transport; + +import akka.actor.ActorRef; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.service.ActorService; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.service.cluster.discovery.DiscoveryService; +import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; +import org.thingsboard.server.service.cluster.rpc.ClusterRpcService; +import org.thingsboard.server.service.encoding.DataDecodingEncodingService; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 09.10.18. + */ +@Slf4j +@Service +@ConditionalOnProperty(prefix = "transport.remote", value = "enabled", havingValue = "true") +public class RemoteRuleEngineTransportService implements RuleEngineTransportService { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Value("${transport.remote.rule_engine.topic}") + private String ruleEngineTopic; + @Value("${transport.remote.notifications.topic}") + private String notificationsTopic; + @Value("${transport.remote.rule_engine.poll_interval}") + private int pollDuration; + @Value("${transport.remote.rule_engine.auto_commit_interval}") + private int autoCommitInterval; + + @Autowired + private TbKafkaSettings kafkaSettings; + + @Autowired + private DiscoveryService discoveryService; + + @Autowired + private ActorSystemContext actorContext; + + @Autowired + private ActorService actorService; + + //TODO: completely replace this routing with the Kafka routing by partition ids. + @Autowired + private ClusterRoutingService routingService; + @Autowired + private ClusterRpcService rpcService; + @Autowired + private DataDecodingEncodingService encodingService; + + private TBKafkaConsumerTemplate ruleEngineConsumer; + private TBKafkaProducerTemplate notificationsProducer; + + private ExecutorService mainConsumerExecutor = Executors.newSingleThreadExecutor(); + + private volatile boolean stopped = false; + + @PostConstruct + public void init() { + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder notificationsProducerBuilder = TBKafkaProducerTemplate.builder(); + notificationsProducerBuilder.settings(kafkaSettings); + notificationsProducerBuilder.defaultTopic(notificationsTopic); + notificationsProducerBuilder.encoder(new ToTransportMsgEncoder()); + + notificationsProducer = notificationsProducerBuilder.build(); + notificationsProducer.init(); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder ruleEngineConsumerBuilder = TBKafkaConsumerTemplate.builder(); + ruleEngineConsumerBuilder.settings(kafkaSettings); + ruleEngineConsumerBuilder.topic(ruleEngineTopic); + ruleEngineConsumerBuilder.clientId(discoveryService.getNodeId()); + ruleEngineConsumerBuilder.groupId("tb-node"); + ruleEngineConsumerBuilder.autoCommit(true); + ruleEngineConsumerBuilder.autoCommitIntervalMs(autoCommitInterval); + ruleEngineConsumerBuilder.decoder(new ToRuleEngineMsgDecoder()); + + ruleEngineConsumer = ruleEngineConsumerBuilder.build(); + ruleEngineConsumer.subscribe(); + + mainConsumerExecutor.execute(() -> { + while (!stopped) { + try { + ConsumerRecords records = ruleEngineConsumer.poll(Duration.ofMillis(pollDuration)); + records.forEach(record -> { + try { + ToRuleEngineMsg toRuleEngineMsg = ruleEngineConsumer.decode(record); + if (toRuleEngineMsg.hasToDeviceActorMsg()) { + forwardToDeviceActor(toRuleEngineMsg.getToDeviceActorMsg()); + } + } catch (Throwable e) { + log.warn("Failed to process the notification.", e); + } + }); + } catch (Exception e) { + log.warn("Failed to obtain messages from queue.", e); + try { + Thread.sleep(pollDuration); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new requests", e2); + } + } + } + }); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg) { + process(nodeId, msg, null, null); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { + notificationsProducer.send(notificationsTopic + "." + nodeId, + ToTransportMsg.newBuilder().setToDeviceSessionMsg(msg).build() + , new QueueCallbackAdaptor(onSuccess, onFailure)); + } + + private void forwardToDeviceActor(TransportToDeviceActorMsg toDeviceActorMsg) { + TransportToDeviceActorMsgWrapper wrapper = new TransportToDeviceActorMsgWrapper(toDeviceActorMsg); + Optional address = routingService.resolveById(wrapper.getDeviceId()); + if (address.isPresent()) { + rpcService.tell(encodingService.convertToProtoDataMessage(address.get(), wrapper)); + } else { + actorContext.getAppActor().tell(wrapper, ActorRef.noSender()); + } + } + + @PreDestroy + public void destroy() { + stopped = true; + if (ruleEngineConsumer != null) { + ruleEngineConsumer.unsubscribe(); + } + if (mainConsumerExecutor != null) { + mainConsumerExecutor.shutdownNow(); + } + } + + private static class QueueCallbackAdaptor implements Callback { + private final Runnable onSuccess; + private final Consumer onFailure; + + QueueCallbackAdaptor(Runnable onSuccess, Consumer onFailure) { + this.onSuccess = onSuccess; + this.onFailure = onFailure; + } + + @Override + public void onCompletion(RecordMetadata metadata, Exception exception) { + if (exception == null) { + if (onSuccess != null) { + onSuccess.run(); + } + } else { + if (onFailure != null) { + onFailure.accept(exception); + } + } + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java new file mode 100644 index 0000000000..e6b3dd3369 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.; + +import java.util.function.Consumer; + +/** + * Created by ashvayka on 05.10.18. + */ +public interface RuleEngineTransportService { + + void process(String nodeId, DeviceActorToTransportMsg msg); + + void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java b/application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java new file mode 100644 index 0000000000..16954eea10 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 05.10.18. + */ +public class ToRuleEngineMsgDecoder implements TbKafkaDecoder { + @Override + public ToRuleEngineMsg decode(byte[] data) throws IOException { + return ToRuleEngineMsg.parseFrom(data); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java b/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java new file mode 100644 index 0000000000..5f3c026249 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.kafka.TbKafkaEncoder; + +/** + * Created by ashvayka on 05.10.18. + */ +public class ToTransportMsgEncoder implements TbKafkaEncoder { + @Override + public byte[] encode(ToTransportMsg value) { + return value.toByteArray(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java new file mode 100644 index 0000000000..3ee185893a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java @@ -0,0 +1,36 @@ +package org.thingsboard.server.service.transport.msg; + +import lombok.Data; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; +import org.thingsboard.server.common.msg.aware.TenantAwareMsg; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Created by ashvayka on 09.10.18. + */ +@Data +public class TransportToDeviceActorMsgWrapper implements TbActorMsg, DeviceAwareMsg, TenantAwareMsg, Serializable { + + private final TenantId tenantId; + private final DeviceId deviceId; + private final TransportToDeviceActorMsg msg; + + public TransportToDeviceActorMsgWrapper(TransportToDeviceActorMsg msg) { + this.msg = msg; + this.tenantId = new TenantId(new UUID(msg.getSessionInfo().getTenantIdMSB(), msg.getSessionInfo().getTenantIdLSB())); + this.deviceId = new DeviceId(new UUID(msg.getSessionInfo().getDeviceIdMSB(), msg.getSessionInfo().getDeviceIdLSB())); + } + + @Override + public MsgType getMsgType() { + return MsgType.TRANSPORT_TO_DEVICE_ACTOR_MSG; + } +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index c58f3a1a38..923976c302 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -462,4 +462,8 @@ transport: request_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" request_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:1000}" rule_engine: - topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" \ No newline at end of file + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + poll_interval: "${TB_RULE_ENGINE_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_RULE_ENGINE_AUTO_COMMIT_INTERVAL_MS:100}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" \ No newline at end of file diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java index 60e5469fa5..dfd8f98ec3 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java @@ -77,11 +77,6 @@ public enum MsgType { */ RULE_TO_SELF_MSG, - /** - * Message that is sent by Session Actor to Device Actor. Represents messages from the device itself. - */ - DEVICE_SESSION_TO_DEVICE_ACTOR_MSG, - DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG, DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG, @@ -111,6 +106,12 @@ public enum MsgType { TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG, SESSION_TIMEOUT_MSG, SESSION_CTRL_MSG, - STATS_PERSIST_TICK_MSG; + STATS_PERSIST_TICK_MSG, + + + /** + * Message that is sent by TransportRuleEngineService to Device Actor. Represents messages from the device itself. + */ + TRANSPORT_TO_DEVICE_ACTOR_MSG; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java deleted file mode 100644 index 92c5105673..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.device; - -import lombok.ToString; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; - -import java.util.Optional; - -@ToString -public class BasicDeviceToDeviceActorMsg implements DeviceToDeviceActorMsg { - - private static final long serialVersionUID = -1866795134993115408L; - - private final TenantId tenantId; - private final CustomerId customerId; - private final DeviceId deviceId; - private final SessionId sessionId; - private final SessionType sessionType; - private final ServerAddress serverAddress; - private final FromDeviceMsg msg; - - public BasicDeviceToDeviceActorMsg(DeviceToDeviceActorMsg other, FromDeviceMsg msg) { - this(null, other.getTenantId(), other.getCustomerId(), other.getDeviceId(), other.getSessionId(), other.getSessionType(), msg); - } - - public BasicDeviceToDeviceActorMsg(TransportToDeviceSessionActorMsg msg, SessionType sessionType) { - this(null, msg.getTenantId(), msg.getCustomerId(), msg.getDeviceId(), msg.getSessionId(), sessionType, msg.getSessionMsg().getMsg()); - } - - private BasicDeviceToDeviceActorMsg(ServerAddress serverAddress, TenantId tenantId, CustomerId customerId, DeviceId deviceId, SessionId sessionId, SessionType sessionType, - FromDeviceMsg msg) { - super(); - this.serverAddress = serverAddress; - this.tenantId = tenantId; - this.customerId = customerId; - this.deviceId = deviceId; - this.sessionId = sessionId; - this.sessionType = sessionType; - this.msg = msg; - } - - @Override - public DeviceId getDeviceId() { - return deviceId; - } - - @Override - public CustomerId getCustomerId() { - return customerId; - } - - public TenantId getTenantId() { - return tenantId; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - @Override - public SessionType getSessionType() { - return sessionType; - } - - @Override - public Optional getServerAddress() { - return Optional.ofNullable(serverAddress); - } - - @Override - public FromDeviceMsg getPayload() { - return msg; - } - - @Override - public DeviceToDeviceActorMsg toOtherAddress(ServerAddress otherAddress) { - return new BasicDeviceToDeviceActorMsg(otherAddress, tenantId, customerId, deviceId, sessionId, sessionType, msg); - } - - @Override - public MsgType getMsgType() { - return MsgType.DEVICE_SESSION_TO_DEVICE_ACTOR_MSG; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java deleted file mode 100644 index fe8d9f2c21..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.device; - -import java.io.Serializable; -import java.util.Optional; - -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.CustomerAwareMsg; -import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; -import org.thingsboard.server.common.msg.aware.TenantAwareMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionType; - -public interface DeviceToDeviceActorMsg extends TbActorMsg, DeviceAwareMsg, CustomerAwareMsg, TenantAwareMsg, Serializable { - - SessionId getSessionId(); - - SessionType getSessionType(); - - Optional getServerAddress(); - - FromDeviceMsg getPayload(); - - DeviceToDeviceActorMsg toOtherAddress(ServerAddress otherAddress); -} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java index 3adb1c368d..90f15a4892 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -49,7 +49,9 @@ public class TBKafkaConsumerTemplate { boolean autoCommit, int autoCommitIntervalMs) { Properties props = settings.toProps(); props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); - props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + if (groupId != null) { + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + } props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, autoCommit); props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitIntervalMs); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index 1e109d2877..8f4c095ed7 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -20,6 +20,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.admin.CreateTopicsResult; import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.producer.Callback; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; @@ -89,28 +90,28 @@ public class TBKafkaProducerTemplate { } } - public Future send(String key, T value) { - return send(key, value, null, null); + public Future send(String key, T value, Callback callback) { + return send(key, value, null, callback); } - public Future send(String key, T value, Iterable

headers) { - return send(key, value, null, headers); + public Future send(String key, T value, Iterable
headers, Callback callback) { + return send(key, value, null, headers, callback); } - public Future send(String key, T value, Long timestamp, Iterable
headers) { - return send(this.defaultTopic, key, value, timestamp, headers); + public Future send(String key, T value, Long timestamp, Iterable
headers, Callback callback) { + return send(this.defaultTopic, key, value, timestamp, headers, callback); } - public Future send(String topic, String key, T value, Iterable
headers) { - return send(topic, key, value, null, headers); + public Future send(String topic, String key, T value, Iterable
headers, Callback callback) { + return send(topic, key, value, null, headers, callback); } - public Future send(String topic, String key, T value, Long timestamp, Iterable
headers) { + public Future send(String topic, String key, T value, Long timestamp, Iterable
headers, Callback callback) { byte[] data = encoder.encode(value); ProducerRecord record; Integer partition = getPartition(topic, key, value, data); record = new ProducerRecord<>(topic, partition, timestamp, key, data, headers); - return producer.send(record); + return producer.send(record, callback); } private Integer getPartition(String topic, String key, T value, byte[] data) { diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java index 0dbf45db14..6f9e8a791b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -77,55 +77,64 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT requestTemplate.subscribe(); loopExecutor.submit(() -> { while (!stopped) { - while (pendingRequestCount.get() >= maxPendingRequests) { + try { + while (pendingRequestCount.get() >= maxPendingRequests) { + try { + Thread.sleep(pollInterval); + } catch (InterruptedException e) { + log.trace("Failed to wait until the server has capacity to handle new requests", e); + } + } + ConsumerRecords requests = requestTemplate.poll(Duration.ofMillis(pollInterval)); + requests.forEach(request -> { + Header requestIdHeader = request.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); + if (requestIdHeader == null) { + log.error("[{}] Missing requestId in header", request); + return; + } + UUID requestId = bytesToUuid(requestIdHeader.value()); + if (requestId == null) { + log.error("[{}] Missing requestId in header and body", request); + return; + } + Header responseTopicHeader = request.headers().lastHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER); + if (responseTopicHeader == null) { + log.error("[{}] Missing response topic in header", request); + return; + } + String responseTopic = bytesToString(responseTopicHeader.value()); + try { + pendingRequestCount.getAndIncrement(); + Request decodedRequest = requestTemplate.decode(request); + AsyncCallbackTemplate.withCallbackAndTimeout(handler.handle(decodedRequest), + response -> { + pendingRequestCount.decrementAndGet(); + reply(requestId, responseTopic, response); + }, + e -> { + pendingRequestCount.decrementAndGet(); + if (e.getCause() != null && e.getCause() instanceof TimeoutException) { + log.warn("[{}] Timedout to process the request: {}", requestId, request, e); + } else { + log.trace("[{}] Failed to process the request: {}", requestId, request, e); + } + }, + requestTimeout, + timeoutExecutor, + callbackExecutor); + } catch (Throwable e) { + pendingRequestCount.decrementAndGet(); + log.warn("[{}] Failed to process the request: {}", requestId, request, e); + } + }); + } catch (Throwable e) { + log.warn("Failed to obtain messages from queue.", e); try { Thread.sleep(pollInterval); - } catch (InterruptedException e) { - log.trace("Failed to wait until the server has capacity to handle new requests", e); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new requests", e2); } } - ConsumerRecords requests = requestTemplate.poll(Duration.ofMillis(pollInterval)); - requests.forEach(request -> { - Header requestIdHeader = request.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); - if (requestIdHeader == null) { - log.error("[{}] Missing requestId in header", request); - return; - } - UUID requestId = bytesToUuid(requestIdHeader.value()); - if (requestId == null) { - log.error("[{}] Missing requestId in header and body", request); - return; - } - Header responseTopicHeader = request.headers().lastHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER); - if (responseTopicHeader == null) { - log.error("[{}] Missing response topic in header", request); - return; - } - String responseTopic = bytesToString(responseTopicHeader.value()); - try { - pendingRequestCount.getAndIncrement(); - Request decodedRequest = requestTemplate.decode(request); - AsyncCallbackTemplate.withCallbackAndTimeout(handler.handle(decodedRequest), - response -> { - pendingRequestCount.decrementAndGet(); - reply(requestId, responseTopic, response); - }, - e -> { - pendingRequestCount.decrementAndGet(); - if (e.getCause() != null && e.getCause() instanceof TimeoutException) { - log.warn("[{}] Timedout to process the request: {}", requestId, request, e); - } else { - log.trace("[{}] Failed to process the request: {}", requestId, request, e); - } - }, - requestTimeout, - timeoutExecutor, - callbackExecutor); - } catch (Throwable e) { - pendingRequestCount.decrementAndGet(); - log.warn("[{}] Failed to process the request: {}", requestId, request, e); - } - }); } }); } @@ -141,7 +150,7 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT } private void reply(UUID requestId, String topic, Response response) { - responseTemplate.send(topic, requestId.toString(), response, Collections.singletonList(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId)))); + responseTemplate.send(topic, requestId.toString(), response, Collections.singletonList(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))), null); } } diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SessionTerminationMsg.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java similarity index 59% rename from application/src/main/java/org/thingsboard/server/actors/session/SessionTerminationMsg.java rename to common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index 38cf878fa7..d0e6d18de2 100644 --- a/application/src/main/java/org/thingsboard/server/actors/session/SessionTerminationMsg.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -1,26 +1,26 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.actors.session; +package org.thingsboard.server.common.transport; -import org.thingsboard.server.actors.shared.ActorTerminationMsg; -import org.thingsboard.server.common.data.id.SessionId; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; -public class SessionTerminationMsg extends ActorTerminationMsg { +/** + * Created by ashvayka on 04.10.18. + */ +public interface SessionMsgListener { - public SessionTerminationMsg(SessionId id) { - super(id); - } + void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 84d34e12cb..dc2e3062f2 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,23 +15,33 @@ */ package org.thingsboard.server.common.transport; -import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; /** * Created by ashvayka on 04.10.18. */ public interface TransportService { - void process(TransportProtos.ValidateDeviceTokenRequestMsg msg, - TransportServiceCallback callback); + void process(ValidateDeviceTokenRequestMsg msg, + TransportServiceCallback callback); - void process(TransportProtos.ValidateDeviceX509CertRequestMsg msg, - TransportServiceCallback callback); + void process(ValidateDeviceX509CertRequestMsg msg, + TransportServiceCallback callback); - void process(TransportProtos.SessionEventMsg msg, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback); - void process(TransportProtos.PostTelemetryMsg msg, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback); - void process(TransportProtos.PostAttributeMsg msg, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback); + + void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener); + + void deregisterSession(SessionInfoProto sessionInfo); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index 7b2c05e400..30689748c5 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,17 +16,12 @@ package org.thingsboard.server.common.transport.session; import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; +import lombok.Getter; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.auth.DeviceAuthResult; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; -import java.util.Optional; +import java.util.UUID; /** * @author Andrew Shvayka @@ -34,7 +29,9 @@ import java.util.Optional; @Data public abstract class DeviceAwareSessionContext implements SessionContext { - private volatile TransportProtos.DeviceInfoProto deviceInfo; + @Getter + private volatile DeviceId deviceId; + private volatile DeviceInfoProto deviceInfo; public long getDeviceIdMSB() { return deviceInfo.getDeviceIdMSB(); @@ -44,6 +41,16 @@ public abstract class DeviceAwareSessionContext implements SessionContext { return deviceInfo.getDeviceIdLSB(); } + public DeviceId getDeviceId() { + return deviceId; + } + + public void setDeviceInfo(DeviceInfoProto deviceInfo) { + this.deviceInfo = deviceInfo; + this.deviceId = new DeviceId(new UUID(deviceInfo.getDeviceIdMSB(), deviceInfo.getDeviceIdLSB())); + } + + public boolean isConnected() { return deviceInfo != null; } diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index e78f87351e..f3b3ae87a4 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -23,9 +23,12 @@ option java_outer_classname = "TransportProtos"; * Data Structures; */ message SessionInfoProto { - string nodeId = 1; - int64 sessionIdMSB = 2; - int64 sessionIdLSB = 3; + int64 sessionIdMSB = 1; + int64 sessionIdLSB = 2; + int64 tenantIdMSB = 3; + int64 tenantIdLSB = 4; + int64 deviceIdMSB = 5; + int64 deviceIdLSB = 6; } enum SessionEvent { @@ -33,12 +36,25 @@ enum SessionEvent { CLOSED = 1; } +enum SessionType { + SYNC = 0; + ASYNC = 1; +} + +enum KeyValueType { + BOOLEAN_V = 0; + LONG_V = 1; + DOUBLE_V = 2; + STRING_V = 3; +} + message KeyValueProto { string key = 1; - bool bool_v = 2; - int64 long_v = 3; - double double_v = 4; - string string_v = 5; + KeyValueType type = 2; + bool bool_v = 3; + int64 long_v = 4; + double double_v = 5; + string string_v = 6; } message TsKvListProto { @@ -60,33 +76,28 @@ message DeviceInfoProto { * Messages that use Data Structures; */ message SessionEventMsg { - SessionInfoProto sessionInfo = 1; - int64 deviceIdMSB = 2; - int64 deviceIdLSB = 3; - SessionEvent event = 4; + string nodeId = 1; + SessionType sessionType = 2; + SessionEvent event = 3; } message PostTelemetryMsg { - SessionInfoProto sessionInfo = 1; - repeated TsKvListProto tsKvList = 2; + repeated TsKvListProto tsKvList = 1; } message PostAttributeMsg { - SessionInfoProto sessionInfo = 1; - repeated TsKvListProto tsKvList = 2; + repeated KeyValueProto kv = 1; } message GetAttributeRequestMsg { - SessionInfoProto sessionInfo = 1; - repeated string clientAttributeNames = 2; - repeated string sharedAttributeNames = 3; + repeated string clientAttributeNames = 1; + repeated string sharedAttributeNames = 2; } message GetAttributeResponseMsg { - SessionInfoProto sessionInfo = 1; - repeated TsKvListProto clientAttributeList = 2; - repeated TsKvListProto sharedAttributeList = 3; - repeated string deletedAttributeKeys = 4; + repeated TsKvListProto clientAttributeList = 1; + repeated TsKvListProto sharedAttributeList = 2; + repeated string deletedAttributeKeys = 3; } message ValidateDeviceTokenRequestMsg { @@ -101,11 +112,34 @@ message ValidateDeviceCredentialsResponseMsg { DeviceInfoProto deviceInfo = 1; } +message SessionCloseNotificationProto { + string message = 1; +} + +message TransportToDeviceActorMsg { + SessionInfoProto sessionInfo = 1; + SessionEventMsg sessionEvent = 2; + PostTelemetryMsg postTelemetry = 3; + PostAttributeMsg postAttributes = 4; + GetAttributeRequestMsg getAttributes = 5; +} + +message DeviceActorToTransportMsg { + int64 sessionIdMSB = 1; + int64 sessionIdLSB = 2; + SessionCloseNotificationProto sessionCloseNotification = 3; + GetAttributeResponseMsg getAttributesResponse = 4; +} + /** * Main messages; */ -message TransportToRuleEngineMsg { +message ToRuleEngineMsg { + TransportToDeviceActorMsg toDeviceActorMsg = 1; +} +message ToTransportMsg { + DeviceActorToTransportMsg toDeviceSessionMsg = 1; } message TransportApiRequestMsg { diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 23bc4cc3bd..d8f1d10b11 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -26,32 +26,25 @@ import io.netty.handler.codec.mqtt.MqttFixedHeader; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; import io.netty.handler.codec.mqtt.MqttPubAckMessage; -import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttSubAckMessage; import io.netty.handler.codec.mqtt.MqttSubAckPayload; -import io.netty.handler.codec.mqtt.MqttSubscribeMessage; -import io.netty.handler.codec.mqtt.MqttTopicSubscription; -import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage; import io.netty.handler.ssl.SslHandler; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.GenericFutureListener; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.data.security.DeviceX509Credentials; -import org.thingsboard.server.common.msg.core.SessionOpenMsg; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.quota.QuotaService; import org.thingsboard.server.dao.EncryptionUtil; -import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; import org.thingsboard.server.transport.mqtt.session.GatewaySessionCtx; @@ -61,49 +54,19 @@ import javax.net.ssl.SSLPeerUnverifiedException; import javax.security.cert.X509Certificate; import java.io.IOException; import java.net.InetSocketAddress; -import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import org.thingsboard.server.gen.transport.TransportProtos.*; - import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_ACCEPTED; import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD; import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED; import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK; -import static io.netty.handler.codec.mqtt.MqttMessageType.PINGRESP; import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK; import static io.netty.handler.codec.mqtt.MqttMessageType.SUBACK; -import static io.netty.handler.codec.mqtt.MqttMessageType.UNSUBACK; import static io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE; import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE; -import static io.netty.handler.codec.mqtt.MqttQoS.FAILURE; -import static org.thingsboard.server.common.msg.session.SessionMsgType.GET_ATTRIBUTES_REQUEST; -import static org.thingsboard.server.common.msg.session.SessionMsgType.POST_ATTRIBUTES_REQUEST; -import static org.thingsboard.server.common.msg.session.SessionMsgType.POST_TELEMETRY_REQUEST; -import static org.thingsboard.server.common.msg.session.SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; -import static org.thingsboard.server.common.msg.session.SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; -import static org.thingsboard.server.common.msg.session.SessionMsgType.TO_DEVICE_RPC_RESPONSE; -import static org.thingsboard.server.common.msg.session.SessionMsgType.TO_SERVER_RPC_REQUEST; -import static org.thingsboard.server.common.msg.session.SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST; -import static org.thingsboard.server.common.msg.session.SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST; -import static org.thingsboard.server.transport.mqtt.MqttTopics.BASE_GATEWAY_API_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_ATTRIBUTES_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_REQUESTS_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_RESPONSE_SUB_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_RPC_RESPONSE_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.DEVICE_TELEMETRY_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_ATTRIBUTES_REQUEST_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_ATTRIBUTES_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_CONNECT_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_DISCONNECT_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_RPC_TOPIC; -import static org.thingsboard.server.transport.mqtt.MqttTopics.GATEWAY_TELEMETRY_TOPIC; /** * @author Andrew Shvayka @@ -389,7 +352,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } else { ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); - transportService.process(getSessionEventMsg(SessionEvent.OPEN), null); + transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.OPEN), null); checkGatewaySession(); } } @@ -418,7 +381,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } else { ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); - transportService.process(getSessionEventMsg(SessionEvent.OPEN), null); + transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.OPEN), null); checkGatewaySession(); } } @@ -452,7 +415,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void processDisconnect(ChannelHandlerContext ctx) { ctx.close(); if (deviceSessionCtx.isConnected()) { - transportService.process(getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.CLOSED), null); if (gatewaySessionCtx != null) { gatewaySessionCtx.onGatewayDisconnect(); } @@ -534,7 +497,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement @Override public void operationComplete(Future future) throws Exception { if (deviceSessionCtx.isConnected()) { - transportService.process(getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.CLOSED), null); } } } diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index 4d945de394..76c1c1b8c1 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,21 +15,41 @@ */ package org.thingsboard.server.mqtt.service; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.RecordMetadata; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; import org.thingsboard.server.kafka.AsyncCallbackTemplate; import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; import org.thingsboard.server.kafka.TBKafkaProducerTemplate; import org.thingsboard.server.kafka.TbKafkaRequestTemplate; -import org.thingsboard.server.gen.transport.TransportProtos.*; import org.thingsboard.server.kafka.TbKafkaSettings; import org.thingsboard.server.transport.mqtt.MqttTransportContext; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -37,10 +57,17 @@ import java.util.concurrent.Executors; * Created by ashvayka on 05.10.18. */ @Service +@Slf4j public class MqttTransportService implements TransportService { @Value("${kafka.rule_engine.topic}") private String ruleEngineTopic; + @Value("${kafka.notifications.topic}") + private String notificationsTopic; + @Value("${kafka.notifications.poll_interval}") + private int notificationsPollDuration; + @Value("${kafka.notifications.auto_commit_interval}") + private int notificationsAutoCommitInterval; @Value("${kafka.transport_api.requests_topic}") private String transportApiRequestsTopic; @Value("${kafka.transport_api.responses_topic}") @@ -54,6 +81,8 @@ public class MqttTransportService implements TransportService { @Value("${kafka.transport_api.response_auto_commit_interval}") private int autoCommitInterval; + private ConcurrentMap sessions = new ConcurrentHashMap<>(); + @Autowired private TbKafkaSettings kafkaSettings; //We use this to get the node id. We should replace this with a component that provides the node id. @@ -63,6 +92,12 @@ public class MqttTransportService implements TransportService { private ExecutorService transportCallbackExecutor; private TbKafkaRequestTemplate transportApiTemplate; + private TBKafkaProducerTemplate ruleEngineProducer; + private TBKafkaConsumerTemplate mainConsumer; + + private ExecutorService mainConsumerExecutor = Executors.newSingleThreadExecutor(); + + private volatile boolean stopped = false; @PostConstruct public void init() { @@ -77,7 +112,7 @@ public class MqttTransportService implements TransportService { responseBuilder.settings(kafkaSettings); responseBuilder.topic(transportApiResponsesTopic + "." + transportContext.getNodeId()); responseBuilder.clientId(transportContext.getNodeId()); - responseBuilder.groupId("transport-node"); + responseBuilder.groupId(null); responseBuilder.autoCommit(true); responseBuilder.autoCommitIntervalMs(autoCommitInterval); responseBuilder.decoder(new TransportApiResponseDecoder()); @@ -91,16 +126,79 @@ public class MqttTransportService implements TransportService { builder.pollInterval(responsePollDuration); transportApiTemplate = builder.build(); transportApiTemplate.init(); + + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder ruleEngineProducerBuilder = TBKafkaProducerTemplate.builder(); + ruleEngineProducerBuilder.settings(kafkaSettings); + ruleEngineProducerBuilder.defaultTopic(ruleEngineTopic); + ruleEngineProducerBuilder.encoder(new ToRuleEngineMsgEncoder()); + ruleEngineProducer = ruleEngineProducerBuilder.build(); + ruleEngineProducer.init(); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder mainConsumerBuilder = TBKafkaConsumerTemplate.builder(); + mainConsumerBuilder.settings(kafkaSettings); + mainConsumerBuilder.topic(notificationsTopic + "." + transportContext.getNodeId()); + mainConsumerBuilder.clientId(transportContext.getNodeId()); + mainConsumerBuilder.groupId(null); + mainConsumerBuilder.autoCommit(true); + mainConsumerBuilder.autoCommitIntervalMs(notificationsAutoCommitInterval); + mainConsumerBuilder.decoder(new ToTransportMsgResponseDecoder()); + mainConsumer = mainConsumerBuilder.build(); + mainConsumer.subscribe(); + + mainConsumerExecutor.execute(() -> { + while (!stopped) { + try { + ConsumerRecords records = mainConsumer.poll(Duration.ofMillis(notificationsPollDuration)); + records.forEach(record -> { + try { + ToTransportMsg toTransportMsg = mainConsumer.decode(record); + if (toTransportMsg.hasToDeviceSessionMsg()) { + TransportProtos.DeviceActorToTransportMsg toSessionMsg = toTransportMsg.getToDeviceSessionMsg(); + UUID sessionId = new UUID(toSessionMsg.getSessionIdMSB(), toSessionMsg.getSessionIdLSB()); + SessionMsgListener listener = sessions.get(sessionId); + if (listener != null) { + transportCallbackExecutor.submit(() -> { + if (toSessionMsg.hasGetAttributesResponse()) { + listener.onGetAttributesResponse(toSessionMsg.getGetAttributesResponse()); + } + }); + } else { + //TODO: should we notify the device actor about missed session? + log.debug("[{}] Missing session.", sessionId); + } + + } + } catch (Throwable e) { + log.warn("Failed to process the notification.", e); + } + }); + } catch (Exception e) { + log.warn("Failed to obtain messages from queue.", e); + try { + Thread.sleep(notificationsPollDuration); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new requests", e2); + } + } + } + }); } @PreDestroy public void destroy() { + stopped = true; if (transportApiTemplate != null) { transportApiTemplate.stop(); } if (transportCallbackExecutor != null) { transportCallbackExecutor.shutdownNow(); } + if (mainConsumer != null) { + mainConsumer.unsubscribe(); + } + if (mainConsumerExecutor != null) { + mainConsumerExecutor.shutdownNow(); + } } @Override @@ -118,17 +216,69 @@ public class MqttTransportService implements TransportService { } @Override - public void process(SessionEventMsg msg, TransportServiceCallback callback) { + public void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSessionEvent(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + @Override + public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostTelemetry(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(PostTelemetryMsg msg, TransportServiceCallback callback) { + public void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + @Override + public void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener) { + sessions.putIfAbsent(toId(sessionInfo), listener); + //TODO: monitor sessions periodically: PING REQ/RESP, etc. } @Override - public void process(PostAttributeMsg msg, TransportServiceCallback callback) { + public void deregisterSession(SessionInfoProto sessionInfo) { + sessions.remove(toId(sessionInfo)); + } + + private UUID toId(SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + } + + private String getRoutingKey(SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB()).toString(); + } + + private static class TransportCallbackAdaptor implements Callback { + private final TransportServiceCallback callback; + + TransportCallbackAdaptor(TransportServiceCallback callback) { + this.callback = callback; + } + + @Override + public void onCompletion(RecordMetadata metadata, Exception exception) { + if (exception == null) { + callback.onSuccess(null); + } else { + callback.onError(exception); + } + } + } + private void send(SessionInfoProto sessionInfo, ToRuleEngineMsg toRuleEngineMsg, TransportServiceCallback callback) { + ruleEngineProducer.send(getRoutingKey(sessionInfo), toRuleEngineMsg, new TransportCallbackAdaptor(callback)); } } diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java new file mode 100644 index 0000000000..ee929ceb6f --- /dev/null +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.mqtt.service; + +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.kafka.TbKafkaEncoder; + +/** + * Created by ashvayka on 05.10.18. + */ +public class ToRuleEngineMsgEncoder implements TbKafkaEncoder { + @Override + public byte[] encode(ToRuleEngineMsg value) { + return value.toByteArray(); + } +} diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToTransportMsgResponseDecoder.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToTransportMsgResponseDecoder.java new file mode 100644 index 0000000000..b8d42049de --- /dev/null +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToTransportMsgResponseDecoder.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.mqtt.service; + +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 05.10.18. + */ +public class ToTransportMsgResponseDecoder implements TbKafkaDecoder { + @Override + public ToTransportMsg decode(byte[] data) throws IOException { + return ToTransportMsg.parseFrom(data); + } +} diff --git a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml index 707fb4bcc1..735eee03ed 100644 --- a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml @@ -82,3 +82,7 @@ kafka: response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" rule_engine: topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" From 8d64f5e66b45adacdfdcdd8a1ac6c3a883c01ef8 Mon Sep 17 00:00:00 2001 From: Sergey Tarnavskiy Date: Tue, 9 Oct 2018 16:21:05 +0300 Subject: [PATCH 182/307] Added map zooming by scroll wheel in GoogleMaps widgets and updated Spanish locale --- ui/src/app/locale/locale.constant-es_ES.json | 2104 ++++++++++-------- ui/src/app/widget/lib/google-map.js | 2 +- 2 files changed, 1172 insertions(+), 934 deletions(-) diff --git a/ui/src/app/locale/locale.constant-es_ES.json b/ui/src/app/locale/locale.constant-es_ES.json index 733db14ff4..285c589bd6 100644 --- a/ui/src/app/locale/locale.constant-es_ES.json +++ b/ui/src/app/locale/locale.constant-es_ES.json @@ -2,9 +2,9 @@ "access": { "unauthorized": "No autorizado", "unauthorized-access": "Acceso no autorizado", - "unauthorized-access-text": "Debes iniciar sesión para tener acceso a este recurso!", + "unauthorized-access-text": "Debe registrarse para tener acceso a este recurso!", "access-forbidden": "Acceso Prohibido", - "access-forbidden-text": "No tienes derechos para acceder a esta ubicación!
Intenta iniciar sesión con otro usuario si todavía quieres acceder a esta ubicación.", + "access-forbidden-text": "No tiene derechos para acceder a esta ubicación!
Intente registrarse con otro usuario si aún desea acceder a esta ubicación.", "refresh-token-expired": "La sesión ha expirado", "refresh-token-failed": "No se puede actualizar la sesión" }, @@ -15,342 +15,374 @@ "saveAs": "Guardar como", "cancel": "Cancelar", "ok": "OK", - "delete": "Borrar", + "delete": "Eliminar", "add": "Agregar", "yes": "Si", "no": "No", "update": "Actualizar", "remove": "Eliminar", "search": "Buscar", + "clear-search": "Borrar búsqueda", "assign": "Asignar", - "unassign": "Cancelar asignación", + "unassign": "Anular asignación", "share": "Compartir", "make-private": "Hacer privado", "apply": "Aplicar", "apply-changes": "Aplicar cambios", - "edit-mode": "Modo Edición", - "enter-edit-mode": "Modo Edición", + "edit-mode": "Modo edición", + "enter-edit-mode": "Ingresar modo edición", "decline-changes": "Descartar cambios", "close": "Cerrar", - "back": "Atrás", - "run": "Correr", - "sign-in": "Regístrate!", + "back": "Atras", + "run": "Ejecutar", + "sign-in": "Registrarse!", "edit": "Editar", "view": "Ver", "create": "Crear", "drag": "Arrastrar", - "refresh": "Refrescar", + "refresh": "Refrecar", "undo": "Deshacer", "copy": "Copiar", "paste": "Pegar", + "copy-reference": "Copiar referencia", + "paste-reference": "Pegar referencia", "import": "Importar", "export": "Exportar", - "share-via": "Compartir vía {{provider}}" + "share-via": "Share via {{provider}}" }, "aggregation": { "aggregation": "Agregación", - "function": "Función de Agregación", - "limit": "Valores Max", - "group-interval": "Intervalo de agrupación", + "function": "Función de agregación de datos", + "limit": "Valores máximos", + "group-interval": "Intervalo de agrupamiento", "min": "Min", "max": "Max", "avg": "Promedio", "sum": "Suma", - "count": "Cuenta", + "count": "Contar", "none": "Ninguno" }, "admin": { "general": "General", - "general-settings": "Ajustes General", - "outgoing-mail": "Mail de Salida", - "outgoing-mail-settings": "Ajustes del Mail de Salida", - "system-settings": "Sistema", - "test-mail-sent": "Mail de prueba enviado correctamente!", - "base-url": "URL Base", - "base-url-required": "URL Base requerida.", - "mail-from": "Mail Desde", - "mail-from-required": "Mail Desde requerido.", + "general-settings": "Configuración general", + "outgoing-mail": "Servidor de correo", + "outgoing-mail-settings": "Configuración del servidor de correo de salida", + "system-settings": "Configuración del sistema", + "test-mail-sent": "Correo de prueba fue enviado exitosamente!", + "base-url": "URL base", + "base-url-required": "URL base es requerida.", + "mail-from": "Correo desde", + "mail-from-required": "Correo Desde es requerido.", "smtp-protocol": "Protocolo SMTP", "smtp-host": "Host SMTP", - "smtp-host-required": "Host SMTP requerido.", + "smtp-host-required": "Host SMTP es requerido.", "smtp-port": "Puerto SMTP", - "smtp-port-required": "Debe ingresar un Puerto SMTP.", - "smtp-port-invalid": "No parece un Puerto SMTP valido.", - "timeout-msec": "Timeout (ms)", - "timeout-required": "Timeout requerido.", - "timeout-invalid": "No parece un Timeout valido.", + "smtp-port-required": "Debe suministrar un puerto SMTP", + "smtp-port-invalid": "Eso no parece un puerto SMTP válido.", + "timeout-msec": "Tiempo de espera (ms)", + "timeout-required": "Tiempo de espera es requerido.", + "timeout-invalid": "Eso no parece un tiempo de espera válido.", "enable-tls": "Habilitar TLS", - "send-test-mail": "Enviar mail de prueba" + "send-test-mail": "Enviar correo de prueba" }, "alarm": { - "alarm": "Alarm", - "alarms": "Alarms", - "select-alarm": "Select alarm", - "no-alarms-matching": "No alarms matching '{{entity}}' were found.", - "alarm-required": "Alarm is required", - "alarm-status": "Alarm status", + "alarm": "Alarma", + "alarms": "Alarmas", + "select-alarm": "Seleccionar alarma", + "no-alarms-matching": "Alarmas que coincidan con '{{entity}}' no fueron encontradas.", + "alarm-required": "Alarma es requerida", + "alarm-status": "Estado de la alarma", "search-status": { - "ANY": "Any", - "ACTIVE": "Active", - "CLEARED": "Cleared", - "ACK": "Acknowledged", - "UNACK": "Unacknowledged" + "ANY": "Alguna", + "ACTIVE": "Activa", + "CLEARED": "Borrada", + "ACK": "Reconocida", + "UNACK": "Ignorada" }, "display-status": { - "ACTIVE_UNACK": "Active Unacknowledged", - "ACTIVE_ACK": "Active Acknowledged", - "CLEARED_UNACK": "Cleared Unacknowledged", - "CLEARED_ACK": "Cleared Acknowledged" + "ACTIVE_UNACK": "Activa ignorada", + "ACTIVE_ACK": "Activa reconocida", + "CLEARED_UNACK": "Borrada ignorada", + "CLEARED_ACK": "Borrada reconocida" }, - "no-alarms-prompt": "No alarms found", - "created-time": "Created time", - "type": "Type", - "severity": "Severity", - "originator": "Originator", - "originator-type": "Originator type", - "details": "Details", - "status": "Status", - "alarm-details": "Alarm details", - "start-time": "Start time", - "end-time": "End time", - "ack-time": "Acknowledged time", - "clear-time": "Cleared time", - "severity-critical": "Critical", - "severity-major": "Major", - "severity-minor": "Minor", - "severity-warning": "Warning", - "severity-indeterminate": "Indeterminate", - "acknowledge": "Acknowledge", - "clear": "Clear", - "search": "Search alarms", - "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} } selected", - "no-data": "No data to display", - "polling-interval": "Alarms polling interval (sec)", - "polling-interval-required": "Alarms polling interval is required.", - "min-polling-interval-message": "At least 1 sec polling interval is allowed.", - "aknowledge-alarms-title": "Acknowledge { count, plural, 1 {1 alarm} other {# alarms} }", - "aknowledge-alarms-text": "Are you sure you want to acknowledge { count, plural, 1 {1 alarm} other {# alarms} }?", - "clear-alarms-title": "Clear { count, plural, 1 {1 alarm} other {# alarms} }", - "clear-alarms-text": "Are you sure you want to clear { count, plural, 1 {1 alarm} other {# alarms} }?" + "no-alarms-prompt": "Alarmas no encontradas", + "created-time": "Tiempo de creación", + "type": "Tipo", + "severity": "Severidad", + "originator": "Origen", + "originator-type": "Tipo de origen", + "details": "Detalles", + "status": "Estado", + "alarm-details": "Detalles de la alarma", + "start-time": "Tiempo de inicio", + "end-time": "Tiempo de finalización", + "ack-time": "Tiempo de reconocimiento", + "clear-time": "Tiempo de borrado", + "severity-critical": "Crítica", + "severity-major": "Mayor", + "severity-minor": "Menor", + "severity-warning": "Alerta", + "severity-indeterminate": "Indeterminada", + "acknowledge": "Reconocer", + "clear": "Borrar", + "search": "buscar alarmas", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} } seleccionadas", + "no-data": "No hay datos para mostrar", + "polling-interval": "Intervalo de sondeo de alarmas (seg)", + "polling-interval-required": "Intervalo de sondeo de alarmas es requerido.", + "min-polling-interval-message": "Se permite al menos 1 segundo de intervalo de sondeo.", + "aknowledge-alarms-title": "Reconocer { count, plural, 1 {1 alarm} other {# alarms} }", + "aknowledge-alarms-text": "¿Está seguro de que desea reconocer { count, plural, 1 {1 alarm} other {# alarms} }?", + "aknowledge-alarm-title": "Acknowledge Alarm", + "aknowledge-alarm-text": "Are you sure you want to acknowledge Alarm?", + "clear-alarms-title": "Borrar { count, plural, 1 {1 alarm} other {# alarms} }", + "clear-alarms-text": "¿Está seguro de que desea borrar { count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarm-title": "Clear Alarm", + "clear-alarm-text": "Are you sure you want to clear Alarm?", + "alarm-status-filter": "Alarm Status Filter" }, "alias": { - "add": "Add alias", - "edit": "Edit alias", - "name": "Alias name", - "name-required": "Alias name is required", - "duplicate-alias": "Alias with same name is already exists.", - "filter-type-single-entity": "Single entity", - "filter-type-entity-list": "Entity list", - "filter-type-entity-name": "Entity name", - "filter-type-state-entity": "Entity from dashboard state", - "filter-type-state-entity-description": "Entity taken from dashboard state parameters", - "filter-type-asset-type": "Asset type", - "filter-type-asset-type-description": "Assets of type '{{assetType}}'", - "filter-type-asset-type-and-name-description": "Assets of type '{{assetType}}' and with name starting with '{{prefix}}'", - "filter-type-device-type": "Device type", - "filter-type-device-type-description": "Devices of type '{{deviceType}}'", - "filter-type-device-type-and-name-description": "Devices of type '{{deviceType}}' and with name starting with '{{prefix}}'", - "filter-type-relations-query": "Relations query", - "filter-type-relations-query-description": "{{entities}} that have {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-asset-search-query": "Asset search query", - "filter-type-asset-search-query-description": "Assets with types {{assetTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-device-search-query": "Device search query", - "filter-type-device-search-query-description": "Devices with types {{deviceTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", - "entity-filter": "Entity filter", - "resolve-multiple": "Resolve as multiple entities", - "filter-type": "Filter type", - "filter-type-required": "Filter type is required.", - "entity-filter-no-entity-matched": "No entities matching specified filter were found.", - "no-entity-filter-specified": "No entity filter specified", - "root-state-entity": "Use dashboard state entity as root", - "root-entity": "Root entity", - "state-entity-parameter-name": "State entity parameter name", - "default-state-entity": "Default state entity", - "default-entity-parameter-name": "By default", - "max-relation-level": "Max relation level", - "unlimited-level": "Unlimited level", - "state-entity": "Dashboard state entity", - "all-entities": "All entities", - "any-relation": "any" + "add": "Agregar alias", + "edit": "Editar alias", + "name": "Nombre de alias", + "name-required": "Nombre de alias es requerido", + "duplicate-alias": "Ya existe un alias con el mismo nombre.", + "filter-type-single-entity": "Entidad única", + "filter-type-entity-list": "Lista de entidades", + "filter-type-entity-name": "Nombre de entidad", + "filter-type-state-entity": "Entidad del panel de estados", + "filter-type-state-entity-description": "Entidad tomada desde los parámetro del panel de estados", + "filter-type-asset-type": "Tipo de activo", + "filter-type-asset-type-description": "Activos de tipo '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Activos de tipo '{{assetType}}' y con nombre comenzando con '{{prefix}}'", + "filter-type-device-type": "Tipo de dispositivo", + "filter-type-device-type-description": "Dispositivos de tipo '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Dispositivos de tipo '{{deviceType}}' y con nombre comenzando con '{{prefix}}'", + "filter-type-relations-query": "Consulta de relaciones", + "filter-type-relations-query-description": "{{entities}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Consultar búsqueda de activos", + "filter-type-asset-search-query-description": "Activos con tipos {{assetTypes}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Consultar búqueda de dispositivos", + "filter-type-device-search-query-description": "Dispositivos con tipos {{deviceTypes}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "entity-filter": "Filtro de entidad", + "resolve-multiple": "Resolver como entidades múltiples", + "filter-type": "Tipo de filtro", + "filter-type-required": "Tipo de filtro es requerido.", + "entity-filter-no-entity-matched": "Entidades que coincidan con el filtro especificado no fueron encontradas.", + "no-entity-filter-specified": "No se especificó el filtro de entidad", + "root-state-entity": "Utilizar la entidad del panel de estados como raíz", + "root-entity": "Entidad raíz", + "state-entity-parameter-name": "Nombre de parámetro de entidad de estado", + "default-state-entity": "Entidad de estado predeterminada", + "default-entity-parameter-name": "Por defecto", + "max-relation-level": "Nivel máximo de relación", + "unlimited-level": "Nivel ilimitado", + "state-entity": "Entidad del panel de estados", + "all-entities": "Todas las entidades", + "any-relation": "alguna" }, "asset": { - "asset": "Asset", - "assets": "Assets", - "management": "Asset management", - "view-assets": "View Assets", - "add": "Add Asset", - "assign-to-customer": "Assign to customer", - "assign-asset-to-customer": "Assign Asset(s) To Customer", - "assign-asset-to-customer-text": "Please select the assets to assign to the customer", - "no-assets-text": "No assets found", - "assign-to-customer-text": "Please select the customer to assign the asset(s)", - "public": "Public", - "assignedToCustomer": "Assigned to customer", - "make-public": "Make asset public", - "make-private": "Make asset private", - "unassign-from-customer": "Unassign from customer", - "delete": "Delete asset", - "asset-public": "Asset is public", - "asset-type": "Asset type", - "asset-type-required": "Asset type is required.", - "select-asset-type": "Select asset type", - "enter-asset-type": "Enter asset type", - "any-asset": "Any asset", - "no-asset-types-matching": "No asset types matching '{{entitySubtype}}' were found.", - "asset-type-list-empty": "No asset types selected.", - "asset-types": "Asset types", - "name": "Name", - "name-required": "Name is required.", - "description": "Description", - "type": "Type", - "type-required": "Type is required.", - "details": "Details", - "events": "Events", - "add-asset-text": "Add new asset", - "asset-details": "Asset details", - "assign-assets": "Assign assets", - "assign-assets-text": "Assign { count, plural, 1 {1 asset} other {# assets} } to customer", - "delete-assets": "Delete assets", - "unassign-assets": "Unassign assets", - "unassign-assets-action-title": "Unassign { count, plural, 1 {1 asset} other {# assets} } from customer", - "assign-new-asset": "Assign new asset", - "delete-asset-title": "Are you sure you want to delete the asset '{{assetName}}'?", - "delete-asset-text": "Be careful, after the confirmation the asset and all related data will become unrecoverable.", - "delete-assets-title": "Are you sure you want to delete { count, plural, 1 {1 asset} other {# assets} }?", - "delete-assets-action-title": "Delete { count, plural, 1 {1 asset} other {# assets} }", - "delete-assets-text": "Be careful, after the confirmation all selected assets will be removed and all related data will become unrecoverable.", - "make-public-asset-title": "Are you sure you want to make the asset '{{assetName}}' public?", - "make-public-asset-text": "After the confirmation the asset and all its data will be made public and accessible by others.", - "make-private-asset-title": "Are you sure you want to make the asset '{{assetName}}' private?", - "make-private-asset-text": "After the confirmation the asset and all its data will be made private and won't be accessible by others.", - "unassign-asset-title": "Are you sure you want to unassign the asset '{{assetName}}'?", - "unassign-asset-text": "After the confirmation the asset will be unassigned and won't be accessible by the customer.", - "unassign-asset": "Unassign asset", - "unassign-assets-title": "Are you sure you want to unassign { count, plural, 1 {1 asset} other {# assets} }?", - "unassign-assets-text": "After the confirmation all selected assets will be unassigned and won't be accessible by the customer.", - "copyId": "Copy asset Id", - "idCopiedMessage": "Asset Id has been copied to clipboard", - "select-asset": "Select asset", - "no-assets-matching": "No assets matching '{{entity}}' were found.", - "asset-required": "Asset is required", - "name-starts-with": "Asset name starts with" + "asset": "Activo", + "assets": "Activos", + "management": "Gestión de activos", + "view-assets": "Ver Activos", + "add": "Agregar Activo", + "assign-to-customer": "Asignar al cliente", + "assign-asset-to-customer": "Asignar Activo(s) Al Cliente", + "assign-asset-to-customer-text": "Por favor seleccionar los activos para asignar al cliente", + "no-assets-text": "Activos no encontrados", + "assign-to-customer-text": "Por favor seleccionar el cliente para asignar el(los) activo(s)", + "public": "Público", + "assignedToCustomer": "Asignado al cliente", + "make-public": "Hacer público el activo", + "make-private": "Hacer privado el activo", + "unassign-from-customer": "Anular asignación del cliente", + "delete": "Eliminar activo", + "asset-public": "El activo es público", + "asset-type": "Tipo de activo", + "asset-type-required": "El tipo de activo es requerido.", + "select-asset-type": "Seleccionar tipo de activo", + "enter-asset-type": "Ingresar tipo de activo", + "any-asset": "Algún activo", + "no-asset-types-matching": "Tipos de activos que coincidan con '{{entitySubtype}}' no fueron encontrados.", + "asset-type-list-empty": "No se seleccionaron tipos de activos.", + "asset-types": "Tipos de activos", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "description": "Descripción", + "type": "Tipo", + "type-required": "El tipo es requerido.", + "details": "Detalles", + "events": "Eventos", + "add-asset-text": "Agregar nuevos activos", + "asset-details": "Detalles del activo", + "assign-assets": "Asignar activos", + "assign-assets-text": "Asignar { count, plural, 1 {1 asset} other {# assets} } al cliente", + "delete-assets": "Eliminar activos", + "unassign-assets": "Anular asignación de activos", + "unassign-assets-action-title": "Anular asignación { count, plural, 1 {1 asset} other {# assets} } del cliente", + "assign-new-asset": "Asignar nuevo activo", + "delete-asset-title": "¿Está seguro de que desea eliminar el activo '{{assetName}}'?", + "delete-asset-text": "Tener cuidado, después de la confirmación, el activo y todos los datos relacionados se volverán irrecuperables.", + "delete-assets-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 asset} other {# assets} }?", + "delete-assets-action-title": "Eliminar { count, plural, 1 {1 asset} other {# assets} }", + "delete-assets-text": "Tener cuidado, después de la confirmación se eliminarán todos los activos seleccionados y todos los datos relacionados se volverán irrecuperables.", + "make-public-asset-title": "¿Está seguro de que desea que el activo '{{assetName}}' sea público?", + "make-public-asset-text": "Después de la confirmación, el activo y todos sus datos se harán públicos y accesibles por otros.", + "make-private-asset-title": "¿Está seguro de que desea que el activo '{{assetName}}' sea privado?", + "make-private-asset-text": "Después de la confirmación, el activo y todos sus datos se harán privados y no serán accesibles para otros", + "unassign-asset-title": "¿Está seguro de que desea anular asignación del activo '{{assetName}}'?", + "unassign-asset-text": "Después de la confirmación, se anulará asignación del activo y no será accesible por el cliente.", + "unassign-asset": "Anular asignación activo", + "unassign-assets-title": "¿Está seguro de que desea anular asignación { count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-text": "Después de la confirmación, se anulará asignación de todos los activos seleccionados y no serán accesibles por el cliente", + "copyId": "Copiar ID del activo", + "idCopiedMessage": "ID del activo has sido copiada al portapapeles", + "select-asset": "Seleccionar activo", + "no-assets-matching": "Activos que coincidan con '{{entity}}' no fueron encontrados.", + "asset-required": "El activo es requerido", + "name-starts-with": "El nombre del activo comienza con" }, "attribute": { "attributes": "Atributos", "latest-telemetry": "Última telemetría", - "attributes-scope": "Alcance de los atributos del dispositivo", + "attributes-scope": "Alcance de los atributos de la entidad", "scope-latest-telemetry": "Última telemetría", - "scope-client": "Atributos del Cliente", - "scope-server": "Atributos del Servidor", - "scope-shared": "Atributos Compartidos", - "add": "Agregar atributo", + "scope-client": "Atributos del cliente", + "scope-server": "Atributos del servidor", + "scope-shared": "Atributos compartidos", + "add": "Agregar atributos", "key": "Clave", - "key-required": "Clave del atributo requerida.", + "last-update-time": "Hora de la última actualización", + "key-required": "La clave del aributo es requerida.", "value": "Valor", - "value-required": "Valor del atributo requerido.", - "delete-attributes-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 atributo} other {# atributos} }?", - "delete-attributes-text": "Ten cuidado, luego de confirmar el atributo será eliminado, y la información relacionada será irrecuperable.", - "delete-attributes": "Borrar atributo", + "value-required": "Valor del atributo es requerido.", + "delete-attributes-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "Tener cuidado, después de la confirmación, se eliminarán todos los atributos seleccionados.", + "delete-attributes": "Eliminar atributos", "enter-attribute-value": "Ingresar valor del atributo", - "show-on-widget": "Mostrar en Widget", - "widget-mode": "Widget", + "show-on-widget": "Mostrar en widget", + "widget-mode": "Modo widget", "next-widget": "Widget siguiente", - "prev-widget": "Widget anterior", - "add-to-dashboard": "Agregar al Panel", - "add-widget-to-dashboard": "Agregar widget al Panel", - "selected-attributes": "{ count, plural, 1 {1 atributo} other {# atributos} } seleccionados", - "selected-telemetry": "{ count, plural, 1 {1 unidad de telemetría } other {# unidades de telemetría} } seleccionadas." + "prev-widget": "Widget previo", + "add-to-dashboard": "Agregar al panel", + "add-widget-to-dashboard": "Agregar widget al panel", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } seleccionados", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } seleccionadas" }, "audit-log": { - "audit": "Audit", - "audit-logs": "Audit Logs", - "timestamp": "Timestamp", - "entity-type": "Entity Type", - "entity-name": "Entity Name", - "user": "User", - "type": "Type", - "status": "Status", - "details": "Details", - "type-added": "Added", - "type-deleted": "Deleted", - "type-updated": "Updated", - "type-attributes-updated": "Attributes updated", - "type-attributes-deleted": "Attributes deleted", - "type-rpc-call": "RPC call", - "type-credentials-updated": "Credentials updated", - "type-assigned-to-customer": "Assigned to Customer", - "type-unassigned-from-customer": "Unassigned from Customer", - "type-activated": "Activated", - "type-suspended": "Suspended", - "type-credentials-read": "Credentials read", - "type-attributes-read": "Attributes read", - "status-success": "Success", - "status-failure": "Failure", - "audit-log-details": "Audit log details", - "no-audit-logs-prompt": "No logs found", - "action-data": "Action data", - "failure-details": "Failure details", - "search": "Search audit logs", - "clear-search": "Clear search" + "audit": "Auditar", + "audit-logs": "Auditar Registros", + "timestamp": "Marca de tiempo", + "entity-type": "Tipo de Entidad", + "entity-name": "Nombre de Entidad", + "user": "Usuario", + "type": "Tipo", + "status": "Estado", + "details": "Detalles", + "type-added": "Agregado", + "type-deleted": "Eliminado", + "type-updated": "Actualizado", + "type-attributes-updated": "Atributos actualizados", + "type-attributes-deleted": "Atributos eliminados", + "type-rpc-call": "Llamada RPC", + "type-credentials-updated": "Credenciales actualizadas", + "type-assigned-to-customer": "Asignado al Cliente", + "type-unassigned-from-customer": "Asignación anulada del cliente", + "type-activated": "Activado", + "type-suspended": "Suspendido", + "type-credentials-read": "Credenciales leídas", + "type-attributes-read": "Atributos leídos", + "type-relation-add-or-update": "Relación actualizada", + "type-relation-delete": "Relación eliminada", + "type-relations-delete": "Toda relación eliminada", + "type-alarm-ack": "Reconocida", + "type-alarm-clear": "Borrada", + "status-success": "Éxito", + "status-failure": "Falla", + "audit-log-details": "Auditar detalles de regisstro", + "no-audit-logs-prompt": "Registros no encontrados", + "action-data": "Datos de acción", + "failure-details": "Detalles de falla", + "search": "Buscar registros de auditoría", + "clear-search": "Borrar búsqueda" }, "confirm-on-exit": { - "message": "Tienes cambios sin guardar. ¿Estás seguro que quieres abandonar la página?", - "html-message": "Tienes cambios sin guardar.
¿Estás seguro que quieres abandonar la página?", + "message": "Tiene cambios sin guardar. ¿Está seguro de que desea salir de esta página?", + "html-message": "Tiene cambios sin guardar..
¿Está seguro de que desea salir de esta página?", "title": "Cambios sin guardar" }, "contact": { "country": "País", "city": "Ciudad", - "state": "Estado/Provincia", + "state": "Estado / Provincia", "postal-code": "Código Postal", - "postal-code-invalid": "Solo se permiten dígitos.", + "postal-code-invalid": "Formato de código postal inválido.", "address": "Dirección", "address2": "Dirección 2", "phone": "Teléfono", - "email": "Email", - "no-address": "Sin Dirección" + "email": "Correo Electrónico", + "no-address": "Sin dirección" }, "common": { - "username": "Usuario", + "username": "Nombre de usuario", "password": "Contraseña", - "enter-username": "Ingresa el nombre de usuario.", - "enter-password": "Ingresa la contraseña", - "enter-search": "Ingresa búsqueda" + "enter-username": "Ingresar nombre de usuario", + "enter-password": "Ingresar contraseña", + "enter-search": "Ingresar búsqueda" }, "content-type": { "json": "Json", - "text": "Text", - "binary": "Binary (Base64)" + "text": "Texto", + "binary": "Binario (Base64)" }, "customer": { + "customer": "Cliente", "customers": "Clientes", - "management": "Gestión de Clientes", + "management": "Gestión del cliente", "dashboard": "Panel del Cliente", "dashboards": "Paneles del Cliente", - "devices": "Panel del Cliente", + "devices": "Dispositivos del Cliente", + "entity-views": "Customer Entity Views", + "assets": "Activos del Cliente", "public-dashboards": "Paneles Públicos", "public-devices": "Dispositivos Públicos", + "public-assets": "Activos Públicos", + "public-entity-views": "Public Entity Views", "add": "Agregar cliente", - "delete": "Borrar cliente", + "delete": "Eliminar cliente", "manage-customer-users": "Gestionar usuarios del cliente", "manage-customer-devices": "Gestionar dispositivos del cliente", "manage-customer-dashboards": "Gestionar paneles del cliente", "manage-public-devices": "Gestionar dispositivos públicos", "manage-public-dashboards": "Gestionar paneles públicos", + "manage-customer-assets": "Gestionar activos del cliente", + "manage-public-assets": "Gestionar activos públicos", "add-customer-text": "Agregar nuevo cliente", - "no-customers-text": "No se encontrar clientes", + "no-customers-text": "Clientes no encontrados", "customer-details": "Detalles del cliente", - "delete-customer-title": "¿Estás seguro que quieres eliminar el cliente '{{customerTitle}}'?", - "delete-customer-text": "Ten cuidado, luego de confirmar el cliente será eliminado y toda la información relacionada será irrecuperable.", - "delete-customers-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 cliente} other {# clientes} }?", - "delete-customers-action-title": "Borrar { count, plural, 1 {1 cliente} other {# clientes} }", - "delete-customers-text": "Ten cuidado, luego de confirmar todos los clientes seleccionados serán eliminados y su información relacionada será irrecuperable.", + "delete-customer-title": "¿Está seguro de que desea eliminar al cliente '{{customerTitle}}'?", + "delete-customer-text": "Tener cuidado, después de la confirmación, el cliente y todos los datos relacionados se volverán irrecuperables.", + "delete-customers-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "Eliminar { count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "Tener cuidado, después de la confirmación, todos los clientes seleccionados serán eliminados y todos los datos relacionados se volverán irrecuperables.", "manage-users": "Gestionar usuarios", + "manage-assets": "Gestionar activos", "manage-devices": "Gestionar dispositivos", "manage-dashboards": "Gestionar paneles", "title": "Título", - "title-required": "Título requerido.", - "description": "Descripción" + "title-required": "El título es requerido.", + "description": "Descripción", + "details": "Detalles", + "events": "Eventos", + "copyId": "Copiar ID del cliente", + "idCopiedMessage": "ID del cliente ha sido copiada al portapapeles", + "select-customer": "Seleccionar cliente", + "no-customers-matching": "Clientes que coincidan con '{{entity}}' no fueron encontrados.", + "customer-required": "El cliente es requerido", + "select-default-customer": "Seleccionar cliente predeterminado", + "default-customer": "Cliente predeterminado", + "default-customer-required": "Cliente predeterminado es requerido para depurar el panel en el nivel Organización" }, "datetime": { "date-from": "Fecha desde", @@ -361,582 +393,733 @@ "dashboard": { "dashboard": "Panel", "dashboards": "Paneles", - "management": "Gestión de Paneles", - "view-dashboards": "Ver paneles", - "add": "Agregar Panel", - "assign-dashboard-to-customer": "Asignar panel(es) a cliente", - "assign-dashboard-to-customer-text": "Por favor, seleccione algún panel para asignar al Cliente.", - "assign-to-customer-text": "Por favor, seleccione algún cliente para asignar al(los) panel(es).", - "assign-to-customer": "Asignar a cliente", - "unassign-from-customer": "Desasignar del cliente", + "management": "Gestión del panel", + "view-dashboards": "Ver Panel", + "add": "Agregar Paneles", + "assign-dashboard-to-customer": "Asignar Panel(es) Al Cliente", + "assign-dashboard-to-customer-text": "Por favor seleccionar los paneles para asignar al cliente", + "assign-to-customer-text": "Por favor seleccionar el cliente para asignar el(los) panel(es)", + "assign-to-customer": "Asignar al cliente", + "unassign-from-customer": "Anular asignación del cliente", "make-public": "Hacer panel público", - "make-private": "Hacer panel privado", - "no-dashboards-text": "Ningún panel encontrado", - "no-widgets": "Ningún widget configurado", + "make-private": "Hcer panel privado", + "manage-assigned-customers": "Gestionar clientes asignados", + "assigned-customers": "Clientes asignados", + "assign-to-customers": "Asignar Panel(es) Al(Los) Cliente(s)", + "assign-to-customers-text": "Por favor seleccionar los clientes para asignar el(los) panel(es)", + "unassign-from-customers": "Anular Asignación Del(De Los) Panel(es) De Los Clientes", + "unassign-from-customers-text": "Por favor seeccionar los clientes oara anular asignación del(de los) panel(es)", + "no-dashboards-text": "Paneles no encontrados", + "no-widgets": "Sin widgets configurados", "add-widget": "Agregar nuevo widget", - "title": "Titulo", + "title": "Título", "select-widget-title": "Seleccionar widget", - "select-widget-subtitle": "Lista de tipos de widgets", + "select-widget-subtitle": "Lista de tipos de widget disponibles", "delete": "Eliminar panel", - "title-required": "Título requerido.", + "title-required": "El título es requerido.", "description": "Descripción", "details": "Detalles", - "dashboard-details": "Detalles del panel", + "dashboard-details": "Detalles del Panel", "add-dashboard-text": "Agregar nuevo panel", "assign-dashboards": "Asignar paneles", - "assign-new-dashboard": "Asignar nuevo panel", - "assign-dashboards-text": "Asignar { count, plural, 1 {1 panel} other {# paneles} } al cliente", + "assign-new-dashboard": "Aignar nuevo panel", + "assign-dashboards-text": "Asignar { count, plural, 1 {1 dashboard} other {# dashboards} } a los clientes", + "unassign-dashboards-action-text": "Anular asignación { count, plural, 1 {1 dashboard} other {# dashboards} } de los clientes", "delete-dashboards": "Eliminar paneles", - "unassign-dashboards": "Desasignar paneles", - "unassign-dashboards-action-title": "Desasignar { count, plural, 1 {1 paneles} other {# paneles} } del cliente", - "delete-dashboard-title": "¿Estás seguro que quieres eliminar el panel '{{dashboardTitle}}'?", - "delete-dashboard-text": "Ten cuidado, el panel seleccionado será eliminado y la información relacionada sera irrecuperable.", - "delete-dashboards-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 panel} other {# paneles} }?", - "delete-dashboards-action-title": "Eliminar { count, plural, 1 {1 panel} other {# paneles} }", - "delete-dashboards-text": "Ten cuidado, los paneles seleccionados serán eliminados y la información relacionada será irrecuperable.", - "unassign-dashboard-title": "¿Estás seguro que quieres desasignar el panel '{{dashboardTitle}}'?", - "unassign-dashboard-text": "Luego de confirmar, el panel será desasignado y no podrá ser accesible por el cliente.", - "unassign-dashboard": "Desasignar panel", - "unassign-dashboards-title": "¿Estás seguro que quieres desasignar { count, plural, 1 {1 panel} other {# paneles} }?", - "unassign-dashboards-text": "Luego de confirmar, los paneles seleccionados serán desasignados y no podrán ser accesibles por el cliente.", - "public-dashboard-title": "El panel ahora es público", - "public-dashboard-text": "Tu panel {{dashboardTitle}} es ahora público y podrá ser accedido desde: aquí:", - "public-dashboard-notice": "Nota: No olvides hacer públicos los dispositivos relacionados para acceder a sus datos.", - "make-private-dashboard-title": "¿Estás seguro que quieres hacer el panel '{{dashboardTitle}}' privado?", - "make-private-dashboard-text": "Luego de confirmar, el panel será privado y no podrá ser accesible por otros.", - "make-private-dashboard": "Hacer panel privado", - "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", - "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "unassign-dashboards": "Anular asignación de paneles", + "unassign-dashboards-action-title": "Anular asignación { count, plural, 1 {1 dashboard} other {# dashboards} } del cliente", + "delete-dashboard-title": "¿Está seguro de que desea eliminar el panel '{{dashboardTitle}}'?", + "delete-dashboard-text": "Tener cuidado, después de la confirmación, el panel y todos los datos relacionados se volverán irrecuperables.", + "delete-dashboards-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "Eliminar { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "Tener cuidado, después de la confirmación, todos los paneles seleccionados serán eliminados y todos los datos relacionados se volverán irrecuperables.", + "unassign-dashboard-title": "¿Está seguro de que desea anular la asignación del panel '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Después de la confirmación, se anulará la asignación del panel y no será accesible por el cliente.", + "unassign-dashboard": "Anular asignación del panel", + "unassign-dashboards-title": "¿Está seguro de que desea anular asignación { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "Después de la confirmación, se anulará la asignación de todos los paneles seleccionados y no serán accesibles por el cliente.", + "public-dashboard-title": "El panel es ahora público", + "public-dashboard-text": "Su panel {{dashboardTitle}} es ahora público y es accesible a través del siguiente enlace público :", + "public-dashboard-notice": "Nota No olvide hacer públicos los dispositivos relacionados para acceder a sus datos.", + "make-private-dashboard-title": "¿Está seguro de que desea hacer el panel '{{dashboardTitle}}' privado?", + "make-private-dashboard-text": "Después de la confirmación el panel se hará privado y no será accesible por otros.", + "make-private-dashboard": "Hacer el panel privado", + "socialshare-text": "'{{dashboardTitle}}' desarrollado por ThingsBoard.", + "socialshare-title": "'{{dashboardTitle}}' desarrollado por ThingsBoard", "select-dashboard": "Seleccionar panel", - "no-dashboards-matching": "Panel '{{entity}}' no encontrado.", - "dashboard-required": "Panel requerido.", - "select-existing": "Seleccionar paneles existentes", + "no-dashboards-matching": "Paneles que coincidan con '{{entity}}' no fueron encontrados.", + "dashboard-required": "Panel es requerido.", + "select-existing": "Seleccionar panel existente", "create-new": "Crear nuevo panel", - "new-dashboard-title": "Nuevo título", + "new-dashboard-title": "Nuevo título de panel", "open-dashboard": "Abrir panel", "set-background": "Definir fondo", "background-color": "Color de fondo", "background-image": "Imagen de fondo", - "background-size-mode": "Modo tamaño de fondo", - "no-image": "No se ha seleccionado ningúna imagen", - "drop-image": "Suelte una imagen o haga clic para seleccionar un archivo para cargar.", - "settings": "Ajustes", - "columns-count": "Número de columnas", - "columns-count-required": "Número de columnas requerido.", - "min-columns-count-message": "Solo se permite un número mínimo de 10 columnas.", - "max-columns-count-message": "Solo se permite un número máximo de 1000 columnas.", + "background-size-mode": "Modo de tamaño de fondo", + "no-image": "Ninguna imagen seleccionada", + "drop-image": "Colocar una imagen o hacer clic para seleccionar un archivo para cargar.", + "settings": "Configuración", + "columns-count": "Conteo de columnas", + "columns-count-required": "Conteo de columnas es requerido.", + "min-columns-count-message": "Solamente contar 10 columnas como mínimo es permitido", + "max-columns-count-message": "Solamente contar 1000 columnas como máximo es permitiddo", "widgets-margins": "Margen entre widgets", "horizontal-margin": "Margen horizontal", - "horizontal-margin-required": "Margen horizontal requerido.", - "min-horizontal-margin-message": "Solo se permite margen horizontal mínimo de 0.", - "max-horizontal-margin-message": "Solo se permite margen horizontal máximo de 50.", + "horizontal-margin-required": "El valor del margen horizontal es requerido.", + "min-horizontal-margin-message": "Solamente es permitido el 0 como valor mínimo para el margen horizontal", + "max-horizontal-margin-message": "Solamente es permitido el 50 como valor máximo para el margen horizontal", "vertical-margin": "Margen vertical", - "vertical-margin-required": "Margen vertical requerido.", - "min-vertical-margin-message": "Solo se permite margen vertical mínimo de 0.", - "max-vertical-margin-message": "Solo se permite margen vertical máximo de 50.", + "vertical-margin-required": "El valor del margen vertical es requerido.", + "min-vertical-margin-message": "Solamente es permitido el 0 como valor mínimo para el margen vertical.", + "max-vertical-margin-message": "Solamente es permitido el 50 como valor máximo para el margen vertical", + "autofill-height": "Llenado automático de altura de diseño", + "mobile-layout": "Configuración de diseño para móvil", + "mobile-row-height": "Altura de fila para móvil, píxel", + "mobile-row-height-required": "Altura de fila para móvil es requerida.", + "min-mobile-row-height-message": "Solamente es permitido 5 píxeles como valor mínimo de altura de fila para móvil.", + "max-mobile-row-height-message": "Solamente es permitido 200 píxeles como valor máximo de altura de fila para móvil.", "display-title": "Mostrar título del panel", + "toolbar-always-open": "Mantener la barra de herramientas abierta", "title-color": "Color del título", - "display-device-selection": "Mostrar selección de dispositivo", + "display-dashboards-selection": "Mostrar selección del panel", + "display-entities-selection": "Mostrar selección de entidades", "display-dashboard-timewindow": "Mostrar ventana de tiempo", "display-dashboard-export": "Mostrar exportar", "import": "Importar panel", "export": "Exportar panel", - "export-failed-error": "Imposible exportar panel: {{error}}", + "export-failed-error": "No se puede exportar el panel: {{error}}", "create-new-dashboard": "Crear nuevo panel", "dashboard-file": "Archivo del panel", - "invalid-dashboard-file-error": "Imposible importar panel: Estructura de datos inválida.", - "dashboard-import-missing-aliases-title": "Configurar alias utilizados por el panel importado", + "invalid-dashboard-file-error": "No se puede importar el panel: estructura de datos del panel no es válida.", + "dashboard-import-missing-aliases-title": "Configurar los alias utilizados por el panel importado", "create-new-widget": "Crear nuevo widget", "import-widget": "Importar widget", - "widget-file": "Archivo de widget", - "invalid-widget-file-error": "Imposible importar widget: Estructura de datos inválida.", - "widget-import-missing-aliases-title": "Configurar alias utilizados por el widget", - "open-toolbar": "Abrir toolbar del panel", - "close-toolbar": "Cerrar toolbar", + "widget-file": "Archivo del widget", + "invalid-widget-file-error": "No se puede importar el widget: estructura de datos del widger no es válida.", + "widget-import-missing-aliases-title": "Configurar los alias utilizados por el widget importado", + "open-toolbar": "Abrir barra de herramientas del panel", + "close-toolbar": "Cerrar barra de herramientas", "configuration-error": "Error de configuración", - "alias-resolution-error-title": "Error de configuración de alias del panel", - "invalid-aliases-config": "No se puede encontrar ningún dispositivo que coincida con algunos de los alias de filtro.
Póngase en contacto con su administrador para resolver este problema.", + "alias-resolution-error-title": "Error de configuración de los alias del panel", + "invalid-aliases-config": "No se puede encontrar algún dispositivo que coincida con algunos alias del filtro.
Por favor, contacte a su administrador para resolver este problema.", "select-devices": "Seleccionar dispositivos", "assignedToCustomer": "Asignado al cliente", + "assignedToCustomers": "Asignado a los clientes", "public": "Público", - "public-link": "Link público", - "copy-public-link": "Copiar link público", - "public-link-copied-message": "El link público del panel se ha copiado al portapapeles" + "public-link": "Enlace público", + "copy-public-link": "Copiar enlace público", + "public-link-copied-message": "El enlace público del panel ha sido copiado al portapapeles", + "manage-states": "Gestionar estados del panel", + "states": "Estados del panel", + "search-states": "Buscar estados del panel", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } seleccionados", + "edit-state": "Editar estado del panel", + "delete-state": "Eliminar estado del panel", + "add-state": "Agregar estado del panel", + "state": "Estado del panel", + "state-name": "Nombre", + "state-name-required": "El nombre del estado del panel es requerido.", + "state-id": "ID del estado", + "state-id-required": "ID del estado del panel es requerida.", + "state-id-exists": "Ya existe el estado del panel con el mismo ID.", + "is-root-state": "Estado raíz", + "delete-state-title": "Eliminar estado del panel", + "delete-state-text": "¿Está seguro de que desea eliminar el estado del panel con el nombre '{{stateName}}'?", + "show-details": "Mostrar detalles", + "hide-details": "Ocultar detalles", + "select-state": "Seleccionar estado objetivo", + "state-controller": "Estado del controlador" }, "datakey": { - "settings": "Ajustes", + "settings": "Configuración", "advanced": "Avanzado", "label": "Etiqueta", "color": "Color", + "units": "Símbolo especial para mostrar al lado del valor", + "decimals": "Número de dígitos después del punto flotante", "data-generation-func": "Función de generación de datos", - "use-data-post-processing-func": "Usar funcíon de post-procesamiendo de datos", - "configuration": "Ajustes de clave de datos", - "timeseries": "Serie de tiempos", + "use-data-post-processing-func": "Usar la función de post-procesamiento de datos", + "configuration": "Configuración de clave de datos", + "timeseries": "Series temporales", "attributes": "Atributos", - "timeseries-required": "Series de tiempo del dispositivo requerido.", - "timeseries-or-attributes-required": "Series de tiempo/Atributos requeridos.", + "alarm": "Campos de alarma", + "timeseries-required": "Series temporales de la entidad son requeridas", + "timeseries-or-attributes-required": "Series temporales/atributos de la entidad son requeridos.", + "maximum-timeseries-or-attributes": "Máximo { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "Campos de alarma son requeridos.", "function-types": "Tipos de funciones", - "function-types-required": "Tipos de funciones requerido." + "function-types-required": "Tipos de funciones son requeridos.", + "maximum-function-types": "Máximo { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }" }, "datasource": { - "type": "Típo de fuente de datos", - "add-datasource-prompt": "Por favor, agrega una fuente de datos" + "type": "Tipo de fuente de datos", + "name": "Nombre", + "add-datasource-prompt": "Por favor agregue fuente de datos" }, "details": { - "edit-mode": "Modo Edición", - "toggle-edit-mode": "Ir a Modo Edición" + "details": "Detalles", + "edit-mode": "Modo de edición" }, "device": { "device": "Dispositivo", - "device-required": "Dispositivo requerido.", + "device-required": "Dispositivo es requerido.", "devices": "Dispositivos", - "management": "Gestión de Dispositivos", - "view-devices": "Ver dispositivos", - "device-alias": "Alias de dispositivo", - "aliases": "Alias de dispositivos", + "management": "Gestión del dispositivo", + "view-devices": "Ver Dispositivos", + "device-alias": "Alias del dispositivo", + "aliases": "Alias de los dispositivos", "no-alias-matching": "'{{alias}}' no encontrado.", - "no-aliases-found": "Ningún alias encontrado.", + "no-aliases-found": "Alias no encontrados.", "no-key-matching": "'{{key}}' no encontrado.", - "no-keys-found": "Ninguna clave encontrada.", - "create-new-alias": "Crear nuevo alias!", - "create-new-key": "Crear nueva clave!", - "duplicate-alias-error": "Alias duplicado '{{alias}}'.
El alias de los dispositivos deben ser únicos dentro del panel.", - "configure-alias": "Configurar alias '{{alias}}'", - "no-devices-matching": "No se encontró dispositivo '{{entity}}'", + "no-keys-found": "Claves no encontradas.", + "create-new-alias": "Crear uno nuevo!", + "create-new-key": "Crear una nueva!", + "duplicate-alias-error": "Alias duplicado encontrado '{{alias}}'.
Los alias del dispositivo deben ser únicos dentro del panel.", + "configure-alias": "Configurar '{{alias}}' alias", + "no-devices-matching": "Dispositivos que coincidan con '{{entity}}' no fueron encontrados.", "alias": "Alias", - "alias-required": "Alias de dispositivo requerido.", - "remove-alias": "Eliminar alias", - "add-alias": "Agregar alias", - "name-starts-with": "Nombre empieza con", + "alias-required": "Alias del dispositivo es requerido.", + "remove-alias": "Eliminar alias del dispositivo", + "add-alias": "Agregar alias del dispositivo", + "name-starts-with": "El nombre del dispositivo comienza con", "device-list": "Lista de dispositivos", - "use-device-name-filter": "Usar filtro", + "use-device-name-filter": "Utilizar filtro", "device-list-empty": "Ningún dispositivo seleccionado.", - "device-name-filter-required": "Nombre de filtro requerido.", - "device-name-filter-no-device-matched": "Ningún dispositivo encontrado que comience con '{{device}}'.", - "add": "Agregar dispositivo", - "assign-to-customer": "Asignar a cliente", - "assign-device-to-customer": "Asignar dispositivo(s) a Cliente", - "assign-device-to-customer-text": "Por favor, seleccione los dispositivos que serán asignados al cliente", - "make-public": "Hacer dispositivo público", - "make-private": "Hacer dispositivo privado", - "no-devices-text": "Ningún dispositivo encontrado", - "assign-to-customer-text": "Por favor, seleccione el cliente para asignar el(los) dispositivo(s)", + "device-name-filter-required": "Filtro de nombre de dispositivo es requerido.", + "device-name-filter-no-device-matched": "Dispositivos que comienzan con '{{device}}' no fueron encontrados.", + "add": "Agregar Dispositivo", + "assign-to-customer": "Asignar al cliente", + "assign-device-to-customer": "Asignar Dispositivo(s) Al Cliente", + "assign-device-to-customer-text": "Por favor seleccionar los dispositivos para asignar al cliente", + "make-public": "Hacer público el dispositivo", + "make-private": "Hacer privado el dispositivo", + "no-devices-text": "Dispositivos no encontrados", + "assign-to-customer-text": "Por favor seleccionar el cliente para asignar el(los) dispositivo(s)", "device-details": "Detalles del dispositivo", "add-device-text": "Agregar nuevo dispositivo", "credentials": "Credenciales", "manage-credentials": "Gestionar credenciales", "delete": "Eliminar dispositivo", - "assign-devices": "Asignar dispositivo", - "assign-devices-text": "Asignar { count, plural, 1 {1 dispositivo} other {# dispositivos} } al cliente", - "delete-devices": "Eliminar dispositivo", - "unassign-from-customer": "Desasignar del cliente", - "unassign-devices": "Desasignar dispositivos", - "unassign-devices-action-title": "Desasignar { count, plural, 1 {1 dispositivo} other {# dispositivos} } del cliente", + "assign-devices": "Asignar dispositivos", + "assign-devices-text": "Asignar { count, plural, 1 {1 device} other {# devices} } al cliente", + "delete-devices": "Eliminar dispositivos", + "unassign-from-customer": "Anular asignación del cliente", + "unassign-devices": "Anular asignación de dispositivos", + "unassign-devices-action-title": "Anular asignación { count, plural, 1 {1 device} other {# devices} } del cliente", "assign-new-device": "Asignar nuevo dispositivo", - "make-public-device-title": "¿Estás seguro que quieres hacer el dispositivo '{{deviceName}}' público?", - "make-public-device-text": "Luego de confirmar, el dispositivo y la información relacionada serán públicos y podrá ser accesible por otros.", - "make-private-device-title": "¿Estás seguro que quieres hacer el dispositivo '{{deviceName}}' privado?", - "make-private-device-text": "Luego de confirmar, el dispositivo y la información relacionada serán privados y no podrá ser accesible por otros.", + "make-public-device-title": "¿Está seguro de que desea hacer el dispositivo '{{deviceName}}' público?", + "make-public-device-text": "Después de la confirmación, el dispositivo y todos sus datos se harán públicos y accesibles por otros.", + "make-private-device-title": "¿Está seguro de que desea hacer el dispositivo '{{deviceName}}' privado?", + "make-private-device-text": "Después de la confirmación, el dispositivo y todos sus datos se harán privados y no serán accesibles para otros.", "view-credentials": "Ver credenciales", - "delete-device-title": "¿Estás seguro que quieres eliminar el dispositivo '{{deviceName}}'?", - "delete-device-text": "Ten cuidado, luego de confirmar los dispositivos serán eliminados y la información relacionada será irrecuperable.", - "delete-devices-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", - "delete-devices-action-title": "Eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }", - "delete-devices-text": "Ten cuidado, luego de confirmar los dispositivos seleccionados serán eliminados y la información relacionada será irrecuperable.", - "unassign-device-title": "¿Estás seguro que quieres desasignar el dispositivo '{{deviceName}}'?", - "unassign-device-text": "Luego de confirmar el dispositivo será desasignado y no podrá ser accesible por el cliente.", - "unassign-device": "Desasignar dispositivo", - "unassign-devices-title": "¿Estás seguro que quieres desasignar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", - "unassign-devices-text": "Luego de confirmar los dispositivos seleccionados serán desasignados y no podrán ser accedidos por el cliente.", + "delete-device-title": "¿Está seguro de que desea hacer el dispositivo '{{deviceName}}'?", + "delete-device-text": "Tener cuidado, después de la confirmación, el dispositivo y todos sus datos relacionados se volverán irrecuperables.", + "delete-devices-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 device} other {# devices} }?", + "delete-devices-action-title": "Eliminar { count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "Tener cuidado, después de la confirmación, todos los dispositivos seleccionados serán eliminados y todos los datos relacionados se volverán irrecuperables.", + "unassign-device-title": "¿Está seguro de que desea anular la asignación del dispositivo '{{deviceName}}'?", + "unassign-device-text": "Después de la confirmación, se anulará asignación del dispositivo y no será accesible por el cliente.", + "unassign-device": "Anular asignación del dispositivo", + "unassign-devices-title": "¿Está seguro de que desea anular asignación { count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-text": "Después de la confirmación, se anulará asignación de todos los dispositivos seleccionados y no serán accesibles por el cliente.", "device-credentials": "Credenciales del dispositivo", - "credentials-type": "Tipo de credencial", - "access-token": "Access token", - "access-token-required": "Access token requerido.", - "access-token-invalid": "Access token debe tener entre 1 a 20 caracteres.", + "credentials-type": "Tipo de credenciales", + "access-token": "Token de acceso", + "access-token-required": "Token de acceso es requerido.", + "access-token-invalid": "La longitud del token de acceso debe ser de 1 a 20 caracteres.", "rsa-key": "Clave pública RSA", - "rsa-key-required": "Clave pública RSA requerida.", - "secret": "Secreta", - "secret-required": "Secreta requerida.", + "rsa-key-required": "Clave pública RSA es requerida.", + "secret": "Secreto", + "secret-required": "Secreto es requerido.", + "device-type": "Tipo de dispositivo", + "device-type-required": "Tipo de dispositivo es requerido.", + "select-device-type": "Seleccionar tipo de dispositivo", + "enter-device-type": "Ingresar tipo de dispositivo", + "any-device": "Algún dispositivo", + "no-device-types-matching": "Tipos de dispositivos que coincidan con '{{entitySubtype}}' no fueron encontrados.", + "device-type-list-empty": "No se seleccionaron tipos de dispositivos.", + "device-types": "Tipo de dispositivos", "name": "Nombre", - "name-required": "Nombre requerido.", + "name-required": "El nombre es requerido.", "description": "Descripción", "events": "Eventos", "details": "Detalles", - "copyId": "Copiar ID", - "copyAccessToken": "Copiar access token", - "idCopiedMessage": "Id del dispositivo copiado al portapapeles", - "accessTokenCopiedMessage": "Access token del dispositivo copiado al portapapeles", + "copyId": "Copiar ID del dispositivo", + "copyAccessToken": "Copiar token de acceso", + "idCopiedMessage": "ID del dispositivo ha sido copiada al portapapeles", + "accessTokenCopiedMessage": "Token de acceso al dispositivo ha sido copiado al portapapeles", "assignedToCustomer": "Asignado al cliente", - "unable-delete-device-alias-title": "Imposible eliminar alias del dispositivo", - "unable-delete-device-alias-text": "Alias '{{deviceAlias}}' no puede ser eliminado. Esta siendo usado por el(los) widget(s):
{{widgetsList}}", - "is-gateway": "Es gateway", + "unable-delete-device-alias-title": "No se puede eliminar el alias del dispositivo", + "unable-delete-device-alias-text": "El alias del dispositivo '{{deviceAlias}}' no puede ser eliminado porque es usado por los siguientes widget(s):
{{widgetsList}}", + "is-gateway": "Es puerta de entrada", "public": "Público", - "device-public": "Dispositivo público" + "device-public": "El dispositivo es público", + "select-device": "Seleccinar dispositivo" }, "dialog": { - "close": "Cerrar cuadro de diálogo" + "close": "Cerrar diálogo" }, "error": { - "unable-to-connect": "Imposible conectar con el servidor! Por favor, revise su conexión a internet.", - "unhandled-error-code": "Código de error no manejado: {{errorCode}}", + "unable-to-connect": "No se puede conectar al servidor! Por favor revise su conexión a Internet.", + "unhandled-error-code": "Código de error no controlado: {{errorCode}}", "unknown-error": "Error desconocido" }, "entity": { - "entity": "Entity", - "entities": "Entities", - "aliases": "Entity aliases", - "entity-alias": "Entity alias", - "unable-delete-entity-alias-title": "Unable to delete entity alias", - "unable-delete-entity-alias-text": "Entity alias '{{entityAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", - "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
Entity aliases must be unique whithin the dashboard.", - "missing-entity-filter-error": "Filter is missing for alias '{{alias}}'.", - "configure-alias": "Configure '{{alias}}' alias", + "entity": "Entidad", + "entities": "Entidades", + "aliases": "Alias de las entidades", + "entity-alias": "Alias de la entidad", + "unable-delete-entity-alias-title": "No se puede borrar alias de la entidad", + "unable-delete-entity-alias-text": "Alias de la entidad '{{entityAlias}}' no piuede ser eliminado porque es usado por los siguientes widget(s):
{{widgetsList}}", + "duplicate-alias-error": "Alias duplicado fue encontrado '{{alias}}'.
Alias de las entidades deben ser únicos dentro del panel.", + "missing-entity-filter-error": "Falta filtro para el alias '{{alias}}'.", + "configure-alias": "Configurar '{{alias}}' alias", "alias": "Alias", - "alias-required": "Entity alias is required.", - "remove-alias": "Remove entity alias", - "add-alias": "Add entity alias", - "entity-list": "Entity list", - "entity-type": "Entity type", - "entity-types": "Entity types", - "entity-type-list": "Entity type list", - "any-entity": "Any entity", - "enter-entity-type": "Enter entity type", - "no-entities-matching": "No entities matching '{{entity}}' were found.", - "no-entity-types-matching": "No entity types matching '{{entityType}}' were found.", - "name-starts-with": "Name starts with", - "use-entity-name-filter": "Use filter", - "entity-list-empty": "No entities selected.", - "entity-type-list-empty": "No entity types selected.", - "entity-name-filter-required": "Entity name filter is required.", - "entity-name-filter-no-entity-matched": "No entities starting with '{{entity}}' were found.", - "all-subtypes": "All", - "select-entities": "Select entities", - "no-aliases-found": "No aliases found.", - "no-alias-matching": "'{{alias}}' not found.", - "create-new-alias": "Create a new one!", - "key": "Key", - "key-name": "Key name", - "no-keys-found": "No keys found.", - "no-key-matching": "'{{key}}' not found.", - "create-new-key": "Create a new one!", - "type": "Type", - "type-required": "Entity type is required.", - "type-device": "Device", - "type-devices": "Devices", + "alias-required": "Alias de la entidad es requerida.", + "remove-alias": "Eliminar alias de la entidad", + "add-alias": "Agregar alias de la entidad", + "entity-list": "Lista de entidades", + "entity-type": "Tipo de entidad", + "entity-types": "Tipos de entidades", + "entity-type-list": "Lista de tipos de entidades", + "any-entity": "Alguna entidad", + "enter-entity-type": "Ingresara tipo de entidad", + "no-entities-matching": "Entidades que coincidan con '{{entity}}' no fueron encontradas.", + "no-entity-types-matching": "Tipos de entidades que coincidan con '{{entityType}}' no fueron encontrados.", + "name-starts-with": "El nombre comienza con", + "use-entity-name-filter": "Utilizar filtro", + "entity-list-empty": "Entidades no seleccionadas.", + "entity-type-list-empty": "Tipos de entidades no seleccionados.", + "entity-name-filter-required": "Filtro del nombre de la entidad es requerido.", + "entity-name-filter-no-entity-matched": "Entidades que comienzan con '{{entity}}' no fueron encontradas.", + "all-subtypes": "Todas", + "select-entities": "Seleccionar entidades", + "no-aliases-found": "Alias no encontrados.", + "no-alias-matching": "'{{alias}}' no encontrado.", + "create-new-alias": "Crear uno nuevo!", + "key": "Clave", + "key-name": "Nombre de clave", + "no-keys-found": "Claves no encontradas.", + "no-key-matching": "'{{key}}' no encontrada.", + "create-new-key": "Crear una nueva!", + "type": "Tipo", + "type-required": "Tipo de entidad es requerido.", + "type-device": "Dispositivo", + "type-devices": "Dispositivos", "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", - "device-name-starts-with": "Devices whose names start with '{{prefix}}'", - "type-asset": "Asset", - "type-assets": "Assets", + "device-name-starts-with": "Dispositivos cuyos nombres comienzan con '{{prefix}}'", + "type-asset": "Activo", + "type-assets": "Activos", "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", - "asset-name-starts-with": "Assets whose names start with '{{prefix}}'", - "type-rule": "Rule", - "type-rules": "Rules", + "asset-name-starts-with": "Activos cuyos nombres comienzan con '{{prefix}}'", + "type-entity-view": "Entity View", + "type-entity-views": "Entity Views", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {List of # entity views} }", + "entity-view-name-starts-with": "Entity Views whose names start with '{{prefix}}'", + "type-rule": "Regla", + "type-rules": "Reglas", "list-of-rules": "{ count, plural, 1 {One rule} other {List of # rules} }", - "rule-name-starts-with": "Rules whose names start with '{{prefix}}'", - "type-plugin": "Plugin", - "type-plugins": "Plugins", + "rule-name-starts-with": "Reglas cuyos nombres comienzan con '{{prefix}}'", + "type-plugin": "Complemento", + "type-plugins": "Complementos", "list-of-plugins": "{ count, plural, 1 {One plugin} other {List of # plugins} }", - "plugin-name-starts-with": "Plugins whose names start with '{{prefix}}'", - "type-tenant": "Tenant", - "type-tenants": "Tenants", + "plugin-name-starts-with": "Complementos cuyos nombres comienzan con '{{prefix}}'", + "type-tenant": "Organización", + "type-tenants": "Organizaciones", "list-of-tenants": "{ count, plural, 1 {One tenant} other {List of # tenants} }", - "tenant-name-starts-with": "Tenants whose names start with '{{prefix}}'", - "type-customer": "Customer", - "type-customers": "Customers", + "tenant-name-starts-with": "Organizaciones cuyos nombres comienzan con '{{prefix}}'", + "type-customer": "Cliente", + "type-customers": "Clientes", "list-of-customers": "{ count, plural, 1 {One customer} other {List of # customers} }", - "customer-name-starts-with": "Customers whose names start with '{{prefix}}'", - "type-user": "User", - "type-users": "Users", + "customer-name-starts-with": "Clientes cuyos nombres comienzan con '{{prefix}}'", + "type-user": "Usuario", + "type-users": "Usuarios", "list-of-users": "{ count, plural, 1 {One user} other {List of # users} }", - "user-name-starts-with": "Users whose names start with '{{prefix}}'", - "type-dashboard": "Dashboard", - "type-dashboards": "Dashboards", + "user-name-starts-with": "Usuarios cuyos nombres comienzan con '{{prefix}}'", + "type-dashboard": "Panel", + "type-dashboards": "Paneles", "list-of-dashboards": "{ count, plural, 1 {One dashboard} other {List of # dashboards} }", - "dashboard-name-starts-with": "Dashboards whose names start with '{{prefix}}'", - "type-alarm": "Alarm", - "type-alarms": "Alarms", + "dashboard-name-starts-with": "Paneles cuyos nombres comienzan con '{{prefix}}'", + "type-alarm": "Alarma", + "type-alarms": "Alarmas", "list-of-alarms": "{ count, plural, 1 {One alarms} other {List of # alarms} }", - "alarm-name-starts-with": "Alarms whose names start with '{{prefix}}'", - "type-rulechain": "Rule chain", - "type-rulechains": "Rule chains", + "alarm-name-starts-with": "Alarmas cuyos nombres comienzan con '{{prefix}}'", + "type-rulechain": "Cadena de reglas", + "type-rulechains": "Cadenas de reglas", "list-of-rulechains": "{ count, plural, 1 {One rule chain} other {List of # rule chains} }", - "rulechain-name-starts-with": "Rule chains whose names start with '{{prefix}}'", - "type-current-customer": "Current Customer", - "search": "Search entities", - "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} } selected", - "entity-name": "Entity name", - "details": "Entity details", - "no-entities-prompt": "No entities found", - "no-data": "No data to display" + "rulechain-name-starts-with": "Cadenas reglas cuyos nombres comienzan con '{{prefix}}'", + "type-rulenode": "Nodo de reglas", + "type-rulenodes": "Nodos de reglas", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "Nodos de reglas cuyos nombres comienzan con '{{prefix}}'", + "type-current-customer": "Cliente Actual", + "search": "Buscar entidades", + "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} } seleccionadas", + "entity-name": "Nombre de la entidad", + "details": "Detalles de la entidad", + "no-entities-prompt": "Entidades no encontradas", + "no-data": "No hay datos para mostrar", + "columns-to-display": "Columns to Display" + }, + "entity-view": { + "entity-view": "Entity View", + "entity-views": "Entity Views", + "management": "Entity View management", + "view-entity-views": "View Entity Views", + "entity-view-alias": "Entity View alias", + "aliases": "Entity View aliases", + "no-alias-matching": "'{{alias}}' not found.", + "no-aliases-found": "No aliases found.", + "no-key-matching": "'{{key}}' not found.", + "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.", + "configure-alias": "Configure '{{alias}}' alias", + "no-entity-views-matching": "No entity views matching '{{entity}}' were found.", + "alias": "Alias", + "alias-required": "Entity View alias is required.", + "remove-alias": "Remove entity view alias", + "add-alias": "Add entity view alias", + "name-starts-with": "Entity View name starts with", + "entity-view-list": "Entity View list", + "use-entity-view-name-filter": "Use filter", + "entity-view-list-empty": "No entity views selected.", + "entity-view-name-filter-required": "Entity view name filter is required.", + "entity-view-name-filter-no-entity-view-matched": "No entity views starting with '{{entityView}}' were found.", + "add": "Add Entity View", + "assign-to-customer": "Assign to customer", + "assign-entity-view-to-customer": "Assign Entity View(s) To Customer", + "assign-entity-view-to-customer-text": "Please select the entity views to assign to the customer", + "no-entity-views-text": "No entity views found", + "assign-to-customer-text": "Please select the customer to assign the entity view(s)", + "entity-view-details": "Entity view details", + "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", + "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", + "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-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-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.", + "select-entity-view-type": "Select entity view type", + "enter-entity-view-type": "Enter entity view type", + "any-entity-view": "Any entity view", + "no-entity-view-types-matching": "No entity view types matching '{{entitySubtype}}' were found.", + "entity-view-type-list-empty": "No entity view types selected.", + "entity-view-types": "Entity View types", + "name": "Name", + "name-required": "Name is required.", + "description": "Description", + "events": "Events", + "details": "Details", + "copyId": "Copy entity view Id", + "assignedToCustomer": "Assigned to customer", + "unable-entity-view-device-alias-title": "Unable to delete entity view alias", + "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", + "select-entity-view": "Select entity view", + "make-public": "Make entity view public", + "start-ts": "Start time", + "end-ts": "End time", + "date-limits": "Date limits", + "client-attributes": "Client attributes", + "shared-attributes": "Shared attributes", + "server-attributes": "Server attributes", + "latest-timeseries": "Latest timeseries" }, "event": { "event-type": "Tipo de evento", "type-error": "Error", - "type-lc-event": "Ciclo de vida", + "type-lc-event": "Ciclo de vida del evento", "type-stats": "Estadísticas", - "no-events-prompt": "Ningún evento encontrado.", + "type-debug-rule-node": "Depurar", + "type-debug-rule-chain": "Depurar", + "no-events-prompt": "Eventos no encontrados", "error": "Error", "alarm": "Alarma", - "event-time": "Hora del evento", + "event-time": "Tiempo del evento", "server": "Servidor", "body": "Cuerpo", "method": "Método", + "type": "Tipo", + "entity": "Entidad", + "message-id": "ID del mensaje", + "message-type": "Tipo de mensaje", + "data-type": "Tipo de datos", + "relation-type": "Tipo de relación", + "metadata": "Metadatos", + "data": "Datos", "event": "Evento", - "status": "Status", + "status": "Estado", "success": "Éxito", - "failed": "Fallo", + "failed": "Falla", "messages-processed": "Mensajes procesados", - "errors-occurred": "Ocurrieron errores" + "errors-occurred": "Errores ocurridos" }, "extension": { - "extensions": "Extensions", - "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} } selected", - "type": "Type", - "key": "Key", - "value": "Value", - "id": "Id", - "extension-id": "Extension id", - "extension-type": "Extension type", + "extensions": "Extensiones", + "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} } seleccionadas", + "type": "Tipo", + "key": "Clave", + "value": "Valor", + "id": "ID", + "extension-id": "ID de extensión", + "extension-type": "Tipo de extensión", "transformer-json": "JSON *", - "unique-id-required": "Current extension id already exists.", - "delete": "Delete extension", - "add": "Add extension", - "edit": "Edit extension", - "delete-extension-title": "Are you sure you want to delete the extension '{{extensionId}}'?", - "delete-extension-text": "Be careful, after the confirmation the extension and all related data will become unrecoverable.", - "delete-extensions-title": "Are you sure you want to delete { count, plural, 1 {1 extension} other {# extensions} }?", - "delete-extensions-text": "Be careful, after the confirmation all selected extensions will be removed.", - "converters": "Converters", - "converter-id": "Converter id", - "configuration": "Configuration", - "converter-configurations": "Converter configurations", - "token": "Security token", - "add-converter": "Add converter", - "add-config": "Add converter configuration", - "device-name-expression": "Device name expression", - "device-type-expression": "Device type expression", - "custom": "Custom", - "to-double": "To Double", - "transformer": "Transformer", - "json-required": "Transformer json is required.", - "json-parse": "Unable to parse transformer json.", - "attributes": "Attributes", - "add-attribute": "Add attribute", - "add-map": "Add mapping element", - "timeseries": "Timeseries", - "add-timeseries": "Add timeseries", - "field-required": "Field is required", - "brokers": "Brokers", - "add-broker": "Add broker", + "unique-id-required": "Ya existe ID de extensión actual.", + "delete": "Eliminar extensión", + "add": "Agregar extensión", + "edit": "Editar extensión", + "delete-extension-title": "¿Está seguro de que desea eliminar la extensión '{{extensionId}}'?", + "delete-extension-text": "Tener cuidado, después de la confirmación, la extensión y todos los datos relacionados se volverán irrecuperables.", + "delete-extensions-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "Tener cuidado, después de la confirmación, se eliminarán todas las extensiones seleccionadas.", + "converters": "Conversores", + "converter-id": "ID del conversor", + "configuration": "Configuración", + "converter-configurations": "Configuraciones del conversor", + "token": "Token de seguridad", + "add-converter": "Agregar conversor", + "add-config": "Configuración para agregar conversor", + "device-name-expression": "Expresión del nombre del dispositivo", + "device-type-expression": "Expresión del tipo del dispositivo", + "custom": "Personalizado", + "to-double": "Para duplicar", + "transformer": "Transformador", + "json-required": "Transformador json es requerido.", + "json-parse": "No se puede analizar el transformador json.", + "attributes": "Atributos", + "add-attribute": "Agregar atributos", + "add-map": "Agregar elemento de mapeo", + "timeseries": "Series temporales", + "add-timeseries": "Agregar series temporales", + "field-required": "Campo es requerido", + "brokers": "Agentes", + "add-broker": "Agregar agente", "host": "Host", - "port": "Port", - "port-range": "Port should be in a range from 1 to 65535.", - "ssl": "Ssl", - "credentials": "Credentials", - "username": "Username", - "password": "Password", - "retry-interval": "Retry interval in milliseconds", - "anonymous": "Anonymous", - "basic": "Basic", + "port": "Puerto", + "port-range": "El puerto debe estar en un rango desde 1 hasta 65535.", + "ssl": "SSL", + "credentials": "Credenciales", + "username": "Nombre de usuario", + "password": "Contraseña", + "retry-interval": "Intervalo de reintento en milisegundos", + "anonymous": "Anónimo", + "basic": "Básico", "pem": "PEM", - "ca-cert": "CA certificate file *", - "private-key": "Private key file *", - "cert": "Certificate file *", - "no-file": "No file selected.", - "drop-file": "Drop a file or click to select a file to upload.", - "mapping": "Mapping", - "topic-filter": "Topic filter", - "converter-type": "Converter type", + "ca-cert": "Archivo de certificado CA *", + "private-key": "Archivo de clave privado *", + "cert": "Archivo de certificado *", + "no-file": "Ningún archivo seleccionado.", + "drop-file": "Colocar un archivo o hacer clic para seleccionar un archivo para cargar.", + "mapping": "Mapeo", + "topic-filter": "Filtro de tema", + "converter-type": "Tipo de conversor", "converter-json": "Json", - "json-name-expression": "Device name json expression", - "topic-name-expression": "Device name topic expression", - "json-type-expression": "Device type json expression", - "topic-type-expression": "Device type topic expression", - "attribute-key-expression": "Attribute key expression", - "attr-json-key-expression": "Attribute key json expression", - "attr-topic-key-expression": "Attribute key topic expression", - "request-id-expression": "Request id expression", - "request-id-json-expression": "Request id json expression", - "request-id-topic-expression": "Request id topic expression", - "response-topic-expression": "Response topic expression", - "value-expression": "Value expression", - "topic": "Topic", - "timeout": "Timeout in milliseconds", - "converter-json-required": "Converter json is required.", - "converter-json-parse": "Unable to parse converter json.", - "filter-expression": "Filter expression", - "connect-requests": "Connect requests", - "add-connect-request": "Add connect request", - "disconnect-requests": "Disconnect requests", - "add-disconnect-request": "Add disconnect request", - "attribute-requests": "Attribute requests", - "add-attribute-request": "Add attribute request", - "attribute-updates": "Attribute updates", - "add-attribute-update": "Add attribute update", - "server-side-rpc": "Server side RPC", - "add-server-side-rpc-request": "Add server-side RPC request", - "device-name-filter": "Device name filter", - "attribute-filter": "Attribute filter", - "method-filter": "Method filter", - "request-topic-expression": "Request topic expression", - "response-timeout": "Response timeout in milliseconds", - "topic-expression": "Topic expression", - "client-scope": "Client scope", - "add-device": "Add device", - "opc-server": "Servers", - "opc-add-server": "Add server", - "opc-add-server-prompt": "Please add server", - "opc-application-name": "Application name", - "opc-application-uri": "Application uri", - "opc-scan-period-in-seconds": "Scan period in seconds", - "opc-security": "Security", - "opc-identity": "Identity", - "opc-keystore": "Keystore", - "opc-type": "Type", - "opc-keystore-type": "Type", - "opc-keystore-location": "Location *", - "opc-keystore-password": "Password", + "json-name-expression": "Expresión json para nombre del dispositivo", + "topic-name-expression": "Expresión temática para nombre del dispositivo", + "json-type-expression": "Expresión json para tipo de dispositivo", + "topic-type-expression": "Expresión temática para tipo de dispositivo", + "attribute-key-expression": "Expresión para clave de atributo", + "attr-json-key-expression": "Expresión json para clave de atributo", + "attr-topic-key-expression": "Expresión temática para clave de atributo", + "request-id-expression": "Expresión para solicitud de ID", + "request-id-json-expression": "Expresión json para solicitud de ID", + "request-id-topic-expression": "Expresión temática para solicitud de ID", + "response-topic-expression": "Expresión temática para respuesta", + "value-expression": "Expresión para valor", + "topic": "Tema", + "timeout": "Tiempo de espera en milisegundos", + "converter-json-required": "Conversor json es requerido.", + "converter-json-parse": "No se puede analizar el conversor json.", + "filter-expression": "Expresión para filtro", + "connect-requests": "Solicitudes de conexión", + "add-connect-request": "Agregar solicitudes de conexión", + "disconnect-requests": "Solicitudes de desconexión", + "add-disconnect-request": "Agregar solicitudes de desconexión", + "attribute-requests": "Solicitudes de atributo", + "add-attribute-request": "Agregar solicitudes de atributo", + "attribute-updates": "Actualizaciones de atributo", + "add-attribute-update": "Agregar actualizaciones de atributo", + "server-side-rpc": "RPC lado servidor", + "add-server-side-rpc-request": "Agregar solicitud RPC lado servidor", + "device-name-filter": "Filtro de nombre de dispositivo", + "attribute-filter": "Filtro de atributo", + "method-filter": "Filtro de método", + "request-topic-expression": "Expresión temática para solicitud", + "response-timeout": "Respuesta a tiempo de espera en milisegundos", + "topic-expression": "Expresión temática", + "client-scope": "Alcance del cliente", + "add-device": "Agregar dispositivo", + "opc-server": "Servidores", + "opc-add-server": "Agregar servidor", + "opc-add-server-prompt": "Por favor agregar servidor", + "opc-application-name": "Nombre de aplicación", + "opc-application-uri": "Aplicación URI", + "opc-scan-period-in-seconds": "Período de exploración en segundos", + "opc-security": "Seguridad", + "opc-identity": "Identidad", + "opc-keystore": "Repositorio", + "opc-type": "Tipo", + "opc-keystore-type": "Tipo", + "opc-keystore-location": "Ubicación *", + "opc-keystore-password": "Contraseña", "opc-keystore-alias": "Alias", - "opc-keystore-key-password": "Key password", - "opc-device-node-pattern": "Device node pattern", - "opc-device-name-pattern": "Device name pattern", - "modbus-server": "Servers/slaves", - "modbus-add-server": "Add server/slave", - "modbus-add-server-prompt": "Please add server/slave", - "modbus-transport": "Transport", - "modbus-port-name": "Serial port name", - "modbus-encoding": "Encoding", - "modbus-parity": "Parity", - "modbus-baudrate": "Baud rate", - "modbus-databits": "Data bits", - "modbus-stopbits": "Stop bits", - "modbus-databits-range": "Data bits should be in a range from 7 to 8.", - "modbus-stopbits-range": "Stop bits should be in a range from 1 to 2.", - "modbus-unit-id": "Unit ID", - "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", - "modbus-device-name": "Device name", - "modbus-poll-period": "Poll period (ms)", - "modbus-attributes-poll-period": "Attributes poll period (ms)", - "modbus-timeseries-poll-period": "Timeseries poll period (ms)", - "modbus-poll-period-range": "Poll period should be positive value.", - "modbus-tag": "Tag", - "modbus-function": "Function", - "modbus-register-address": "Register address", - "modbus-register-address-range": "Register address should be in a range from 0 to 65535.", - "modbus-register-bit-index": "Bit index", - "modbus-register-bit-index-range": "Bit index should be in a range from 0 to 15.", - "modbus-register-count": "Register count", - "modbus-register-count-range": "Register count should be a positive value.", - "modbus-byte-order": "Byte order", + "opc-keystore-key-password": "Clave de contraseña", + "opc-device-node-pattern": "Patrón de nodo de dispostivo", + "opc-device-name-pattern": "Patrón de nombre de dispositivo", + "modbus-server": "Servidores/esclavos", + "modbus-add-server": "Agregar servidor/esclavo", + "modbus-add-server-prompt": "Por favor agregar servidor/esclavo", + "modbus-transport": "Transporte", + "modbus-port-name": "Nombre del puerto serial", + "modbus-encoding": "Codificación", + "modbus-parity": "Paridad", + "modbus-baudrate": "Tasa de baudios", + "modbus-databits": "Bits de datos", + "modbus-stopbits": "Bits de parada", + "modbus-databits-range": "Bits de datos deben estar en un rango entre 7 y 8.", + "modbus-stopbits-range": "Bits de parada deben estar en un rango entre 1 a 2.", + "modbus-unit-id": "ID de unidad", + "modbus-unit-id-range": "ID de unidad debe estar en un rango entre 1 a 247.", + "modbus-device-name": "Nombre del dispositivo", + "modbus-poll-period": "Período de sondeo (ms)", + "modbus-attributes-poll-period": "Atributos del período de sondeo (ms)", + "modbus-timeseries-poll-period": "Período de sondeo de las series temporales (ms)", + "modbus-poll-period-range": "El período de sondeo debe ser una valor positivo.", + "modbus-tag": "Etiqueta", + "modbus-function": "Función", + "modbus-register-address": "Dirección del registro", + "modbus-register-address-range": "Dirección del registro debe estar en un rango entre 0 y 65535.", + "modbus-register-bit-index": "Índice de bit", + "modbus-register-bit-index-range": "Índice de bit debe estar en un rango entre 0 y 15.", + "modbus-register-count": "Contador del registro", + "modbus-register-count-range": "Contador del registro debe ser un valor positivo.", + "modbus-byte-order": "Orden del byte", "sync": { - "status": "Status", - "sync": "Sync", - "not-sync": "Not sync", - "last-sync-time": "Last sync time", - "not-available": "Not available" + "status": "Estado", + "sync": "Sincronización", + "not-sync": "No sincronización", + "last-sync-time": "Último tiempo de sincroniación", + "not-available": "No disponible" }, - "export-extensions-configuration": "Export extensions configuration", - "import-extensions-configuration": "Import extensions configuration", - "import-extensions": "Import extensions", - "import-extension": "Import extension", - "export-extension": "Export extension", - "file": "Extensions file", - "invalid-file-error": "Invalid extension file" + "export-extensions-configuration": "Exportar configuración de extensiones", + "import-extensions-configuration": "Importar configuración de extensiones", + "import-extensions": "Importar extensiones", + "import-extension": "Importar extensión", + "export-extension": "Exportar extensión", + "file": "Archivo de extensiones", + "invalid-file-error": "Archivo de extensión no válido" }, "fullscreen": { - "expand": "Expandir a Pantalla Completa", - "exit": "Salir de Pantalla Completa", - "toggle": "Cambiar el modo de Pantalla Completa", - "fullscreen": "Pantalla Completa" + "expand": "Expandir a pantalla completa", + "exit": "Salir de pantalla completa", + "toggle": "Alternar el modo de pantalla completa", + "fullscreen": "Pantalla completa" }, "function": { "function": "Función" }, "grid": { - "delete-item-title": "¿Estás seguro que quieres eliminar este item?", - "delete-item-text": "Ten cuidado, luego de confirmar el item será eliminado y la información relacionada será irrecuperable.", - "delete-items-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 item} other {# items} }?", + "delete-item-title": "¿Está seguro de que desea eliminar este ítem?", + "delete-item-text": "Tener cuidado, después de la confirmación, este ítem y todos los datos relacionados se volverán irrecuperables.", + "delete-items-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 item} other {# items} }?", "delete-items-action-title": "Eliminar { count, plural, 1 {1 item} other {# items} }", - "delete-items-text": "Ten cuidado, luego de confirmar los items seleccionados serán eliminados y la información relacionada será irrecuperable.", - "add-item-text": "Agregar nuevo item", - "no-items-text": "Ningún item encontrado", - "item-details": "Detalles del item", - "delete-item": "Borrar Item", - "delete-items": "Borrar Items", - "scroll-to-top": "Ir hacia arriba" + "delete-items-text": "Tener cuidado, después de la confirmación se eliminarán todos los ítems seleccionados y todos los datos relacionados se volverán irrecuperables.", + "add-item-text": "Agregar nuevo ítem", + "no-items-text": "Ítems no encontrados", + "item-details": "Detalles del ítem", + "delete-item": "Eliminar ítem", + "delete-items": "Eliminar ítems", + "scroll-to-top": "Desplazar al inicio" }, "help": { - "goto-help-page": "Ir a Página de Ayuda" + "goto-help-page": "Ir a la página de ayuda" }, "home": { - "home": "Principal", + "home": "Página principal", "profile": "Perfil", - "logout": "Salir", - "menu": "Menu", + "logout": "Cerrar sesión", + "menu": "Menú", "avatar": "Avatar", "open-user-menu": "Abrir menú de usuario" }, "import": { "no-file": "Ningún archivo seleccionado", - "drop-file": "Arrastra un archivo JSON o clickea para seleccionar uno." + "drop-file": "Colocar un archivo JSON o hacer clic para seleccionar un archivo para cargar." }, "item": { "selected": "Seleccionado" }, "js-func": { - "no-return-error": "La función debe retornar un valor!", - "return-type-mismatch": "La función debe retornar un valor de tipo: '{{type}}'!" + "no-return-error": "La función debe devolver el valor!", + "return-type-mismatch": "La función debe devolver el valor de '{{type}}' type!", + "tidy": "Ordenado" }, "key-val": { - "key": "Key", - "value": "Value", - "remove-entry": "Remove entry", - "add-entry": "Add entry", - "no-data": "No entries" + "key": "Clave", + "value": "Valor", + "remove-entry": "Eliminar entrada", + "add-entry": "Agregar entrada", + "no-data": "Ninguna entrada" }, "layout": { - "layout": "Layout", - "manage": "Manage layouts", - "settings": "Layout settings", + "layout": "Diseño", + "manage": "Gestionar diseños", + "settings": "Configuaración de diseño", "color": "Color", - "main": "Main", - "right": "Right", - "select": "Select target layout" + "main": "Principal", + "right": "Derecha", + "select": "Seleccionar diseño objetivo" }, "legend": { - "position": "Posición de leyenda", - "show-max": "Mostrar máximo", - "show-min": "Mostrar mínimo", - "show-avg": "Mostrar promedio", - "show-total": "Mostrar total", - "settings": "Ajustes de leyenda.", - "min": "min", - "max": "max", - "avg": "prom", + "position": "Posición de la leyenda", + "show-max": "Mostrar valor máximo", + "show-min": "Mostrar valor mínimo", + "show-avg": "Mostrar valor promedio", + "show-total": "Mostrar valor total", + "settings": "Configuración de la leyenda", + "min": "mínimo", + "max": "máximo", + "avg": "promedio", "total": "total" }, "login": { - "login": "Ingresar", - "request-password-reset": "Pedir restablecer contraseña", - "reset-password": "Restablecer contraseña", - "create-password": "Crear contraseña", - "passwords-mismatch-error": "Las contraseñas deben ser las mismas!", - "password-again": "Reingresa la contraseña", - "sign-in": "Iniciar sesión", - "username": "Usuario (email)", - "remember-me": "Recordar", - "forgot-password": "¿Olvidaste tu contraseña?", - "password-reset": "Restablecer Contraseña", + "login": "Iniciar sesión", + "request-password-reset": "Solicitar Restablecimiento Contraseña", + "reset-password": "Restablecer Contraseña", + "create-password": "Crear Contraseña", + "passwords-mismatch-error": "Las contraseñas introducidas deben ser las mismas!", + "password-again": "Contraseña nuevamente", + "sign-in": "Por favor registrarse", + "username": "Nombre de usuario (correo electrónico)", + "remember-me": "Recordarme", + "forgot-password": "Olvidó Contraseña?", + "password-reset": "Restablecimiento de Contraseña", "new-password": "Nueva contraseña", - "new-password-again": "Repita la nueva contraseña", - "password-link-sent-message": "Se ha enviado el enlace de restablecimiento de contraseña con éxito!", - "email": "Email" + "new-password-again": "Nueva contraseña otra vez", + "password-link-sent-message": "El enlace para el restablecimieneto de la contraseña fue enviado exitosamente!", + "email": "Correo electrónico" }, "position": { - "top": "Arriba", - "bottom": "Abajo", + "top": "Superior", + "bottom": "Inferior", "left": "Izquierda", "right": "Derecha" }, @@ -946,163 +1129,179 @@ "current-password": "Contraseña actual" }, "relation": { - "relations": "Relations", - "direction": "Direction", + "relations": "Relaciones", + "direction": "Dirección", "search-direction": { - "FROM": "From", - "TO": "To" + "FROM": "Desde", + "TO": "Hacia" }, "direction-type": { - "FROM": "from", - "TO": "to" + "FROM": "desde", + "TO": "hacia" }, - "from-relations": "Outbound relations", - "to-relations": "Inbound relations", - "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } selected", - "type": "Type", - "to-entity-type": "To entity type", - "to-entity-name": "To entity name", - "from-entity-type": "From entity type", - "from-entity-name": "From entity name", - "to-entity": "To entity", - "from-entity": "From entity", - "delete": "Delete relation", - "relation-type": "Relation type", - "relation-type-required": "Relation type is required.", - "any-relation-type": "Any type", - "add": "Add relation", - "edit": "Edit relation", - "delete-to-relation-title": "Are you sure you want to delete relation to the entity '{{entityName}}'?", - "delete-to-relation-text": "Be careful, after the confirmation the entity '{{entityName}}' will be unrelated from the current entity.", - "delete-to-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-to-relations-text": "Be careful, after the confirmation all selected relations will be removed and corresponding entities will be unrelated from the current entity.", - "delete-from-relation-title": "Are you sure you want to delete relation from the entity '{{entityName}}'?", - "delete-from-relation-text": "Be careful, after the confirmation current entity will be unrelated from the entity '{{entityName}}'.", - "delete-from-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-from-relations-text": "Be careful, after the confirmation all selected relations will be removed and current entity will be unrelated from the corresponding entities.", - "remove-relation-filter": "Remove relation filter", - "add-relation-filter": "Add relation filter", - "any-relation": "Any relation", - "relation-filters": "Relation filters", - "additional-info": "Additional info (JSON)", - "invalid-additional-info": "Unable to parse additional info json." + "from-relations": "Relaciones salientes", + "to-relations": "Relaciones entrantes", + "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } selecciondas", + "type": "Tipo", + "to-entity-type": "Hacia tipo de entidad", + "to-entity-name": "Hacia nombre de entidad", + "from-entity-type": "Desde tipo de entidad", + "from-entity-name": "Desde nombre de entidad", + "to-entity": "Hacia entidad", + "from-entity": "Desde entidad", + "delete": "Eliminar relación", + "relation-type": "Tipo de relación", + "relation-type-required": "Tipo de relación es requerido.", + "any-relation-type": "Algún tipo", + "add": "Agregar relación", + "edit": "Editar relación", + "delete-to-relation-title": "¿Está seguro de que desea eliminar la relación hacia la entidad '{{entityName}}'?", + "delete-to-relation-text": "Tener cuidado, después de la confirmación, la entidad '{{entityName}}' no estará relacionada desde la entidad actual.", + "delete-to-relations-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 relation} other {# relations} }?", + "delete-to-relations-text": "Tener cuidado, después de la confirmación, se eliminarán todas las relaciones seleccionadas y las entidades correspondientes no estarán relacionadas desde la entidad actual.", + "delete-from-relation-title": "¿Está seguro de que desea eliminar la relación desde la entidad '{{entityName}}'?", + "delete-from-relation-text": "Tener cuidado, después de la confirmación, la entidad actual no será relacionada desde la entidad '{{entityName}}'.", + "delete-from-relations-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 relation} other {# relations} }?", + "delete-from-relations-text": "Tener cuidado, después de la confirmación, se eliminarán todas las relaciones seleccionadas y la entidad actual no será relacionada desde las correspondientes entidades.", + "remove-relation-filter": "Eliminar filtro de relación", + "add-relation-filter": "Agregar filtro de relación", + "any-relation": "Alguna relación", + "relation-filters": "Filtros de relación", + "additional-info": "Información adicional (JSON)", + "invalid-additional-info": "No se puede analizar información adicional json." }, "rulechain": { - "rulechain": "Rule chain", - "rulechains": "Rule chains", - "root": "Root", - "delete": "Delete rule chain", - "name": "Name", - "name-required": "Name is required.", - "description": "Description", - "add": "Add Rule Chain", - "set-root": "Make rule chain root", - "set-root-rulechain-title": "Are you sure you want to make the rule chain '{{ruleChainName}}' root?", - "set-root-rulechain-text": "After the confirmation the rule chain will become root and will handle all incoming transport messages.", - "delete-rulechain-title": "Are you sure you want to delete the rule chain '{{ruleChainName}}'?", - "delete-rulechain-text": "Be careful, after the confirmation the rule chain and all related data will become unrecoverable.", - "delete-rulechains-title": "Are you sure you want to delete { count, plural, 1 {1 rule chain} other {# rule chains} }?", - "delete-rulechains-action-title": "Delete { count, plural, 1 {1 rule chain} other {# rule chains} }", - "delete-rulechains-text": "Be careful, after the confirmation all selected rule chains will be removed and all related data will become unrecoverable.", - "add-rulechain-text": "Add new rule chain", - "no-rulechains-text": "No rule chains found", - "rulechain-details": "Rule chain details", - "details": "Details", - "events": "Events", - "system": "System", - "import": "Import rule chain", - "export": "Export rule chain", - "export-failed-error": "Unable to export rule chain: {{error}}", - "create-new-rulechain": "Create new rule chain", - "rulechain-file": "Rule chain file", - "invalid-rulechain-file-error": "Unable to import rule chain: Invalid rule chain data structure.", - "copyId": "Copy rule chain Id", - "idCopiedMessage": "Rule chain Id has been copied to clipboard", - "select-rulechain": "Select rule chain", - "no-rulechains-matching": "No rule chains matching '{{entity}}' were found.", - "rulechain-required": "Rule chain is required", - "management": "Rules management", - "debug-mode": "Debug mode" + "rulechain": "Cadena de reglas", + "rulechains": "Cadenas de reglas", + "root": "Raíz", + "delete": "Eliminar cadena de reglas", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "description": "Descripción", + "add": "Agregar cadena de reglas", + "set-root": "Hacer la cadena de reglas raíz", + "set-root-rulechain-title": "¿Está seguro de que desea hacer la cadena de reglas '{{ruleChainName}}' root?", + "set-root-rulechain-text": "Después de la confirmación, la cadena de reglas se volverá raíz y manejará todos los mensajes de transporte entrantes.", + "delete-rulechain-title": "¿Está seguro de que desea eliminar la cadena de reglas '{{ruleChainName}}'?", + "delete-rulechain-text": "Tener cuidado, después de la confirmación, la cadena de reglas y todos los datos relacionados se volverán irrecuperables.", + "delete-rulechains-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 rule chain} other {# rule chains} }?", + "delete-rulechains-action-title": "Eliminar { count, plural, 1 {1 rule chain} other {# rule chains} }", + "delete-rulechains-text": "Tener cuidado, después de la confirmación se eliminarán todas las cadenas de reglas seleccionadas y todos los datos relacionados se volverán irrecuperables.", + "add-rulechain-text": "Agregar nueva cadena de reglas", + "no-rulechains-text": "Cadenas de reglas no encontradas", + "rulechain-details": "Detalles de la cadena de reglas", + "details": "Detalles", + "events": "Eventos", + "system": "Sistema", + "import": "Importar cadena de reglas", + "export": "Exportar cadena de reglas", + "export-failed-error": "No se puede exportar la cadena de reglas: {{error}}", + "create-new-rulechain": "Crear nueva cadena de reglas", + "rulechain-file": "Archivo de la cadena de reglas", + "invalid-rulechain-file-error": "No se puede importar la cadena de reglas: Estructura de datos de la cadena de reglas inválida.", + "copyId": "Copiar ID de la cadena de reglas", + "idCopiedMessage": "ID de la cadena de reglas ha sido copiada al portapapeles", + "select-rulechain": "Seleccionar cadena de reglas", + "no-rulechains-matching": "Cadenas de reglas que coincidan con '{{entity}}' no fueron encontradas.", + "rulechain-required": "Cadena de reglas es requerida", + "management": "Gestión de reglas", + "debug-mode": "Mode de depuración" }, "rulenode": { - "details": "Details", - "events": "Events", - "search": "Search nodes", - "open-node-library": "Open node library", - "add": "Add rule node", - "name": "Name", - "name-required": "Name is required.", - "type": "Type", - "description": "Description", - "delete": "Delete rule node", - "select-all-objects": "Select all nodes and connections", - "deselect-all-objects": "Deselect all nodes and connections", - "delete-selected-objects": "Delete selected nodes and connections", - "delete-selected": "Delete selected", - "select-all": "Select all", - "copy-selected": "Copy selected", - "deselect-all": "Deselect all", - "rulenode-details": "Rule node details", - "debug-mode": "Debug mode", - "configuration": "Configuration", - "link": "Link", - "link-details": "Rule node link details", - "add-link": "Add link", - "link-label": "Link label", - "link-label-required": "Link label is required.", - "custom-link-label": "Custom link label", - "custom-link-label-required": "Custom link label is required.", - "type-filter": "Filter", - "type-filter-details": "Filter incoming messages with configured conditions", - "type-enrichment": "Enrichment", - "type-enrichment-details": "Add additional information into Message Metadata", - "type-transformation": "Transformation", - "type-transformation-details": "Change Message payload and Metadata", - "type-action": "Action", - "type-action-details": "Perform special action", - "type-external": "External", - "type-external-details": "Interacts with external system", - "type-rule-chain": "Rule Chain", - "type-rule-chain-details": "Forwards incoming messages to specified Rule Chain", - "type-input": "Input", - "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node", - "directive-is-not-loaded": "Defined configuration directive '{{directiveName}}' is not available.", - "ui-resources-load-error": "Failed to load configuration ui resources.", - "invalid-target-rulechain": "Unable to resolve target rule chain!", - "test-script-function": "Test script function", - "message": "Message", - "message-type": "Message type", - "message-type-required": "Message type is required", - "metadata": "Metadata", - "metadata-required": "Metadata entries can't be empty.", - "output": "Output", - "test": "Test", - "help": "Help" + "details": "Detalles", + "events": "Eventos", + "search": "Nodos de búsqueda", + "open-node-library": "Abrir libreria de nodos", + "add": "Agregar nodo de reglas", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "type": "Tipo", + "description": "Descripción", + "delete": "Eliminar nodo de reglas", + "select-all-objects": "Seleccionar todos los nodos y conexiones", + "deselect-all-objects": "Deshacer selección de todos los nodos y conexiones", + "delete-selected-objects": "Eliminar nodos y conexiones seleccionados", + "delete-selected": "Eliminar seleccionado", + "select-all": "Seleccionar todos", + "copy-selected": "Copiar seleccionado", + "deselect-all": "Deshace selección de todos", + "rulenode-details": "Detalles del nodo de reglas", + "debug-mode": "Modo de depuración", + "configuration": "Configuración", + "link": "Enlace", + "link-details": "Detalles del enlace del nodo de reglas", + "add-link": "Agregar enlace", + "link-label": "Etiqueta del enlace", + "link-label-required": "Etiqueta del enlace es requerida.", + "custom-link-label": "Etiqueta del enlace personalizada", + "custom-link-label-required": "Etiqueta del enlace personalizado es requerida.", + "link-labels": "Etiquetas del enlace", + "link-labels-required": "Etiquetas del enlace son requeridas.", + "no-link-labels-found": "Etiquetas de enlaces no encontradas", + "no-link-label-matching": "'{{label}}' no encontrada.", + "create-new-link-label": "Crear una nueva!", + "type-filter": "Filtro", + "type-filter-details": "Filtrar mensajes entrantes con las condiciones configuradas", + "type-enrichment": "Enriquecimiento", + "type-enrichment-details": "Agregar información adicional en mensajes de metadatos", + "type-transformation": "Transformación", + "type-transformation-details": "Cambiar carga útil del Mensaje y Metadatos", + "type-action": "Acción", + "type-action-details": "Ejecutar acción especial", + "type-external": "Externo", + "type-external-details": "Interactuar con sistemas externos", + "type-rule-chain": "Cadena de reglas", + "type-rule-chain-details": "Reenvíar los mensajes entrantes a la cadena de reglas especificada", + "type-input": "Entrada", + "type-input-details": "Entrada lógica de la Cadena de Reglas, reenvíar los mensajes entrantes al siguiente nodo de regla relacionado.", + "type-unknown": "Desconocido", + "type-unknown-details": "Regla de nodo no resuelta", + "directive-is-not-loaded": "La directiva de configuración definida '{{directiveName}}' no está disponible.", + "ui-resources-load-error": "Error al cargar los recursos de configuración ui.", + "invalid-target-rulechain": "No se puede resolver la cadena de reglas objetivo!", + "test-script-function": "Probar función script", + "message": "Mensaje", + "message-type": "Tipo de mensaje", + "select-message-type": "Seleccionar tipo de mensaje", + "message-type-required": "Tipo de mensaje es requerido", + "metadata": "Metadatos", + "metadata-required": "La entradas de matadatos no pueden estar vacías.", + "output": "Salida", + "test": "Prueba", + "help": "Ayuda" }, "tenant": { - "tenants": "Tenants", - "management": "Gestión de Tenant", - "add": "Agregar Tenant", - "admins": "Admins", - "manage-tenant-admins": "Gestionar administradores tenant", - "delete": "Eliminar tenant", - "add-tenant-text": "Agregar nuevo tenant", - "no-tenants-text": "Ningún tenant encontrado", - "tenant-details": "Detalles del Tenant", - "delete-tenant-title": "¿Estás seguro que quieres eliminar el tenant '{{tenantTitle}}'?", - "delete-tenant-text": "Ten cuidado, luego de confirmar el tenant será eliminado y la información relacionada será irrecuperable.", - "delete-tenants-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 tenant} other {# tenants} }?", + "tenant": "Organización", + "tenants": "Organizaciones", + "management": "Gestión de la organización", + "add": "Agregar Organización", + "admins": "Administradores", + "manage-tenant-admins": "Gestionar administradores de la organización", + "delete": "Eliminar organización", + "add-tenant-text": "Agregar nueva organización", + "no-tenants-text": "Organizaciones no encontradas", + "tenant-details": "Detalles de la organización", + "delete-tenant-title": "¿Está seguro de que desea eliminar la organización '{{tenantTitle}}'?", + "delete-tenant-text": "Tener cuidado, después de la confirmación, la organización y todos los datos relacionados se volverán irrecuperables.", + "delete-tenants-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 tenant} other {# tenants} }?", "delete-tenants-action-title": "Eliminar { count, plural, 1 {1 tenant} other {# tenants} }", - "delete-tenants-text": "Ten cuidado, luego de confirmar los tenants seleccionados serán eliminados y la información relacionada será irrecuperable.", + "delete-tenants-text": "Tener cuidado, después de la confirmación se eliminarán todas las organizaciones seleccionadas y todos los datos relacionados se volverán irrecuperables.", "title": "Título", - "title-required": "Título requerido.", - "description": "Descripción" + "title-required": "Título es requerido.", + "description": "Descripción", + "details": "Detalles", + "events": "Eventos", + "copyId": "Copiar ID de la organización", + "idCopiedMessage": "ID de la organización ha sido copiado al portapapeles", + "select-tenant": "Seleccionar organización", + "no-tenants-matching": "Organizaciones que coincidan con '{{entity}}' no fueron encontradas.", + "tenant-required": "Organización es requerida" }, "timeinterval": { - "seconds-interval": "{ seconds, plural, 1 {1 segundo} other {# segundos} }", - "minutes-interval": "{ minutes, plural, 1 {1 minuto} other {# minutos} }", - "hours-interval": "{ hours, plural, 1 {1 hora} other {# horas} }", - "days-interval": "{ days, plural, 1 {1 día} other {# días} }", + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minutes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# hours} }", + "days-interval": "{ days, plural, 1 {1 day} other {# days} }", "days": "Días", "hours": "Horas", "minutes": "Minutos", @@ -1110,209 +1309,248 @@ "advanced": "Avanzado" }, "timewindow": { - "days": "{ days, plural, 1 { día } other {# días } }", - "hours": "{ hours, plural, 0 { horas } 1 {1 hora } other {# horas } }", - "minutes": "{ minutes, plural, 0 { minutos } 1 {1 minuto } other {# minutos } }", - "seconds": "{ seconds, plural, 0 { segundos } 1 {1 segundo } other {# segundos } }", - "realtime": "Tiempo-real", - "history": "Histórico", - "last-prefix": "último", - "period": "desde {{ startTime }} hasta {{ endTime }}", - "edit": "Editar ventana de tiempo", - "date-range": "Rango de fechas", - "last": "Últimos", + "days": "{ days, plural, 1 { day } other {# days } }", + "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", + "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", + "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", + "realtime": "Tiempo real", + "history": "Historia", + "last-prefix": "última", + "period": "desde {{ startTime }} to {{ endTime }}", + "edit": "Editat ventana de tiempo", + "date-range": "Rango de fecha", + "last": "Última", "time-period": "Período de tiempo" }, "user": { + "user": "Usuario", "users": "Usuarios", - "customer-users": "Usuarios del Cliente", - "tenant-admins": "Tenant Admins", - "sys-admin": "Administrador del Sistema", - "tenant-admin": "Administrador Tenant", + "customer-users": "Usuarios cliente", + "tenant-admins": "Administradores de la Organización", + "sys-admin": "Administrador del sistema", + "tenant-admin": "Administrador de la organización", "customer": "Cliente", "anonymous": "Anónimo", - "add": "Agregar usuario", + "add": "Agregar Usuario", "delete": "Eliminar usuario", "add-user-text": "Agregar nuevo usuario", - "no-users-text": "Ningún usuario encontrado", - "user-details": "Detalles del usuario", - "delete-user-title": "¿Estás seguro que quieres eliminar el usuario '{{userEmail}}'?", - "delete-user-text": "Ten cuidado, luego de confirmar el usuario seleccionado será eliminado y la información relacionada será irrecuperable.", - "delete-users-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 usuario} other {# usuarios} }?", - "delete-users-action-title": "Borrar { count, plural, 1 {1 usuario} other {# usuarios} }", - "delete-users-text": "Ten cuidado, luego de confirmar los usuarios seleccionados serán eliminados y la información relacionada será irrecuperable.", - "activation-email-sent-message": "Mail de activación enviado con éxito!", + "no-users-text": "Usuarios no encontrados", + "user-details": "Detalles de usuario", + "delete-user-title": "¿Está seguro de que desea eliminar el usuario '{{userEmail}}'?", + "delete-user-text": "Tener cuidado, después de la confirmación, el usuario y todos los datos relacionados se volverán irrecuperables.", + "delete-users-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 user} other {# users} }?", + "delete-users-action-title": "Delete { count, plural, 1 {1 user} other {# users} }", + "delete-users-text": "Tener cuidado, después de la confirmación se eliminarán todas los usuarios seleccionados y todos los datos relacionados se volverán irrecuperables.", + "activation-email-sent-message": "Correo electrónico de activación fue enviado exitosamente!", "resend-activation": "Reenviar activación", - "email": "Email", - "email-required": "Email requerido.", + "email": "Correo electrónico", + "email-required": "Correo electrónico es requerido.", + "invalid-email-format": "Formato de correo electrónico inválido.", "first-name": "Nombre", "last-name": "Apellido", "description": "Descripción", - "default-dashboard": "Panel por defecto", - "always-fullscreen": "Siempre en pantalla completa" + "default-dashboard": "Panel predeterminado", + "always-fullscreen": "Siempre pantalla completa", + "select-user": "Seleccionar usuario", + "no-users-matching": "Usuarios que coincidan con '{{entity}}' no fueron encontrados.", + "user-required": "Usuario es requerido", + "activation-method": "Método de activación", + "display-activation-link": "Mostrar enlace de activación", + "send-activation-mail": "Enviar correo electrónico de activación", + "activation-link": "Enlace de activación de usuario", + "activation-link-text": "Para activar el usuario, utilizar los siguientes activation link :", + "copy-activation-link": "Copiar enlace de activación", + "activation-link-copied-message": "El enlace de activación de usuario ha sido copiado al portapapeles", + "details": "Detalles", + "login-as-tenant-admin": "Iniciar sesión como Administrador de la Organización", + "login-as-customer-user": "Iniciar sesión como Usuario Cliente" }, "value": { "type": "Tipo de valor", - "string": "Cadena de texto", - "string-value": "Valor de cadena de texto", - "integer": "Nro entero", - "integer-value": "Valor de nro entero", - "invalid-integer-value": "Valor inválido", - "double": "Nro decimal", - "double-value": "Valor nro decimal", + "string": "Cadena de caracteres", + "string-value": "Valor de la cadena de caracteres", + "integer": "Entero", + "integer-value": "Valor entero", + "invalid-integer-value": "Valor de entero inválido", + "double": "Doble", + "double-value": "Valor doble", "boolean": "Booleano", "boolean-value": "Valor booleano", "false": "Falso", - "true": "Verdadero" + "true": "Verdadero", + "long": "Largo" }, "widget": { - "widget-library": "Bibloteca de Widgets", - "widget-bundle": "Paquetes de Widgets", + "widget-library": "Libreria de Widgets", + "widget-bundle": "Paquete de Widgets", "select-widgets-bundle": "Seleccionar paquete de widgets", - "management": "Gestión de Widgets", - "editor": "Editor de widgets", - "widget-type-not-found": "Problema al cargar la configuración del widget.
Probablemente asociado\n El tipo de widget fue eliminado.", - "widget-type-load-error": "Widget no pudo ser cargado debido a estos errores:", + "management": "Gestión de widget", + "editor": "Editor de Widget", + "widget-type-not-found": "Problema cargando configuración de widget.
Probablemente tipo de widget asociado fue eliminado.", + "widget-type-load-error": "El widget no fue cargado debido a los siguientes errores:", "remove": "Eliminar widget", "edit": "Editar widget", - "remove-widget-title": "¿Estás seguro que quieres eliminar el widget '{{widgetTitle}}'?", - "remove-widget-text": "Luego de confirmar el widget será eliminado y toda la información relacionada será irrecuperable..", - "timeseries": "Series de tiempo", + "remove-widget-title": "¿Está seguro de que desea eliminar el widget '{{widgetTitle}}'?", + "remove-widget-text": "Después de la confirmación, el widget y todos los datos relacionados se volverán irrecuperables.", + "timeseries": "Series temporales", + "search-data": "Buscar datos", + "no-data-found": "Datos no encontrados", "latest-values": "Últimos valores", "rpc": "Widget de control", + "alarm": "Widget de alarma", "static": "Widget estático", "select-widget-type": "Seleccionar tipo de widget", - "missing-widget-title-error": "El titulo del widget debe ser especificado!", + "missing-widget-title-error": "Título del widget debe ser especificado!", "widget-saved": "Widget guardado", - "unable-to-save-widget-error": "Imposible guardar widget! Tiene errores!", + "unable-to-save-widget-error": "No se puede guardar widget! El widget tiene errores!", "save": "Guardar widget", "saveAs": "Guardar widget como", "save-widget-type-as": "Guardar tipo de widget como", - "save-widget-type-as-text": "Por favor, ingrese un nuevo titulo y/o seleccione un paquete de destino.", - "toggle-fullscreen": "Cambiar a pantalla completa", - "run": "Correr widget", - "title": "Titulo", - "title-required": "Titulo requerido.", - "type": "Tipo", + "save-widget-type-as-text": "Por favor ingresar nuevo título del widget y/o seleccionar paquete de widgets objetivo", + "toggle-fullscreen": "Alternar pantalla completa", + "run": "Ejecutar widget", + "title": "Título del widget", + "title-required": "Título del widget es requerido.", + "type": "Tipo de widget", "resources": "Recursos", "resource-url": "JavaScript/CSS URL", "remove-resource": "Eliminar recurso", - "add-resource": "Agregar recurso", + "add-resource": "Agregar recursose", "html": "HTML", - "tidy": "Tidy", + "tidy": "Ordenado", "css": "CSS", "settings-schema": "Esquema de configuración", "datakey-settings-schema": "Esquema de configuración de clave de datos", "javascript": "Javascript", - "remove-widget-type-title": "¿Estás seguro que quieres eliminar el tipo del widget '{{widgetName}}'?", - "remove-widget-type-text": "Luego de confirmar el tipo será eliminado y la información relacionada será irrecuperable.", - "remove-widget-type": "Eliminar tipo de widget.", + "remove-widget-type-title": "¿Está seguro de que desea eliminar el tipo de widget '{{widgetName}}'?", + "remove-widget-type-text": "Después de la confirmación, el tipo de widget y todos los datos relacionados se volverán irrecuperables.", + "remove-widget-type": "Eliminar tipo de widget", "add-widget-type": "Agregar nuevo tipo de widget", - "widget-type-load-failed-error": "Error al cargar el tipo de widget!", - "widget-template-load-failed-error": "Error al cargar el template del widget!", + "widget-type-load-failed-error": "Failed to load widget type!", + "widget-template-load-failed-error": "Error al cargar la plantilla del widget!", "add": "Agregar Widget", - "undo": "Deshacer cambios", + "undo": "Deshacer cambios en el widget", "export": "Exportar widget" }, "widget-action": { - "header-button": "Widget header button", - "open-dashboard-state": "Navigate to new dashboard state", - "update-dashboard-state": "Update current dashboard state", - "open-dashboard": "Navigate to other dashboard", - "custom": "Custom action", - "target-dashboard-state": "Target dashboard state", - "target-dashboard-state-required": "Target dashboard state is required", - "set-entity-from-widget": "Set entity from widget", - "target-dashboard": "Target dashboard", - "open-right-layout": "Open right dashboard layout (mobile view)" + "header-button": "Botón del encabezado del widget", + "open-dashboard-state": "Navegar a nuevo estado del panel", + "update-dashboard-state": "Actualizar estado vigente del panel", + "open-dashboard": "Navegar a otro panel", + "custom": "Acción personalizada", + "target-dashboard-state": "Estado del panel objetivo", + "target-dashboard-state-required": "Estado del panel objetivo es requerido", + "set-entity-from-widget": "Asignar entidad desde widget", + "target-dashboard": "Panel objetivo", + "open-right-layout": "Abrir diseño del panel derecho (vista móvil)" }, "widgets-bundle": { "current": "Paquete actual", - "widgets-bundles": "Paquete de Widgets", - "add": "Agregar paquete de widgets", - "delete": "Eliminar paquete de widgets", + "widgets-bundles": "Paquetes de Widgets", + "add": "Agregar Paquete de Widgets", + "delete": "Eliminar paquete de widget", "title": "Título", - "title-required": "Título requerido.", + "title-required": "Título es requerido.", "add-widgets-bundle-text": "Agregar nuevo paquete de widgets", - "no-widgets-bundles-text": "Ningún paquete de widgets encontrado", - "empty": "Paquete de widgets vacío.", + "no-widgets-bundles-text": "Paquetes de widgets no encontrados", + "empty": "Paquete de widgets está vacío", "details": "Detalles", - "widgets-bundle-details": "Detalles del paquete de Widgets", - "delete-widgets-bundle-title": "¿Estás seguro que desea eliminar el paquete de widgets '{{widgetsBundleTitle}}'?", - "delete-widgets-bundle-text": "Ten cuidado, luego de confirmar todos los paquetes seleccionados serán eliminados y su información relacionada será irrecuperable.", - "delete-widgets-bundles-title": "¿Estás seguro que deseas eliminar { count, plural, 1 {1 paquete de widgets} other {# paquetes de widgets} }?", - "delete-widgets-bundles-action-title": "Eliminar { count, plural, 1 {1 paquete de widgets} other {# paquetes de widgets} }", - "delete-widgets-bundles-text": "Ten cuidado, luego de confirmar todos los paquetes seleccionados serán eliminados y la información relacionada será irrecuperable.", - "no-widgets-bundles-matching": "Ningún paquete '{{widgetsBundle}}' encontrado.", - "widgets-bundle-required": "Paquete de widget requerido.", + "widgets-bundle-details": "Detalles del paquete de widgets", + "delete-widgets-bundle-title": "¿Está seguro de que desea eliminar el paquete de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Tener cuidado, después de la confirmación, el paquete de widgets y todos los datos relacionados se volverán irrecuperables.", + "delete-widgets-bundles-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }?", + "delete-widgets-bundles-action-title": "Eliminar { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }", + "delete-widgets-bundles-text": "Tener cuidado, después de la confirmación se eliminarán todas los paquetes de widgets seleccionados y todos los datos relacionados se volverán irrecuperables.", + "no-widgets-bundles-matching": "Paquetes de widgets que coincidan con '{{widgetsBundle}}' no fueron encontrados.", + "widgets-bundle-required": "Paquete de widgets es requerido.", "system": "Sistema", "import": "Importar paquete de widgets", "export": "Exportar paquete de widgets", - "export-failed-error": "Imposible exportar paquete de widgets: {{error}}", + "export-failed-error": "No se puede exportar paquete de widgets: {{error}}", "create-new-widgets-bundle": "Crear nuevo paquete de widgets", "widgets-bundle-file": "Archivo de paquete de widgets", - "invalid-widgets-bundle-file-error": "Imposible importar paquete de widgets: Estructura de datos inválida." + "invalid-widgets-bundle-file-error": "No se puede importar paquete de widgets: Estructura de datos del paquete de widgets inválida." }, "widget-config": { "data": "Datos", - "settings": "Ajustes", + "settings": "Configuración", "advanced": "Avanzado", - "title": "Titulo", - "general-settings": "Ajustes generales", - "display-title": "Mostrar titulo", - "drop-shadow": "Sombra", + "title": "Título", + "general-settings": "Configuaración general", + "display-title": "Mostrar título", + "drop-shadow": "Colocar sombra", "enable-fullscreen": "Habilitar pantalla completa", "background-color": "Color de fondo", "text-color": "Color del texto", "padding": "Relleno", - "title-style": "Estilo de título", - "mobile-mode-settings": "Ajustes mobile.", + "margin": "Margen", + "widget-style": "Estilo del widget", + "title-style": "Estilo del título", + "mobile-mode-settings": "Configuración del modo móvil", "order": "Orden", "height": "Altura", - "units": "Caracter especial a mostrar en el siguiente valor", - "decimals": "Números de dígitos después de la coma", + "units": "Símbolo especial para mostrar junto al valor.", + "decimals": "Número de dígitos después del punto flotante", "timewindow": "Ventana de tiempo", - "use-dashboard-timewindow": "Usar ventana de tiempo del Panel", + "use-dashboard-timewindow": "Utilizar ventana de timpo del panel", "display-legend": "Mostrar leyenda", - "datasources": "Set de datos", + "datasources": "Fuentes de datos", + "maximum-datasources": "Máximo { count, plural, 1 {1 datasource is allowed.} other {# datasources are allowed} }", "datasource-type": "Tipo", - "datasource-parameters": "Parámetros", - "remove-datasource": "Eliminar set de datos", - "add-datasource": "Agregar set de datos", - "target-device": "Dispositivo destino" + "datasource-parameters": "Parametros", + "remove-datasource": "Eliminar fuente de datos", + "add-datasource": "Agregar fuente de datos", + "target-device": "Dispositivo objetivo", + "alarm-source": "Fuente de alarma", + "actions": "Accines", + "action": "Acción", + "add-action": "Agregar acción", + "search-actions": "Buscar acciones", + "action-source": "Fuente de acción", + "action-source-required": "Fuente de acción es requerida.", + "action-name": "Nombre", + "action-name-required": "Nombre de acción es requerido.", + "action-name-not-unique": "Ya existe otra acción con el mismo nombre.
El nombre de la acción debe ser único dentro de la misma fuente de acción.", + "action-icon": "Icono", + "action-type": "Tipo", + "action-type-required": "Tipo de acción es requerido.", + "edit-action": "Editar acción", + "delete-action": "Eliminar acción", + "delete-action-title": "Eliminar acción del widget", + "delete-action-text": "¿Está seguro de que desea eliminar la acción del widget con nombre '{{actionName}}'?" }, "widget-type": { "import": "Importar tipo de widget", "export": "Exportar tipo de widget", - "export-failed-error": "Imposible exportar tipo de widget: {{error}}", + "export-failed-error": "No se puede exportar tipo de widget: {{error}}", "create-new-widget-type": "Crear nuevo tipo de widget", - "widget-type-file": "Tipo de archivo del widget", - "invalid-widget-type-file-error": "Imposible de importar tipo de widget: Estructura de datos inválida." + "widget-type-file": "Archivo de tipo de widget", + "invalid-widget-type-file-error": "No se puede importar tipo de widget: Estructura de datos del tipo de widget es inválida." }, "icon": { - "icon": "Icon", - "select-icon": "Select icon", - "material-icons": "Material icons", - "show-all": "Show all icons" + "icon": "Icono", + "select-icon": "Seleccionar icono", + "material-icons": "Iconos de maerial", + "show-all": "Mostrar todos los iconos" }, "custom": { "widget-action": { - "action-cell-button": "Action cell button", - "row-click": "On row click", - "marker-click": "On marker click", - "tooltip-tag-action": "Tooltip tag action" + "action-cell-button": "Botón de acción de celda", + "row-click": "Clic en la fila", + "marker-click": "Clic en el marcador", + "tooltip-tag-action": "Acción de etiqueta para globo de ayuda" } }, "language": { "language": "Lenguaje", "locales": { + "fr_FR": "Francés", + "zh_CN": "Chino", "en_US": "Inglés", - "fr_FR": "Francés", + "it_IT": "Italiano", "ko_KR": "Coreano", - "zh_CN": "Chino", "ru_RU": "Ruso", "es_ES": "Español", - "it_IT": "Italiano", "ja_JA": "Japonés" } } diff --git a/ui/src/app/widget/lib/google-map.js b/ui/src/app/widget/lib/google-map.js index 25bf510645..2fe7ce4132 100644 --- a/ui/src/app/widget/lib/google-map.js +++ b/ui/src/app/widget/lib/google-map.js @@ -44,7 +44,7 @@ export default class TbGoogleMap { function initGoogleMap() { tbMap.map = new google.maps.Map($containerElement[0], { // eslint-disable-line no-undef - scrollwheel: false, + scrollwheel: true, mapTypeId: getGoogleMapTypeId(tbMap.defaultMapType), zoom: tbMap.defaultZoomLevel || 8 }); From 446f4a95018f75b85b4611c9148ad8bc815ab40a Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Oct 2018 17:32:53 +0300 Subject: [PATCH 183/307] Improve MSA docker build --- msa/js-executor/docker/Dockerfile | 2 ++ msa/js-executor/src/main/scripts/control/deb/postinst | 2 +- msa/web-ui/docker/Dockerfile | 2 ++ msa/web-ui/src/main/scripts/control/deb/postinst | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/msa/js-executor/docker/Dockerfile b/msa/js-executor/docker/Dockerfile index 2b62eb33ae..30c659422b 100644 --- a/msa/js-executor/docker/Dockerfile +++ b/msa/js-executor/docker/Dockerfile @@ -23,4 +23,6 @@ RUN chmod a+x /tmp/*.sh \ RUN dpkg -i /tmp/${pkg.name}.deb +RUN update-rc.d ${pkg.name} disable + CMD ["start-js-executor.sh"] diff --git a/msa/js-executor/src/main/scripts/control/deb/postinst b/msa/js-executor/src/main/scripts/control/deb/postinst index 4c48c5508a..0767d3f2c7 100644 --- a/msa/js-executor/src/main/scripts/control/deb/postinst +++ b/msa/js-executor/src/main/scripts/control/deb/postinst @@ -2,5 +2,5 @@ chown -R ${pkg.user}: ${pkg.logFolder} chown -R ${pkg.user}: ${pkg.installFolder} -# update-rc.d ${pkg.name} defaults +update-rc.d ${pkg.name} defaults diff --git a/msa/web-ui/docker/Dockerfile b/msa/web-ui/docker/Dockerfile index 7a75cc3f1f..a4c4ff2976 100644 --- a/msa/web-ui/docker/Dockerfile +++ b/msa/web-ui/docker/Dockerfile @@ -23,4 +23,6 @@ RUN chmod a+x /tmp/*.sh \ RUN dpkg -i /tmp/${pkg.name}.deb +RUN update-rc.d ${pkg.name} disable + CMD ["start-web-ui.sh"] diff --git a/msa/web-ui/src/main/scripts/control/deb/postinst b/msa/web-ui/src/main/scripts/control/deb/postinst index 4c48c5508a..0767d3f2c7 100644 --- a/msa/web-ui/src/main/scripts/control/deb/postinst +++ b/msa/web-ui/src/main/scripts/control/deb/postinst @@ -2,5 +2,5 @@ chown -R ${pkg.user}: ${pkg.logFolder} chown -R ${pkg.user}: ${pkg.installFolder} -# update-rc.d ${pkg.name} defaults +update-rc.d ${pkg.name} defaults From 94cb347899bab68fde840a9841853b0e7d1b2b55 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 9 Oct 2018 18:51:10 +0300 Subject: [PATCH 184/307] Added entity view type --- .../main/data/upgrade/2.1.2/schema_update.cql | 110 ++++++++++++++++++ .../main/data/upgrade/2.1.2/schema_update.sql | 32 +++++ .../controller/EntityViewController.java | 33 +++++- .../CassandraDatabaseUpgradeService.java | 39 +++++++ .../service/install/DatabaseHelper.java | 9 ++ .../install/SqlDatabaseUpgradeService.java | 34 ++++++ .../BaseEntityViewControllerTest.java | 6 +- .../rules/RuleEngineNoSqlTestSuite.java | 4 +- .../server/common/data/EntityView.java | 1 + .../entityview/EntityViewSearchQuery.java | 1 + .../entityview/CassandraEntityViewDao.java | 69 ++++++++++- .../server/dao/entityview/EntityViewDao.java | 32 +++++ .../dao/entityview/EntityViewService.java | 7 ++ .../dao/entityview/EntityViewServiceImpl.java | 54 +++++++++ .../server/dao/model/ModelConstants.java | 7 +- .../dao/model/nosql/EntityViewEntity.java | 7 ++ .../dao/model/sql/EntityViewEntity.java | 5 + .../sql/entityview/EntityViewRepository.java | 41 +++++-- .../dao/sql/entityview/JpaEntityViewDao.java | 42 ++++++- .../resources/cassandra/schema-entities.cql | 49 ++++++-- .../main/resources/sql/schema-entities.sql | 3 +- .../test/resources/sql/drop-all-tables.sql | 2 +- ui/src/app/api/entity.service.js | 33 ++++++ ui/src/app/common/types.constant.js | 8 ++ .../entity-view/entity-view-fieldset.tpl.html | 7 ++ .../entity/entity-filter-view.directive.js | 22 +++- ui/src/app/entity/entity-filter.directive.js | 8 ++ ui/src/app/entity/entity-filter.tpl.html | 83 +++++++++++++ .../entity-subtype-autocomplete.directive.js | 2 + .../entity/entity-subtype-list.directive.js | 2 + .../entity/entity-subtype-select.directive.js | 2 + ui/src/app/locale/locale.constant-en_US.json | 7 +- 32 files changed, 727 insertions(+), 34 deletions(-) create mode 100644 application/src/main/data/upgrade/2.1.2/schema_update.cql create mode 100644 application/src/main/data/upgrade/2.1.2/schema_update.sql diff --git a/application/src/main/data/upgrade/2.1.2/schema_update.cql b/application/src/main/data/upgrade/2.1.2/schema_update.cql new file mode 100644 index 0000000000..c02571ada4 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.2/schema_update.cql @@ -0,0 +1,110 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_name; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_customer; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_entity_id; + +DROP TABLE IF EXISTS thingsboard.entity_views; + +CREATE TABLE IF NOT EXISTS thingsboard.entity_view ( + id timeuuid, + entity_id timeuuid, + entity_type text, + tenant_id timeuuid, + customer_id timeuuid, + name text, + type text, + keys text, + start_ts bigint, + end_ts bigint, + search_text text, + additional_info text, + PRIMARY KEY (id, entity_id, tenant_id, customer_id, type) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_name AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, customer_id, entity_id, type) + WITH CLUSTERING ORDER BY (name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id, type) + WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, search_text, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id, type) + WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer_and_type AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, customer_id, search_text, id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_id AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id, type) + WITH CLUSTERING ORDER BY (entity_id DESC, customer_id DESC, search_text ASC, id DESC); \ No newline at end of file diff --git a/application/src/main/data/upgrade/2.1.2/schema_update.sql b/application/src/main/data/upgrade/2.1.2/schema_update.sql new file mode 100644 index 0000000000..14b717c07c --- /dev/null +++ b/application/src/main/data/upgrade/2.1.2/schema_update.sql @@ -0,0 +1,32 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DROP TABLE IF EXISTS entity_views; + +CREATE TABLE IF NOT EXISTS entity_view ( + id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, + entity_id varchar(31), + entity_type varchar(255), + tenant_id varchar(31), + customer_id varchar(31), + type varchar(255), + name varchar(255), + keys varchar(255), + start_ts bigint, + end_ts bigint, + search_text varchar(255), + additional_info varchar +); diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 0ba35e85ad..a1ba1c63f8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import com.google.common.util.concurrent.ListenableFuture; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.audit.ActionType; @@ -38,6 +40,7 @@ import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.service.security.model.SecurityUser; import java.util.List; import java.util.stream.Collectors; @@ -161,6 +164,7 @@ public class EntityViewController extends BaseController { public TextPageData getCustomerEntityViews( @PathVariable("customerId") String strCustomerId, @RequestParam int limit, + @RequestParam(required = false) String type, @RequestParam(required = false) String textSearch, @RequestParam(required = false) String idOffset, @RequestParam(required = false) String textOffset) throws ThingsboardException { @@ -170,7 +174,11 @@ public class EntityViewController extends BaseController { CustomerId customerId = new CustomerId(toUUID(strCustomerId)); checkCustomerId(customerId); TextPageLink pageLink = createPageLink(limit, textSearch, idOffset, textOffset); - return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + if (type != null && type.trim().length() > 0) { + return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId, customerId, pageLink, type)); + } else { + return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } } catch (Exception e) { throw handleException(e); } @@ -181,13 +189,19 @@ public class EntityViewController extends BaseController { @ResponseBody public TextPageData getTenantEntityViews( @RequestParam int limit, + @RequestParam(required = false) String type, @RequestParam(required = false) String textSearch, @RequestParam(required = false) String idOffset, @RequestParam(required = false) String textOffset) throws ThingsboardException { try { TenantId tenantId = getCurrentUser().getTenantId(); TextPageLink pageLink = createPageLink(limit, textSearch, idOffset, textOffset); - return checkNotNull(entityViewService.findEntityViewByTenantId(tenantId, pageLink)); + + if (type != null && type.trim().length() > 0) { + return checkNotNull(entityViewService.findEntityViewByTenantIdAndType(tenantId, pageLink, type)); + } else { + return checkNotNull(entityViewService.findEntityViewByTenantId(tenantId, pageLink)); + } } catch (Exception e) { throw handleException(e); } @@ -199,6 +213,7 @@ public class EntityViewController extends BaseController { public List findByQuery(@RequestBody EntityViewSearchQuery query) throws ThingsboardException { checkNotNull(query); checkNotNull(query.getParameters()); + checkNotNull(query.getEntityViewTypes()); checkEntityId(query.getParameters().getEntityId()); try { List entityViews = checkNotNull(entityViewService.findEntityViewsByQuery(query).get()); @@ -215,4 +230,18 @@ public class EntityViewController extends BaseController { throw handleException(e); } } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView/types", method = RequestMethod.GET) + @ResponseBody + public List getEntityViewTypes() throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + ListenableFuture> entityViewTypes = entityViewService.findEntityViewTypesByTenantId(tenantId); + return checkNotNull(entityViewTypes.get()); + } catch (Exception e) { + throw handleException(e); + } + } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index 934cf8d995..d57371f4e6 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -39,10 +39,19 @@ import static org.thingsboard.server.service.install.DatabaseHelper.CONFIGURATIO import static org.thingsboard.server.service.install.DatabaseHelper.CUSTOMER_ID; import static org.thingsboard.server.service.install.DatabaseHelper.DASHBOARD; import static org.thingsboard.server.service.install.DatabaseHelper.DEVICE; +import static org.thingsboard.server.service.install.DatabaseHelper.END_TS; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_ID; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_TYPE; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEW; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEWS; import static org.thingsboard.server.service.install.DatabaseHelper.ID; +import static org.thingsboard.server.service.install.DatabaseHelper.KEYS; +import static org.thingsboard.server.service.install.DatabaseHelper.NAME; import static org.thingsboard.server.service.install.DatabaseHelper.SEARCH_TEXT; +import static org.thingsboard.server.service.install.DatabaseHelper.START_TS; import static org.thingsboard.server.service.install.DatabaseHelper.TENANT_ID; import static org.thingsboard.server.service.install.DatabaseHelper.TITLE; +import static org.thingsboard.server.service.install.DatabaseHelper.TYPE; @Service @NoSqlDao @@ -213,6 +222,36 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { break; + case "2.1.1": + + log.info("Upgrading Cassandara DataBase from version {} to 2.1.1 ...", fromVersion); + + cluster.getSession(); + + ks = cluster.getCluster().getMetadata().getKeyspace(cluster.getKeyspaceName()); + + log.info("Dumping entity views ..."); + Path entityViewsDump = CassandraDbHelper.dumpCfIfExists(ks, cluster.getSession(), ENTITY_VIEWS, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, NAME, TYPE, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, + new String[]{"", "", "", "", "", "", "default", "", "0", "0", "", ""}, + "tb-entity-views"); + log.info("Entity views dumped."); + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.2", SCHEMA_UPDATE_CQL); + loadCql(schemaUpdateFile); + log.info("Schema updated."); + + log.info("Restoring entity views ..."); + if (entityViewsDump != null) { + CassandraDbHelper.loadCf(ks, cluster.getSession(), ENTITY_VIEW, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, NAME, TYPE, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, entityViewsDump); + Files.deleteIfExists(entityViewsDump); + } + log.info("Entity views restored."); + + break; + default: throw new RuntimeException("Unable to upgrade Cassandra database, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java b/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java index 2f9b4a5ed3..699eaf1846 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java @@ -45,14 +45,23 @@ public class DatabaseHelper { public static final CSVFormat CSV_DUMP_FORMAT = CSVFormat.DEFAULT.withNullString("\\N"); public static final String DEVICE = "device"; + public static final String ENTITY_ID = "entity_id"; public static final String TENANT_ID = "tenant_id"; + public static final String ENTITY_TYPE = "entity_type"; public static final String CUSTOMER_ID = "customer_id"; public static final String SEARCH_TEXT = "search_text"; public static final String ADDITIONAL_INFO = "additional_info"; public static final String ASSET = "asset"; public static final String DASHBOARD = "dashboard"; + public static final String ENTITY_VIEWS = "entity_views"; + public static final String ENTITY_VIEW = "entity_view"; public static final String ID = "id"; public static final String TITLE = "title"; + public static final String TYPE = "type"; + public static final String NAME = "name"; + public static final String KEYS = "keys"; + public static final String START_TS = "start_ts"; + public static final String END_TS = "end_ts"; public static final String ASSIGNED_CUSTOMERS = "assigned_customers"; public static final String CONFIGURATION = "configuration"; diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 7d701b7a53..2c7f89c75a 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -31,14 +31,24 @@ import java.nio.file.Paths; import java.sql.Connection; import java.sql.DriverManager; +import static org.thingsboard.server.service.install.DatabaseHelper.ADDITIONAL_INFO; import static org.thingsboard.server.service.install.DatabaseHelper.ASSIGNED_CUSTOMERS; import static org.thingsboard.server.service.install.DatabaseHelper.CONFIGURATION; import static org.thingsboard.server.service.install.DatabaseHelper.CUSTOMER_ID; import static org.thingsboard.server.service.install.DatabaseHelper.DASHBOARD; +import static org.thingsboard.server.service.install.DatabaseHelper.END_TS; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_ID; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_TYPE; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEW; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEWS; import static org.thingsboard.server.service.install.DatabaseHelper.ID; +import static org.thingsboard.server.service.install.DatabaseHelper.KEYS; +import static org.thingsboard.server.service.install.DatabaseHelper.NAME; import static org.thingsboard.server.service.install.DatabaseHelper.SEARCH_TEXT; +import static org.thingsboard.server.service.install.DatabaseHelper.START_TS; import static org.thingsboard.server.service.install.DatabaseHelper.TENANT_ID; import static org.thingsboard.server.service.install.DatabaseHelper.TITLE; +import static org.thingsboard.server.service.install.DatabaseHelper.TYPE; @Service @Profile("install") @@ -115,6 +125,30 @@ public class SqlDatabaseUpgradeService implements DatabaseUpgradeService { log.info("Schema updated."); } break; + case "2.1.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + + log.info("Dumping entity views ..."); + Path entityViewsDump = SqlDbHelper.dumpTableIfExists(conn, ENTITY_VIEWS, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, TYPE, NAME, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, + new String[]{"", "", "", "", "", "default", "", "", "0", "0", "", ""}, + "tb-entity-views", true); + log.info("Entity views dumped."); + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.2", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + + log.info("Restoring entity views ..."); + if (entityViewsDump != null) { + SqlDbHelper.loadTable(conn, ENTITY_VIEW, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, TYPE, NAME, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, entityViewsDump, true); + Files.deleteIfExists(entityViewsDump); + } + log.info("Entity views restored."); + } + break; default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index ee97b97fa0..cb45006c8c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -144,7 +144,9 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes @Test public void testSaveEntityViewWithEmptyName() throws Exception { - doPost("/api/entityView", new EntityView()) + EntityView entityView = new EntityView(); + entityView.setType("default"); + doPost("/api/entityView", entityView) .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString("Entity view name should be specified!"))); } @@ -355,6 +357,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setEntityId(testDevice.getId()); view.setTenantId(savedTenant.getId()); view.setName("Test entity view"); + view.setType("default"); view.setKeys(telemetry); view.setStartTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") * 10); view.setEndTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") / 10); @@ -402,6 +405,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setEntityId(testDevice.getId()); view.setTenantId(savedTenant.getId()); view.setName(name); + view.setType("default"); view.setKeys(telemetry); return doPost("/api/entityView", view, EntityView.class); } diff --git a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java index c1d1e6cbaf..c910cadcbe 100644 --- a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java @@ -26,8 +26,8 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.rules.flow.nosql.*Test", - "org.thingsboard.server.rules.lifecycle.nosql.*Test" + "org.thingsboard.server.rules.flow.nosql.RuleEngineFlowNoSqlIntegrationTest", +// "org.thingsboard.server.rules.lifecycle.nosql.*Test" }) public class RuleEngineNoSqlTestSuite { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 25b066d2ef..f684e2afa9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -40,6 +40,7 @@ public class EntityView extends SearchTextBasedWithAdditionalInfo private TenantId tenantId; private CustomerId customerId; private String name; + private String type; private TelemetryEntityView keys; private long startTimeMs; private long endTimeMs; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java index 752eafc6ad..3bf08ea0b4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java @@ -30,6 +30,7 @@ public class EntityViewSearchQuery { private RelationsSearchParameters parameters; private String relationType; + private List entityViewTypes; public EntityRelationsQuery toEntitySearchQuery() { EntityRelationsQuery query = new EntityRelationsQuery(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index a03dd89815..fe1a8d1f8d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -15,8 +15,13 @@ */ package org.thingsboard.server.dao.entityview; +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.ResultSetFuture; import com.datastax.driver.core.Statement; import com.datastax.driver.core.querybuilder.Select; +import com.datastax.driver.mapping.Result; +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -30,6 +35,8 @@ import org.thingsboard.server.dao.model.nosql.EntityViewEntity; import org.thingsboard.server.dao.nosql.CassandraAbstractSearchTextDao; import org.thingsboard.server.dao.util.NoSqlDao; +import javax.annotation.Nullable; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -39,14 +46,21 @@ import java.util.UUID; import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; import static com.datastax.driver.core.querybuilder.QueryBuilder.select; import static org.thingsboard.server.dao.model.ModelConstants.CUSTOMER_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.DEVICE_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_ID_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_SUBTYPE_COLUMN_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_SUBTYPE_ENTITY_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_SUBTYPE_TENANT_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_TYPE_CF; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_NAME; -import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_CF; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_CF; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_NAME_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.TENANT_ID_PROPERTY; /** @@ -82,13 +96,25 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink) { log.debug("Try to find entity views by tenantId [{}] and pageLink [{}]", tenantId, pageLink); List entityViewEntities = - findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME, + findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_CF, Collections.singletonList(eq(TENANT_ID_PROPERTY, tenantId)), pageLink); log.trace("Found entity views [{}] by tenantId [{}] and pageLink [{}]", entityViewEntities, tenantId, pageLink); return DaoUtil.convertDataList(entityViewEntities); } + @Override + public List findEntityViewsByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink) { + log.debug("Try to find entity views by tenantId [{}], type [{}] and pageLink [{}]", tenantId, type, pageLink); + List entityViewEntities = + findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_CF, + Arrays.asList(eq(ENTITY_VIEW_TYPE_PROPERTY, type), + eq(TENANT_ID_PROPERTY, tenantId)), pageLink); + log.trace("Found entity views [{}] by tenantId [{}], type [{}] and pageLink [{}]", + entityViewEntities, tenantId, type, pageLink); + return DaoUtil.convertDataList(entityViewEntities); + } + @Override public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { Select.Where query = select().from(ENTITY_VIEW_BY_TENANT_AND_NAME).where(); @@ -110,6 +136,19 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, UUID customerId, String type, TextPageLink pageLink) { + log.debug("Try to find entity views by tenantId [{}], customerId[{}], type [{}] and pageLink [{}]", + tenantId, customerId, type, pageLink); + List entityViewEntities = findPageWithTextSearch( + ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_TYPE_CF, + Arrays.asList(eq(DEVICE_TYPE_PROPERTY, type), eq(CUSTOMER_ID_PROPERTY, customerId), eq(TENANT_ID_PROPERTY, tenantId)), + pageLink); + log.trace("Found find entity views [{}] by tenantId [{}], customerId [{}], type [{}] and pageLink [{}]", + entityViewEntities, tenantId, customerId, type, pageLink); + return DaoUtil.convertDataList(entityViewEntities); + } + @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { log.debug("Try to find entity views by tenantId [{}] and entityId [{}]", tenantId, entityId); @@ -118,4 +157,30 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao> findTenantEntityViewTypesAsync(UUID tenantId) { + Select select = select().from(ENTITY_SUBTYPE_COLUMN_FAMILY_NAME); + Select.Where query = select.where(); + query.and(eq(ENTITY_SUBTYPE_TENANT_ID_PROPERTY, tenantId)); + query.and(eq(ENTITY_SUBTYPE_ENTITY_TYPE_PROPERTY, EntityType.ENTITY_VIEW)); + query.setConsistencyLevel(cluster.getDefaultReadConsistencyLevel()); + ResultSetFuture resultSetFuture = executeAsyncRead(query); + return Futures.transform(resultSetFuture, new Function>() { + @Nullable + @Override + public List apply(@Nullable ResultSet resultSet) { + Result result = cluster.getMapper(EntitySubtypeEntity.class).map(resultSet); + if (result != null) { + List entitySubtypes = new ArrayList<>(); + result.all().forEach((entitySubtypeEntity) -> + entitySubtypes.add(entitySubtypeEntity.toEntitySubtype()) + ); + return entitySubtypes; + } else { + return Collections.emptyList(); + } + } + }); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java index ba433855e2..8147a07a88 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.entityview; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.Dao; @@ -47,6 +48,16 @@ public interface EntityViewDao extends Dao { */ List findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink); + /** + * Find entity views by tenantId, type and page link. + * + * @param tenantId the tenantId + * @param type the type + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink); + /** * Find entity views by tenantId and entity view name. * @@ -68,6 +79,27 @@ public interface EntityViewDao extends Dao { UUID customerId, TextPageLink pageLink); + /** + * Find entity views by tenantId, customerId, type and page link. + * + * @param tenantId the tenantId + * @param customerId the customerId + * @param type the type + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, + UUID customerId, + String type, + TextPageLink pageLink); ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId); + + /** + * Find tenants entity view types. + * + * @return the list of tenant entity view type objects + */ + ListenableFuture> findTenantEntityViewTypesAsync(UUID tenantId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 19f326c0bc..da87b44322 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.entityview; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; @@ -44,8 +45,12 @@ public interface EntityViewService { TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); + TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, TextPageLink pageLink, String type); + TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink); + TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, TextPageLink pageLink, String type); + ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query); ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId); @@ -55,4 +60,6 @@ public interface EntityViewService { void deleteEntityView(EntityViewId entityViewId); void deleteEntityViewsByTenantId(TenantId tenantId); + + ListenableFuture> findEntityViewTypesByTenantId(TenantId tenantId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 2c5ec75485..6b4ffbcbc4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.entityview; +import com.google.common.base.Function; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -29,6 +30,8 @@ import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; @@ -54,6 +57,8 @@ import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -63,6 +68,7 @@ import static org.thingsboard.server.common.data.CacheConstants.RELATIONS_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; +import static org.thingsboard.server.dao.service.Validator.validateString; /** * Created by Victor Basanets on 8/28/2017. @@ -157,6 +163,16 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } + @Override + public TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, TextPageLink pageLink, String type) { + log.trace("Executing findEntityViewByTenantIdAndType, tenantId [{}], pageLink [{}], type [{}]", tenantId, pageLink, type); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + validateString(type, "Incorrect type " + type); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndType(tenantId.getId(), type, pageLink); + return new TextPageData<>(entityViews, pageLink); + } + @Override public TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink) { @@ -170,6 +186,19 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return new TextPageData<>(entityViews, pageLink); } + @Override + public TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, TextPageLink pageLink, String type) { + log.trace("Executing findEntityViewsByTenantIdAndCustomerIdAndType, tenantId [{}], customerId [{}]," + + " pageLink [{}], type [{}]", tenantId, customerId, pageLink, type); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + validateString(type, "Incorrect type " + type); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId.getId(), + customerId.getId(), type, pageLink); + return new TextPageData<>(entityViews, pageLink); + } + @Override public ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query) { ListenableFuture> relations = relationService.findByQuery(query.toEntitySearchQuery()); @@ -184,6 +213,15 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti } return Futures.successfulAsList(futures); }); + + entityViews = Futures.transform(entityViews, new Function, List>() { + @Nullable + @Override + public List apply(@Nullable List entityViewList) { + return entityViewList == null ? Collections.emptyList() : entityViewList.stream().filter(entityView -> query.getEntityViewTypes().contains(entityView.getType())).collect(Collectors.toList()); + } + }); + return entityViews; } @@ -216,6 +254,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti public void onSuccess(@Nullable List result) { cache.putIfAbsent(tenantIdAndEntityId, result); } + @Override public void onFailure(Throwable t) { log.error("Error while finding entity views by tenantId and entityId", t); @@ -243,6 +282,18 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti tenantEntityViewRemover.removeEntities(tenantId); } + @Override + public ListenableFuture> findEntityViewTypesByTenantId(TenantId tenantId) { + log.trace("Executing findEntityViewTypesByTenantId, tenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + ListenableFuture> tenantEntityViewTypes = entityViewDao.findTenantEntityViewTypesAsync(tenantId.getId()); + return Futures.transform(tenantEntityViewTypes, + entityViewTypes -> { + entityViewTypes.sort(Comparator.comparing(EntitySubtype::getType)); + return entityViewTypes; + }); + } + private ListenableFuture> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { if (keys != null && !keys.isEmpty()) { ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); @@ -296,6 +347,9 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Override protected void validateDataImpl(EntityView entityView) { + if (StringUtils.isEmpty(entityView.getType())) { + throw new DataValidationException("Entity View type should be specified!"); + } if (StringUtils.isEmpty(entityView.getName())) { throw new DataValidationException("Entity view name should be specified!"); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index a487ede93a..86ba594aa5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -145,18 +145,21 @@ public class ModelConstants { /** * Cassandra entityView constants. */ - public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_views"; + public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_view"; public static final String ENTITY_VIEW_ENTITY_ID_PROPERTY = ENTITY_ID_COLUMN; public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF = "entity_view_by_tenant_and_customer"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_TYPE_CF = "entity_view_by_tenant_and_customer_and_type"; public static final String ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF = "entity_view_by_tenant_and_entity_id"; public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; + public static final String ENTITY_VIEW_TYPE_PROPERTY = "type"; public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; public static final String ENTITY_VIEW_END_TS_PROPERTY = "end_ts"; public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; - public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "entity_view_by_tenant_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_CF = "entity_view_by_tenant_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_CF = "entity_view_by_tenant_by_type_and_search_text"; public static final String ENTITY_VIEW_BY_TENANT_AND_NAME = "entity_view_by_tenant_and_name"; /** diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java index eb7f4fe65e..cda4217694 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -41,6 +41,7 @@ import javax.persistence.Enumerated; import java.io.IOException; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.DEVICE_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; import static org.thingsboard.server.dao.model.ModelConstants.ID_PROPERTY; @@ -71,6 +72,10 @@ public class EntityViewEntity implements SearchTextEntity { @Column(name = ModelConstants.ENTITY_VIEW_CUSTOMER_ID_PROPERTY) private UUID customerId; + @PartitionKey(value = 3) + @Column(name = DEVICE_TYPE_PROPERTY) + private String type; + @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) private UUID entityId; @@ -113,6 +118,7 @@ public class EntityViewEntity implements SearchTextEntity { if (entityView.getCustomerId() != null) { this.customerId = entityView.getCustomerId().getId(); } + this.type = entityView.getType(); this.name = entityView.getName(); try { this.keys = mapper.writeValueAsString(entityView.getKeys()); @@ -143,6 +149,7 @@ public class EntityViewEntity implements SearchTextEntity { if (customerId != null) { entityView.setCustomerId(new CustomerId(customerId)); } + entityView.setType(type); entityView.setName(name); try { entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java index 60f6951b80..6999c0c910 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java @@ -69,6 +69,9 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc @Column(name = ModelConstants.ENTITY_VIEW_CUSTOMER_ID_PROPERTY) private String customerId; + @Column(name = ModelConstants.DEVICE_TYPE_PROPERTY) + private String type; + @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) private String name; @@ -108,6 +111,7 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc if (entityView.getCustomerId() != null) { this.customerId = toString(entityView.getCustomerId().getId()); } + this.type = entityView.getType(); this.name = entityView.getName(); try { this.keys = mapper.writeValueAsString(entityView.getKeys()); @@ -144,6 +148,7 @@ public class EntityViewEntity extends BaseSqlEntity implements Searc if (customerId != null) { entityView.setCustomerId(new CustomerId(toUUID(customerId))); } + entityView.setType(type); entityView.setName(name); try { entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 0f5fdf5fba..efd1bd9cd8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -19,8 +19,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; -import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.dao.model.sql.EntityViewEntity; import org.thingsboard.server.dao.util.SqlDao; @@ -36,21 +34,46 @@ public interface EntityViewRepository extends CrudRepository :idOffset ORDER BY e.id") List findByTenantId(@Param("tenantId") String tenantId, - @Param("textSearch") String textSearch, - @Param("idOffset") String idOffset, - Pageable pageable); + @Param("textSearch") String textSearch, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.type = :type " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:textSearch, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndType(@Param("tenantId") String tenantId, + @Param("type") String type, + @Param("textSearch") String textSearch, + @Param("idOffset") String idOffset, + Pageable pageable); @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + "AND e.customerId = :customerId " + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:searchText, '%')) " + "AND e.id > :idOffset ORDER BY e.id") List findByTenantIdAndCustomerId(@Param("tenantId") String tenantId, - @Param("customerId") String customerId, - @Param("searchText") String searchText, - @Param("idOffset") String idOffset, - Pageable pageable); + @Param("customerId") String customerId, + @Param("searchText") String searchText, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.customerId = :customerId " + + "AND e.type = :type " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:searchText, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndCustomerIdAndType(@Param("tenantId") String tenantId, + @Param("customerId") String customerId, + @Param("type") String type, + @Param("searchText") String searchText, + @Param("idOffset") String idOffset, + Pageable pageable); EntityViewEntity findByTenantIdAndName(String tenantId, String name); List findAllByTenantIdAndEntityId(String tenantId, String entityId); + + @Query("SELECT DISTINCT ev.type FROM EntityViewEntity ev WHERE ev.tenantId = :tenantId") + List findTenantEntityViewTypes(@Param("tenantId") String tenantId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 912c9d5eed..1ba9b9e3cf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -24,7 +24,6 @@ import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.UUIDConverter; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.dao.DaoUtil; @@ -41,7 +40,6 @@ import java.util.Optional; import java.util.UUID; import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; -import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUIDs; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID_STR; /** @@ -75,6 +73,17 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao findEntityViewsByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndType( + fromTimeUUID(tenantId), + type, + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()))); + } + @Override public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { return Optional.ofNullable( @@ -95,9 +104,38 @@ public class JpaEntityViewDao extends JpaAbstractSearchTextDao findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, UUID customerId, String type, TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndCustomerIdAndType( + fromTimeUUID(tenantId), + fromTimeUUID(customerId), + type, + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()) + )); + } + @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { return service.submit(() -> DaoUtil.convertDataList( entityViewRepository.findAllByTenantIdAndEntityId(UUIDConverter.fromTimeUUID(tenantId), UUIDConverter.fromTimeUUID(entityId)))); } + + @Override + public ListenableFuture> findTenantEntityViewTypesAsync(UUID tenantId) { + return service.submit(() -> convertTenantEntityViewTypesToDto(tenantId, entityViewRepository.findTenantEntityViewTypes(fromTimeUUID(tenantId)))); + } + + private List convertTenantEntityViewTypesToDto(UUID tenantId, List types) { + List list = Collections.emptyList(); + if (types != null && !types.isEmpty()) { + list = new ArrayList<>(); + for (String type : types) { + list.add(new EntitySubtype(new TenantId(tenantId), EntityType.ENTITY_VIEW, type)); + } + } + return list; + } } diff --git a/dao/src/main/resources/cassandra/schema-entities.cql b/dao/src/main/resources/cassandra/schema-entities.cql index 7ccd41f03a..ef27c70625 100644 --- a/dao/src/main/resources/cassandra/schema-entities.cql +++ b/dao/src/main/resources/cassandra/schema-entities.cql @@ -624,61 +624,90 @@ CREATE TABLE IF NOT EXISTS thingsboard.rule_node ( PRIMARY KEY (id) ); -CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( +CREATE TABLE IF NOT EXISTS thingsboard.entity_view ( id timeuuid, entity_id timeuuid, entity_type text, tenant_id timeuuid, customer_id timeuuid, name text, + type text, keys text, start_ts bigint, end_ts bigint, search_text text, additional_info text, - PRIMARY KEY (id, entity_id, tenant_id, customer_id) + PRIMARY KEY (id, entity_id, tenant_id, customer_id, type) ); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_name AS SELECT * - from thingsboard.entity_views + from thingsboard.entity_view WHERE tenant_id IS NOT NULL AND entity_id IS NOT NULL AND customer_id IS NOT NULL + AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, name, id, customer_id, entity_id) + PRIMARY KEY (tenant_id, name, id, customer_id, entity_id, type) WITH CLUSTERING ORDER BY (name ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS SELECT * - from thingsboard.entity_views + from thingsboard.entity_view WHERE tenant_id IS NOT NULL AND entity_id IS NOT NULL AND customer_id IS NOT NULL + AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id) + PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id, type) WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, search_text, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, search_text ASC, id DESC, customer_id DESC); + CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer AS SELECT * - from thingsboard.entity_views + from thingsboard.entity_view WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND entity_id IS NOT NULL + AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) + PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id, type) WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer_and_type AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, customer_id, search_text, id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, customer_id DESC, search_text ASC, id DESC); + CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_id AS SELECT * - from thingsboard.entity_views + from thingsboard.entity_view WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND entity_id IS NOT NULL + AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL - PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id) + PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id, type) WITH CLUSTERING ORDER BY (entity_id DESC, customer_id DESC, search_text ASC, id DESC); \ No newline at end of file diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index abd20abd40..0b9c8535fb 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -228,12 +228,13 @@ CREATE TABLE IF NOT EXISTS rule_node ( search_text varchar(255) ); -CREATE TABLE IF NOT EXISTS entity_views ( +CREATE TABLE IF NOT EXISTS entity_view ( id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, entity_id varchar(31), entity_type varchar(255), tenant_id varchar(31), customer_id varchar(31), + type varchar(255), name varchar(255), keys varchar(255), start_ts bigint, diff --git a/dao/src/test/resources/sql/drop-all-tables.sql b/dao/src/test/resources/sql/drop-all-tables.sql index b1fb72cc07..1bdc1a7ece 100644 --- a/dao/src/test/resources/sql/drop-all-tables.sql +++ b/dao/src/test/resources/sql/drop-all-tables.sql @@ -19,4 +19,4 @@ DROP TABLE IF EXISTS widget_type; DROP TABLE IF EXISTS widgets_bundle; DROP TABLE IF EXISTS rule_node; DROP TABLE IF EXISTS rule_chain; -DROP TABLE IF EXISTS entity_views; +DROP TABLE IF EXISTS entity_view; diff --git a/ui/src/app/api/entity.service.js b/ui/src/app/api/entity.service.js index ce4dc42dcd..205abc9e4e 100644 --- a/ui/src/app/api/entity.service.js +++ b/ui/src/app/api/entity.service.js @@ -533,6 +533,21 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device } ); break; + case types.aliasFilterType.entityViewType.value: + getEntitiesByNameFilter(types.entityType.entityView, filter.entityViewNameFilter, maxItems, {ignoreLoading: true}, filter.entityViewType).then( + function success(entities) { + if (entities && entities.length || !failOnEmpty) { + result.entities = entitiesToEntitiesInfo(entities); + deferred.resolve(result); + } else { + deferred.reject(); + } + }, + function fail() { + deferred.reject(); + } + ); + break; case types.aliasFilterType.relationsQuery.value: result.stateEntity = filter.rootStateEntity; var rootEntityType; @@ -578,6 +593,7 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device break; case types.aliasFilterType.assetSearchQuery.value: case types.aliasFilterType.deviceSearchQuery.value: + case types.aliasFilterType.entityViewSearchQuery.value: result.stateEntity = filter.rootStateEntity; if (result.stateEntity && stateEntityId) { rootEntityType = stateEntityId.entityType; @@ -604,6 +620,9 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device } else if (filter.type == types.aliasFilterType.deviceSearchQuery.value) { searchQuery.deviceTypes = filter.deviceTypes; findByQueryPromise = deviceService.findByQuery(searchQuery, false, {ignoreLoading: true}); + } else if (filter.type == types.aliasFilterType.entityViewSearchQuery.value) { + searchQuery.entityViewTypes = filter.entityViewTypes; + findByQueryPromise = entityViewService.findByQuery(searchQuery, false, {ignoreLoading: true}); } findByQueryPromise.then( function success(entities) { @@ -646,6 +665,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return entityTypes.indexOf(types.entityType.asset) > -1 ? true : false; case types.aliasFilterType.deviceType.value: return entityTypes.indexOf(types.entityType.device) > -1 ? true : false; + case types.aliasFilterType.entityViewType.value: + return entityTypes.indexOf(types.entityType.entityView) > -1 ? true : false; case types.aliasFilterType.relationsQuery.value: if (filter.filters && filter.filters.length) { var match = false; @@ -671,6 +692,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return entityTypes.indexOf(types.entityType.asset) > -1 ? true : false; case types.aliasFilterType.deviceSearchQuery.value: return entityTypes.indexOf(types.entityType.device) > -1 ? true : false; + case types.aliasFilterType.entityViewSearchQuery.value: + return entityTypes.indexOf(types.entityType.entityView) > -1 ? true : false; } } return false; @@ -690,12 +713,16 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return entityType === types.entityType.asset; case types.aliasFilterType.deviceType.value: return entityType === types.entityType.device; + case types.aliasFilterType.entityViewType.value: + return entityType === types.entityType.entityView; case types.aliasFilterType.relationsQuery.value: return true; case types.aliasFilterType.assetSearchQuery.value: return entityType === types.entityType.asset; case types.aliasFilterType.deviceSearchQuery.value: return entityType === types.entityType.device; + case types.aliasFilterType.entityViewSearchQuery.value: + return entityType === types.entityType.entityView; } return false; } @@ -1046,6 +1073,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return assetService.deleteAsset(entityId.id); } else if (entityId.entityType == types.entityType.device) { return deviceService.deleteDevice(entityId.id); + } else if (entityId.entityType == types.entityType.entityView) { + return entityViewService.deleteEntityView(entityId.id); } } @@ -1151,6 +1180,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return assetService.saveAsset(entity); } else if (entityType == types.entityType.device) { return deviceService.saveDevice(entity); + } else if (entityType == types.entityType.entityView) { + return entityViewService.saveEntityView(entity); } } @@ -1279,6 +1310,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device searchQuery.assetTypes = entitySubTypes; } else if (entityType == types.entityType.device) { searchQuery.deviceTypes = entitySubTypes; + } else if (entityType == types.entityType.entityView) { + searchQuery.entityViewTypes = entitySubTypes; } else { return null; //Not supported } diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 15da584968..506ef1d9b6 100644 --- a/ui/src/app/common/types.constant.js +++ b/ui/src/app/common/types.constant.js @@ -253,6 +253,10 @@ export default angular.module('thingsboard.types', []) value: 'deviceType', name: 'alias.filter-type-device-type' }, + entityViewType: { + value: 'entityViewType', + name: 'alias.filter-type-entity-view-type' + }, relationsQuery: { value: 'relationsQuery', name: 'alias.filter-type-relations-query' @@ -264,6 +268,10 @@ export default angular.module('thingsboard.types', []) deviceSearchQuery: { value: 'deviceSearchQuery', name: 'alias.filter-type-device-search-query' + }, + entityViewSearchQuery: { + value: 'entityViewSearchQuery', + name: 'alias.filter-type-entity-view-search-query' } }, position: { diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index 3894eb9d63..82ecb55de3 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -52,6 +52,13 @@

entity-view.name-required
+ + +
+ + + + + + +
@@ -311,4 +325,73 @@ ng-model="filter.deviceTypes">
+
+ +
+ + + +
+
+ + +
+
+ + + + +
+ + + +
+
+
+ + + + + {{ ('relation.search-direction.' + direction) | translate}} + + + + + + + +
+
relation.relation-type
+ + +
entity-view.entity-view-types
+ + +
diff --git a/ui/src/app/entity/entity-subtype-autocomplete.directive.js b/ui/src/app/entity/entity-subtype-autocomplete.directive.js index 5812715ee3..6848b5f72f 100644 --- a/ui/src/app/entity/entity-subtype-autocomplete.directive.js +++ b/ui/src/app/entity/entity-subtype-autocomplete.directive.js @@ -96,6 +96,8 @@ export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, entitySubtypesPromise = assetService.getAssetTypes({ignoreLoading: true}); } else if (scope.entityType == types.entityType.device) { entitySubtypesPromise = deviceService.getDeviceTypes({ignoreLoading: true}); + } else if (scope.entityType == types.entityType.entityView) { + entitySubtypesPromise = entityViewService.getEntityViewTypes({ignoreLoading: true}); } if (entitySubtypesPromise) { entitySubtypesPromise.then( diff --git a/ui/src/app/entity/entity-subtype-list.directive.js b/ui/src/app/entity/entity-subtype-list.directive.js index d74a7b0523..a0ab15a493 100644 --- a/ui/src/app/entity/entity-subtype-list.directive.js +++ b/ui/src/app/entity/entity-subtype-list.directive.js @@ -97,6 +97,8 @@ export default function EntitySubtypeListDirective($compile, $templateCache, $q, entitySubtypesPromise = assetService.getAssetTypes({ignoreLoading: true}); } else if (scope.entityType == types.entityType.device) { entitySubtypesPromise = deviceService.getDeviceTypes({ignoreLoading: true}); + } else if (scope.entityType == types.entityType.entityView) { + entitySubtypesPromise = entityViewService.getEntityViewTypes({ignoreLoading: true}); } if (entitySubtypesPromise) { entitySubtypesPromise.then( diff --git a/ui/src/app/entity/entity-subtype-select.directive.js b/ui/src/app/entity/entity-subtype-select.directive.js index b86e944411..33e0a1766d 100644 --- a/ui/src/app/entity/entity-subtype-select.directive.js +++ b/ui/src/app/entity/entity-subtype-select.directive.js @@ -75,6 +75,8 @@ export default function EntitySubtypeSelect($compile, $templateCache, $translate entitySubtypesPromise = assetService.getAssetTypes({ignoreLoading: true}); } else if (scope.entityType == types.entityType.device) { entitySubtypesPromise = deviceService.getDeviceTypes({ignoreLoading: true}); + } else if (scope.entityType == types.entityType.entityView) { + entitySubtypesPromise = entityViewService.getEntityViewTypes({ignoreLoading: true}); } if (entitySubtypesPromise) { entitySubtypesPromise.then( diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 481d677955..276b432a34 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -158,12 +158,17 @@ "filter-type-device-type": "Device type", "filter-type-device-type-description": "Devices of type '{{deviceType}}'", "filter-type-device-type-and-name-description": "Devices of type '{{deviceType}}' and with name starting with '{{prefix}}'", + "filter-type-entity-view-type": "Entity View type", + "filter-type-entity-view-type-description": "Entity Views of type '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Entity Views of type '{{entityView}}' and with name starting with '{{prefix}}'", "filter-type-relations-query": "Relations query", "filter-type-relations-query-description": "{{entities}} that have {{relationType}} relation {{direction}} {{rootEntity}}", "filter-type-asset-search-query": "Asset search query", "filter-type-asset-search-query-description": "Assets with types {{assetTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", "filter-type-device-search-query": "Device search query", "filter-type-device-search-query-description": "Devices with types {{deviceTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Entity view search query", + "filter-type-entity-view-search-query-description": "Entity views with types {{entityViewTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", "entity-filter": "Entity filter", "resolve-multiple": "Resolve as multiple entities", "filter-type": "Filter type", @@ -839,7 +844,7 @@ "client-attributes": "Client attributes", "shared-attributes": "Shared attributes", "server-attributes": "Server attributes", - "latest-timeseries": "Latest timeseries" + "latest-timeseries": "Latest timeseries", }, "event": { "event-type": "Event type", From d0cb354063dedd662c1670feaf075c12fae7acff Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 9 Oct 2018 19:15:12 +0300 Subject: [PATCH 185/307] Added 2.1.1 install verison --- .../server/install/ThingsboardInstallService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index da154e9906..45eb66a332 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -97,6 +97,11 @@ public class ThingsboardInstallService { databaseUpgradeService.upgradeDatabase("2.0.0"); + case "2.1.1": + log.info("Upgrading ThingsBoard from version 2.1.1 to 2.1.2 ..."); + + databaseUpgradeService.upgradeDatabase("2.1.1"); + log.info("Updating system data..."); systemDataLoaderService.deleteSystemWidgetBundle("charts"); From befb89a63b8ba29663dfad8c1b8a8d8fc4ca4819 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 9 Oct 2018 19:45:41 +0300 Subject: [PATCH 186/307] Fixed ui complication --- ui/src/app/entity/entity-subtype-autocomplete.directive.js | 2 +- ui/src/app/entity/entity-subtype-list.directive.js | 2 +- ui/src/app/entity/entity-subtype-select.directive.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/app/entity/entity-subtype-autocomplete.directive.js b/ui/src/app/entity/entity-subtype-autocomplete.directive.js index 6848b5f72f..96222d9d87 100644 --- a/ui/src/app/entity/entity-subtype-autocomplete.directive.js +++ b/ui/src/app/entity/entity-subtype-autocomplete.directive.js @@ -22,7 +22,7 @@ import entitySubtypeAutocompleteTemplate from './entity-subtype-autocomplete.tpl /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, $filter, assetService, deviceService, types) { +export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, $filter, assetService, deviceService, entityViewService, types) { var linker = function (scope, element, attrs, ngModelCtrl) { var template = $templateCache.get(entitySubtypeAutocompleteTemplate); diff --git a/ui/src/app/entity/entity-subtype-list.directive.js b/ui/src/app/entity/entity-subtype-list.directive.js index a0ab15a493..2da948d435 100644 --- a/ui/src/app/entity/entity-subtype-list.directive.js +++ b/ui/src/app/entity/entity-subtype-list.directive.js @@ -22,7 +22,7 @@ import entitySubtypeListTemplate from './entity-subtype-list.tpl.html'; import './entity-subtype-list.scss'; /*@ngInject*/ -export default function EntitySubtypeListDirective($compile, $templateCache, $q, $mdUtil, $translate, $filter, types, assetService, deviceService) { +export default function EntitySubtypeListDirective($compile, $templateCache, $q, $mdUtil, $translate, $filter, types, assetService, deviceService, entityViewService) { var linker = function (scope, element, attrs, ngModelCtrl) { diff --git a/ui/src/app/entity/entity-subtype-select.directive.js b/ui/src/app/entity/entity-subtype-select.directive.js index 33e0a1766d..d6163daae6 100644 --- a/ui/src/app/entity/entity-subtype-select.directive.js +++ b/ui/src/app/entity/entity-subtype-select.directive.js @@ -22,7 +22,7 @@ import entitySubtypeSelectTemplate from './entity-subtype-select.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function EntitySubtypeSelect($compile, $templateCache, $translate, assetService, deviceService, types) { +export default function EntitySubtypeSelect($compile, $templateCache, $translate, assetService, deviceService, entityViewService, types) { var linker = function (scope, element, attrs, ngModelCtrl) { var template = $templateCache.get(entitySubtypeSelectTemplate); From 0cfb73127b3dfd5c0311aed803858a1d86e0053e Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 9 Oct 2018 20:15:23 +0300 Subject: [PATCH 187/307] Fixes --- .../service/install/CassandraDatabaseUpgradeService.java | 2 +- .../thingsboard/server/rules/RuleEngineNoSqlTestSuite.java | 4 ++-- ui/src/app/locale/locale.constant-en_US.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index d57371f4e6..65c8884703 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -224,7 +224,7 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { case "2.1.1": - log.info("Upgrading Cassandara DataBase from version {} to 2.1.1 ...", fromVersion); + log.info("Upgrading Cassandara DataBase from version {} to 2.1.2 ...", fromVersion); cluster.getSession(); diff --git a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java index c910cadcbe..c1d1e6cbaf 100644 --- a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java @@ -26,8 +26,8 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.rules.flow.nosql.RuleEngineFlowNoSqlIntegrationTest", -// "org.thingsboard.server.rules.lifecycle.nosql.*Test" + "org.thingsboard.server.rules.flow.nosql.*Test", + "org.thingsboard.server.rules.lifecycle.nosql.*Test" }) public class RuleEngineNoSqlTestSuite { diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 276b432a34..299b5d4a15 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -844,7 +844,7 @@ "client-attributes": "Client attributes", "shared-attributes": "Shared attributes", "server-attributes": "Server attributes", - "latest-timeseries": "Latest timeseries", + "latest-timeseries": "Latest timeseries" }, "event": { "event-type": "Event type", From 563d27c979688e69e296175d7244223c42176d19 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Oct 2018 20:36:39 +0300 Subject: [PATCH 188/307] Improve docker-compose config --- msa/docker/docker-compose.yml | 3 ++- msa/docker/docker-start-services.sh | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index cfa37d1311..79d0ef03e9 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -15,7 +15,7 @@ # -version: '2' +version: '2.2' services: zookeeper: @@ -41,6 +41,7 @@ services: tb-js-executor: restart: always image: "${DOCKER_REPO}/${JS_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" + scale: 20 environment: TB_KAFKA_SERVERS: kafka:9092 env_file: diff --git a/msa/docker/docker-start-services.sh b/msa/docker/docker-start-services.sh index 560c4351c0..44093934cd 100755 --- a/msa/docker/docker-start-services.sh +++ b/msa/docker/docker-start-services.sh @@ -17,4 +17,4 @@ ./check-dirs.sh -docker-compose up -d --scale tb-js-executor=20 +docker-compose up -d From c34fb5d963d4680df69eb9733665575b3dd59532 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 9 Oct 2018 20:58:27 +0300 Subject: [PATCH 189/307] Misc fixes --- .../app/entity-view/entity-view-fieldset.tpl.html | 15 +++++++++------ .../entity-subtype-autocomplete.directive.js | 7 +++++++ ui/src/app/help/help-links.constant.js | 1 + ui/src/app/locale/locale.constant-en_US.json | 3 ++- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index 82ecb55de3..66dc116cc8 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -59,12 +59,15 @@ ng-model="entityView.type" entity-type="types.entityType.entityView"> - - +
+ + + +
diff --git a/ui/src/app/entity/entity-subtype-autocomplete.directive.js b/ui/src/app/entity/entity-subtype-autocomplete.directive.js index 96222d9d87..4c79f9a92d 100644 --- a/ui/src/app/entity/entity-subtype-autocomplete.directive.js +++ b/ui/src/app/entity/entity-subtype-autocomplete.directive.js @@ -136,6 +136,13 @@ export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, scope.$on('deviceSaved', function() { scope.entitySubtypes = null; }); + } else if (scope.entityType == types.entityType.entityView) { + scope.selectEntitySubtypeText = 'entity-view.select-entity-view-type'; + scope.entitySubtypeText = 'entity-view.entity-view-type'; + scope.entitySubtypeRequiredText = 'entity-view.entity-view-type-required'; + scope.$on('entityViewSaved', function() { + scope.entitySubtypes = null; + }); } } diff --git a/ui/src/app/help/help-links.constant.js b/ui/src/app/help/help-links.constant.js index 458c118883..9e6bb524d0 100644 --- a/ui/src/app/help/help-links.constant.js +++ b/ui/src/app/help/help-links.constant.js @@ -96,6 +96,7 @@ export default angular.module('thingsboard.help', []) customers: helpBaseUrl + "/docs/user-guide/ui/customers", assets: helpBaseUrl + "/docs/user-guide/ui/assets", devices: helpBaseUrl + "/docs/user-guide/ui/devices", + entityViews: helpBaseUrl + "/docs/user-guide/ui/entity-views", dashboards: helpBaseUrl + "/docs/user-guide/ui/dashboards", users: helpBaseUrl + "/docs/user-guide/ui/users", widgetsBundles: helpBaseUrl + "/docs/user-guide/ui/widget-library#bundles", diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 299b5d4a15..6c6ea438af 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -844,7 +844,8 @@ "client-attributes": "Client attributes", "shared-attributes": "Shared attributes", "server-attributes": "Server attributes", - "latest-timeseries": "Latest timeseries" + "latest-timeseries": "Latest timeseries", + "related-entity": "Related entity" }, "event": { "event-type": "Event type", From 2fbe3f555f921d55a48cbe1a81a0811fcb79d8e3 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 10 Oct 2018 10:00:04 +0300 Subject: [PATCH 190/307] Update tb docker logging params --- msa/docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 79d0ef03e9..93c2ab08cd 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -58,7 +58,7 @@ services: logging: driver: "json-file" options: - max-size: "6g" + max-size: "200m" max-file: "30" env_file: - tb-node.env From b8c8f57a82d3de7685fc5c889c0943b2e65bf280 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 10 Oct 2018 11:21:49 +0300 Subject: [PATCH 191/307] Fixed dump for bigint --- .../service/install/CassandraDatabaseUpgradeService.java | 2 +- .../server/service/install/cql/CassandraDbHelper.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index 65c8884703..552695314e 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -224,7 +224,7 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { case "2.1.1": - log.info("Upgrading Cassandara DataBase from version {} to 2.1.2 ...", fromVersion); + log.info("Upgrading Cassandra DataBase from version {} to 2.1.2 ...", fromVersion); cluster.getSession(); diff --git a/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java index bdf980fea2..7c03a719c9 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java +++ b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java @@ -147,6 +147,8 @@ public class CassandraDbHelper { str = new Double(row.getDouble(index)).toString(); } else if (type == DataType.cint()) { str = new Integer(row.getInt(index)).toString(); + } else if (type == DataType.bigint()) { + str = new Long(row.getLong(index)).toString(); } else if (type == DataType.uuid()) { str = row.getUUID(index).toString(); } else if (type == DataType.timeuuid()) { @@ -193,6 +195,8 @@ public class CassandraDbHelper { boundStatement.setDouble(column, Double.valueOf(value)); } else if (type == DataType.cint()) { boundStatement.setInt(column, Integer.valueOf(value)); + } else if (type == DataType.bigint()) { + boundStatement.setLong(column, Long.valueOf(value)); } else if (type == DataType.uuid()) { boundStatement.setUUID(column, UUID.fromString(value)); } else if (type == DataType.timeuuid()) { From 2637babfe3e06ee31013c08c7b83b6475d16dbce Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 10 Oct 2018 13:06:07 +0300 Subject: [PATCH 192/307] Mqtt transport implementation: POST telemetry, attributes --- .../server/actors/ActorSystemContext.java | 8 - .../server/actors/app/AppActor.java | 8 - .../server/actors/device/DeviceActor.java | 7 - .../device/DeviceActorMessageProcessor.java | 306 +++++++++--------- .../device/RuleEngineQueuePutAckMsg.java | 36 --- .../server/actors/device/SessionInfo.java | 8 +- .../RuleChainActorMessageProcessor.java | 40 +-- .../actors/service/DefaultActorService.java | 14 - .../actors/shared/ComponentMsgProcessor.java | 16 - .../RemoteRuleEngineTransportService.java | 17 + .../transport/RuleEngineTransportService.java | 9 +- .../transport/ToTransportMsgEncoder.java | 8 +- .../msg/TransportToDeviceActorMsgWrapper.java | 16 +- .../server/common/msg/MsgType.java | 3 - ...sicActorSystemToDeviceSessionActorMsg.java | 52 --- .../timeout/DeviceActorQueueTimeoutMsg.java | 36 --- .../server/kafka/TBKafkaConsumerTemplate.java | 8 +- .../server/kafka/TBKafkaProducerTemplate.java | 12 +- .../server/kafka/TbKafkaRequestTemplate.java | 2 +- .../server/kafka/TbKafkaResponseTemplate.java | 8 +- .../common/transport/SessionMsgListener.java | 8 +- .../common/transport/SessionMsgProcessor.java | 2 - .../common/transport/TransportAdaptor.java | 1 + .../common/transport/TransportService.java | 11 +- .../transport/adaptor/JsonConverter.java | 180 ++++++++++- .../session/DeviceAwareSessionContext.java | 8 +- .../transport/src/main/proto/transport.proto | 36 ++- .../server/transport/coap/CoapServerTest.java | 48 +-- .../transport/mqtt/MqttTransportHandler.java | 302 ++++++++++------- .../mqtt/adaptors/JsonMqttAdaptor.java | 61 ++++ .../mqtt/adaptors/MqttTransportAdaptor.java | 13 + .../mqtt/session/DeviceSessionCtx.java | 4 +- .../mqtt/service/MqttTransportService.java | 33 +- .../mqtt/service/ToRuleEngineMsgEncoder.java | 8 +- 34 files changed, 738 insertions(+), 591 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/actors/device/RuleEngineQueuePutAckMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorQueueTimeoutMsg.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 8ff61aca46..1081ab2aec 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -198,10 +198,6 @@ public class ActorSystemContext { @Getter private MailService mailService; - @Autowired - @Getter - private MsgQueueService msgQueueService; - @Autowired @Getter private DeviceStateService deviceStateService; @@ -267,10 +263,6 @@ public class ActorSystemContext { @Setter private ActorRef appActor; - @Getter - @Setter - private ActorRef sessionManagerActor; - @Getter @Setter private ActorRef statsActor; diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 14ca58610d..f4373db9be 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -38,7 +38,6 @@ import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.aware.TenantAwareMsg; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.core.BasicActorSystemToDeviceSessionActorMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.model.ModelConstants; @@ -113,19 +112,12 @@ public class AppActor extends RuleChainManagerActor { case REMOTE_TO_RULE_CHAIN_TELL_NEXT_MSG: onToDeviceActorMsg((TenantAwareMsg) msg); break; - case ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG: - onToDeviceSessionMsg((BasicActorSystemToDeviceSessionActorMsg) msg); - break; default: return false; } return true; } - private void onToDeviceSessionMsg(BasicActorSystemToDeviceSessionActorMsg msg) { - systemContext.getSessionManagerActor().tell(msg, self()); - } - private void onPossibleClusterMsg(SendToClusterMsg msg) { Optional address = systemContext.getRoutingService().resolveById(msg.getEntityId()); if (address.isPresent()) { diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java index 7fabe493dd..bd2a0f4486 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -27,7 +27,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; -import org.thingsboard.server.common.msg.timeout.DeviceActorQueueTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; @@ -74,12 +73,6 @@ public class DeviceActor extends ContextAwareActor { case DEVICE_ACTOR_CLIENT_SIDE_RPC_TIMEOUT_MSG: processor.processClientSideRpcTimeout(context(), (DeviceActorClientSideRpcTimeoutMsg) msg); break; - case DEVICE_ACTOR_QUEUE_TIMEOUT_MSG: - processor.processQueueTimeout(context(), (DeviceActorQueueTimeoutMsg) msg); - break; - case RULE_ENGINE_QUEUE_PUT_ACK_MSG: - processor.processQueueAck(context(), (RuleEngineQueuePutAckMsg) msg); - break; default: return false; } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 1313f60be5..0822766b4b 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,7 +16,6 @@ package org.thingsboard.server.actors.device; import akka.actor.ActorContext; -import akka.actor.ActorRef; import akka.event.LoggingAdapter; import com.datastax.driver.core.utils.UUIDs; import com.google.common.util.concurrent.FutureCallback; @@ -46,29 +45,15 @@ import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; import org.thingsboard.server.common.msg.core.AttributesUpdateNotification; -import org.thingsboard.server.common.msg.core.AttributesUpdateRequest; -import org.thingsboard.server.common.msg.core.BasicActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.core.BasicCommandAckResponse; -import org.thingsboard.server.common.msg.core.BasicGetAttributesResponse; -import org.thingsboard.server.common.msg.core.BasicStatusCodeResponse; -import org.thingsboard.server.common.msg.core.GetAttributesRequest; import org.thingsboard.server.common.msg.core.RuleEngineError; import org.thingsboard.server.common.msg.core.RuleEngineErrorMsg; -import org.thingsboard.server.common.msg.core.SessionCloseMsg; -import org.thingsboard.server.common.msg.core.SessionCloseNotification; -import org.thingsboard.server.common.msg.core.SessionOpenMsg; -import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; -import org.thingsboard.server.common.msg.core.ToDeviceRpcResponseMsg; -import org.thingsboard.server.common.msg.core.ToServerRpcRequestMsg; import org.thingsboard.server.common.msg.kv.BasicAttributeKVMsg; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; import org.thingsboard.server.common.msg.session.SessionMsgType; import org.thingsboard.server.common.msg.session.SessionType; import org.thingsboard.server.common.msg.session.ToDeviceMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; -import org.thingsboard.server.common.msg.timeout.DeviceActorQueueTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.service.rpc.FromDeviceRpcResponse; @@ -88,9 +73,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeoutException; import java.util.function.Consumer; -import java.util.function.Predicate; import java.util.stream.Collectors; import org.thingsboard.server.gen.transport.TransportProtos.*; @@ -192,19 +175,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - void processQueueAck(ActorContext context, RuleEngineQueuePutAckMsg msg) { - PendingSessionMsgData data = pendingMsgs.remove(msg.getId()); - if (data != null && data.isReplyOnQueueAck()) { - int remainingAcks = data.getAckMsgCount() - 1; - data.setAckMsgCount(remainingAcks); - logger.debug("[{}] Queue put [{}] ack detected. Remaining acks: {}!", deviceId, msg.getId(), remainingAcks); - if (remainingAcks == 0) { - ToDeviceMsg toDeviceMsg = BasicStatusCodeResponse.onSuccess(data.getSessionMsgType(), data.getRequestId()); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServerAddress()); - } - } - } - private void sendPendingRequests(ActorContext context, SessionId sessionId, SessionType type, Optional server) { if (!toDeviceRpcPendingMap.isEmpty()) { logger.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId); @@ -239,8 +209,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso body.getMethod(), body.getParams() ); - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sessionId); - sendMsgToSessionActor(response, server); +// ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sessionId); +// sendMsgToSessionActor(response, server); }; } @@ -292,57 +262,25 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso private void handleGetAttributesRequest(ActorContext context, SessionInfoProto sessionInfo, GetAttributeRequestMsg request) { ListenableFuture> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, toOptionalSet(request.getClientAttributeNamesList())); ListenableFuture> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, toOptionalSet(request.getSharedAttributeNamesList())); - - Futures.addCallback(Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)), new FutureCallback>>() { - @Override - public void onSuccess(@Nullable List> result) { - systemContext.getRuleEngineTransportService().process(); - BasicGetAttributesResponse response = BasicGetAttributesResponse.onSuccess(request.getMsgType(), - request.getRequestId(), BasicAttributeKVMsg.from(result.get(0), result.get(1))); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, src.getSessionId()), src.getServerAddress()); - } - - @Override - public void onFailure(Throwable t) { - if (t instanceof Exception) { - ToDeviceMsg toDeviceMsg = BasicStatusCodeResponse.onError(SessionMsgType.GET_ATTRIBUTES_REQUEST, request.getRequestId(), (Exception) t); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, src.getSessionId()), src.getServerAddress()); - } else { - logger.error("[{}] Failed to process attributes request", deviceId, t); - } - } - }); - } - - private Optional> toOptionalSet(List strings) { - if (strings == null || strings.isEmpty()) { - return Optional.empty(); - } else { - return Optional.of(new HashSet<>(strings)); - } - } - - private void handleGetAttributesRequest(DeviceToDeviceActorMsg src) { - GetAttributesRequest request = (GetAttributesRequest) src.getPayload(); - ListenableFuture> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, request.getClientAttributeNames()); - ListenableFuture> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, request.getSharedAttributeNames()); - + UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + int requestId = request.getRequestId(); Futures.addCallback(Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)), new FutureCallback>>() { @Override public void onSuccess(@Nullable List> result) { - BasicGetAttributesResponse response = BasicGetAttributesResponse.onSuccess(request.getMsgType(), - request.getRequestId(), BasicAttributeKVMsg.from(result.get(0), result.get(1))); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, src.getSessionId()), src.getServerAddress()); + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setRequestId(requestId) + .addAllClientAttributeList(toTsKvProtos(result.get(0))) + .addAllSharedAttributeList(toTsKvProtos(result.get(1))) + .build(); + sendToTransport(responseMsg, sessionId, sessionInfo); } @Override public void onFailure(Throwable t) { - if (t instanceof Exception) { - ToDeviceMsg toDeviceMsg = BasicStatusCodeResponse.onError(SessionMsgType.GET_ATTRIBUTES_REQUEST, request.getRequestId(), (Exception) t); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, src.getSessionId()), src.getServerAddress()); - } else { - logger.error("[{}] Failed to process attributes request", deviceId, t); - } + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setError(t.getMessage()) + .build(); + sendToTransport(responseMsg, sessionId, sessionInfo); } }); } @@ -376,36 +314,36 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void handleClientSideRPCRequest(ActorContext context, DeviceToDeviceActorMsg src) { - ToServerRpcRequestMsg request = (ToServerRpcRequestMsg) src.getPayload(); - - JsonObject json = new JsonObject(); - json.addProperty("method", request.getMethod()); - json.add("params", jsonParser.parse(request.getParams())); - - TbMsgMetaData requestMetaData = defaultMetaData.copy(); - requestMetaData.putValue("requestId", Integer.toString(request.getRequestId())); - TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.TO_SERVER_RPC_REQUEST.name(), deviceId, requestMetaData, TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); - PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), SessionMsgType.TO_SERVER_RPC_REQUEST, request.getRequestId(), false, 1); - pushToRuleEngineWithTimeout(context, tbMsg, msgData); - - scheduleMsgWithDelay(context, new DeviceActorClientSideRpcTimeoutMsg(request.getRequestId(), systemContext.getClientSideRpcTimeout()), systemContext.getClientSideRpcTimeout()); - toServerRpcPendingMap.put(request.getRequestId(), new ToServerRpcRequestMetadata(src.getSessionId(), src.getSessionType(), src.getServerAddress())); - } +// private void handleClientSideRPCRequest(ActorContext context, DeviceToDeviceActorMsg src) { +// ToServerRpcRequestMsg request = (ToServerRpcRequestMsg) src.getPayload(); +// +// JsonObject json = new JsonObject(); +// json.addProperty("method", request.getMethod()); +// json.add("params", jsonParser.parse(request.getParams())); +// +// TbMsgMetaData requestMetaData = defaultMetaData.copy(); +// requestMetaData.putValue("requestId", Integer.toString(request.getRequestId())); +// TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.TO_SERVER_RPC_REQUEST.name(), deviceId, requestMetaData, TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); +// PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), SessionMsgType.TO_SERVER_RPC_REQUEST, request.getRequestId(), false, 1); +// pushToRuleEngineWithTimeout(context, tbMsg, msgData); +// +// scheduleMsgWithDelay(context, new DeviceActorClientSideRpcTimeoutMsg(request.getRequestId(), systemContext.getClientSideRpcTimeout()), systemContext.getClientSideRpcTimeout()); +// toServerRpcPendingMap.put(request.getRequestId(), new ToServerRpcRequestMetadata(src.getSessionId(), src.getSessionType(), src.getServerAddress())); +// } public void processClientSideRpcTimeout(ActorContext context, DeviceActorClientSideRpcTimeoutMsg msg) { ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(msg.getId()); if (data != null) { logger.debug("[{}] Client side RPC request [{}] timeout detected!", deviceId, msg.getId()); ToDeviceMsg toDeviceMsg = new RuleEngineErrorMsg(SessionMsgType.TO_SERVER_RPC_REQUEST, RuleEngineError.TIMEOUT); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServer()); +// sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServer()); } } void processToServerRPCResponse(ActorContext context, ToServerRpcResponseActorMsg msg) { ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(msg.getMsg().getRequestId()); if (data != null) { - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(msg.getMsg(), data.getSessionId()), data.getServer()); +// sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(msg.getMsg(), data.getSessionId()), data.getServer()); } } @@ -433,68 +371,68 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } if (notification != null) { ToDeviceMsg finalNotification = notification; - attributeSubscriptions.entrySet().forEach(sub -> { - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(finalNotification, sub.getKey()); - sendMsgToSessionActor(response, sub.getValue().getServer()); - }); +// attributeSubscriptions.entrySet().forEach(sub -> { +// ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(finalNotification, sub.getKey()); +// sendMsgToSessionActor(response, sub.getValue().getServer()); +// }); } } else { logger.debug("[{}] No registered attributes subscriptions to process!", deviceId); } } - private void processRpcResponses(ActorContext context, DeviceToDeviceActorMsg msg) { - SessionId sessionId = msg.getSessionId(); - FromDeviceMsg inMsg = msg.getPayload(); - if (inMsg.getMsgType() == SessionMsgType.TO_DEVICE_RPC_RESPONSE) { - logger.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); - ToDeviceRpcResponseMsg responseMsg = (ToDeviceRpcResponseMsg) inMsg; - ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); - boolean success = requestMd != null; - if (success) { - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), - requestMd.getMsg().getServerAddress(), responseMsg.getData(), null)); - } else { - logger.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); - } - if (msg.getSessionType() == SessionType.SYNC) { - BasicCommandAckResponse response = success - ? BasicCommandAckResponse.onSuccess(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId()) - : BasicCommandAckResponse.onError(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId(), new TimeoutException()); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, msg.getSessionId()), msg.getServerAddress()); - } - } - } +// private void processRpcResponses(ActorContext context, DeviceToDeviceActorMsg msg) { +// SessionId sessionId = msg.getSessionId(); +// FromDeviceMsg inMsg = msg.getPayload(); +// if (inMsg.getMsgType() == SessionMsgType.TO_DEVICE_RPC_RESPONSE) { +// logger.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); +// ToDeviceRpcResponseMsg responseMsg = (ToDeviceRpcResponseMsg) inMsg; +// ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); +// boolean success = requestMd != null; +// if (success) { +// systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), +// requestMd.getMsg().getServerAddress(), responseMsg.getData(), null)); +// } else { +// logger.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); +// } +// if (msg.getSessionType() == SessionType.SYNC) { +// BasicCommandAckResponse response = success +// ? BasicCommandAckResponse.onSuccess(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId()) +// : BasicCommandAckResponse.onError(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId(), new TimeoutException()); +// sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, msg.getSessionId()), msg.getServerAddress()); +// } +// } +// } void processClusterEventMsg(ClusterEventMsg msg) { - if (!msg.isAdded()) { - logger.debug("[{}] Clearing attributes/rpc subscription for server [{}]", deviceId, msg.getServerAddress()); - Predicate> filter = e -> e.getValue().getServer() - .map(serverAddress -> serverAddress.equals(msg.getServerAddress())).orElse(false); - attributeSubscriptions.entrySet().removeIf(filter); - rpcSubscriptions.entrySet().removeIf(filter); - } +// if (!msg.isAdded()) { +// logger.debug("[{}] Clearing attributes/rpc subscription for server [{}]", deviceId, msg.getServerAddress()); +// Predicate> filter = e -> e.getValue().getServer() +// .map(serverAddress -> serverAddress.equals(msg.getServerAddress())).orElse(false); +// attributeSubscriptions.entrySet().removeIf(filter); +// rpcSubscriptions.entrySet().removeIf(filter); +// } } - private void processSubscriptionCommands(ActorContext context, DeviceToDeviceActorMsg msg) { - SessionId sessionId = msg.getSessionId(); - SessionType sessionType = msg.getSessionType(); - FromDeviceMsg inMsg = msg.getPayload(); - if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST) { - logger.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); - attributeSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); - } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST) { - logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); - attributeSubscriptions.remove(sessionId); - } else if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST) { - logger.debug("[{}] Registering rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); - rpcSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); - sendPendingRequests(context, sessionId, sessionType, msg.getServerAddress()); - } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST) { - logger.debug("[{}] Canceling rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); - rpcSubscriptions.remove(sessionId); - } - } +// private void processSubscriptionCommands(ActorContext context, DeviceToDeviceActorMsg msg) { +// SessionId sessionId = msg.getSessionId(); +// SessionType sessionType = msg.getSessionType(); +// FromDeviceMsg inMsg = msg.getPayload(); +// if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST) { +// logger.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); +// attributeSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); +// } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST) { +// logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); +// attributeSubscriptions.remove(sessionId); +// } else if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST) { +// logger.debug("[{}] Registering rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); +// rpcSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); +// sendPendingRequests(context, sessionId, sessionType, msg.getServerAddress()); +// } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST) { +// logger.debug("[{}] Canceling rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); +// rpcSubscriptions.remove(sessionId); +// } +// } private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); @@ -506,15 +444,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso closeSession(sessionIdToRemove, sessions.remove(sessionIdToRemove)); } } - sessions.put(sessionId, new SessionInfo(SessionType.ASYNC, msg.getServerAddress())); + sessions.put(sessionId, new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfo.getNodeId())); if (sessions.size() == 1) { reportSessionOpen(); } - } - FromDeviceMsg inMsg = msg.getPayload(); - if (inMsg instanceof SessionOpenMsg) { - logger.debug("[{}] Processing new session [{}]", deviceId, sessionId); - } else if (inMsg instanceof SessionCloseMsg) { + } else if (msg.getEvent() == SessionEvent.CLOSED) { logger.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId); sessions.remove(sessionId); attributeSubscriptions.remove(sessionId); @@ -532,7 +466,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso systemContext.getRpcService().tell(systemContext.getEncodingService() .convertToProtoDataMessage(sessionAddress.get(), response)); } else { - systemContext.getSessionManagerActor().tell(response, ActorRef.noSender()); +// systemContext.getSessionManagerActor().tell(response, ActorRef.noSender()); } } @@ -578,4 +512,62 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } return json; } + + private Optional> toOptionalSet(List strings) { + if (strings == null || strings.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(new HashSet<>(strings)); + } + } + + private void sendToTransport(GetAttributeResponseMsg responseMsg, UUID sessionId, SessionInfoProto sessionInfo) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setGetAttributesResponse(responseMsg).build(); + systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); + } + + private List toTsKvProtos(@Nullable List result) { + List clientAttributes; + if (result == null || result.isEmpty()) { + clientAttributes = Collections.emptyList(); + } else { + clientAttributes = new ArrayList<>(result.size()); + for (AttributeKvEntry attrEntry : result) { + clientAttributes.add(toTsKvProto(attrEntry)); + } + } + return clientAttributes; + } + + private TsKvProto toTsKvProto(AttributeKvEntry attrEntry) { + return TsKvProto.newBuilder().setTs(attrEntry.getLastUpdateTs()) + .setKv(toKeyValueProto(attrEntry)).build(); + } + + private KeyValueProto toKeyValueProto(KvEntry kvEntry) { + KeyValueProto.Builder builder = KeyValueProto.newBuilder(); + builder.setKey(kvEntry.getKey()); + switch (kvEntry.getDataType()) { + case BOOLEAN: + builder.setType(KeyValueType.BOOLEAN_V); + builder.setBoolV(kvEntry.getBooleanValue().get()); + break; + case DOUBLE: + builder.setType(KeyValueType.DOUBLE_V); + builder.setDoubleV(kvEntry.getDoubleValue().get()); + break; + case LONG: + builder.setType(KeyValueType.LONG_V); + builder.setLongV(kvEntry.getLongValue().get()); + break; + case STRING: + builder.setType(KeyValueType.STRING_V); + builder.setStringV(kvEntry.getStrValue().get()); + break; + } + return builder.build(); + } } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/RuleEngineQueuePutAckMsg.java b/application/src/main/java/org/thingsboard/server/actors/device/RuleEngineQueuePutAckMsg.java deleted file mode 100644 index 0b6fe54c4a..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/device/RuleEngineQueuePutAckMsg.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.device; - -import lombok.Data; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.TbActorMsg; - -import java.util.UUID; - -/** - * Created by ashvayka on 15.03.18. - */ -@Data -public final class RuleEngineQueuePutAckMsg implements TbActorMsg { - - private final UUID id; - - @Override - public MsgType getMsgType() { - return MsgType.RULE_ENGINE_QUEUE_PUT_ACK_MSG; - } -} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java index 9faaade208..32cb60da5f 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java index fe02335223..3da90d1e74 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java @@ -25,7 +25,6 @@ import java.util.Optional; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.device.DeviceActorToRuleEngineMsg; -import org.thingsboard.server.actors.device.RuleEngineQueuePutAckMsg; import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.actors.shared.ComponentMsgProcessor; import org.thingsboard.server.common.data.EntityType; @@ -90,26 +89,12 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor ruleNodeList) { - for (RuleNode ruleNode : ruleNodeList) { - for (TbMsg tbMsg : queue.findUnprocessed(tenantId, ruleNode.getId().getId(), systemContext.getQueuePartitionId())) { - pushMsgToNode(nodeActors.get(ruleNode.getId()), tbMsg, ""); - } - } - if (firstNode != null) { - for (TbMsg tbMsg : queue.findUnprocessed(tenantId, entityId.getId(), systemContext.getQueuePartitionId())) { - pushMsgToNode(firstNode, tbMsg, ""); - } - } - } - @Override public void onUpdate(ActorContext context) throws Exception { RuleChain ruleChain = service.findRuleChainById(entityId); @@ -134,7 +119,6 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor pushMsgToNode(firstNode, msg, "")); + pushMsgToNode(firstNode, enrichWithRuleChainId(envelope.getTbMsg()), ""); } } void onDeviceActorToRuleEngineMsg(DeviceActorToRuleEngineMsg envelope) { checkActive(); if (firstNode != null) { - putToQueue(enrichWithRuleChainId(envelope.getTbMsg()), msg -> { - pushMsgToNode(firstNode, msg, ""); - envelope.getCallbackRef().tell(new RuleEngineQueuePutAckMsg(msg.getId()), self); - }); + pushMsgToNode(firstNode, enrichWithRuleChainId(envelope.getTbMsg()), ""); } } @@ -206,15 +187,16 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor pushMsgToNode(firstNode, msg, envelope.getFromRelationType())); + pushMsgToNode(firstNode, enrichWithRuleChainId(envelope.getMsg()), envelope.getFromRelationType()); } } else { if (firstNode != null) { pushMsgToNode(firstNode, envelope.getMsg(), envelope.getFromRelationType()); } else { - TbMsg msg = envelope.getMsg(); - EntityId ackId = msg.getRuleNodeId() != null ? msg.getRuleNodeId() : msg.getRuleChainId(); - queue.ack(tenantId, envelope.getMsg(), ackId.getId(), msg.getClusterPartition()); +// TODO: Ack this message in Kafka +// TbMsg msg = envelope.getMsg(); +// EntityId ackId = msg.getRuleNodeId() != null ? msg.getRuleNodeId() : msg.getRuleChainId(); +// queue.ack(tenantId, envelope.getMsg(), ackId.getId(), msg.getClusterPartition()); } } } @@ -249,7 +231,8 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor pushMsgToNode(targetNodeCtx, queuedMsg, fromRelationType)); + pushMsgToNode(targetNodeCtx, copy, fromRelationType); } private void pushToTarget(TbMsg msg, EntityId target, String fromRelationType) { diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java index 1e9e23bae3..b70d1ed480 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -30,7 +30,6 @@ import org.thingsboard.server.actors.app.AppActor; import org.thingsboard.server.actors.rpc.RpcBroadcastMsg; import org.thingsboard.server.actors.rpc.RpcManagerActor; import org.thingsboard.server.actors.rpc.RpcSessionCreateRequestMsg; -import org.thingsboard.server.actors.session.SessionManagerActor; import org.thingsboard.server.actors.stats.StatsActor; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; @@ -90,8 +89,6 @@ public class DefaultActorService implements ActorService { private ActorRef appActor; - private ActorRef sessionManagerActor; - private ActorRef rpcManagerActor; @PostConstruct @@ -104,10 +101,6 @@ public class DefaultActorService implements ActorService { appActor = system.actorOf(Props.create(new AppActor.ActorCreator(actorContext)).withDispatcher(APP_DISPATCHER_NAME), "appActor"); actorContext.setAppActor(appActor); - sessionManagerActor = system.actorOf(Props.create(new SessionManagerActor.ActorCreator(actorContext)).withDispatcher(CORE_DISPATCHER_NAME), - "sessionManagerActor"); - actorContext.setSessionManagerActor(sessionManagerActor); - rpcManagerActor = system.actorOf(Props.create(new RpcManagerActor.ActorCreator(actorContext)).withDispatcher(CORE_DISPATCHER_NAME), "rpcManagerActor"); @@ -134,12 +127,6 @@ public class DefaultActorService implements ActorService { appActor.tell(msg, ActorRef.noSender()); } - @Override - public void process(SessionAwareMsg msg) { - log.debug("Processing session aware msg: {}", msg); - sessionManagerActor.tell(msg, ActorRef.noSender()); - } - @Override public void onServerAdded(ServerInstance server) { log.trace("Processing onServerAdded msg: {}", server); @@ -194,7 +181,6 @@ public class DefaultActorService implements ActorService { private void broadcast(ClusterEventMsg msg) { this.appActor.tell(msg, ActorRef.noSender()); - this.sessionManagerActor.tell(msg, ActorRef.noSender()); this.rpcManagerActor.tell(msg, ActorRef.noSender()); } diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java index 1cf83397fc..3441abc8a6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -35,14 +35,12 @@ public abstract class ComponentMsgProcessor extends Abstract protected final TenantId tenantId; protected final T entityId; - protected final MsgQueueService queue; protected ComponentLifecycleState state; protected ComponentMsgProcessor(ActorSystemContext systemContext, LoggingAdapter logger, TenantId tenantId, T id) { super(systemContext, logger); this.tenantId = tenantId; this.entityId = id; - this.queue = systemContext.getMsgQueueService(); } public abstract void start(ActorContext context) throws Exception; @@ -86,18 +84,4 @@ public abstract class ComponentMsgProcessor extends Abstract } } - protected void putToQueue(final TbMsg tbMsg, final Consumer onSuccess) { - EntityId entityId = tbMsg.getRuleNodeId() != null ? tbMsg.getRuleNodeId() : tbMsg.getRuleChainId(); - Futures.addCallback(queue.put(this.tenantId, tbMsg, entityId.getId(), tbMsg.getClusterPartition()), new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - onSuccess.accept(tbMsg); - } - - @Override - public void onFailure(Throwable t) { - logger.debug("Failed to push message [{}] to queue due to [{}]", tbMsg, t); - } - }); - } } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index 6b827921cd..aa36b9b2cb 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.transport; import akka.actor.ActorRef; @@ -30,6 +45,7 @@ import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.time.Duration; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; @@ -136,6 +152,7 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ @Override public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { notificationsProducer.send(notificationsTopic + "." + nodeId, + new UUID(msg.getSessionIdMSB(), msg.getSessionIdLSB()).toString(), ToTransportMsg.newBuilder().setToDeviceSessionMsg(msg).build() , new QueueCallbackAdaptor(onSuccess, onFailure)); } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java index e6b3dd3369..8ca484ffcf 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,7 +16,6 @@ package org.thingsboard.server.service.transport; import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; -import org.thingsboard.server.gen.transport.TransportProtos.; import java.util.function.Consumer; diff --git a/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java b/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java index 5f3c026249..bb0f5b620e 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java index 3ee185893a..570082d542 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.transport.msg; import lombok.Data; @@ -7,7 +22,6 @@ import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; import org.thingsboard.server.common.msg.aware.TenantAwareMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; import java.io.Serializable; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java index dfd8f98ec3..24758b5b36 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java @@ -91,8 +91,6 @@ public enum MsgType { DEVICE_ACTOR_CLIENT_SIDE_RPC_TIMEOUT_MSG, - DEVICE_ACTOR_QUEUE_TIMEOUT_MSG, - /** * Message that is sent from the Device Actor to Rule Engine. Requires acknowledgement */ @@ -101,7 +99,6 @@ public enum MsgType { /** * Message that is sent from Rule Engine to the Device Actor when message is successfully pushed to queue. */ - RULE_ENGINE_QUEUE_PUT_ACK_MSG, ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG, TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG, SESSION_TIMEOUT_MSG, diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java deleted file mode 100644 index cc298915c8..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -public class BasicActorSystemToDeviceSessionActorMsg implements ActorSystemToDeviceSessionActorMsg { - - private final ToDeviceMsg msg; - private final SessionId sessionId; - - public BasicActorSystemToDeviceSessionActorMsg(ToDeviceMsg msg, SessionId sessionId) { - super(); - this.msg = msg; - this.sessionId = sessionId; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - @Override - public ToDeviceMsg getMsg() { - return msg; - } - - @Override - public String toString() { - return "BasicActorSystemToDeviceSessionActorMsg [msg=" + msg + ", sessionId=" + sessionId + "]"; - } - - @Override - public MsgType getMsgType() { - return MsgType.ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorQueueTimeoutMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorQueueTimeoutMsg.java deleted file mode 100644 index 0277983d8b..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorQueueTimeoutMsg.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.timeout; - -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.timeout.TimeoutMsg; - -import java.util.UUID; - -/** - * @author Andrew Shvayka - */ -public final class DeviceActorQueueTimeoutMsg extends TimeoutMsg { - - public DeviceActorQueueTimeoutMsg(UUID id, long timeout) { - super(id, timeout); - } - - @Override - public MsgType getMsgType() { - return MsgType.DEVICE_ACTOR_QUEUE_TIMEOUT_MSG; - } -} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java index 90f15a4892..86be3a3c38 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index 8f4c095ed7..610a49007d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -106,6 +106,10 @@ public class TBKafkaProducerTemplate { return send(topic, key, value, null, headers, callback); } + public Future send(String topic, String key, T value, Callback callback) { + return send(topic, key, value, null, null, callback); + } + public Future send(String topic, String key, T value, Long timestamp, Iterable

headers, Callback callback) { byte[] data = encoder.encode(value); ProducerRecord record; diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index ba08bf8946..77ad0331ab 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -160,7 +160,7 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe SettableFuture future = SettableFuture.create(); pendingRequests.putIfAbsent(requestId, new ResponseMetaData<>(tickTs + maxRequestTimeout, future)); request = requestTemplate.enrich(request, responseTemplate.getTopic(), requestId); - requestTemplate.send(key, request, headers); + requestTemplate.send(key, request, headers, null); return future; } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java index 6f9e8a791b..4c23ac2211 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index d0e6d18de2..44fcd2a39d 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java index 4cff643a5a..80b2690b21 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java @@ -20,8 +20,6 @@ import org.thingsboard.server.common.msg.aware.SessionAwareMsg; public interface SessionMsgProcessor { - void process(SessionAwareMsg msg); - void onDeviceAdded(Device device); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java index 080f874efb..86067d536e 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.msg.session.SessionMsgType; import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; import org.thingsboard.server.common.msg.session.SessionContext; import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Optional; diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index dc2e3062f2..c3817a9ffa 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,6 +22,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; /** * Created by ashvayka on 04.10.18. @@ -40,6 +41,8 @@ public interface TransportService { void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback); + void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener); void deregisterSession(SessionInfoProto sessionInfo); diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index e7c173485c..6bb8eff1be 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -15,18 +15,38 @@ */ package org.thingsboard.server.common.transport.adaptor; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; +import org.thingsboard.server.common.data.kv.AttributeKey; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.msg.core.AttributesUpdateRequest; +import org.thingsboard.server.common.msg.core.BasicAttributesUpdateRequest; +import org.thingsboard.server.common.msg.core.BasicRequest; +import org.thingsboard.server.common.msg.core.BasicTelemetryUploadRequest; +import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; +import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; +import org.thingsboard.server.common.msg.core.ToServerRpcRequestMsg; +import org.thingsboard.server.common.msg.core.ToServerRpcResponseMsg; +import org.thingsboard.server.common.msg.kv.AttributesKVMsg; +import org.thingsboard.server.gen.transport.TransportProtos.*; + import java.util.ArrayList; import java.util.List; import java.util.Map.Entry; import java.util.function.Consumer; import java.util.stream.Collectors; -import com.google.gson.*; -import org.thingsboard.server.common.msg.core.*; - -import org.thingsboard.server.common.data.kv.*; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; - public class JsonConverter { private static final Gson GSON = new Gson(); @@ -44,6 +64,109 @@ public class JsonConverter { return convertToTelemetry(jsonObject, System.currentTimeMillis(), requestId); } + public static PostTelemetryMsg convertToTelemetryProto(JsonElement jsonObject) throws JsonSyntaxException { + long systemTs = System.currentTimeMillis(); + PostTelemetryMsg.Builder builder = PostTelemetryMsg.newBuilder(); + if (jsonObject.isJsonObject()) { + parseObject(builder, systemTs, jsonObject); + } else if (jsonObject.isJsonArray()) { + jsonObject.getAsJsonArray().forEach(je -> { + if (je.isJsonObject()) { + parseObject(builder, systemTs, je.getAsJsonObject()); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + je); + } + }); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); + } + return builder.build(); + } + + public static PostAttributeMsg convertToAttributesProto(JsonElement jsonObject) throws JsonSyntaxException { + if (jsonObject.isJsonObject()) { + PostAttributeMsg.Builder result = PostAttributeMsg.newBuilder(); + List keyValueList = parseProtoValues(jsonObject.getAsJsonObject()); + result.addAllKv(keyValueList); + return result.build(); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); + } + } + + + private static void parseObject(PostTelemetryMsg.Builder builder, long systemTs, JsonElement jsonObject) { + JsonObject jo = jsonObject.getAsJsonObject(); + if (jo.has("ts") && jo.has("values")) { + parseWithTs(builder, jo); + } else { + parseWithoutTs(builder, systemTs, jo); + } + } + + private static void parseWithoutTs(PostTelemetryMsg.Builder request, long systemTs, JsonObject jo) { + TsKvListProto.Builder builder = TsKvListProto.newBuilder(); + builder.setTs(systemTs); + builder.addAllKv(parseProtoValues(jo)); + request.addTsKvList(builder.build()); + } + + public static void parseWithTs(PostTelemetryMsg.Builder request, JsonObject jo) { + TsKvListProto.Builder builder = TsKvListProto.newBuilder(); + builder.setTs(jo.get("ts").getAsLong()); + builder.addAllKv(parseProtoValues(jo.get("values").getAsJsonObject())); + request.addTsKvList(builder.build()); + } + + public static List parseProtoValues(JsonObject valuesObject) { + List result = new ArrayList<>(); + for (Entry valueEntry : valuesObject.entrySet()) { + JsonElement element = valueEntry.getValue(); + if (element.isJsonPrimitive()) { + JsonPrimitive value = element.getAsJsonPrimitive(); + if (value.isString()) { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.STRING_V) + .setStringV(value.getAsString()).build()); + } else if (value.isBoolean()) { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.BOOLEAN_V) + .setBoolV(value.getAsBoolean()).build()); + } else if (value.isNumber()) { + if (value.getAsString().contains(".")) { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.DOUBLE_V) + .setDoubleV(value.getAsDouble()).build()); + } else { + try { + long longValue = Long.parseLong(value.getAsString()); + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.LONG_V) + .setLongV(longValue).build()); + } catch (NumberFormatException e) { + throw new JsonSyntaxException("Big integer values are not supported!"); + } + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); + } + } + return result; + } + + private static void parseNumericProto(List result, Entry valueEntry, JsonPrimitive value) { + if (value.getAsString().contains(".")) { + result.add(new DoubleDataEntry(valueEntry.getKey(), value.getAsDouble())); + } else { + try { + long longValue = Long.parseLong(value.getAsString()); + result.add(new LongDataEntry(valueEntry.getKey(), longValue)); + } catch (NumberFormatException e) { + throw new JsonSyntaxException("Big integer values are not supported!"); + } + } + } + + private static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, long systemTs, int requestId) throws JsonSyntaxException { BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); if (jsonObject.isJsonObject()) { @@ -140,6 +263,26 @@ public class JsonConverter { } } + public static JsonObject toJson(GetAttributeResponseMsg payload) { + JsonObject result = new JsonObject(); + if (payload.getClientAttributeListCount() > 0) { + JsonObject attrObject = new JsonObject(); + payload.getClientAttributeListList().forEach(addToObjectFromProto(attrObject)); + result.add("client", attrObject); + } + if (payload.getSharedAttributeListCount() > 0) { + JsonObject attrObject = new JsonObject(); + payload.getSharedAttributeListList().forEach(addToObjectFromProto(attrObject)); + result.add("shared", attrObject); + } + if (payload.getDeletedAttributeKeysCount() > 0) { + JsonArray attrObject = new JsonArray(); + payload.getDeletedAttributeKeysList().forEach(attrObject::add); + result.add("deleted", attrObject); + } + return result; + } + public static JsonObject toJson(AttributesKVMsg payload, boolean asMap) { JsonObject result = new JsonObject(); if (asMap) { @@ -166,8 +309,29 @@ public class JsonConverter { } private static Consumer addToObject(JsonArray result) { - return key -> { - result.add(key.getAttributeKey()); + return key -> result.add(key.getAttributeKey()); + } + + private static Consumer addToObjectFromProto(JsonObject result) { + return de -> { + JsonPrimitive value; + switch (de.getKv().getType()) { + case BOOLEAN_V: + value = new JsonPrimitive(de.getKv().getBoolV()); + break; + case DOUBLE_V: + value = new JsonPrimitive(de.getKv().getDoubleV()); + break; + case LONG_V: + value = new JsonPrimitive(de.getKv().getLongV()); + break; + case STRING_V: + value = new JsonPrimitive(de.getKv().getStringV()); + break; + default: + throw new IllegalArgumentException("Unsupported data type: " + de.getKv().getType()); + } + result.add(de.getKv().getKey(), value); }; } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index 30689748c5..b46cfb81df 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index f3b3ae87a4..d32bac99a7 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -23,12 +23,13 @@ option java_outer_classname = "TransportProtos"; * Data Structures; */ message SessionInfoProto { - int64 sessionIdMSB = 1; - int64 sessionIdLSB = 2; - int64 tenantIdMSB = 3; - int64 tenantIdLSB = 4; - int64 deviceIdMSB = 5; - int64 deviceIdLSB = 6; + string nodeId = 1; + int64 sessionIdMSB = 2; + int64 sessionIdLSB = 3; + int64 tenantIdMSB = 4; + int64 tenantIdLSB = 5; + int64 deviceIdMSB = 6; + int64 deviceIdLSB = 7; } enum SessionEvent { @@ -57,6 +58,11 @@ message KeyValueProto { string string_v = 6; } +message TsKvProto { + int64 ts = 1; + KeyValueProto kv = 2; +} + message TsKvListProto { int64 ts = 1; repeated KeyValueProto kv = 2; @@ -76,9 +82,8 @@ message DeviceInfoProto { * Messages that use Data Structures; */ message SessionEventMsg { - string nodeId = 1; - SessionType sessionType = 2; - SessionEvent event = 3; + SessionType sessionType = 1; + SessionEvent event = 2; } message PostTelemetryMsg { @@ -90,14 +95,17 @@ message PostAttributeMsg { } message GetAttributeRequestMsg { - repeated string clientAttributeNames = 1; - repeated string sharedAttributeNames = 2; + int32 requestId = 1; + repeated string clientAttributeNames = 2; + repeated string sharedAttributeNames = 3; } message GetAttributeResponseMsg { - repeated TsKvListProto clientAttributeList = 1; - repeated TsKvListProto sharedAttributeList = 2; - repeated string deletedAttributeKeys = 3; + int32 requestId = 1; + repeated TsKvProto clientAttributeList = 2; + repeated TsKvProto sharedAttributeList = 3; + repeated string deletedAttributeKeys = 4; + string error = 5; } message ValidateDeviceTokenRequestMsg { diff --git a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java index e2fd7cd6c4..ec69cec673 100644 --- a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java +++ b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java @@ -106,30 +106,30 @@ public class CoapServerTest { public static SessionMsgProcessor sessionMsgProcessor() { return new SessionMsgProcessor() { - @Override - public void process(SessionAwareMsg toActorMsg) { - if (toActorMsg instanceof TransportToDeviceSessionActorMsg) { - AdaptorToSessionActorMsg sessionMsg = ((TransportToDeviceSessionActorMsg) toActorMsg).getSessionMsg(); - try { - FromDeviceMsg deviceMsg = sessionMsg.getMsg(); - ToDeviceMsg toDeviceMsg = null; - if (deviceMsg.getMsgType() == SessionMsgType.POST_TELEMETRY_REQUEST) { - toDeviceMsg = BasicStatusCodeResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID); - } else if (deviceMsg.getMsgType() == SessionMsgType.GET_ATTRIBUTES_REQUEST) { - List data = new ArrayList<>(); - data.add(new BaseAttributeKvEntry(new StringDataEntry("key1", "value1"), System.currentTimeMillis())); - data.add(new BaseAttributeKvEntry(new LongDataEntry("key2", 42L), System.currentTimeMillis())); - BasicAttributeKVMsg kv = BasicAttributeKVMsg.fromClient(data); - toDeviceMsg = BasicGetAttributesResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID, kv); - } - if (toDeviceMsg != null) { - sessionMsg.getSessionContext().onMsg(new BasicSessionActorToAdaptorMsg(sessionMsg.getSessionContext(), toDeviceMsg)); - } - } catch (Exception e) { - e.printStackTrace(); - } - } - } +// @Override +// public void process(SessionAwareMsg toActorMsg) { +// if (toActorMsg instanceof TransportToDeviceSessionActorMsg) { +// AdaptorToSessionActorMsg sessionMsg = ((TransportToDeviceSessionActorMsg) toActorMsg).getSessionMsg(); +// try { +// FromDeviceMsg deviceMsg = sessionMsg.getMsg(); +// ToDeviceMsg toDeviceMsg = null; +// if (deviceMsg.getMsgType() == SessionMsgType.POST_TELEMETRY_REQUEST) { +// toDeviceMsg = BasicStatusCodeResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID); +// } else if (deviceMsg.getMsgType() == SessionMsgType.GET_ATTRIBUTES_REQUEST) { +// List data = new ArrayList<>(); +// data.add(new BaseAttributeKvEntry(new StringDataEntry("key1", "value1"), System.currentTimeMillis())); +// data.add(new BaseAttributeKvEntry(new LongDataEntry("key2", 42L), System.currentTimeMillis())); +// BasicAttributeKVMsg kv = BasicAttributeKVMsg.fromClient(data); +// toDeviceMsg = BasicGetAttributesResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID, kv); +// } +// if (toDeviceMsg != null) { +// sessionMsg.getSessionContext().onMsg(new BasicSessionActorToAdaptorMsg(sessionMsg.getSessionContext(), toDeviceMsg)); +// } +// } catch (Exception e) { +// e.printStackTrace(); +// } +// } +// } @Override public void onDeviceAdded(Device device) { diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index d8f1d10b11..c64ab5d559 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -26,18 +26,25 @@ import io.netty.handler.codec.mqtt.MqttFixedHeader; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; import io.netty.handler.codec.mqtt.MqttPubAckMessage; +import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttSubAckMessage; import io.netty.handler.codec.mqtt.MqttSubAckPayload; +import io.netty.handler.codec.mqtt.MqttSubscribeMessage; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage; import io.netty.handler.ssl.SslHandler; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.GenericFutureListener; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; +import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.quota.QuotaService; import org.thingsboard.server.dao.EncryptionUtil; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; @@ -54,6 +61,7 @@ import javax.net.ssl.SSLPeerUnverifiedException; import javax.security.cert.X509Certificate; import java.io.IOException; import java.net.InetSocketAddress; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -65,14 +73,16 @@ import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUS import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK; import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK; import static io.netty.handler.codec.mqtt.MqttMessageType.SUBACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.UNSUBACK; import static io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE; import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE; +import static io.netty.handler.codec.mqtt.MqttQoS.FAILURE; /** * @author Andrew Shvayka */ @Slf4j -public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener> { +public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener>, SessionMsgListener { public static final MqttQoS MAX_SUPPORTED_QOS_LVL = AT_LEAST_ONCE; @@ -84,8 +94,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private final SslHandler sslHandler; private final ConcurrentMap mqttQoSMap; - private final SessionInfoProto sessionInfo; - + private volatile SessionInfoProto sessionInfo; private volatile InetSocketAddress address; private volatile DeviceSessionCtx deviceSessionCtx; private volatile GatewaySessionCtx gatewaySessionCtx; @@ -98,11 +107,6 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement this.quotaService = context.getQuotaService(); this.sslHandler = context.getSslHandler(); this.mqttQoSMap = new ConcurrentHashMap<>(); - this.sessionInfo = SessionInfoProto.newBuilder() - .setNodeId(context.getNodeId()) - .setSessionIdMSB(sessionId.getMostSignificantBits()) - .setSessionIdLSB(sessionId.getLeastSignificantBits()) - .build(); this.deviceSessionCtx = new DeviceSessionCtx(mqttQoSMap); } @@ -135,15 +139,15 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement case CONNECT: processConnect(ctx, (MqttConnectMessage) msg); break; -// case PUBLISH: -// processPublish(ctx, (MqttPublishMessage) msg); -// break; -// case SUBSCRIBE: -// processSubscribe(ctx, (MqttSubscribeMessage) msg); -// break; -// case UNSUBSCRIBE: -// processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); -// break; + case PUBLISH: + processPublish(ctx, (MqttPublishMessage) msg); + break; + case SUBSCRIBE: + processSubscribe(ctx, (MqttSubscribeMessage) msg); + break; + case UNSUBSCRIBE: + processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); + break; // case PINGREQ: // if (checkConnected(ctx)) { // ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); @@ -160,24 +164,25 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } -// private void processPublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg) { -// if (!checkConnected(ctx)) { -// return; -// } -// String topicName = mqttMsg.variableHeader().topicName(); -// int msgId = mqttMsg.variableHeader().packetId(); -// log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId); -// -// if (topicName.startsWith(BASE_GATEWAY_API_TOPIC)) { -// if (gatewaySessionCtx != null) { -// gatewaySessionCtx.setChannel(ctx); + private void processPublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg) { + if (!checkConnected(ctx)) { + return; + } + String topicName = mqttMsg.variableHeader().topicName(); + int msgId = mqttMsg.variableHeader().packetId(); + log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId); + + if (topicName.startsWith(MqttTopics.BASE_GATEWAY_API_TOPIC)) { + if (gatewaySessionCtx != null) { + gatewaySessionCtx.setChannel(ctx); // handleMqttPublishMsg(topicName, msgId, mqttMsg); -// } -// } else { -// processDevicePublish(ctx, mqttMsg, topicName, msgId); -// } -// } -// + } + } else { + processDevicePublish(ctx, mqttMsg, topicName, msgId); + } + } + + // // private void handleMqttPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { // try { // switch (topicName) { @@ -205,7 +210,23 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement // } // } // -// private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { + private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { + try { + if (topicName.equals(MqttTopics.DEVICE_TELEMETRY_TOPIC)) { + TransportProtos.PostTelemetryMsg postTelemetryMsg = adaptor.convertToPostTelemetry(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, postTelemetryMsg, getPubAckCallback(ctx, msgId, postTelemetryMsg)); + } else if (topicName.equals(MqttTopics.DEVICE_ATTRIBUTES_TOPIC)) { + TransportProtos.PostAttributeMsg postAttributeMsg = adaptor.convertToPostAttributes(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, postAttributeMsg, getPubAckCallback(ctx, msgId, postAttributeMsg)); + } else if (topicName.startsWith(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { + TransportProtos.GetAttributeRequestMsg getAttributeMsg = adaptor.convertToGetAttributes(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, getAttributeMsg, getPubAckCallback(ctx, msgId, getAttributeMsg)); + } + } catch (AdaptorException e) { + log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); + log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); + ctx.close(); + } // AdaptorToSessionActorMsg msg = null; // try { // if (topicName.equals(DEVICE_TELEMETRY_TOPIC)) { @@ -237,20 +258,38 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement // log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); // ctx.close(); // } -// } -// -// private void processSubscribe(ChannelHandlerContext ctx, MqttSubscribeMessage mqttMsg) { -// if (!checkConnected(ctx)) { -// return; -// } -// log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); -// List grantedQoSList = new ArrayList<>(); -// for (MqttTopicSubscription subscription : mqttMsg.payload().topicSubscriptions()) { -// String topic = subscription.topicName(); -// MqttQoS reqQoS = subscription.qualityOfService(); -// try { -// switch (topic) { -// case DEVICE_ATTRIBUTES_TOPIC: { + } + + private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final int msgId, final T msg) { + return new TransportServiceCallback() { + @Override + public void onSuccess(Void dummy) { + log.trace("[{}] Published msg: {}", sessionId, msg); + if (msgId > 0) { + ctx.writeAndFlush(createMqttPubAckMsg(msgId)); + } + } + + @Override + public void onError(Throwable e) { + log.trace("[{}] Failed to publish msg: {}", sessionId, msg, e); + ctx.close(); + } + }; + } + + private void processSubscribe(ChannelHandlerContext ctx, MqttSubscribeMessage mqttMsg) { + if (!checkConnected(ctx)) { + return; + } + log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); + List grantedQoSList = new ArrayList<>(); + for (MqttTopicSubscription subscription : mqttMsg.payload().topicSubscriptions()) { + String topic = subscription.topicName(); + MqttQoS reqQoS = subscription.qualityOfService(); + try { + switch (topic) { +// case MqttTopics.DEVICE_ATTRIBUTES_TOPIC: { // AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); // processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); // registerSubQoS(topic, grantedQoSList, reqQoS); @@ -267,37 +306,37 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement // case GATEWAY_RPC_TOPIC: // registerSubQoS(topic, grantedQoSList, reqQoS); // break; -// case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: -// deviceSessionCtx.setAllowAttributeResponses(); -// registerSubQoS(topic, grantedQoSList, reqQoS); -// break; -// default: -// log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); -// grantedQoSList.add(FAILURE.value()); -// break; -// } -// } catch (AdaptorException e) { -// log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); -// grantedQoSList.add(FAILURE.value()); -// } -// } -// ctx.writeAndFlush(createSubAckMessage(mqttMsg.variableHeader().messageId(), grantedQoSList)); -// } -// -// private void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { -// grantedQoSList.add(getMinSupportedQos(reqQoS)); -// mqttQoSMap.put(topic, getMinSupportedQos(reqQoS)); -// } -// -// private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { -// if (!checkConnected(ctx)) { -// return; -// } -// log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); -// for (String topicName : mqttMsg.payload().topics()) { -// mqttQoSMap.remove(topicName); -// try { -// switch (topicName) { + case MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC: + deviceSessionCtx.setAllowAttributeResponses(); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + default: + log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); + grantedQoSList.add(FAILURE.value()); + break; + } + } catch (Exception e) { + log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); + grantedQoSList.add(FAILURE.value()); + } + } + ctx.writeAndFlush(createSubAckMessage(mqttMsg.variableHeader().messageId(), grantedQoSList)); + } + + private void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { + grantedQoSList.add(getMinSupportedQos(reqQoS)); + mqttQoSMap.put(topic, getMinSupportedQos(reqQoS)); + } + + private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { + if (!checkConnected(ctx)) { + return; + } + log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); + for (String topicName : mqttMsg.payload().topics()) { + mqttQoSMap.remove(topicName); + try { + switch (topicName) { // case DEVICE_ATTRIBUTES_TOPIC: { // AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); // processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); @@ -308,23 +347,23 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement // processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); // break; // } -// case DEVICE_ATTRIBUTES_RESPONSES_TOPIC: -// deviceSessionCtx.setDisallowAttributeResponses(); -// break; -// } -// } catch (AdaptorException e) { -// log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); -// } -// } -// ctx.writeAndFlush(createUnSubAckMessage(mqttMsg.variableHeader().messageId())); -// } -// -// private MqttMessage createUnSubAckMessage(int msgId) { -// MqttFixedHeader mqttFixedHeader = -// new MqttFixedHeader(UNSUBACK, false, AT_LEAST_ONCE, false, 0); -// MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); -// return new MqttMessage(mqttFixedHeader, mqttMessageIdVariableHeader); -// } + case MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC: + deviceSessionCtx.setDisallowAttributeResponses(); + break; + } + } catch (Exception e) { + log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); + } + } + ctx.writeAndFlush(createUnSubAckMessage(mqttMsg.variableHeader().messageId())); + } + + private MqttMessage createUnSubAckMessage(int msgId) { + MqttFixedHeader mqttFixedHeader = + new MqttFixedHeader(UNSUBACK, false, AT_LEAST_ONCE, false, 0); + MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); + return new MqttMessage(mqttFixedHeader, mqttMessageIdVariableHeader); + } private void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) { log.info("[{}] Processing connect msg for client: {}!", sessionId, msg.payload().clientIdentifier()); @@ -346,15 +385,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement new TransportServiceCallback() { @Override public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { - if (!msg.hasDeviceInfo()) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); - } else { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); - deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); - transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.OPEN), null); - checkGatewaySession(); - } + onValidateDeviceResponse(msg, ctx); } @Override @@ -375,15 +406,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement new TransportServiceCallback() { @Override public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { - if (!msg.hasDeviceInfo()) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); - } else { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); - deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); - transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.OPEN), null); - checkGatewaySession(); - } + onValidateDeviceResponse(msg, ctx); } @Override @@ -415,7 +438,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void processDisconnect(ChannelHandlerContext ctx) { ctx.close(); if (deviceSessionCtx.isConnected()) { - transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.deregisterSession(sessionInfo); if (gatewaySessionCtx != null) { gatewaySessionCtx.onGatewayDisconnect(); } @@ -488,16 +512,46 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private SessionEventMsg getSessionEventMsg(SessionEvent event) { return SessionEventMsg.newBuilder() - .setSessionInfo(sessionInfo) - .setDeviceIdMSB(deviceSessionCtx.getDeviceIdMSB()) - .setDeviceIdLSB(deviceSessionCtx.getDeviceIdLSB()) + .setSessionType(TransportProtos.SessionType.ASYNC) .setEvent(event).build(); } @Override public void operationComplete(Future future) throws Exception { if (deviceSessionCtx.isConnected()) { - transportService.process(deviceSessionCtx, getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.deregisterSession(sessionInfo); + } + } + + private void onValidateDeviceResponse(ValidateDeviceCredentialsResponseMsg msg, ChannelHandlerContext ctx) { + if (!msg.hasDeviceInfo()) { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); + ctx.close(); + } else { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); + deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); + sessionInfo = SessionInfoProto.newBuilder() + .setNodeId(context.getNodeId()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceInfo().getDeviceIdMSB()) + .setDeviceIdLSB(msg.getDeviceInfo().getDeviceIdLSB()) + .setTenantIdMSB(msg.getDeviceInfo().getTenantIdMSB()) + .setTenantIdLSB(msg.getDeviceInfo().getTenantIdLSB()) + .build(); + transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.OPEN), null); + transportService.registerSession(sessionInfo, this); + checkGatewaySession(); + } + } + + @Override + public void onGetAttributesResponse(TransportProtos.GetAttributeResponseMsg response) { + try { + adaptor.convertToPublish(deviceSessionCtx, response).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes to MQTT msg", sessionId, e); } } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index c8baaf7e53..ee41d2c3d6 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt.adaptors; import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import io.netty.buffer.ByteBuf; @@ -25,12 +26,14 @@ import io.netty.buffer.UnpooledByteBufAllocator; import io.netty.handler.codec.mqtt.*; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.core.*; import org.thingsboard.server.common.msg.kv.AttributesKVMsg; import org.thingsboard.server.common.msg.session.*; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.MqttTopics; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; @@ -52,6 +55,64 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { private static final Charset UTF8 = Charset.forName("UTF-8"); private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); + @Override + public TransportProtos.PostTelemetryMsg convertToPostTelemetry(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + String payload = validatePayload(ctx.getSessionId(), inbound.payload()); + try { + return JsonConverter.convertToTelemetryProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.PostAttributeMsg convertToPostAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + String payload = validatePayload(ctx.getSessionId(), inbound.payload()); + try { + return JsonConverter.convertToAttributesProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + String topicName = inbound.variableHeader().topicName(); + try { + TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); + result.setRequestId(Integer.valueOf(topicName.substring(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX.length()))); + String payload = inbound.payload().toString(UTF8); + JsonElement requestBody = new JsonParser().parse(payload); + Set clientKeys = toStringSet(requestBody, "clientKeys"); + Set sharedKeys = toStringSet(requestBody, "sharedKeys"); + if (clientKeys != null) { + result.addAllClientAttributeNames(clientKeys); + } + if (sharedKeys != null) { + result.addAllSharedAttributeNames(sharedKeys); + } + return result.build(); + } catch (RuntimeException e) { + log.warn("Failed to decode get attributes request", e); + throw new AdaptorException(e); + } + } + + @Override + public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { + if (!StringUtils.isEmpty(responseMsg.getError())) { + throw new AdaptorException(responseMsg.getError()); + } else { + Integer requestId = responseMsg.getRequestId(); + if (requestId >= 0) { + return Optional.of(createMqttPublishMsg(ctx, + MqttTopics.DEVICE_ATTRIBUTES_RESPONSE_TOPIC_PREFIX + requestId, + JsonConverter.toJson(responseMsg))); + } + return Optional.empty(); + } + } + @Override public AdaptorToSessionActorMsg convertToActorMsg(DeviceSessionCtx ctx, SessionMsgType type, MqttMessage inbound) throws AdaptorException { FromDeviceMsg msg; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index bf83a1fd98..54e7c5ca3c 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -16,11 +16,24 @@ package org.thingsboard.server.transport.mqtt.adaptors; import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttPublishMessage; import org.thingsboard.server.common.transport.TransportAdaptor; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; +import java.util.Optional; + /** * @author Andrew Shvayka */ public interface MqttTransportAdaptor extends TransportAdaptor { + + TransportProtos.PostTelemetryMsg convertToPostTelemetry(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + + TransportProtos.PostAttributeMsg convertToPostAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + + TransportProtos.GetAttributeRequestMsg convertToGetAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + + Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException; } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index 6661cb12f4..d704634ded 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt.session; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.mqtt.*; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; @@ -41,12 +42,13 @@ import java.util.concurrent.atomic.AtomicInteger; public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { private final MqttSessionId sessionId; + @Getter private ChannelHandlerContext channel; private volatile boolean allowAttributeResponses; private AtomicInteger msgIdSeq = new AtomicInteger(0); public DeviceSessionCtx(ConcurrentMap mqttQoSMap) { - super(null, null, null); + super(null, null, mqttQoSMap); this.sessionId = new MqttSessionId(); } diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index 76c1c1b8c1..7a2f698647 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -111,8 +111,8 @@ public class MqttTransportService implements TransportService { TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); responseBuilder.settings(kafkaSettings); responseBuilder.topic(transportApiResponsesTopic + "." + transportContext.getNodeId()); - responseBuilder.clientId(transportContext.getNodeId()); - responseBuilder.groupId(null); + responseBuilder.clientId("transport-api-client-" + transportContext.getNodeId()); + responseBuilder.groupId("transport-api-client"); responseBuilder.autoCommit(true); responseBuilder.autoCommitIntervalMs(autoCommitInterval); responseBuilder.decoder(new TransportApiResponseDecoder()); @@ -137,8 +137,8 @@ public class MqttTransportService implements TransportService { TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder mainConsumerBuilder = TBKafkaConsumerTemplate.builder(); mainConsumerBuilder.settings(kafkaSettings); mainConsumerBuilder.topic(notificationsTopic + "." + transportContext.getNodeId()); - mainConsumerBuilder.clientId(transportContext.getNodeId()); - mainConsumerBuilder.groupId(null); + mainConsumerBuilder.clientId("transport-" + transportContext.getNodeId()); + mainConsumerBuilder.groupId("transport"); mainConsumerBuilder.autoCommit(true); mainConsumerBuilder.autoCommitIntervalMs(notificationsAutoCommitInterval); mainConsumerBuilder.decoder(new ToTransportMsgResponseDecoder()); @@ -242,6 +242,15 @@ public class MqttTransportService implements TransportService { send(sessionInfo, toRuleEngineMsg, callback); } + @Override + public void process(SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setGetAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + @Override public void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener) { sessions.putIfAbsent(toId(sessionInfo), listener); @@ -271,9 +280,13 @@ public class MqttTransportService implements TransportService { @Override public void onCompletion(RecordMetadata metadata, Exception exception) { if (exception == null) { - callback.onSuccess(null); + if (callback != null) { + callback.onSuccess(null); + } } else { - callback.onError(exception); + if (callback != null) { + callback.onError(exception); + } } } } diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java index ee929ceb6f..179a2a13cf 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. From afa3d5c3757dd001a812a493c2aaaa85d5762956 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 10 Oct 2018 14:19:49 +0300 Subject: [PATCH 193/307] Fix entity view timeseries --- .../BaseEntityViewControllerTest.java | 98 ++++++++++++++++--- .../dao/timeseries/BaseTimeseriesService.java | 23 ++++- 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index cb45006c8c..ce55e710b9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -85,11 +85,11 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes testDevice = doPost("/api/device", device, Device.class); telemetry = new TelemetryEntityView( - Arrays.asList("109", "209", "309"), + Arrays.asList("tsKey1", "tsKey2", "tsKey3"), new AttributesEntityView( - Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4"), - Arrays.asList("saValue1", "saValue2", "saValue3", "saValue4"), - Arrays.asList("shValue1", "shValue2", "shValue3", "shValue4"))); + Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4"), + Arrays.asList("saKey1", "saKey2", "saKey3", "saKey4"), + Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4"))); } @After @@ -324,10 +324,10 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes @Test public void testTheCopyOfAttrsIntoTSForTheView() throws Exception { Set actualAttributesSet = - getAttributesByKeys("{\"caValue1\":\"value1\", \"caValue2\":true, \"caValue3\":42.0, \"caValue4\":73}"); + getAttributesByKeys("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}"); Set expectedActualAttributesSet = - new HashSet<>(Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4")); + new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4")); assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); Thread.sleep(1000); @@ -335,18 +335,18 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes List> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); - assertEquals("value1", getValue(values, "caValue1")); - assertEquals(true, getValue(values, "caValue2")); - assertEquals(42.0, getValue(values, "caValue3")); - assertEquals(73, getValue(values, "caValue4")); + assertEquals("value1", getValue(values, "caKey1")); + assertEquals(true, getValue(values, "caKey2")); + assertEquals(42.0, getValue(values, "caKey3")); + assertEquals(73, getValue(values, "caKey4")); } @Test public void testTheCopyOfAttrsOutOfTSForTheView() throws Exception { Set actualAttributesSet = - getAttributesByKeys("{\"caValue1\":\"value1\", \"caValue2\":true, \"caValue3\":42.0, \"caValue4\":73}"); + getAttributesByKeys("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}"); - Set expectedActualAttributesSet = new HashSet<>(Arrays.asList("caValue1", "caValue2", "caValue3", "caValue4")); + Set expectedActualAttributesSet = new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4")); assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); Thread.sleep(1000); @@ -368,6 +368,69 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes assertEquals(0, values.size()); } + + @Test + public void testGetTelemetryWhenEntityViewTimeRangeInsideTimestampRange() throws Exception { + uploadTelemetry("{\"tsKey1\":\"value1\", \"tsKey2\":true, \"tsKey3\":40.0}"); + Thread.sleep(1000); + long startTimeMs = System.currentTimeMillis(); + uploadTelemetry("{\"tsKey1\":\"value2\", \"tsKey2\":false, \"tsKey3\":80.0}"); + Thread.sleep(1000); + uploadTelemetry("{\"tsKey1\":\"value3\", \"tsKey2\":false, \"tsKey3\":120.0}"); + long endTimeMs = System.currentTimeMillis(); + uploadTelemetry("{\"tsKey1\":\"value4\", \"tsKey2\":true, \"tsKey3\":160.0}"); + + String deviceId = testDevice.getId().getId().toString(); + Set keys = getTelemetryKeys("DEVICE", deviceId); + Thread.sleep(1000); + + EntityView view = createEntityView("Test entity view", startTimeMs, endTimeMs); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + String entityViewId = savedView.getId().getId().toString(); + + Map>> expectedValues = getTelemetryValues("DEVICE", deviceId, keys, 0L, (startTimeMs + endTimeMs) / 2); + Assert.assertEquals(2, expectedValues.get("tsKey1").size()); + Assert.assertEquals(2, expectedValues.get("tsKey2").size()); + Assert.assertEquals(2, expectedValues.get("tsKey3").size()); + + Map>> actualValues = getTelemetryValues("ENTITY_VIEW", entityViewId, keys, 0L, (startTimeMs + endTimeMs) / 2); + Assert.assertEquals(1, actualValues.get("tsKey1").size()); + Assert.assertEquals(1, actualValues.get("tsKey2").size()); + Assert.assertEquals(1, actualValues.get("tsKey3").size()); + } + + private void uploadTelemetry(String strKvs) throws Exception { + String viewDeviceId = testDevice.getId().getId().toString(); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + viewDeviceId + "/credentials", DeviceCredentials.class); + assertEquals(testDevice.getId(), deviceCredentials.getDeviceId()); + + String accessToken = deviceCredentials.getCredentialsId(); + assertNotNull(accessToken); + + String clientId = MqttAsyncClient.generateClientId(); + MqttAsyncClient client = new MqttAsyncClient("tcp://localhost:1883", clientId); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options); + Thread.sleep(3000); + + MqttMessage message = new MqttMessage(); + message.setPayload(strKvs.getBytes()); + client.publish("v1/devices/me/telemetry", message); + Thread.sleep(1000); + } + + private Set getTelemetryKeys(String type, String id) throws Exception { + return new HashSet<>(doGetAsync("/api/plugins/telemetry/" + type + "/" + id + "/keys/timeseries", List.class)); + } + + private Map>> getTelemetryValues(String type, String id, Set keys, Long startTs, Long endTs) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + type + "/" + id + + "/values/timeseries?keys=" + String.join(",", keys) + "&startTs=" + startTs + "&endTs=" + endTs, Map.class); + } + private Set getAttributesByKeys(String stringKV) throws Exception { String viewDeviceId = testDevice.getId().getId().toString(); DeviceCredentials deviceCredentials = @@ -390,7 +453,7 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes client.publish("v1/devices/me/attributes", message); Thread.sleep(1000); - return new HashSet<>(doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class)); + return new HashSet<>(doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class)); } private Object getValue(List> values, String stringValue) { @@ -401,13 +464,20 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes } private EntityView getNewSavedEntityView(String name) throws Exception { + EntityView view = createEntityView(name, 0, 0); + return doPost("/api/entityView", view, EntityView.class); + } + + private EntityView createEntityView(String name, long startTimeMs, long endTimeMs) { EntityView view = new EntityView(); view.setEntityId(testDevice.getId()); view.setTenantId(savedTenant.getId()); view.setName(name); view.setType("default"); view.setKeys(telemetry); - return doPost("/api/entityView", view, EntityView.class); + view.setStartTimeMs(startTimeMs); + view.setEndTimeMs(endTimeMs); + return view; } private Customer getNewCustomer(String title) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 264cdc8f8d..4a09bb7a23 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -87,7 +87,11 @@ public class BaseTimeseriesService implements TimeseriesService { .map(key -> new BaseReadTsKvQuery(key, entityView.getStartTimeMs(), entityView.getEndTimeMs(), 1, "ASC")) .collect(Collectors.toList()); - return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, queries)); + if (queries.size() > 0) { + return timeseriesDao.findAllAsync(entityView.getEntityId(), queries); + } else { + return Futures.immediateFuture(new ArrayList<>()); + } } keys.forEach(key -> futures.add(timeseriesDao.findLatest(entityId, key))); return Futures.allAsList(futures); @@ -133,11 +137,20 @@ public class BaseTimeseriesService implements TimeseriesService { private List updateQueriesForEntityView(EntityView entityView, List queries) { return queries.stream().map(query -> { - long startTs = entityView.getStartTimeMs() == 0 ? query.getStartTs() : entityView.getStartTimeMs(); - long endTs = entityView.getEndTimeMs() == 0 ? query.getEndTs() : entityView.getEndTimeMs(); + long startTs; + if (entityView.getStartTimeMs() != 0 && entityView.getStartTimeMs() > query.getStartTs()) { + startTs = entityView.getStartTimeMs(); + } else { + startTs = query.getStartTs(); + } - return startTs <= query.getStartTs() && endTs >= query.getEndTs() ? query : - new BaseReadTsKvQuery(query.getKey(), startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); + long endTs; + if (entityView.getEndTimeMs() != 0 && entityView.getEndTimeMs() < query.getEndTs()) { + endTs = entityView.getEndTimeMs(); + } else { + endTs = query.getEndTs(); + } + return new BaseReadTsKvQuery(query.getKey(), startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); }).collect(Collectors.toList()); } From 5a80d75584d09b33a88b25fd63d9e41e95e2eafc Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 10 Oct 2018 15:20:08 +0300 Subject: [PATCH 194/307] Added support of attribute update notifications --- .../device/DeviceActorMessageProcessor.java | 113 +++++++++++------- .../server/actors/device/SessionInfo.java | 1 - .../common/transport/SessionMsgListener.java | 11 +- .../common/transport/TransportService.java | 6 + .../transport/adaptor/JsonConverter.java | 37 +++--- .../transport/src/main/proto/transport.proto | 16 +++ .../transport/mqtt/MqttTransportHandler.java | 91 +++++++------- .../mqtt/adaptors/JsonMqttAdaptor.java | 15 ++- .../mqtt/adaptors/MqttTransportAdaptor.java | 4 + .../mqtt/session/DeviceSessionCtx.java | 9 -- .../mqtt/service/MqttTransportService.java | 29 ++++- 11 files changed, 204 insertions(+), 128 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 0822766b4b..7abcca2cab 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -216,12 +216,16 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso void process(ActorContext context, TransportToDeviceActorMsgWrapper wrapper) { TransportToDeviceActorMsg msg = wrapper.getMsg(); -// processSubscriptionCommands(context, msg); // processRpcResponses(context, msg); if (msg.hasSessionEvent()) { processSessionStateMsgs(msg.getSessionInfo(), msg.getSessionEvent()); } - + if (msg.hasSubscribeToAttributes()) { + processSubscriptionCommands(context, msg.getSessionInfo(), msg.getSubscribeToAttributes()); + } + if (msg.hasSubscribeToRPC()) { + processSubscriptionCommands(context, msg.getSessionInfo(), msg.getSubscribeToRPC()); + } if (msg.hasPostAttributes()) { handlePostAttributesRequest(context, msg.getSessionInfo(), msg.getPostAttributes()); reportActivity(); @@ -236,9 +240,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso // SessionMsgType sessionMsgType = msg.getPayload().getMsgType(); // if (sessionMsgType.requiresRulesProcessing()) { // switch (sessionMsgType) { -// case GET_ATTRIBUTES_REQUEST: -// handleGetAttributesRequest(msg); -// break; // case TO_SERVER_RPC_REQUEST: // handleClientSideRPCRequest(context, msg); // reportActivity(); @@ -262,7 +263,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso private void handleGetAttributesRequest(ActorContext context, SessionInfoProto sessionInfo, GetAttributeRequestMsg request) { ListenableFuture> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, toOptionalSet(request.getClientAttributeNamesList())); ListenableFuture> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, toOptionalSet(request.getSharedAttributeNamesList())); - UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); int requestId = request.getRequestId(); Futures.addCallback(Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)), new FutureCallback>>() { @Override @@ -272,7 +272,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso .addAllClientAttributeList(toTsKvProtos(result.get(0))) .addAllSharedAttributeList(toTsKvProtos(result.get(1))) .build(); - sendToTransport(responseMsg, sessionId, sessionInfo); + sendToTransport(responseMsg, sessionInfo); } @Override @@ -280,7 +280,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() .setError(t.getMessage()) .build(); - sendToTransport(responseMsg, sessionId, sessionInfo); + sendToTransport(responseMsg, sessionInfo); } }); } @@ -353,28 +353,37 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso void processAttributesUpdate(ActorContext context, DeviceAttributesEventNotificationMsg msg) { if (attributeSubscriptions.size() > 0) { - ToDeviceMsg notification = null; + boolean hasNotificationData = false; + AttributeUpdateNotificationMsg.Builder notification = AttributeUpdateNotificationMsg.newBuilder(); if (msg.isDeleted()) { - List sharedKeys = msg.getDeletedKeys().stream() + List sharedKeys = msg.getDeletedKeys().stream() .filter(key -> DataConstants.SHARED_SCOPE.equals(key.getScope())) + .map(AttributeKey::getAttributeKey) .collect(Collectors.toList()); - notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromDeleted(sharedKeys)); + if (!sharedKeys.isEmpty()) { + notification.addAllSharedDeleted(sharedKeys); + hasNotificationData = true; + } } else { if (DataConstants.SHARED_SCOPE.equals(msg.getScope())) { List attributes = new ArrayList<>(msg.getValues()); if (attributes.size() > 0) { - notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromShared(attributes)); + List sharedUpdated = msg.getValues().stream().map(this::toTsKvProto) + .collect(Collectors.toList()); + if (!sharedUpdated.isEmpty()) { + notification.addAllSharedUpdated(sharedUpdated); + hasNotificationData = true; + } } else { logger.debug("[{}] No public server side attributes changed!", deviceId); } } } - if (notification != null) { - ToDeviceMsg finalNotification = notification; -// attributeSubscriptions.entrySet().forEach(sub -> { -// ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(finalNotification, sub.getKey()); -// sendMsgToSessionActor(response, sub.getValue().getServer()); -// }); + if (hasNotificationData) { + AttributeUpdateNotificationMsg finalNotification = notification.build(); + attributeSubscriptions.entrySet().forEach(sub -> { + sendToTransport(finalNotification, sub.getKey(), sub.getValue()); + }); } } else { logger.debug("[{}] No registered attributes subscriptions to process!", deviceId); @@ -414,25 +423,35 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso // } } -// private void processSubscriptionCommands(ActorContext context, DeviceToDeviceActorMsg msg) { -// SessionId sessionId = msg.getSessionId(); -// SessionType sessionType = msg.getSessionType(); -// FromDeviceMsg inMsg = msg.getPayload(); -// if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST) { -// logger.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); -// attributeSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); -// } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST) { -// logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); -// attributeSubscriptions.remove(sessionId); -// } else if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST) { -// logger.debug("[{}] Registering rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); -// rpcSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); -// sendPendingRequests(context, sessionId, sessionType, msg.getServerAddress()); -// } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST) { -// logger.debug("[{}] Canceling rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); -// rpcSubscriptions.remove(sessionId); -// } -// } + private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg subscribeCmd) { + UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + if (subscribeCmd.getUnsubscribe()) { + logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); + attributeSubscriptions.remove(sessionId); + } else { + SessionInfo session = sessions.get(sessionId); + if (session == null) { + session = new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId()); + } + logger.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); + attributeSubscriptions.put(sessionId, session); + } + } + + private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToRPCMsg subscribeCmd) { + UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + if (subscribeCmd.getUnsubscribe()) { + logger.debug("[{}] Canceling rpc subscription for session [{}]", deviceId, sessionId); + rpcSubscriptions.remove(sessionId); + } else { + SessionInfo session = sessions.get(sessionId); + if (session == null) { + session = new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId()); + } + logger.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId); + rpcSubscriptions.put(sessionId, session); + } + } private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); @@ -521,11 +540,19 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void sendToTransport(GetAttributeResponseMsg responseMsg, UUID sessionId, SessionInfoProto sessionInfo) { + private void sendToTransport(GetAttributeResponseMsg responseMsg, SessionInfoProto sessionInfo) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionInfo.getSessionIdMSB()) + .setSessionIdLSB(sessionInfo.getSessionIdLSB()) + .setGetAttributesResponse(responseMsg).build(); + systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); + } + + private void sendToTransport(AttributeUpdateNotificationMsg notificationMsg, UUID sessionId, SessionInfo sessionInfo) { DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() .setSessionIdMSB(sessionId.getMostSignificantBits()) .setSessionIdLSB(sessionId.getLeastSignificantBits()) - .setGetAttributesResponse(responseMsg).build(); + .setAttributeUpdateNotification(notificationMsg).build(); systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java index 32cb60da5f..43ae592cee 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java @@ -25,5 +25,4 @@ import org.thingsboard.server.gen.transport.TransportProtos.SessionType; public class SessionInfo { private final SessionType type; private final String nodeId; - } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index 44fcd2a39d..17d4a1f42c 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; /** @@ -23,4 +24,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponse public interface SessionMsgListener { void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); + + void onAttributeUpdate(AttributeUpdateNotificationMsg attributeUpdateNotification); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index c3817a9ffa..bf33b1d8a5 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.transport; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; @@ -43,6 +45,10 @@ public interface TransportService { void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback); + void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener); void deregisterSession(SessionInfoProto sessionInfo); diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index 6bb8eff1be..872dfaa03e 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -39,6 +39,7 @@ import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; import org.thingsboard.server.common.msg.core.ToServerRpcRequestMsg; import org.thingsboard.server.common.msg.core.ToServerRpcResponseMsg; import org.thingsboard.server.common.msg.kv.AttributesKVMsg; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.*; import java.util.ArrayList; @@ -153,20 +154,6 @@ public class JsonConverter { return result; } - private static void parseNumericProto(List result, Entry valueEntry, JsonPrimitive value) { - if (value.getAsString().contains(".")) { - result.add(new DoubleDataEntry(valueEntry.getKey(), value.getAsDouble())); - } else { - try { - long longValue = Long.parseLong(value.getAsString()); - result.add(new LongDataEntry(valueEntry.getKey(), longValue)); - } catch (NumberFormatException e) { - throw new JsonSyntaxException("Big integer values are not supported!"); - } - } - } - - private static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, long systemTs, int requestId) throws JsonSyntaxException { BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); if (jsonObject.isJsonObject()) { @@ -283,6 +270,19 @@ public class JsonConverter { return result; } + public static JsonElement toJson(AttributeUpdateNotificationMsg payload) { + JsonObject result = new JsonObject(); + if (payload.getSharedUpdatedCount() > 0) { + payload.getSharedUpdatedList().forEach(addToObjectFromProto(result)); + } + if (payload.getSharedDeletedCount() > 0) { + JsonArray attrObject = new JsonArray(); + payload.getSharedDeletedList().forEach(attrObject::add); + result.add("deleted", attrObject); + } + return result; + } + public static JsonObject toJson(AttributesKVMsg payload, boolean asMap) { JsonObject result = new JsonObject(); if (asMap) { @@ -377,4 +377,5 @@ public class JsonConverter { error.addProperty("error", errorMsg); return error; } + } diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index d32bac99a7..66c0d8183a 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -108,6 +108,11 @@ message GetAttributeResponseMsg { string error = 5; } +message AttributeUpdateNotificationMsg { + repeated TsKvProto sharedUpdated = 1; + repeated string sharedDeleted = 2; +} + message ValidateDeviceTokenRequestMsg { string token = 1; } @@ -124,12 +129,22 @@ message SessionCloseNotificationProto { string message = 1; } +message SubscribeToAttributeUpdatesMsg { + bool unsubscribe = 1; +} + +message SubscribeToRPCMsg { + bool unsubscribe = 1; +} + message TransportToDeviceActorMsg { SessionInfoProto sessionInfo = 1; SessionEventMsg sessionEvent = 2; PostTelemetryMsg postTelemetry = 3; PostAttributeMsg postAttributes = 4; GetAttributeRequestMsg getAttributes = 5; + SubscribeToAttributeUpdatesMsg subscribeToAttributes = 6; + SubscribeToRPCMsg subscribeToRPC= 7; } message DeviceActorToTransportMsg { @@ -137,6 +152,7 @@ message DeviceActorToTransportMsg { int64 sessionIdLSB = 2; SessionCloseNotificationProto sessionCloseNotification = 3; GetAttributeResponseMsg getAttributesResponse = 4; + AttributeUpdateNotificationMsg attributeUpdateNotification = 5; } /** diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index c64ab5d559..b1ee037dc3 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -71,6 +71,7 @@ import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_ACCEP import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD; import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED; import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.PINGRESP; import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK; import static io.netty.handler.codec.mqtt.MqttMessageType.SUBACK; import static io.netty.handler.codec.mqtt.MqttMessageType.UNSUBACK; @@ -148,16 +149,17 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement case UNSUBSCRIBE: processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); break; -// case PINGREQ: -// if (checkConnected(ctx)) { -// ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); -// } -// break; -// case DISCONNECT: -// if (checkConnected(ctx)) { -// processDisconnect(ctx); -// } -// break; + case PINGREQ: + //TODO: should we push the notification to the rule engine? + if (checkConnected(ctx)) { + ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); + } + break; + case DISCONNECT: + if (checkConnected(ctx)) { + processDisconnect(ctx); + } + break; default: break; } @@ -289,25 +291,20 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement MqttQoS reqQoS = subscription.qualityOfService(); try { switch (topic) { -// case MqttTopics.DEVICE_ATTRIBUTES_TOPIC: { -// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); -// registerSubQoS(topic, grantedQoSList, reqQoS); -// break; -// } -// case DEVICE_RPC_REQUESTS_SUB_TOPIC: { -// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); -// registerSubQoS(topic, grantedQoSList, reqQoS); -// break; -// } -// case DEVICE_RPC_RESPONSE_SUB_TOPIC: -// case GATEWAY_ATTRIBUTES_TOPIC: -// case GATEWAY_RPC_TOPIC: -// registerSubQoS(topic, grantedQoSList, reqQoS); -// break; + case MqttTopics.DEVICE_ATTRIBUTES_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.newBuilder().build(), null); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + } + case MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToRPCMsg.newBuilder().build(), null); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + } + case MqttTopics.DEVICE_RPC_RESPONSE_SUB_TOPIC: + case MqttTopics.GATEWAY_ATTRIBUTES_TOPIC: + case MqttTopics.GATEWAY_RPC_TOPIC: case MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC: - deviceSessionCtx.setAllowAttributeResponses(); registerSubQoS(topic, grantedQoSList, reqQoS); break; default: @@ -337,19 +334,14 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement mqttQoSMap.remove(topicName); try { switch (topicName) { -// case DEVICE_ATTRIBUTES_TOPIC: { -// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); -// break; -// } -// case DEVICE_RPC_REQUESTS_SUB_TOPIC: { -// AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); -// break; -// } - case MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC: - deviceSessionCtx.setDisallowAttributeResponses(); + case MqttTopics.DEVICE_ATTRIBUTES_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.newBuilder().setUnsubscribe(true).build(), null); + break; + } + case MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToRPCMsg.newBuilder().setUnsubscribe(true).build(), null); break; + } } } catch (Exception e) { log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); @@ -551,7 +543,16 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement try { adaptor.convertToPublish(deviceSessionCtx, response).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); } catch (Exception e) { - log.trace("[{}] Failed to convert device attributes to MQTT msg", sessionId, e); + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } + } + + @Override + public void onAttributeUpdate(TransportProtos.AttributeUpdateNotificationMsg notification) { + try { + adaptor.convertToPublish(deviceSessionCtx, notification).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes update to MQTT msg", sessionId, e); } } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index ee41d2c3d6..d4ab033cee 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -113,6 +113,13 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } } + @Override + public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, + MqttTopics.DEVICE_ATTRIBUTES_TOPIC, + JsonConverter.toJson(notificationMsg))); + } + @Override public AdaptorToSessionActorMsg convertToActorMsg(DeviceSessionCtx ctx, SessionMsgType type, MqttMessage inbound) throws AdaptorException { FromDeviceMsg msg; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index 54e7c5ca3c..602fde12f6 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -36,4 +36,8 @@ public interface MqttTransportAdaptor extends TransportAdaptor convertToPublish(DeviceSessionCtx ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException; + + Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + + } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index d704634ded..6bbb9fa6fd 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -44,7 +44,6 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { private final MqttSessionId sessionId; @Getter private ChannelHandlerContext channel; - private volatile boolean allowAttributeResponses; private AtomicInteger msgIdSeq = new AtomicInteger(0); public DeviceSessionCtx(ConcurrentMap mqttQoSMap) { @@ -103,14 +102,6 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { this.channel = channel; } - public void setAllowAttributeResponses() { - allowAttributeResponses = true; - } - - public void setDisallowAttributeResponses() { - allowAttributeResponses = false; - } - public int nextMsgId() { return msgIdSeq.incrementAndGet(); } diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index 7a2f698647..648b01c9c3 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -161,6 +161,9 @@ public class MqttTransportService implements TransportService { if (toSessionMsg.hasGetAttributesResponse()) { listener.onGetAttributesResponse(toSessionMsg.getGetAttributesResponse()); } + if (toSessionMsg.hasAttributeUpdateNotification()) { + listener.onAttributeUpdate(toSessionMsg.getAttributeUpdateNotification()); + } }); } else { //TODO: should we notify the device actor about missed session? @@ -251,6 +254,24 @@ public class MqttTransportService implements TransportService { send(sessionInfo, toRuleEngineMsg, callback); } + @Override + public void process(SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToRPC(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + @Override public void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener) { sessions.putIfAbsent(toId(sessionInfo), listener); From 4259ac8b51327458952fa96f871a20bd9478bf96 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 10 Oct 2018 17:08:50 +0300 Subject: [PATCH 195/307] Remove unused cql statements --- .../main/resources/cassandra/system-data.cql | 240 +----------------- 1 file changed, 1 insertion(+), 239 deletions(-) diff --git a/dao/src/main/resources/cassandra/system-data.cql b/dao/src/main/resources/cassandra/system-data.cql index 16a53cad5c..3a0a9fd8e8 100644 --- a/dao/src/main/resources/cassandra/system-data.cql +++ b/dao/src/main/resources/cassandra/system-data.cql @@ -41,242 +41,4 @@ VALUES ( now ( ), 'mail', '{ "enableTls": "false", "username": "", "password": "" -}' ); - -/** System widgets library **/ -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', 'gpio widgets', 'GPIO widgets' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'maps', 'Maps' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'digital gauges', 'Digital gauges' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'charts', 'Charts' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'cards', 'Cards' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', 'analogue gauges', 'Analogue gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries', -'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.4.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var lineData = {\n labels: [],\n datasets: []\n };\n \n for (var i=0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n var keySettings = dataKey.settings;\n var backgroundColor = tinycolor(dataKey.color);\n backgroundColor.setAlpha(0.4);\n var dataset = {\n label: dataKey.label,\n data: [],\n borderColor: dataKey.color,\n borderWidth: 2,\n backgroundColor: backgroundColor.toRgbString(),\n pointRadius: keySettings.showPoints ? 1 : 0,\n fill: keySettings.fillLines || false,\n showLine: keySettings.showLines || true,\n spanGaps: false,\n lineTension: angular.isDefined(keySettings.tension) ? keySettings.tension : 0.2\n }\n lineData.datasets.push(dataset);\n }\n\n var ctx = $(''#lineChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''line'',\n data: lineData,\n options: {\n responsive: false,\n tooltips: {\n mode: ''index'',\n inersect: true\n },\n hover: {\n mode: ''index'',\n inersect: true\n },\n maintainAspectRatio: false,\n /*animation: {\n duration: 200,\n easing: ''linear''\n },*/\n elements: {\n line: {\n tension: 0.2\n } \n },\n scales: {\n xAxes: [{\n type: ''time'',\n ticks: {\n maxRotation: 20,\n autoSkip: true\n },\n time: {\n displayFormats: {\n second: ''hh:mm:ss'',\n minute: ''hh:mm:ss''\n }\n }\n }]\n },\n zoom: {\n onSelect: function(startTimeMs, endTimeMs) {\n self.ctx.timewindowFunctions.onUpdateTimewindow(startTimeMs, endTimeMs);\n },\n onResetSelect: function() {\n self.ctx.timewindowFunctions.onResetTimewindow();\n }\n }\n }\n });\n \n self.onResize();\n \n}\n\nself.onDataUpdated = function() {\n \n if (self.ctx.chart.zoom.isMouseInteraction) {\n return;\n }\n if (!self.ctx.tickUpdate) {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataSetData = [];\n var dataKeyData = self.ctx.data[i].data;\n for (var i2 = 0; i2 < dataKeyData.length; i2 ++) {\n dataSetData.push({x: moment(dataKeyData[i2][0]), y: dataKeyData[i2][1]});\n \n }\n self.ctx.chart.data.datasets[i].data = dataSetData; \n }\n }\n \n self.ctx.chart.options.scales.xAxes[0].time.min = moment(self.ctx.timeWindow.minTime);\n self.ctx.chart.options.scales.xAxes[0].time.max = moment(self.ctx.timeWindow.maxTime);\n \n self.ctx.chart.update(0, true);\n\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n self.ctx.chart.update(0, true);\n}\n\nself.onDestroy = function() {\n}\n\nfunction getYAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (!scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction getXAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction eventPointer (e) {\n if (angular.isDefined(e.touches) && e.touches.length > 0) {\n return {\n x : e.touches[0].pageX,\n y : e.touches[0].pageY\n };\n } else if (angular.isDefined(e.changedTouches) && e.changedTouches.length > 0) {\n return {\n x : e.changedTouches[0].pageX,\n y : e.changedTouches[0].pageY\n };\n } else if (e.pageX || e.pageY) {\n return {\n x : e.pageX,\n y : e.pageY\n };\n } else if (e.clientX || e.clientY) {\n var\n d = document,\n b = d.body,\n de = d.documentElement;\n return {\n x: e.clientX + b.scrollLeft + de.scrollLeft,\n y: e.clientY + b.scrollTop + de.scrollTop\n };\n }\n}\n\nvar zoomPlugin = {\n beforeInit: function(chartInstance) {\n chartInstance.zoom = {};\n var node = chartInstance.zoom.node = chartInstance.chart.ctx.canvas;\n \n chartInstance.zoom.mouseDownHandler = function(event) {\n chartInstance.zoom.dragZoomStart = event;\n chartInstance.zoom.dragZoomStartPointer = eventPointer(event);\n chartInstance.zoom.isMouseInteraction = true;\n };\n\n node.addEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n \n chartInstance.zoom.mouseMoveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n chartInstance.update(0);\n chartInstance.zoom.dragZoomEnd = event;\n chartInstance.zoom.dragZoomEndPointer = eventPointer(event);\n }\n };\n \n node.addEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n \n chartInstance.zoom.mouseUpHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \n var chartArea = chartInstance.chartArea;\n var yAxis = getYAxis(chartInstance);\n\t\t\t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n\t\t\t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n\t\t\t\t\tvar upEventPointer = eventPointer(event);\n\t\t\t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n\t\t\t\t\tvar startX = Math.min(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar endX = Math.max(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar dragDistance = endX - startX;\n\t\t\t\t\t\n\t\t\t\t\tif (dragDistance > 0) {\n \t\t\t\t\tvar xAxis = getXAxis(chartInstance);\n \t\t\t\t\tvar options = chartInstance.options;\n \t\t\t\t\tif (options.scales.xAxes[0].time) {\n \t\t\t\t\t startX = Math.max(startX, xAxis.left);\n \t\t\t\t\t endX = Math.min(endX, xAxis.right);\n \t\t\t\t\t if (endX - startX > 0) {\n \t\t\t\t\t startX = startX - xAxis.left;\n \t\t\t\t\t endX = endX - xAxis.left;\n \t\t\t\t\t var time = options.scales.xAxes[0].time;\n \t\t\t\t\t var min = time.min.valueOf();\n \t\t\t\t\t var max = time.max.valueOf();\n \t\t\t\t\t var axisDistance = xAxis.right - xAxis.left;\n \t\t\t\t\t var timeDistance = max - min;\n \t\t\t\t\t \n \t\t\t\t\t var zoomStartTime = min + startX / axisDistance * timeDistance;\n \t\t\t\t\t var zoomEndTime = min + endX / axisDistance * timeDistance;\n\n \t\t\t\t\t if (options.zoom && options.zoom.onSelect) {\n \t\t\t\t\t options.zoom.onSelect(zoomStartTime, zoomEndTime);\n \t\t\t\t\t }\n \t\t\t\t\t }\n \t\t\t\t\t}\n\t\t\t\t\t}\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n \n chartInstance.zoom.mouseLeaveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\n \n chartInstance.zoom.dblClickHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n var options = chartInstance.options;\n if (options.zoom && options.zoom.onResetSelect) {\n options.zoom.onResetSelect();\n }\n };\n \n node.addEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n },\n beforeDatasetsDraw: function(chartInstance, easing) {\n \t\tvar ctx = chartInstance.chart.ctx;\n \t\tvar chartArea = chartInstance.chartArea;\n \t\tctx.save();\n \t\tctx.beginPath();\n \t\tif (chartInstance.zoom && chartInstance.zoom.dragZoomEnd) {\n \t\t\tvar yAxis = getYAxis(chartInstance);\n \t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n \t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n \t\t\tvar endPoint = chartInstance.zoom.dragZoomEnd;\n \t\t\tvar endPointer = chartInstance.zoom.dragZoomEndPointer;\n \t\t\t\n \t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n \t\t\tvar startX = Math.min(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar endX = Math.max(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar rectWidth = endX - startX;\n \t\t\tctx.fillStyle = ''rgba(157,203,255,0.1)'';\n \t\t\tctx.lineWidth = 1;\n \t\t\tctx.strokeRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t\tctx.fillRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t}\n \t\tif (chartArea) {\n \t\t ctx.rect(chartArea.left, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);\n \t\t}\n\t\t ctx.clip(); \n },\n \n afterDatasetsDraw: function(chartInstance) {\n\t chartInstance.chart.ctx.restore();\n },\n \n destroy: function(chartInstance) {\n if (chartInstance.zoom) {\n var node = chartInstance.zoom.node;\n\t\t\t\tnode.removeEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n\t\t\t\tnode.removeEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n\t\t\t\tnode.removeEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n\t\t\t\tnode.removeEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\t \n\t\t\t\tnode.removeEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n\t\t\t\tdelete chartInstance.zoom;\n }\n }\n };\n\nChart.pluginService.register(zoomPlugin);\n","settingsSchema":"{}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"showLines\": {\n \"title\": \"Show lines\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"fillLines\": {\n \"title\": \"Fill lines\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"showPoints\": {\n \"title\": \"Show points\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"tension\": {\n \"title\": \"Line tension\",\n \"type\": \"number\",\n \"default\": 0.2\n }\n },\n \"required\": [\"showLines\", \"fillLines\", \"showPoints\"]\n },\n \"form\": [\n \"showLines\",\n \"fillLines\",\n \"showPoints\",\n \"tension\"\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.5644745944820795,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.18379294198604845,\"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\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Timeseries - Chart.js (Deprecated)\"}"}', -'Timeseries - Chart.js (Deprecated)' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'digital_vertical_bar', -'{"type":"latest","sizeX":2.5,"sizeY":4.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":60,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#3d5afe\",\"#f44336\"],\"refreshAnimationType\":\"<>\",\"refreshAnimationTime\":700,\"startAnimationType\":\"<>\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":14},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":8,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#cccccc\"},\"neonGlowBrightness\":20,\"decimals\":0,\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"verticalBar\",\"showTitle\":false,\"units\":\"°C\",\"minValue\":-60,\"dashThickness\":1.2},\"title\":\"Digital vertical bar\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital vertical bar' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', 'gpio_panel', -'{"type":"latest","sizeX":5,"sizeY":2,"resources":[],"templateHtml":"

\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel tb-led-light > div {\n margin: auto;\n}\n\n.led-panel {\n margin: 0;\n width: 66px;\n min-width: 66px;\n}\n\n.led-container {\n width: 48px;\n min-width: 48px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.led-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.led-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n var i, gpio;\n \n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n \n scope.gpioList = [];\n scope.gpioByPin = {};\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false,\n colorOn: tinycolor(gpio.color).lighten(20).toHexString(),\n colorOff: tinycolor(gpio.color).darken().toHexString()\n }\n );\n scope.gpioByPin[gpio.pin] = scope.gpioList[scope.gpioList.length-1];\n }\n\n scope.ledPanelBackgroundColor = settings.ledPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var changed = false;\n for (var d = 0; d < self.ctx.data.length; d++) {\n var cellData = self.ctx.data[d];\n var dataKey = cellData.dataKey;\n var gpio = self.ctx.$scope.gpioByPin[dataKey.label];\n if (gpio) {\n var enabled = false;\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n enabled = (tvPair[1] === true || tvPair[1] === ''true'');\n }\n if (gpio.enabled != enabled) {\n changed = true;\n gpio.enabled = enabled;\n }\n }\n }\n if (changed) {\n self.ctx.$scope.$digest();\n } \n}\n\nself.onResize = function() {\n var rowCount = self.ctx.$scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n self.ctx.$scope.prefferedRowHeight = prefferedRowHeight;\n \n var ratio = prefferedRowHeight/32;\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio leds\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio led\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\",\n \"default\": \"red\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\", \"color\"]\n }\n },\n \"ledPanelBackgroundColor\": {\n \"title\": \"LED panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n } \n },\n \"required\": [\"gpioList\", \n \"ledPanelBackgroundColor\"]\n },\n \"form\": [\n {\n \"key\": \"gpioList\",\n \"items\": [\n \"gpioList[].pin\",\n \"gpioList[].label\",\n \"gpioList[].row\",\n \"gpioList[].col\",\n {\n \"key\": \"gpioList[].color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"ledPanelBackgroundColor\",\n \"type\": \"color\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"gpioList\":[{\"pin\":1,\"label\":\"GPIO 1\",\"row\":0,\"col\":0,\"color\":\"#008000\",\"_uniqueKey\":0},{\"pin\":2,\"label\":\"GPIO 2\",\"row\":0,\"col\":1,\"color\":\"#ffff00\",\"_uniqueKey\":1},{\"pin\":3,\"label\":\"GPIO 3\",\"row\":1,\"col\":0,\"color\":\"#cf006f\",\"_uniqueKey\":2}],\"ledPanelBackgroundColor\":\"#b71c1c\"},\"title\":\"Basic GPIO Panel\",\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"1\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.22518255793320163,\"funcBody\":\"var period = time % 1500;\\nreturn period < 500;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"2\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.7008206860666621,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 500 && period < 1000;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"3\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.42600325102193426,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 1000;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}}}"}', -'Basic GPIO Panel' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'timeseries_table', -'{"type":"timeseries","sizeX":8,"sizeY":6.5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get(''utils'').guid();\n\n scope.config = {\n settings: self.ctx.settings,\n widgetConfig: self.ctx.widgetConfig\n }\n\n scope.datasources = self.ctx.datasources;\n scope.data = self.ctx.data;\n scope.tableId = \"table-\"+id;\n \n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.data = self.ctx.data;\n self.ctx.$scope.$broadcast(''timeseries-table-data-updated'', self.ctx.$scope.tableId);\n}\n\nself.onDestroy = function() {\n}","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\"\n ]\n}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, filter)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix(''blue'', ''red'', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: ''20px'',\\n color: ''#ffffff'',\\n background: color.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor(''blue'');\\n backgroundColor.setAlpha(value/100);\\n var color = ''blue'';\\n if (value > 50) {\\n color = ''white'';\\n }\\n \\n return {\\n paddingLeft: ''20px'',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":false,\"showLegend\":false}"}', -'Timeseries table' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'polar_area_chart_js', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''polarArea'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n try {\n self.ctx.chart.resize();\n } catch (e) {}\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fifth\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.2074391823443591,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Polar Area - Chart.js\"}"}', -'Polar Area - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'html_card', -'{"type":"static","sizeX":7.5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"","controllerScript":"self.onInit = function() {\n\n var cssParser = new cssjs();\n cssParser.testMode = false;\n var namespace = ''html-card-'' + hashCode(self.ctx.settings.cardCss);\n cssParser.cssPreviewNamespace = namespace;\n cssParser.createStyleElement(namespace, self.ctx.settings.cardCss);\n self.ctx.$container.addClass(namespace);\n cardHtml = self.ctx.settings.cardHtml;\n self.ctx.$container.html(cardHtml);\n \n function hashCode(str) {\n var hash = 0;\n var i, char;\n if (str.length === 0) return hash;\n for (i = 0; i < str.length; i++) {\n char = str.charCodeAt(i);\n hash = ((hash << 5) - hash) + char;\n hash = hash & hash;\n }\n return hash;\n }\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"required\": [\"cardHtml\"],\n \"properties\": {\n \"cardCss\": {\n \"title\": \"CSS\",\n \"type\": \"string\",\n \"default\": \".card {\\n font-weight: bold; \\n}\"\n },\n \"cardHtml\": {\n \"title\": \"HTML\",\n \"type\": \"string\",\n \"default\": \"
HTML code here
\"\n }\n }\n },\n \"form\": [\n {\n \"key\": \"cardCss\",\n \"type\": \"css\"\n }, \n {\n \"key\": \"cardHtml\",\n \"type\": \"html\"\n } \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\":{\"cardHtml\":\"
HTML code here
\",\"cardCss\":\".card {\\n font-weight: bold;\\n font-size: 32px;\\n color: #999;\\n width: 100%;\\n height: 100%;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n}\"},\"title\":\"HTML Card\",\"dropShadow\":true}"}', -'HTML Card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'simple_neon_gauge_justgage', -'{"type":"latest","sizeX":3,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#388e3c\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":1,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":40,\"dashThickness\":1.5,\"decimals\":0,\"gaugeType\":\"donut\"},\"title\":\"Simple neon gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Simple neon gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'radial_gauge_canvas_gauges', -'{"type":"latest","sizeX":6,"sizeY":5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, ''radialGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueRadialGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 50 - 25;\\nif (value < -100) {\\n\\tvalue = -100;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":100,\"startAngle\":45,\"ticksAngle\":270,\"showBorder\":true,\"defaultColor\":\"#e65100\",\"needleCircleSize\":10,\"highlights\":[],\"showUnitTitle\":true,\"colorPlate\":\"#fff\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"minorTicks\":10,\"valueInt\":3,\"valueDec\":0,\"highlightsWidth\":15,\"valueBox\":true,\"animation\":true,\"animationDuration\":500,\"animationRule\":\"cycle\",\"colorNeedleShadowUp\":\"rgba(2, 255, 255, 0)\",\"numbersFont\":{\"family\":\"Roboto\",\"size\":18,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#888\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":22,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"size\":36,\"style\":\"normal\",\"weight\":\"normal\",\"shadowColor\":\"rgba(0, 0, 0, 0.49)\",\"color\":\"#444\"},\"minValue\":-100,\"colorNeedleShadowDown\":\"rgba(188,143,143,0.45)\",\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\"},\"title\":\"Radial gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Radial gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'digital_speedometer', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 45) {\\n\\tvalue = 45;\\n} else if (value > 130) {\\n\\tvalue = 130;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#008000\",\"#fbc02d\",\"#f44336\"],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#ffffff\"},\"neonGlowBrightness\":40,\"dashThickness\":1.5,\"decimals\":0,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"arc\"},\"title\":\"Digital speedometer\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital speedometer' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'doughnut_chart_js', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n var borderColor = self.ctx.settings.borderColor || ''#fff'';\n var borderWidth = angular.isDefined(self.ctx.settings.borderWidth) ? self.ctx.settings.borderWidth : 5;\n \n pieData.datasets.push(dataset);\n \n for (var i=0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(borderColor);\n dataset.borderWidth.push(borderWidth);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var options = {\n responsive: false,\n maintainAspectRatio: false,\n legend: {\n display: true,\n labels: {\n fontColor: ''#666''\n }\n },\n tooltips: {\n callbacks: {\n label: function(tooltipItem, data) {\n var label = data.labels[tooltipItem.index];\n var value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];\n var content = label + '': '' + value;\n var units = self.ctx.settings.units ? self.ctx.settings.units : self.ctx.units;\n if (units) {\n content += '' '' + units;\n } \n return content;\n }\n }\n }\n };\n\n if (self.ctx.settings.legend) {\n options.legend.display = self.ctx.settings.legend.display !== false;\n options.legend.labels.fontColor = self.ctx.settings.legend.labelsFontColor || ''#666'';\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''doughnut'',\n data: pieData,\n options: options\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"borderWidth\": {\n \"title\": \"Border width\",\n \"type\": \"number\",\n \"default\": 5\n },\n \"borderColor\": {\n \"title\": \"Border color\",\n \"type\": \"string\",\n \"default\": \"#fff\"\n },\n \"legend\": {\n \"title\": \"Legend settings\",\n \"type\": \"object\",\n \"properties\": {\n \"display\": {\n \"title\": \"Display legend\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"labelsFontColor\": {\n \"title\": \"Labels font color\",\n \"type\": \"string\",\n \"default\": \"#666\"\n }\n }\n }\n },\n \"required\": []\n },\n \"form\": [\n \"borderWidth\", \n {\n \"key\": \"borderColor\",\n \"type\": \"color\"\n }, \n {\n \"key\": \"legend\",\n \"items\": [\n \"legend.display\",\n {\n \"key\": \"legend.labelsFontColor\",\n \"type\": \"color\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#26a69a\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#f57c00\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#afb42b\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#673ab7\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"borderWidth\":5,\"borderColor\":\"#fff\",\"legend\":{\"display\":true,\"labelsFontColor\":\"#666666\"}},\"title\":\"Doughnut - Chart.js\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Doughnut - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie_chart_js', -'{"type":"latest","sizeX":8,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i=0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''pie'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n }); \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Pie - Chart.js\"}"}', -'Pie - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'bars', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n \n for (var i = 0; i < self.ctx.datasources.length; i++) {\n var datasource = self.ctx.datasources[i];\n for (var d = 0; d < datasource.dataKeys.length; d++) {\n var dataset = {\n label: datasource.dataKeys[d].label,\n data: [0],\n backgroundColor: [datasource.dataKeys[d].color],\n borderColor: [datasource.dataKeys[d].color],\n borderWidth: 1\n }\n barData.datasets.push(dataset);\n }\n }\n\n var ctx = $(''#barChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''bar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false,\n scales: {\n yAxes: [{\n ticks: {\n beginAtZero:true\n }\n }]\n }\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var c = 0;\n for (var i = 0; i < self.ctx.chart.data.datasets.length; i++) {\n var dataset = self.ctx.chart.data.datasets[i];\n var cellData = self.ctx.data[i]; \n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n dataset.data[0] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Bars - Chart.js\"}"}', -'Bars - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'digital_bar', -'{"type":"latest","sizeX":6,"sizeY":2.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 80) {\\n\\tvalue = 80;\\n} else if (value > 160) {\\n\\tvalue = 160;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#008000\",\"#fbc02d\",\"#f44336\"],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":18},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#ffffff\"},\"neonGlowBrightness\":40,\"dashThickness\":1.5,\"decimals\":0,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"horizontalBar\",\"showTitle\":false},\"title\":\"Digital horizontal bar\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital horizontal bar' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'mini_gauge_justgage', -'{"type":"latest","sizeX":2,"sizeY":2,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#7cb342\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":0,\"decimals\":0,\"roundedLineCap\":true,\"gaugeType\":\"donut\"},\"title\":\"Mini gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Mini gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map_openstreetmap', -'{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''openstreet-map'', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n ]\n },\n \"form\": [\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].useColorFunction\",\n {\n \"key\": \"routesSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"routesSettings[].markerImageSize\",\n \"routesSettings[].useMarkerImageFunction\",\n {\n \"key\": \"routesSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"routesSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8950926999078694,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.2757675428823283,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.14481354591724638,\"funcBody\":\"var value = prevValue;\\nif (time % 500 < 100) {\\n value = value + Math.random() * 40 - 20;\\n if (value < 45) {\\n \\tvalue = 45;\\n } else if (value > 130) {\\n \\tvalue = 130;\\n }\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"strokeWeight\":4,\"label\":\"First route\",\"color\":\"#3d5afe\",\"strokeOpacity\":1,\"useColorFunction\":true,\"useMarkerImageFunction\":true,\"markerImages\":[\"data:image/png;base64,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\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"colorFunction\":\"var speed = data[''Speed''];\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n if (percent < 0.5) {\\n percent *=2*100; \\n return tinycolor.mix(''green'', ''yellow'', amount = percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix(''yellow'', ''red'', amount = percent).toHexString();\\n }\\n}\\nreturn ''green'';\",\"markerImageFunction\":\"var speed = data[''Speed''];\\nvar res = {\\n url: images[0],\\n size: 55\\n};\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n var index = Math.floor(3 * percent);\\n res.url = images[index];\\n}\\nreturn res;\"}]},\"title\":\"Route Map - OpenStreetMap\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Route Map - OpenStreetMap' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'label_widget', -'{"type":"latest","sizeX":4.5,"sizeY":5,"resources":[],"templateHtml":"","templateCss":"#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}","controllerScript":"self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n \n var imageUrl = self.ctx.settings.backgroundImageUrl ? self.ctx.settings.backgroundImageUrl :\n ''data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg=='';\n\n self.ctx.$container.css(''background'', ''url(\"''+imageUrl+''\") no-repeat'');\n self.ctx.$container.css(''backgroundSize'', ''contain'');\n self.ctx.$container.css(''backgroundPosition'', ''50% 50%'');\n \n function processLabelPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split('':'');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith(''#'')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n var configuredLabels = self.ctx.settings.labels;\n if (!configuredLabels) {\n configuredLabels = [];\n }\n \n self.ctx.labels = [];\n\n for (var l = 0; l < configuredLabels.length; l++) {\n var labelConfig = configuredLabels[l];\n var localConfig = {};\n localConfig.font = {};\n \n localConfig.pattern = labelConfig.pattern ? labelConfig.pattern : ''${#0}'';\n localConfig.x = labelConfig.x ? labelConfig.x : 0;\n localConfig.y = labelConfig.y ? labelConfig.y : 0;\n localConfig.backgroundColor = labelConfig.backgroundColor ? labelConfig.backgroundColor : ''rgba(0,0,0,0)'';\n \n var settingsFont = labelConfig.font;\n if (!settingsFont) {\n settingsFont = {};\n }\n \n localConfig.font.family = settingsFont.family || ''RobotoDraft'';\n localConfig.font.size = settingsFont.size ? settingsFont.size : 6;\n localConfig.font.style = settingsFont.style ? settingsFont.style : ''normal'';\n localConfig.font.weight = settingsFont.weight ? settingsFont.weight : ''500'';\n localConfig.font.color = settingsFont.color ? settingsFont.color : ''#fff'';\n \n localConfig.replaceInfo = processLabelPattern(localConfig.pattern, self.ctx.data);\n \n var label = {};\n var labelElement = $(''
'');\n labelElement.css(''position'', ''absolute'');\n labelElement.css(''display'', ''none'');\n labelElement.css(''top'', ''0'');\n labelElement.css(''left'', ''0'');\n labelElement.css(''backgroundColor'', localConfig.backgroundColor);\n labelElement.css(''color'', localConfig.font.color);\n labelElement.css(''fontFamily'', localConfig.font.family);\n labelElement.css(''fontStyle'', localConfig.font.style);\n labelElement.css(''fontWeight'', localConfig.font.weight);\n \n labelElement.html(localConfig.pattern);\n self.ctx.$container.append(labelElement);\n label.element = labelElement;\n label.config = localConfig;\n label.htmlSet = false;\n label.visible = false;\n self.ctx.labels.push(label);\n }\n\n var bgImg = $('''');\n bgImg.hide();\n bgImg.bind(''load'', function()\n {\n self.ctx.bImageHeight = $(this).height();\n self.ctx.bImageWidth = $(this).width();\n self.onResize();\n });\n self.ctx.$container.append(bgImg);\n bgImg.attr(''src'', imageUrl);\n \n self.onDataUpdated();\n}\n\nself.onDataUpdated = function() {\n updateLabels();\n}\n\nself.onResize = function() {\n if (self.ctx.bImageHeight && self.ctx.bImageWidth) {\n var backgroundRect = {};\n var imageRatio = self.ctx.bImageWidth / self.ctx.bImageHeight;\n var componentRatio = self.ctx.width / self.ctx.height;\n if (componentRatio >= imageRatio) {\n backgroundRect.top = 0;\n backgroundRect.bottom = 1.0;\n backgroundRect.xRatio = imageRatio / componentRatio;\n backgroundRect.yRatio = 1;\n var offset = (1 - backgroundRect.xRatio) / 2;\n backgroundRect.left = offset;\n backgroundRect.right = 1 - offset;\n } else {\n backgroundRect.left = 0;\n backgroundRect.right = 1.0;\n backgroundRect.xRatio = 1;\n backgroundRect.yRatio = componentRatio / imageRatio;\n var offset = (1 - backgroundRect.yRatio) / 2;\n backgroundRect.top = offset;\n backgroundRect.bottom = 1 - offset;\n }\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var labelLeft = backgroundRect.left*100 + (label.config.x*backgroundRect.xRatio);\n var labelTop = backgroundRect.top*100 + (label.config.y*backgroundRect.yRatio);\n var fontSize = self.ctx.height * backgroundRect.yRatio * label.config.font.size / 100;\n label.element.css(''top'', labelTop + ''%'');\n label.element.css(''left'', labelLeft + ''%'');\n label.element.css(''fontSize'', fontSize + ''px'');\n if (!label.visible) {\n label.element.css(''display'', ''block'');\n label.visible = true;\n }\n }\n } \n}\n\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n\n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n\n strVal = (n ? ''-'' : '''') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateLabels() {\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var text = label.config.pattern;\n var replaceInfo = label.config.replaceInfo;\n var updated = false;\n for (var v = 0; v < replaceInfo.variables.length; v++) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '''';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n updated = true;\n } else {\n txtVal = val;\n updated = true;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n if (updated || !label.htmlSet) {\n label.element.html(text);\n if (!label.htmlSet) {\n label.htmlSet = true;\n }\n }\n }\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"required\": [\"backgroundImageUrl\"],\n \"properties\": {\n \"backgroundImageUrl\": {\n \"title\": \"Background image\",\n \"type\": \"string\",\n \"default\": \"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\"\n },\n \"labels\": {\n \"title\": \"Labels\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Label\",\n \"type\": \"object\",\n \"required\": [\"pattern\"],\n \"properties\": {\n \"pattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"${#0}\"\n },\n \"x\": {\n \"title\": \"X (Percentage relative to background)\",\n \"type\": \"number\",\n \"default\": 50\n },\n \"y\": {\n \"title\": \"Y (Percentage relative to background)\",\n \"type\": \"number\",\n \"default\": 50\n },\n \"backgroundColor\": {\n \"title\": \"Backround color\",\n \"type\": \"string\",\n \"default\": \"rgba(0,0,0,0)\"\n },\n \"font\": {\n \"type\": \"object\",\n \"properties\": {\n \"family\": {\n \"title\": \"Font family\",\n \"type\": \"string\",\n \"default\": \"RobotoDraft\"\n },\n \"size\": {\n \"title\": \"Relative font size (percents)\",\n \"type\": \"number\",\n \"default\": 6\n },\n \"style\": {\n \"title\": \"Style\",\n \"type\": \"string\",\n \"default\": \"normal\"\n },\n \"weight\": {\n \"title\": \"Weight\",\n \"type\": \"string\",\n \"default\": \"500\"\n },\n \"color\": {\n \"title\": \"color\",\n \"type\": \"string\",\n \"default\": \"#fff\"\n }\n }\n }\n }\n }\n }\n }\n },\n \"form\": [\n {\n \"key\": \"backgroundImageUrl\",\n \"type\": \"image\"\n },\n {\n \"key\": \"labels\",\n \"items\": [\n \"labels[].pattern\",\n \"labels[].x\",\n \"labels[].y\",\n {\n \"key\": \"labels[].backgroundColor\",\n \"type\": \"color\"\n },\n \"labels[].font.family\",\n \"labels[].font.size\",\n {\n \"key\": \"labels[].font.style\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"italic\",\n \"label\": \"Italic\"\n },\n {\n \"value\": \"oblique\",\n \"label\": \"Oblique\"\n }\n ]\n\n },\n {\n \"key\": \"labels[].font.weight\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"bold\",\n \"label\": \"Bold\"\n },\n {\n \"value\": \"bolder\",\n \"label\": \"Bolder\"\n },\n {\n \"value\": \"lighter\",\n \"label\": \"Lighter\"\n },\n {\n \"value\": \"100\",\n \"label\": \"100\"\n },\n {\n \"value\": \"200\",\n \"label\": \"200\"\n },\n {\n \"value\": \"300\",\n \"label\": \"300\"\n },\n {\n \"value\": \"400\",\n \"label\": \"400\"\n },\n {\n \"value\": \"500\",\n \"label\": \"500\"\n },\n {\n \"value\": \"600\",\n \"label\": \"600\"\n },\n {\n \"value\": \"700\",\n \"label\": \"800\"\n },\n {\n \"value\": \"800\",\n \"label\": \"800\"\n },\n {\n \"value\": \"900\",\n \"label\": \"900\"\n }\n ]\n },\n {\n \"key\": \"labels[].font.color\",\n \"type\": \"color\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"var\",\"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\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"backgroundImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"labels\":[{\"pattern\":\"Value: ${#0:2} units.\",\"x\":20,\"y\":47,\"font\":{\"color\":\"#515151\",\"family\":\"Roboto\",\"size\":6,\"style\":\"normal\",\"weight\":\"500\"}}]},\"title\":\"Label widget\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Label widget' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'simple_card', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n\n.tbDatasource-table {\n width: 100%;\n height: 100%;\n border-collapse: collapse;\n white-space: nowrap;\n font-weight: 100;\n text-align: right;\n}\n\n.tbDatasource-table td {\n padding: 12px;\n position: relative;\n box-sizing: border-box;\n}\n\n.tbDatasource-data-key {\n opacity: 0.7;\n font-weight: 400;\n font-size: 3.500rem;\n}\n\n.tbDatasource-value {\n font-size: 5.000rem;\n}","controllerScript":"self.onInit = function() {\n self.ctx.units = self.ctx.settings.units || self.ctx.units;\n self.ctx.valueDec = (typeof self.ctx.settings.valueDec !== ''undefined'' && self.ctx.settings.valueDec !== null)\n ? self.ctx.settings.valueDec : self.ctx.decimals;\n \n self.ctx.labelPosition = self.ctx.settings.labelPosition || ''left'';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = ''tbDatasource'' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $(''#'' + datasourceId,\n self.ctx.$container);\n \n var tableId = ''table'' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $(''#'' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === ''top'') {\n table.css(''text-align'', ''left'');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = ''labelCell'' + 0;\n var cellId = ''cell'' + 0;\n if (self.ctx.labelPosition === ''left'') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $(''#'' + labelCellId, table);\n self.ctx.valueCell = $(''#'' + cellId, table);\n self.ctx.valueCell.html(0 + '' '' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '''' + html_org + '''';\n $(this).html(html_calc);\n var width = $(this).find(''span:first'').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n \n function padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n \n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n \n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n \n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n \n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n \n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n \n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n \n strVal = (n ? ''-'' : '''') + strVal;\n }\n \n return strVal;\n }\n \n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n txtValue = padValue(value, self.ctx.alueDec, 0) + '' '' + self.ctx.units;\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === ''left'') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css(''font-size'', fontSize+''px'');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n}\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === ''top'') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css(''font-size'', labelFontSize+''px'');\n self.ctx.labelCell.css(''padding'', self.ctx.padding+''px'');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css(''font-size'', self.ctx.valueFontSize+''px'');\n self.ctx.valueCell.css(''padding'', self.ctx.padding+''px'');\n } \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"labelPosition\": {\n \"title\": \"Label position\",\n \"type\": \"string\",\n \"default\": \"left\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"labelPosition\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"left\",\n \"label\": \"Left\"\n },\n {\n \"value\": \"top\",\n \"label\": \"Top\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ff5722\",\"color\":\"rgba(255, 255, 255, 0.87)\",\"padding\":\"16px\",\"settings\":{\"labelPosition\":\"top\"},\"title\":\"Simple card\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"units\":\"°C\",\"decimals\":0,\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Simple card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'lcd_bar_gauge', -'{"type":"latest","sizeX":2,"sizeY":3.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#babab2\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"400\",\"size\":16},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":1.5,\"decimals\":0,\"showUnitTitle\":true,\"defaultColor\":\"#444444\",\"gaugeType\":\"verticalBar\",\"units\":\"%\"},\"title\":\"LCD bar gauge\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'LCD bar gauge' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'vertical_bar_justgage', -'{"type":"latest","sizeX":2,"sizeY":3.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#f57c00\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#999999\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":12,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#666666\"},\"neonGlowBrightness\":0,\"decimals\":0,\"dashThickness\":1.5,\"gaugeColor\":\"#eeeeee\",\"showTitle\":false,\"gaugeType\":\"verticalBar\"},\"title\":\"Vertical bar - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Vertical bar - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'html_value_card', -'{"type":"latest","sizeX":7.5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n self.ctx.htmlSet = false;\n \n var cssParser = new cssjs();\n cssParser.testMode = false;\n var namespace = ''html-value-card-'' + hashCode(self.ctx.settings.cardCss);\n cssParser.cssPreviewNamespace = namespace;\n cssParser.createStyleElement(namespace, self.ctx.settings.cardCss);\n self.ctx.$container.addClass(namespace);\n self.ctx.html = self.ctx.settings.cardHtml;\n self.ctx.replaceInfo = processHtmlPattern(self.ctx.html, self.ctx.data);\n \n updateHtml();\n \n function hashCode(str) {\n var hash = 0;\n var i, char;\n if (str.length === 0) return hash;\n for (i = 0; i < str.length; i++) {\n char = str.charCodeAt(i);\n hash = ((hash << 5) - hash) + char;\n hash = hash & hash;\n }\n return hash;\n }\n \n function processHtmlPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split('':'');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith(''#'')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n } \n}\n\nself.onDataUpdated = function() {\n updateHtml();\n}\n\nself.onDestroy = function() {\n}\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n\n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n\n strVal = (n ? ''-'' : '''') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateHtml() {\n var text = self.ctx.html;\n var updated = false;\n for (var v in self.ctx.replaceInfo.variables) {\n var variableInfo = self.ctx.replaceInfo.variables[v];\n var txtVal = '''';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n } else {\n txtVal = val;\n }\n }\n }\n if (typeof variableInfo.lastVal === undefined ||\n variableInfo.lastVal !== txtVal) {\n updated = true;\n variableInfo.lastVal = txtVal;\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n if (updated || !self.ctx.htmlSet) {\n self.ctx.$container.html(text);\n if (!self.ctx.htmlSet) {\n self.ctx.htmlSet = true;\n }\n }\n}\n\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"required\": [\"cardHtml\"],\n \"properties\": {\n \"cardCss\": {\n \"title\": \"CSS\",\n \"type\": \"string\",\n \"default\": \".card {\\n font-weight: bold; \\n}\"\n },\n \"cardHtml\": {\n \"title\": \"HTML\",\n \"type\": \"string\",\n \"default\": \"
HTML code here
\"\n }\n }\n },\n \"form\": [\n {\n \"key\": \"cardCss\",\n \"type\": \"css\"\n }, \n {\n \"key\": \"cardHtml\",\n \"type\": \"html\"\n } \n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"My value\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"return Math.random() * 5.45;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"cardCss\":\".card {\\n width: 100%;\\n height: 100%;\\n border: 2px solid #ccc;\\n box-sizing: border-box;\\n}\\n\\n.card .content {\\n padding: 20px;\\n display: flex;\\n flex-direction: row;\\n align-items: center;\\n justify-content: space-around;\\n height: 100%;\\n box-sizing: border-box;\\n}\\n\\n.card .content .column {\\n display: flex;\\n flex-direction: column; \\n justify-content: space-around;\\n height: 100%;\\n}\\n\\n.card h1 {\\n text-transform: uppercase;\\n color: #999;\\n font-size: 20px;\\n font-weight: bold;\\n margin: 0;\\n padding-bottom: 10px;\\n line-height: 32px;\\n}\\n\\n.card .value {\\n font-size: 38px;\\n font-weight: 200;\\n}\\n\\n.card .description {\\n font-size: 20px;\\n color: #999;\\n}\\n\",\"cardHtml\":\"
\\n
\\n
\\n

Value title

\\n
\\n ${My value:2} units.\\n
\\n
\\n Value description text\\n
\\n
\\n \\n
\\n
\"},\"title\":\"HTML Value Card\",\"dropShadow\":false}"}', -'HTML Value Card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', -'raspberry_pi_gpio_panel', -'{"type":"latest","sizeX":7,"sizeY":10.5,"resources":[],"templateHtml":"
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel tb-led-light > div {\n margin: auto;\n}\n\n.led-panel {\n margin: 0;\n width: 66px;\n min-width: 66px;\n}\n\n.led-container {\n width: 48px;\n min-width: 48px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.led-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.led-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n var i, gpio;\n \n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n \n scope.gpioList = [];\n scope.gpioByPin = {};\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false,\n colorOn: tinycolor(gpio.color).lighten(20).toHexString(),\n colorOff: tinycolor(gpio.color).darken().toHexString()\n }\n );\n scope.gpioByPin[gpio.pin] = scope.gpioList[scope.gpioList.length-1];\n }\n\n scope.ledPanelBackgroundColor = settings.ledPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var changed = false;\n for (var d = 0; d < self.ctx.data.length; d++) {\n var cellData = self.ctx.data[d];\n var dataKey = cellData.dataKey;\n var gpio = self.ctx.$scope.gpioByPin[dataKey.label];\n if (gpio) {\n var enabled = false;\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n enabled = (tvPair[1] === true || tvPair[1] === ''true'');\n }\n if (gpio.enabled != enabled) {\n changed = true;\n gpio.enabled = enabled;\n }\n }\n }\n if (changed) {\n self.ctx.$scope.$digest();\n } \n}\n\nself.onResize = function() {\n var rowCount = self.ctx.$scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n self.ctx.$scope.prefferedRowHeight = prefferedRowHeight;\n \n var ratio = prefferedRowHeight/32;\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio leds\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio led\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\",\n \"default\": \"red\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\", \"color\"]\n }\n },\n \"ledPanelBackgroundColor\": {\n \"title\": \"LED panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n } \n },\n \"required\": [\"gpioList\", \n \"ledPanelBackgroundColor\"]\n },\n \"form\": [\n {\n \"key\": \"gpioList\",\n \"items\": [\n \"gpioList[].pin\",\n \"gpioList[].label\",\n \"gpioList[].row\",\n \"gpioList[].col\",\n {\n \"key\": \"gpioList[].color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"ledPanelBackgroundColor\",\n \"type\": \"color\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"gpioList\":[{\"pin\":1,\"label\":\"3.3V\",\"row\":0,\"col\":0,\"color\":\"#fc9700\",\"_uniqueKey\":0},{\"pin\":2,\"label\":\"5V\",\"row\":0,\"col\":1,\"color\":\"#fb0000\",\"_uniqueKey\":1},{\"pin\":3,\"label\":\"GPIO 2 (I2C1_SDA)\",\"row\":1,\"col\":0,\"color\":\"#02fefb\",\"_uniqueKey\":2},{\"color\":\"#fb0000\",\"pin\":4,\"label\":\"5V\",\"row\":1,\"col\":1},{\"color\":\"#02fefb\",\"pin\":5,\"label\":\"GPIO 3 (I2C1_SCL)\",\"row\":2,\"col\":0},{\"color\":\"#000000\",\"pin\":6,\"label\":\"GND\",\"row\":2,\"col\":1},{\"color\":\"#00fd00\",\"pin\":7,\"label\":\"GPIO 4 (GPCLK0)\",\"row\":3,\"col\":0},{\"color\":\"#fdfb00\",\"pin\":8,\"label\":\"GPIO 14 (UART_TXD)\",\"row\":3,\"col\":1},{\"color\":\"#000000\",\"pin\":9,\"label\":\"GND\",\"row\":4,\"col\":0},{\"color\":\"#fdfb00\",\"pin\":10,\"label\":\"GPIO 15 (UART_RXD)\",\"row\":4,\"col\":1},{\"color\":\"#00fd00\",\"pin\":11,\"label\":\"GPIO 17\",\"row\":5,\"col\":0},{\"color\":\"#00fd00\",\"pin\":12,\"label\":\"GPIO 18\",\"row\":5,\"col\":1},{\"color\":\"#00fd00\",\"pin\":13,\"label\":\"GPIO 27\",\"row\":6,\"col\":0},{\"color\":\"#000000\",\"pin\":14,\"label\":\"GND\",\"row\":6,\"col\":1},{\"color\":\"#00fd00\",\"pin\":15,\"label\":\"GPIO 22\",\"row\":7,\"col\":0},{\"color\":\"#00fd00\",\"pin\":16,\"label\":\"GPIO 23\",\"row\":7,\"col\":1},{\"color\":\"#fc9700\",\"pin\":17,\"label\":\"3.3V\",\"row\":8,\"col\":0},{\"color\":\"#00fd00\",\"pin\":18,\"label\":\"GPIO 24\",\"row\":8,\"col\":1},{\"color\":\"#fd01fd\",\"pin\":19,\"label\":\"GPIO 10 (SPI_MOSI)\",\"row\":9,\"col\":0},{\"color\":\"#000000\",\"pin\":20,\"label\":\"GND\",\"row\":9,\"col\":1},{\"color\":\"#fd01fd\",\"pin\":21,\"label\":\"GPIO 9 (SPI_MISO)\",\"row\":10,\"col\":0},{\"color\":\"#00fd00\",\"pin\":22,\"label\":\"GPIO 25\",\"row\":10,\"col\":1},{\"color\":\"#fd01fd\",\"pin\":23,\"label\":\"GPIO 11 (SPI_SCLK)\",\"row\":11,\"col\":0},{\"color\":\"#fd01fd\",\"pin\":24,\"label\":\"GPIO 8 (SPI_CE0)\",\"row\":11,\"col\":1},{\"color\":\"#000000\",\"pin\":25,\"label\":\"GND\",\"row\":12,\"col\":0},{\"color\":\"#fd01fd\",\"pin\":26,\"label\":\"GPIO 7 (SPI_CE1)\",\"row\":12,\"col\":1},{\"color\":\"#ffffff\",\"pin\":27,\"label\":\"ID_SD\",\"row\":13,\"col\":0},{\"color\":\"#ffffff\",\"pin\":28,\"label\":\"ID_SC\",\"row\":13,\"col\":1},{\"color\":\"#00fd00\",\"pin\":29,\"label\":\"GPIO 5\",\"row\":14,\"col\":0},{\"color\":\"#000000\",\"pin\":30,\"label\":\"GND\",\"row\":14,\"col\":1},{\"color\":\"#00fd00\",\"pin\":31,\"label\":\"GPIO 6\",\"row\":15,\"col\":0},{\"color\":\"#00fd00\",\"pin\":32,\"label\":\"GPIO 12\",\"row\":15,\"col\":1},{\"color\":\"#00fd00\",\"pin\":33,\"label\":\"GPIO 13\",\"row\":16,\"col\":0},{\"color\":\"#000000\",\"pin\":34,\"label\":\"GND\",\"row\":16,\"col\":1},{\"color\":\"#00fd00\",\"pin\":35,\"label\":\"GPIO 19\",\"row\":17,\"col\":0},{\"color\":\"#00fd00\",\"pin\":36,\"label\":\"GPIO 16\",\"row\":17,\"col\":1},{\"color\":\"#00fd00\",\"pin\":37,\"label\":\"GPIO 26\",\"row\":18,\"col\":0},{\"color\":\"#00fd00\",\"pin\":38,\"label\":\"GPIO 20\",\"row\":18,\"col\":1},{\"color\":\"#000000\",\"pin\":39,\"label\":\"GND\",\"row\":19,\"col\":0},{\"color\":\"#00fd00\",\"pin\":40,\"label\":\"GPIO 21\",\"row\":19,\"col\":1}],\"ledPanelBackgroundColor\":\"#008a00\"},\"title\":\"Raspberry Pi GPIO Panel\",\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"7\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.22518255793320163,\"funcBody\":\"var period = time % 1500;\\nreturn period < 500;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"11\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.7008206860666621,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 500 && period < 1000;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"12\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.42600325102193426,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 1000;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"13\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.48362241571415243,\"funcBody\":\"var period = time % 1500;\\nreturn period < 500;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"29\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.7217670147518815,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 500 && period < 1000;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}}}"}', -'Raspberry Pi GPIO Panel' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'radar_chart_js', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n\n var backgroundColor = tinycolor(self.ctx.data[0].dataKey.color);\n backgroundColor.setAlpha(0.2);\n var borderColor = tinycolor(self.ctx.data[0].dataKey.color);\n borderColor.setAlpha(1);\n var dataset = {\n label: self.ctx.datasources[0].name,\n data: [],\n backgroundColor: backgroundColor.toRgbString(),\n borderColor: borderColor.toRgbString(),\n pointBackgroundColor: borderColor.toRgbString(),\n pointBorderColor: borderColor.darken().toRgbString(),\n borderWidth: 1\n }\n \n barData.datasets.push(dataset);\n \n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n barData.labels.push(dataKey.label);\n dataset.data.push(0);\n }\n\n var ctx = $(''#radarChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''radar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n } \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n self.ctx.chart.resize();\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Radar - Chart.js\"}"}', -'Radar - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'neon_gauge_justgage', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":70,\"dashThickness\":1,\"decimals\":1,\"gaugeType\":\"arc\"},\"title\":\"Neon gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Neon gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'simple_gauge_justgage', -'{"type":"latest","sizeX":2,"sizeY":2,"resources":[],"templateHtml":"\n","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"\nself.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#ef6c00\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":0,\"decimals\":0,\"gaugeColor\":\"#eeeeee\",\"gaugeType\":\"donut\"},\"title\":\"Simple gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Simple gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map', -'{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".error {\n color: red;\n}\n.tb-labels {\n color: #222;\n font: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n text-align: center;\n width: 100px;\n white-space: nowrap;\n}","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''google-map'', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"gmApiKey\": {\n \"title\": \"Google Maps API Key\",\n \"type\": \"string\"\n },\n \"gmDefaultMapType\": {\n \"title\": \"Default map type\",\n \"type\": \"string\",\n \"default\": \"roadmap\"\n },\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all routes\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n \"gmApiKey\"\n ]\n },\n \"form\": [\n \"gmApiKey\",\n {\n \"key\": \"gmDefaultMapType\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"roadmap\",\n \"label\": \"Roadmap\"\n },\n {\n \"value\": \"satellite\",\n \"label\": \"Satellite\"\n },\n {\n \"value\": \"hybrid\",\n \"label\": \"Hybrid\"\n },\n {\n \"value\": \"terrain\",\n \"label\": \"Terrain\"\n }\n ]\n }, \n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].useColorFunction\",\n {\n \"key\": \"routesSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"routesSettings[].markerImageSize\",\n \"routesSettings[].useMarkerImageFunction\",\n {\n \"key\": \"routesSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"routesSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.3467277073670627,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.058309787276281666,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.14288960550237473,\"funcBody\":\"var value = prevValue;\\nif (time % 500 < 100) {\\n value = value + Math.random() * 40 - 20;\\n if (value < 45) {\\n \\tvalue = 45;\\n } else if (value > 130) {\\n \\tvalue = 130;\\n }\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"color\":\"#1976d2\",\"strokeWeight\":4,\"strokeOpacity\":0.65,\"label\":\"First route\",\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"useColorFunction\":true,\"markerImageSize\":34,\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var speed = data[''Speed''];\\nvar res = {\\n url: images[0],\\n size: 55\\n};\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n var index = Math.floor(3 * percent);\\n res.url = images[index];\\n}\\nreturn res;\",\"markerImages\":[\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABdCAYAAAAyj+FzAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAH3gAAB94BHQKrYQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAACAASURBVHic7b13uB3VdTb+rrX3zJx6i7qQUAEJIQlRBAZc6BgLDDYmIIExLjgJcQk/YkKc4gIGHH+fDSHg2CGOHRuCQ4ltbBODJIroIIoQIJCQdNXLvVe3nT4ze6/1/XHOlYWQAJuWP37refYz58yd3d6zyt5rr1mX8B7S5Xo5/0nPYaNFM1PY0gGqOhfAgQCNBGlWFFUAYEIeihigbhFdZQwt85BV5Gj9r/718R2XX365vFdzoHe7w6d77xnPkn4YpAtU0YiizNJcmPNkMQFkDiSlowHt2HNtGlTSJ6B+pTpsKTfKgTj3Pi8SMtFtEZnFs8d8dPu7OZ93BcCHtt0+OiL+FJjOiqy5K5dtLwD4PBHGvy0dKLYo8B+1+lAldv50FfmFzWX+84i2M3a8Le2/Dr1jAKqCHtl2y1wC/pEMP9ZRLBaYzF8CCN+pPluUkOKfB6qlmk/dBwTyt8eOv2AZCPpOdPaOAPjA1h9/SJX+TyGXuz0TZi4EcPBeOk+U+RErZh2YyMAyQJEoZUjFgtkCAEScgDyx1hmInTglqDj2U1X0WILaPbWvwHO1WummeuLONhaXHTf2wsfe7rm+rQDe133j/i5xPyrmCr+OouhSKPbdQ5fLiezTIYUBQGMJBgYWxMYSISZhbxgQT8wGAgDiwWxUvCiBxKhSKOqdh4OyV5+6XiEfK/kjVOXQ13apG+I0+adKpXaG0/Si0yZdvPbtmvPbAuCNT98YTBhT/8fAmEpHoXgKgPe/6gFGP0nwG8s2YykcaRCAYYQ5tKTkDVuArDEwMRF5AICS4VZ1AQBSr6oEgL36CBAvlKqIsyLOKQl5TZH4uN+TawDuY6o64lWTJX20v1S633uJNvfmvnbRERelb3XubxnAX26+5gDy6Y9HtrU/wERff1XjSt0WwULDmZEMawPOgilgQ4FaGCEygaXQMQyRMaxiUijUkAEAImIGAFURAOrVA1AmI1ZExGuqoqkVFefhyGtKDql4X4eHc6LxJof0VIVM3nVc4uXaHUPlo0Tpc2fv/zer38r83xKAd6y74iImO31EMf9REA7cpdVBY8NbA5+dFNqsCTQipkitBjAUsLUZNd4qm8AyjDMmJAIRhDzDEBEbJkBVAyJWQJ14AEaciIeSGicOgBeBWNHEeXLkXIM8UvFI4bVBCVJNfdk7STd5xOcp0LZzjIqV/eXq/4i61edM/eaN7yqAqpfzf62Nf5LP5lbko/DbCuxU4saEN1mN2kKTzQbIkuEIEWfVagRDEVkOyXCkVq0aDg2p9YYNAySVerU0WN1R27Jjo6ulMQ1V+ggAOgsjNRNEus/IiUFnYUy2kM23AcrivXh2RiTxjhx5iSmVWEWdpmhQ4qvwSBBrXVPfqDmuVsT7C3aZvKslyZcr9dpxdr81F8ynO/w7DuD1q/8y6kDw2872ticN0deG7wvQHXHmdxGK+1ibQag5ikweliIElNUAEayNYBCSRQRiYzf2rNtx11O/rC5d9dj+1aQyM2Pyz3WGozaNisYNWY7SYtgWA0A5KUVO4qAn3t4+lOzYt+Grh+bDwstHzvjA2tPfd1Z+39FTRhGpi7VBKrE4nyBFDKcNJL5OCerqUEXdVeEQb0mk8lECjR0euxe9cqBUOnoQ6RkXT78hfscAvH71X0Z5kf8Z0dH2CgNf2NkI0d0ZbmtElMtFVEAQ5BFIlkKb00AzFJqCGooQcJjv7t868P3/ubayZvua48ZlJt57xLjjB/cpTssXokK7IQNrbeoZ3pIRJm1aYSUW9cwixglZ7xNU40ppY7mr+sy2ezt7G1s+vP+EGfd/+fS/Ko5pH9/pJK04X6MUDSRapcTXkXJN46QKp1UkqNVqvpxVyLzhOajihh1DpVkmrJ7+uak/bbztAF6/+i8j62p3j20vbgXR+cP3LYU/Djg/KcsdEnIWERcRIk+hzWtEOYSch2U76tk1T6+84Tf/NCdni2tOmbRgy6T26WOiKDBhGFEQhrBhiNAyjDGiQp4DFgI8AChg1BGBXOC9p8QJ0kas3jvEcUxxnLgNpTW9izfdOqGWlve7+OOXrThk6qEHKtKehq9xIlWkvoaYytrwFYqlglgrcZxW+oXSz+ycpOLmnsHypDTIfuTNcuKbAvD2288x22dn7hrVnt/ATBftBE/CH2aCtqkZU6CI2hHZomS4YCPK+5AKHFB2ZNe2Nev/739/e9qY3KRnPzHtQp/LtnfkMhnKZDMa2oDCTIjQhghDC2MCCQITAyYxpmkhAIAZDDA7l4bOSeR9YpLEwfkUjXqMOE0QN2LU4waq9aGBX6/+d7O9sXnu3579jbVTx02dlEilL0FDG1pJG64cJX5IGr6MupY5duU1npIv7sTQ4196ytUDx8+sf+TN6MQ3AyBd8+L8W0a15zYw0d8O3ww4vC7ijlkZU5QctVPE7QhNEVlTRNYUjHcy7tu3fuuVSqXBF8z66962fMeIfDaHfD4nmUyWsrk8BdaYIAh9EFoxzExEysYoAQ5A0ioAEIpIBGZmAM459iKaJo6cT209TnyjWkOSNLRWi1GtV9A3sGPg56uvG1vIZ9N/OO9rM8jS9oavSOwqaEhZYh3khq9K3fdpXWsbvdR3MoYCV/UOVadcOvv2C/AG9IYAfue5j1/U0R5mIhNctxM8yvxLyMVpOduJyLRRnto1MkXK23axlB27sXtT1z//8vqDTt3vk/fMGnX4xGyhiEI2Qi6X1Ww2S7lCIQ3DkCxzQEQKYADANgCbW6UHvwcRaO6fAwCjAewLYAKAcao6UkRIBEniEtRqNVOrVKjeSFCP61oaqurKvqe237P2lnkXn/X/PT9l3OT9Eql2V90QN1wZdRqSuhukhi9T3Q2s9ki+NDzHWppeUqnG/qsH/+b7fzSA33ruI7ODIDh/RCH6KkEZAEINfhia4n4ZO0KzphN5005Z06aRaeOAcjP++4Ff3P/86hWTLjr08i3FfEeurS3LUTanhVwe+XxOwjAw1loLoB/ASgBrAdSAV232Gc0NyJGt70+27mlrzNT6nAEwDcBMACO892kcx1KvN6hUqWu9Xka9XsfgUP/Qjcu+Nf3g6bO7zj7urBNT1F+quxLXfUkaMmDrviQ13+8THdqYqvuLZpfq+qrJNXFDbrp87t0v/cEAXr5iduiTMQvHd2QnKDC9+bC9NUfF9kwwgvNmBGW5Q3O2SFkzAkaCg/71Nz9+2MTZ6rlzLs4Vi0WbyWS5o63N5fM5G0VRaoxpA7ChBVw3ANMq1AKoHUAewCwARwHYvzWctQCeaNUrt4pvgeha17Gtevt47+M4jrVSqZlSqepqjQpVyyX/8xU3VBHF2T//+OeOFbgXaq5fa75ENR3SarzDxDToYz846FTORbPRV7oHG9sm+qEPX3TEM3vc9pm9AfiBP53+T6Pbwo0Cd4aog4p/yXK+lDX5IDIFZDinGS7CckEM+JB//u9/e3Z8NGPTgjl/Maq9s8N2FNtcPpc1bW1tFIZhaIxJATwFYA2AtAVWh4hERBQByIgIE1Gsql8gou8AeAjAfQAeVdUvEtE9reFFIpIloiyATgARgCqALQAGmHmUtTYTRWHDhhaGYE0YYmbHEXZj//rBRc/fXTly5qGHEus2FUceCbxP4DShRJ2mvuIFboyqG5kNcNuWVM965MbNd71pAC99+vADA+MnR6F+TeAg6h1TeE/I2bbAFjVLBbJcpIDzZNke8qNf//yxKblZWz42+9Pj2opFbutop7ZCQdva2hAEQZGZXwGwDEBDRCJV7VTVfVV1BDNPUtXZqnomER2tqi8S0REAzgJwUqvMI6JBAM+p6pdU9f1ElGu1E6lqUVVZVYWI6gA2EFFijJmSiUIPsDbXmGT3b59V6Kv0dd334uLGYTPmHK7Q7lRi65DCawqviXWSrEm1PlvgWMh9KPbut+/77Ohtj/97d98bA6igo7aM+O/Ogp0l8BNFPQhyY2RyE0MqcC7Ia2jyGpksBYj2//WDCx9uk/EDZ8783JhiW5HbigXpaG9HNpvNMXMGwAoR6SWiUKS5KhERS0QqIgmAHcz8sqrOA7AdwCcB9AK4CcBvAdwP4EVV3V9VPwGgC8B4Zv4PIqqoqgPQYObEOadExC1A60RUJaLxURQaZqoRW0NEsm/xgI6u7rV9L295vmvGlKmHQ32vk0QdxfA+oYTq+Vgbi70mR4p6BEaKlTid98S/9f4MV7wBgF/66AEnFbPUz+z/VNTBiywLgxxCFDgwGQqR5wznOeR8+6p1657r6uopfu7wv4mKbW0oFvIoFovIZDIBEXkReUlVG6o6Fs2N/EjvfSczj2Hm/YnoY6r6Ae/9w0T0cVXdSkTfE5FsC8iTAZwI4DAAjxDRj0TkUABTACxS1csAzG39MHlmzqvqGCLKt1xZA0Q0QERtQRBkDZMngrcmNAeMmB08uHpxNsrz2pFtbft4TWInDZtSLE5T8i7uSKRS8XDjBX4fYbnusI2jMkt/tGP9rnjxrl+gICP4Riagrzb1ssKa4CkrYRhwwBFHYGSUOZJKo8oPP/vCoV846opSoZCnQj7HxUJRMplMgGblR5h5wHtfbE1oZAvIHBFtVtX7RKTQ4pSrnHOXAThQRK4BcIaqNkTkRRF5UVUTVf1462/TVPVSEfm2974qIm3MvBhAl6pGAEYAaBcR45zLiUiPiDxKRC6bzZpsNhtGUaj5fIG/dNTltYeeWja3ltbVcGgMZX1IWbUUqDUBbBA+OYxDPuDLSORq6KsN76s48MvzZnwwlzNDgaFzAIBAi0LKtGVtEQHlOaQCQpOHoWDWL+9+ZODCuV99cnTbmM5cIY+2JudZIpronHukxUWemavOuZIxpuG9H8fM8wDMJaJHVfV0ANcDOIyIPg5ghTHm+0S0UETWq2oCoA/AI6r6C2PMgyKyD4BPM/MggJ8COIGIFqnqV1T1YADbVXUjEfUaYxrOOcPMBVXdCmCutbZirQGIlIBwavucl2577NaJM6ftO1nJ9aY+YfEpvDryknamSNdAMQ1AGwxdc/DqDjz9k/7Nw5i96ixBSK/MhTRxJ7oUbracmWAoVGNCtRSCYOxLazfcN7VjdjK+beK4KAqpkMtpJpNRABNVdT2AowHUvffjAYgxZpNz7hUiuk9VT1LVWFX/iojuBfA1IrpfVRcS0Xne+6tUX33+M/zdew8AzxljLvPefxTA3xPRIufcpQA8EYUAFhPRSCKaKSL7EFGgqjtU1RDRZmaeGIbh1sh78s7LxM59R09um7585fqNdtqUMZOMMc4igE0DthSppcYWL80VTNbyX1QCPgNN1fJqDvzi0tnjQviObGia3Ee0JEAml+E8DOUo4pxaE4GUJz3yxJr9/vSIv+8uFAu2kM8jl8vBGNNJRE+q6grn3AZV3QRgi6q2AZjHzHNE5FEAp3vvv8HM8wFQSywvADAPwDgAi0TkPwDcBWDhcFHVh9FcXH9ARE4BMI6ZvyEiHwYwSVW/CeB0IlpERJeo6hwiepmIlnrvVzLzemZex8yDzDwZqlUikGGm6R0H66+evuPYafuNynvFkCCF4xjiBd67otN4C4GmEDAqTuVnR3++beWT/z5YfRUHio8/0dEe7DynJTUvswmmEiwxWcCDwGyee37j4ydNO6ucy+YmZMJQM5kMWWvHqmqPc24eADCzENEGAMvTNH2AiM5Q1W1E9GkR2cLM3yOiS0TkO0R0lao+zMy/8N7PBHAmEZ2C3YiIoKrdqnqjqq5i5j/x3n8bTQt8iapeKyKbjDGfFpEhAGOccw8EQdBhjPmQqk723rP3PrTWvhxF0Xgi6vHeayaTyx075fS7nlvxcPGgg8ZNIjHeSKRMdbEUIEHwEuCOA4DOvB25vSRnAfghMGxEFNRb7ZoM0HFNadFeIjvRgMFkhEDKbEl8Oqq7u3bs+/c9cXQUWo2iCGEYsqrG3vvHAPwEwL2qulZETnXO/Zm1FqoKVf2Bqh6qqr8SkW3e++tU9T4i+ntVnem9vw7ARQA6ReQ5AL9yzl3vnLsewK8APIfmovkiIrpWVWeo6t977x/w3l8nIluI6Dcicqiq/quqgpnJOfdnIvJR59wmEVlCRD9S1QeJKLHWmmw2hyAM9bhpp47q7q4d733aSVBlkBoNQGxgYPdVRZ82N5In9lS7dp42GgA483hMyUY0RXgwXzAjQgUtshp1WhOR5YgDzoiB0U2baqsPLB7z0oxxBxWz2Rxls1lh5gNVdbn3/rwWR68moi5VPZWZt4nIvgBGquoRAH5BRH+OprH4oYh8XlVPQXMvfIOI/BJAFxF1qupxRPRBIjpKVSe3dOtdInKbqj5PRIe3RHayiHydiMYDOIuZfyIin0HTfI4kIgAYa4y5UUQaAI4QkY8ZY5YR0aGq0kcE8k5NNS4t665u6G9r47xDCi8pqabsNbFe9WkoRvU0upYl8GunnqebX7kZQ00O9DipLbKjRfQTPWnXYyBTBxMBBiIML2IVkt20sf6B46d9rJjJ5chaQ0EQRAC2pWm6VlVXq+rZIvIXSZKELcX/Y1U9RlW/AWC8iJyqql9V1aOcc99W1SXMfAmAh1X1qy3O+rKIHCMiGRGptUqude9iIrqWiC4brisiDxHRt1X1KFX9qnPuowDGe++vUNUPishNLQkIiOjPVPVs7/02EVkLYHsYhtYYg0wm1FNmnZPftKF2lFPJisCIkhE1DFiFaNLr1i5R+PntGR5lFMcBLWfCxxbhrgkjgqMAjCKgkrWFX48KZ7RHJm8CziJLOXJpUNu4omAuOfbKOMxkKBOGHIbhHBG576qrrtLHH3/8QmaOdtdd/5tIROLTTjvtyc9//vN3BUGQs9aOA3CyiDxXr9dRrzfo2gf/Ljt1TpyYIMnWtQ4nVW2kNd+bri41fOlMADkQerb1p4/f+WGcaS9X8HOLUQIwCgCUdFGi6ehBt7k+3k4DqQ8cOd2+mQdPnP6xijHB+MAYhGEoqppL03T/J5544iRmpvnz5z+4Zs2a1dOnT5/+8ssvr5o5c+aMWq1WSdM0VdXORYsWHW+tXXbmmWcONV2jQG9v744dO3b0jR07dvSIESNG3HbbbbNFpHPBggWPtMTvVUREWL58ee2VV145bcSIEU+ddNJJ1RY4unLlytXTpk2bEoZh2N/f37dw4cKTrLUdxWLxvnnz5pnf/e53unDhwhPa2tpWnnfeecekabopCIIMEYGIyBjGCfufvmbpltuKY6a4LKkzCh8PpZu913g0oIsAOhOKMQTElyvYPrsY43IRP6uK8wCAYHrUo+gpiXoaG+LR0X5VaNgxNEAHz5pz6PIgMGBmBTCKiJZVKpUjjDEmTdPG/PnzPwSgLCJHoLlY/omqXgLgWSJauHjx4uNPP/30obPPPnsAwGNoLl+O32Xdt/a3v/3txnK5HM6fP/+3aJ2JAAi89zkAUwGcdOqpp+YvvPBCnH322fEJJ5yQA3CH9/5YY8yft0C+SkTmP/roo72NRqPjhhtuODCTyRTPOuusRy+88MJVd9xxx8cWLFiwiog+oqp3ARgVBMEO7xVzJ70/v2jdHbNGqu/16uq98WakmuQgANhsU98MRQwMP7N0iYxhUuybD/n3WzqlAMROROElzfY3NrXHrtTNFHTkMvkiGQNiZhGZ7ZzbPDx5IoKIXK2qZzDzd9F0T/0pEV2qqoeKyN8BwLZt27ap6hmq+l0RmQXgZhH5iohcpaqrwzA0RATn3DXOueta5buqeoWqnqWqT9dqte8DwPbt2zeKyBGq+l1m/giA7wL4map+jYj2S5LEA0AYhp0AvsvMp5577rn3Axi/YcOGxaoKEdkCYBYzqzGEMMgUWILRjXSopzfekFUf5wUKYXYQCoZhykcM08C+DMUMw7Rva8sHqHZCJFD1VtTDaYLuoe3xrLGH/Yu1NiZVtcYAQEVVy7vpmPNU9VHv/RUArgZQ9d5f473/qYj8OwBMmDBhPIBnnXNfAfAj59w5AK4F8DURmcfM1JrY/4jIrSJyq/f+XlV9vmVMPlEoFC4GgM7OznEicmPrB3hJRC4Tkc+IyI+897cFQWBay5lrVfVKVX30lFNOOUZV/aJFiz7YMi79RFQiIgbg2NrazHEHf7+70q1eGiwkROoteQkhOmIYp8DQBGUcYIVwOJMepCCAkBCooCAnUPVwXoU1rrXVoyi7nwgoDO1QyymwzTn34d7e3p8B+NsWFx4AYLP3/l4iuoKIHhaR/yaiLw1z6rp169Z57+cR0bUiAiIaVNU7ReR5Y0xcrVbPbf0ek1U1DwCq2qOqG4jofhHZUi6XAeC7IkIAvqCqIKItaG4LZ4jInxERvPevtK5fY+b7W+0eBGD78uXLx6nqd51z85i5G0Bore1rNJJsxuan1EumFo3w3mtKSupAMASNRJEACBk6ixWphWCaKs1tqegVUIWyiBcPIYhRQlLKhQccNDtW9YEIh0TkiciJyGFtbW29LfCCxx577PtHHHHEhdbabd77bzLzFap6jPf+X5o46Jf333//qWh6kP+P934HMx8F4HQA53rvkc/nl9frdYjIQbsw99SWy6opPvl8BQC6u7u3ENFfq+poVb1IRK4iIvHeX7dy5UpKkuR8Zka9Xv9WNps9n4j2B/DNkSNHnrV9+/ZRIvIhIjpMVZeoqlfVEcyQ6WNmpQ8+nyva9m4IO/XeQ1XFE6UKfYkUhyrTEVDEFkAWO4NuZAuAsPnDKlgFzih8ku0cU5y4NQiCxFrLAPYDUCOizxpjrgAAY4y54YYbvtwS5f1E5B9UdSgIgloURR8BIESEO++8c8qmTZtetNYeHYahdnR0wHv/pIhsrVarvX19fQsA5H71q1/dYq01pVKpkCRJXCqVaGBgwDcaDdfX1zcRwDELFy788JIlS96XJEnBOQcADSIKmfkSIsKwpXfO/bmItBljLlHVa6dNm/bIE088sR+AMUT0WRG5kIgmWWtfIWPcuPZJDJ9r90hIRVTEq5KAlBIIdYH0UCg6FMhZUvDvjSDVnZBhUhUSUijICxHCbDFXZGOMqKoH0KmqQ/l8/ptdXV0/rlar38rn8zs5hJmJmUM0jyPb4/j3h/ze+ylLly6dgr2QaepX3Hnnnefv7ZmdoyUamyTJWABoHvTtmbq6un4xa9asSQCuA7DSWvtSo9E4zHt/dbFYvKLRaKwF0E5EwoBENlKVMOPFkcJDCRBVUlEloLQTLgWz1987FAhImCECJVEh8Z6cdzBk20ITkIg4Y4xX1ZFoHuJM3XfffT/S29uLLVu2oFKp7HQ9/W8ia+2RzHyGqv6TiPzjsccei97e3kxbW9uZACYTURVNb7mIiIYmJIOwLUWqTqQVIqFEDFHV6nC7orDMBB22LOzhWbRC0LJRLalqGYqyQWAJVDPGVJIkqQPYrKq9AGCMmQoAaZpix44d2Lx5M/r7+5Gmbzn4822jVatWvei9/9M0Ted77/9j5syZawAk27ZtswCgqt0AtohIzRhTssZWDdvQkA4RtETaxAOqZSWWnXgR1Kr8/kTbG2ThtaAE9QQSZWIQ2EilFteyhoJCa4lxYMvf9xry3qNUKqFUKiEMQxQKBeRyudcVsXeC0jRFrVZDtVrFzTffnOnp6Tl2/Pjx944ePXrt9OnTzyGirY888sjLCxYsOERExhPRDGvtswACrz4m60pOqIMIBIX4ZqCYAWsZLXumAtid6z8A5DSvlgkKFkcMiBERqHUDiUu8994SkQCoEFF+jyPfhZIkQX9/P/r7+xEEAbLZLKIoQhRFbzugzjnEcYxGo4FGo/EqCejp6Tnv5ptvfk2dH/zgB8sWLFgAVS0CqHjvyTlnq2mFYF3VORnJICKwI2IFI0Qi7TCtLaYCVgnbAdoA6GRhaoPXhipIVJkEUCXP7CrleBAd2RHsvYcxpopmfMreaICZN6LpQWYRmZSmaeeuk7LWIggCWGsRhiGstWBmWGuxqwUFABEZ9ilCROCcQ5qmcM7BOYckSYbd/XuiTczcT80YHHjvZ6MZZ4O+vr5hx+14Va1Qa/M9WB0Asa+SUCcIRuAtg5QEBKDYrEJrwdhiIXhBRQyIJkMxQxQvkELh4RUq4kCJ2VHdOLiOx+YmmTC0trWwnQOgsvtoiegFInKdnZ3rRo0aJT09PTw0NAQAm0VkzvBzw5N/B0mMMU+pqhk7dmxXsVjkzZs35xuNhojICDSPRpPt27c/WSgU5hLRC95722g0aOPgWnbcW5VUBYCSJYBBChgQzWnt2J4BsJyheFkVr7Q6Hc2kZYU6ARSejCjZFN259UOrc6reOucMEfWpqnXOPQIAhULhN8PgMXNl3rx5Y4IgOIuZz46i6KyTTz55JBFVmXnFO4nYrmSMeTKKooEPfvCDs40x8621Z3d2dp566qmnxsxcArC1s7PzkVWrVi1X1QBAv/eeiYg2DK0upOgpiCBQIlIBBOrBOgTCCAAQ0jUQrGS1WF1vUPewLlTlKoQCOARewOqVUgzmtlXWTWuKiqiIVAAgjuOtuy1bgtNOO21ET0/PhO9973sQEXznO99BT0/PxJNPPrkDQAO/97C8k7RBVaO5c+ce19nZmb3yyisxZcoU/NVf/RVWrFjx/kMOOWQ9M3dXKpVRjUYjbKmGinOOnPPYWt04PZGhjHoQCZigAQsFpFwbxqlRpx6k6LI6gK5Kpz8zm20d0JHWQFAYTSUlALDexSNdEB+Y+nQxpZRlppSZ4ZybdPvttz9QqVSOt9Y+SkR+xYoVxx522GF4/PHHceCBB2LZsmWYPn06nnrqqQOZ+REiekZERr+T6BFR37hx47rWr18/NwxDvPLKKygWi3jhhRdw5JFHolarzXvuuee60jSdYFordxFJnHNI0rghiGc4jb3xUDEQEngyYEBrwx7KcuJHZzux1t79KZQ++iv5AHTnCadVBZGQhULh1SsIMfoe7KlsGRqTm5Q1xmkQBJtV9dijjz766f06bwAAEgVJREFUnpUrVy4EgIMPPjh300034bjjjsOaNWtQqVQgIjjqqKOwZMkSzJs3b/Xy5cstgFUA3rZF954cr6eccsrYxx57DJ/85CexcOFCDA0N4cQTT0S1WsWjjz4azp49+4l6vc5Tp049TVU3eu/hVXVbZUN/TH33k8c4DVRIiMFEohCjCIdXLC6VY+44DV+zACCEXiiWgnCkEp1EpKsEqqTEIsTq1Axg+eCy/kczp+QmqDZfuXpRVedNmjRpx9VXX32hiEBEsHTpUtx5551YsGABnHM47LDDcNNNN+GAAw7Al770pc8NPzdsUXe1rsOA7n4dBmjXK3NzgbHrZ2beWQDg7rvvxq233oqLL74YS5YswY4dO/Dkk09i7ty5uOCCCz4bx/FPRGSUiNydph71ap2W9T9eGGgsr4iqZSVVsLJ6Z5lIlU5srfmWAlgHtE7lDjgP5SjgAWb6MBTtoroMgpwoERTwniiJhwq5aPrxB+YOWwuQIaKEmWd573NBEHSoKosIpk+fjltvvRWqitWrV6O7uxvLli3DV77yFRQKhVeBtzcgd/2+exmm3bl3dy4kIowfPx4LFy5EpVLBpk2b0Nvbi+7ublx22WWw1ro4jgsARgJYVq/XUG/Uk2fK95+ypXxfrESGGUIEMhYGTP1ovQOYOr2+kcjvVt+K9c130cp4slyX4nDnBqYbRCAGkTZXUELIVtPeezeUu3rjOEaSJFDVpwEcmKbpLcMTnDhxIm644QYEQQDTPDvBNddcg3322ec1IL1e8d6/qryZOruDffTRR+PrX/866vU6kiTBAQccgOuvvx5hGKI15hki8lTz76lura/fUUt6F4siJIKCiREAakhB6BnGp1ST9lwbngJ2CfE99Zd4cPzIcDqg4xl4wQl64EE+BlyicCnYanHz4RMumviR9vO7C4UC5fN5JqKzVfXlKIomtzzGr5nwGwGwOxe+ngi/ntjuXowxe/s+0Gg0+ohofxG5o1KpoFqv6+LBn496dssPt6dcmWAtlCOCNRDKgJgxEopDoLRl60Cy5p5P4Hhgl/A2NbgmTuUGBeCBOUTokVZAtyiIFJSk5QmJlJKeyvaeer2u9XpdVPVxVZ1Zr9dv25PI7Q7M3sDbEwe+0Q+wt/b21vdwqdVqv1XVaar6eJwkqNdj9JY3bW9IKU5cZRwUDNPcuagBE2G7Kg5RAKnI9SD832HcdgJIARYOVdyknXtjoTpBoaRsTPOMHQy7fMutQy/qQzOr1arW63VNvd+kTc/NfO/9I3vTXXub0N5E9/U+v57Yvp7+VFWkabpYVc8DMJSm6aZyqcSNRk1fxOMHPb/5v+pQtWwgUBCxErGCiOJhXHYMuRkU4r7XAHj3aYhTAaC4rakI9dNkMMSWPBhMSsRKmjRKIyuuZ3Bzfe32crnGlVJJReQ+Vc3HcdyuqgPD4re3ib1ZHfhmVcDuYO4JxNaYetI0HYvmMen91WqVqo1YNqVdW2uutz9NSp3KTNpcxMEYgjEYVNULmvVxiwLVu09D/BoAAcAZXL6j7F9SBVRgiUwPkRJYCQaqrEoMWrrqp4WN2ZfmxXGtWq7UqFwuJyJyP4A5cRw/qKryelywNw7ck+58I336ZvtR1Uaj0XgewMEicl+5XPblcpXqtXJtk33x1KUr/6MAbnKdgQKsDFUVMTtUYFWBvpLvohRX7orZqyJU192K6tSz9Qv5HPcQaCpBZyvjRSiyEFIVkDioiBbL1W3LglGduWJ9LKDExnAtCIJEVU/w3t/MzIfsbiD2dn0jHbkrF+1qSPZkXHY3MMNX59ydaB5ePdNoNLZUqlVfrpSxOvO4earr5xvqvm8iGfggBFNIyiGYQwwQ4xwABqqLhmo+c885eJVf7NUx0gDE4iv9Q/JYc1+MDABvDJQs2DDYhlBmxD2Da6YNxOulW9dsr1TLWiqVtF6vrwawXFU/7Zz7TwB/FCf+MUuW1ylJmqY/F5GzVXVZvV5fWy6XaahU5q26asuA22L7hlbvR4a8NVAYKFsgMBACJZDm7mNHSZ41HpfujtdrovS7bkV58p/oRwpZ8zIIhwM0C0SLoBipCmqNnaHAhq3L7MT9D9mfhjIrrYRt3nu0fG9VAKd673+Npq8t82a5cW9ADdOb4bZdljfbRWSpNt9BeSJJknVDQ0MYHBqiwXRHd9+IriPvffpa4YBCE0I5grCFMRlSGFoF4DMt3ffDUtXLPfPxyzcEEADGnoNH01gWFLNmChQhgTJEOqiKQIQEAiPNU09+Zf3jfZNnH3yY9mVWasoFL16sMWVm3gzgNO/9KiJaq6qTdlfyewNv9+f+QNCGPz8qIgLgaFVdVK83egcGBk25UtWBel9f/4Q1x931yFUbYLWNIxgOoDYgDSJYE6IB8CEEjFKg1D2QdscVfHn9r/EaB+YeAdx8B9z0+Sgz8HxgeR6AMVB6hgzaVMk3Q/2JSQHvJOra+GTXlMPmfEi6o+d87NpTLyTeN5j5ZWae6b3fV0RuIaKZqmr3ZJ33BNzuAO4G0B7vMfOQiNyqzcBN8t7fN1QuN0pDJVQqJe2v9u2oTt9w0l0P/uNz3iQjghA2CMmEGXgOCSYDIqJuAk4AgHrDf7We6u/uPx97zO6x13fl1tyOtfucqRcXM+ZFAHNAmA2iu4gwRkBKos0jAVXy4vKvrHvslWlHHHZk2m1eQKJ5VfXOOauqG4Mg6FXVj4nIalVdpKoHqSrtsrzYed1VXAHsDaQ9caAQ0S0iMoqIPkBEDzWSZHWlXI6HBkvBUKWsQ2nf5uSA7SfeueTqFxPUxtpQAxMSmxBqAhKTBZhoBYALAUCBW3ZU/D6Lz8E1e8NprwACwKQv4nf1fvlUMWsJwEgC5oDpIVJ0EhGrJ6sAICCXuvYVqx8uzXj/YZPSWFbWelyHeA/nPRLvqwxa3XRN4COqugrNKPwx2ozifxVww1y3K4CvA95WAHdQ8xWHDwJY4b1/tlwupwNDVVTKQ9rfP6j19h3dsv+Ow29bdEWvUmO0CWBshowJCTZL3kQAW1pPTb1noPTK9oG0no7Cp9b/7LWi+6YAXP8zuMnn4rFG4kfnQ3MYgIgIU5jxDCmKCigBpE1xZlEfvPDSErffrFkU7BNQpSutxQ1PLo6zSerFi9RV/CvMXFXVQ1R1H1VdhGaIbxnAzgQ5u4vtLsUx8yMA7mPmbQAOJKI2VV2XJMlLtVqtViqVaLBUlUqpn0vloTofOhBVMptzv1h4dd4Yn7cR1GSJwwhiQhIbIjUBthBwJoC8ElzvUHqzKL5+/+l4zQuGu9Kbyplw4m04Ix/xjI68+W6r2gZifdI1dFSaEEtdOW2AJYG6hnqXEMaOnL7ptGO/+L5kjVks2/JjM5nIZKJAoihLmUyIIAjIGANjTEBEHSIyWUQ6RWSdqm5V1YqIpC3RDImoQETjiGgKM5eIaKOIDKpq4r2Hcw6NRgO1egzvUq3V6l5Hxhuys9OPP7T0lke7tj41nQNiG0FtBmojeBMR2yzIRNhKQh9U6L6kkMGq/7t6Ii8uXoDfvRE2bzprx0n/hc93FLiQi8x1zYq0CdAHvcdkV4V3Dupi9b6OgosR+wRGvU3PPuXSHcXcPiMGnvAvcJIZlwsjG2UzMESUzWa16SExZGxLGFS9sVbFK5SUAGBYWYoIMzN5BbnUgSCaph5xXCfvvSZJouVaw1NWejrfL3NK1a07frHwmpFsXcgRvA3hTRahNeRsHmKaXpZtIDoa0P0AoBb7SwZqEt+/AP/6ZnD5g/LGnHwbvtlZCAYzAYbzJwwo4U5xOl0aUB8jcDHUxUSuoQ4pJE0gmbCt9vFTLm4UM2NHDCxNlidDweiQOAyCUDkwFLBBEFhSZrVEqkDzHLEVAiA6PFBFE0pFkjhS9YjjVJ1Lkfg0sZ3SO+rI8NBSo7vvznuuz8S+lDMhwBbWhmRtVr3JgmwAmAhqAlolij+h5svfqMW4ZKiaFu49F1e/WUz+4MxFJ92GS3MR246M+bYSGEAizD8mJ4d6p+oa8L4OcQnUJzA+hhWnqU+gUdA2cPKxnylNHj/rmOrW9N7+F5JGOiQjyXIYcgC2zRejiVXFw5Np5Y3xMGxgxBMJPMSlFHtPUI1NG/eNmhNm8uODUzZse+nB+x78WVs9KXXaDMgYspyBNyG8iQATwIRZwIawYPOCQj4LICSFDNX9V6qJ5O5bgH/8Q/D4o3JnnfhzfC6yvM/IdvPXADpaLd0KoaJPNS+xmjSF1QYkTeEkVfYpGR8j9Q5WRKvjRkztPf5DC3j0iCkn+AQvlDdUu6rbXaPWn5KrCEEErTwXTTKALbDmRgSaGxNk26bmppoQc7p7ux546PE7ZHvfutHGUJ4DOGMRmEi9sSQcwgYR2GTgOCRvDFXVaJUU81sA9PcM+X92Trru+yT+8w/F4o/O3nbyrTiaGF8cUwgOIMZRreZegerDgB6YJiQSw0uqgYsh3sFrjMB5eE1gfAovHka9pjaM+ke2TxiaNnWujBkzOcxnO/KFXKHNBpnRAODSRm+lVh6q1odqPT0bkjXrnuW+oS3tLo1HsKGADIQDsAnhjEFAFgmHsDYCmYBSG4BMRgMQvQTQcYBOBwBVPN5TStd6hxvuPx9L/xgc3lL6u5N+hpGwuHl0u33a2N/nDiTSXxBIRHWCNMilMdQ7DSVF6h1YUxXvyKhD6h0CCKCCVLxa9YASKYlyK/AOIJAyCUFBDGImB4KlEEoMbywCCtQbQ8QhxFiEJqDYWLDJakBEm4g1UKFPDI/Rq16xY9AdZQzOXzgf/X8sBm85AeM5t8P0eXwtItYRbfZToOavCyDxKj81RCPgaKJ3iL1TAw9xCVgdvHcw6uBVm/pNvQIKpwJV2pkKBQCEFKoMYoKFITVGQQxPBsZYeLIwNoQQw3BAjiNEzNioQKzAebQzkJRW9lXcbXEqctx5uOryYUv1R9LblkP1+JsxjS1+MDJn7wkDuhKEHACQQqD4OUgExJPFq/EpqTglcXDqEXoPJYETDwbgROBVAQY7ABCIJQKYYQBYZogaWGMAMkhhEJiQPLMaG5BTlvWUsgXjvJahAxS1RqpfH6i5eYjxhfs/i7clj+rbm8VXQSf/HB8T4LOj2uwzgaF/0GZ2oeHuVqjq48zIQzHee4QiSLUZgwN4kDYdt0Kkqq38BM1XhYnAMMwKGDQ979y0rERIRbENQJWIPgDorF0m2Ei9Xt0/5N4njH+//zzc9XamRH5H0iAffiOC9gLOVeD8kXl7bxjyxYC+OqMv0VaoPsCEukAigNqg1EEEFlWBQKHUFC9SBoOYiEUhRDoIaInBiSgyBDpJoeN2m9qG2Mv1/SV3iir+s1zFbc9chLc97vgdzWR+uYIfugUnC/C3keUlHQXTaQiX7LUCox9en1XwIBENCqTcvM1FVe0gSAcMzYVgxN6a8IrrBit+IHFyrCF850Orcf/ll781Pfd69K7l0j/mJxhtLb4+ot2uDy3t1T30Vihxeml/2U1WxpVLPol3PA088O7/MwI6/ib819j2YDOb154vvBVSxfXdA+nEBz6Ns4G3T8e9Eb3mUOkdJsW++NT2UjpHVO/V5vrvrRfVh7f3pTNLdZyLdxE84N0HEEtOgMsRzukdcBUV2vRWwYOnbTuG3HZXw4J3wki8Eb2uQ/WdojW/RLz/n+CluKaZTMhzm4eJwB9aFHADFf1X7+X6h/4MG9+LubzrHDhM934KLyhoaSPB3/yx3Nco42+811UPfBbvWvD67vSu/0eb3enEn/K17RkeNExXvPHTvyfxeuVQQ0be9zn50hs//c7Re8aBw3T/Z+TScl3niuBm9cCbLLeXGjr3mA3yl+/1+N9zAEHQ6oA/rxLLBPF49o1Fl54vxVJ08Ge/kwvkN0vvPYAAHv8K6ur8BbVEnlNF6XUArNQS/ziJv2jJ5/Cm07W/k/SeWOE9UddvUJ5+pimpYhODTtyT1Y29fsOrv2fxhXj+vR3t7+l/BQcO0z2fc0ucEyeil+7OfV7xFYXI4gvx4Hs9zl3pPbfCeyA67cfmFiaziVX/BgCUcL1XGf27z/vz8S7vNN6I3t23oN8caW0//+lcF/0PC+4VIBJgZm2aPw3/y8AD/peJ8DAtOQEuZLfAQ0sK7Q0rbv6SE/Yen/L/017ojH8LZ5/xb+Hs93ocr0f/D6s769KBP+5xAAAAAElFTkSuQmCC\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"colorFunction\":\"var speed = data[''Speed''];\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n if (percent < 0.5) {\\n percent *=2*100; \\n return tinycolor.mix(''green'', ''yellow'', amount = percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix(''yellow'', ''red'', amount = percent).toHexString();\\n }\\n}\\nreturn ''green'';\"}]},\"title\":\"Route Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Route Map' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'temperature_gauge_canvas_gauges', -'{"type":"latest","sizeX":7,"sizeY":3,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueLinearGauge(self.ctx, ''linearGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueLinearGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 30 - 15;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":100,\"defaultColor\":\"#e64a19\",\"barStrokeWidth\":2.5,\"colorBar\":\"rgba(255, 255, 255, 0.4)\",\"colorBarEnd\":\"rgba(221, 221, 221, 0.38)\",\"showUnitTitle\":true,\"minorTicks\":2,\"valueBox\":true,\"valueInt\":3,\"colorPlate\":\"#fff\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"colorNeedleShadowUp\":\"rgba(2,255,255,0.2)\",\"colorNeedleShadowDown\":\"rgba(188,143,143,0.45)\",\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\",\"highlightsWidth\":10,\"animation\":true,\"animationDuration\":1500,\"animationRule\":\"linear\",\"showBorder\":false,\"majorTicksCount\":8,\"numbersFont\":{\"family\":\"Arial\",\"size\":18,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#78909c\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":26,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#37474f\"},\"valueFont\":{\"family\":\"Roboto\",\"size\":40,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#444\",\"shadowColor\":\"rgba(0,0,0,0.3)\"},\"minValue\":-60,\"highlights\":[{\"from\":-60,\"to\":-40,\"color\":\"#90caf9\"},{\"from\":-40,\"to\":-20,\"color\":\"rgba(144, 202, 249, 0.66)\"},{\"from\":-20,\"to\":0,\"color\":\"rgba(144, 202, 249, 0.33)\"},{\"from\":0,\"to\":20,\"color\":\"rgba(244, 67, 54, 0.2)\"},{\"from\":20,\"to\":40,\"color\":\"rgba(244, 67, 54, 0.4)\"},{\"from\":40,\"to\":60,\"color\":\"rgba(244, 67, 54, 0.6)\"},{\"from\":60,\"to\":80,\"color\":\"rgba(244, 67, 54, 0.8)\"},{\"from\":80,\"to\":100,\"color\":\"#f44336\"}],\"unitTitle\":\"Temperature\",\"units\":\"°C\",\"colorBarProgress\":\"#90caf9\",\"colorBarProgressEnd\":\"#f44336\",\"colorBarStroke\":\"#b0bec5\",\"valueDec\":1},\"title\":\"Temperature gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Temperature gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'google_maps', -'{"type":"latest","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".error {\n color: red;\n}\n.tb-labels {\n color: #222;\n font: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n text-align: center;\n width: 100px;\n white-space: nowrap;\n}","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''google-map'', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Google Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"gmApiKey\": {\n \"title\": \"Google Maps API Key\",\n \"type\": \"string\"\n },\n \"gmDefaultMapType\": {\n \"title\": \"Default map type\",\n \"type\": \"string\",\n \"default\": \"roadmap\"\n },\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"markersSettings\": {\n \"title\": \"Markers\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n }, \n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n }\n }\n }\n }\n },\n \"required\": [\n \"gmApiKey\"\n ]\n },\n \"form\": [\n \"gmApiKey\",\n {\n \"key\": \"gmDefaultMapType\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"roadmap\",\n \"label\": \"Roadmap\"\n },\n {\n \"value\": \"satellite\",\n \"label\": \"Satellite\"\n },\n {\n \"value\": \"hybrid\",\n \"label\": \"Hybrid\"\n },\n {\n \"value\": \"terrain\",\n \"label\": \"Terrain\"\n }\n ]\n },\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"markersSettings\",\n \"items\": [\n \"markersSettings[].latKeyName\",\n \"markersSettings[].lngKeyName\",\n \"markersSettings[].showLabel\",\n \"markersSettings[].label\",\n \"markersSettings[].tooltipPattern\",\n {\n \"key\": \"markersSettings[].color\",\n \"type\": \"color\"\n },\n \"markersSettings[].useColorFunction\",\n {\n \"key\": \"markersSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"markersSettings[].markerImageSize\",\n \"markersSettings[].useMarkerImageFunction\",\n {\n \"key\": \"markersSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"markersSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"lat\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"lng\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.799863043034289,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"markersSettings\":[{\"label\":\"First point\",\"color\":\"#1e88e5\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"useColorFunction\":true,\"colorFunction\":\"var temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix(''blue'', ''red'', amount = percent).toHexString();\\n}\\nreturn ''blue'';\",\"markerImages\":[],\"useMarkerImageFunction\":false},{\"label\":\"Second point\",\"color\":\"#fdd835\",\"latKeyName\":\"lat\",\"lngKeyName\":\"lng\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${lat:7}
Longitude: ${lng:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"markerImageSize\":34,\"markerImages\":[\"data:image/png;base64,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\",\"data:image/png;base64,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\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.floor(4 * percent);\\n res.url = images[index];\\n}\\nreturn res;\",\"useColorFunction\":false}],\"fitMapBounds\":true,\"gmDefaultMapType\":\"roadmap\"},\"title\":\"Google Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Google Maps' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'temperature_radial_gauge_canvas_gauges', -'{"type":"latest","sizeX":6,"sizeY":5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, ''radialGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueRadialGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":60,\"startAngle\":67.5,\"ticksAngle\":225,\"showBorder\":true,\"defaultColor\":\"#e65100\",\"needleCircleSize\":7,\"highlights\":[{\"from\":-60,\"to\":-50,\"color\":\"#42a5f5\"},{\"from\":-50,\"to\":-40,\"color\":\"rgba(66, 165, 245, 0.83)\"},{\"from\":-40,\"to\":-30,\"color\":\"rgba(66, 165, 245, 0.66)\"},{\"from\":-30,\"to\":-20,\"color\":\"rgba(66, 165, 245, 0.5)\"},{\"from\":-20,\"to\":-10,\"color\":\"rgba(66, 165, 245, 0.33)\"},{\"from\":-10,\"to\":0,\"color\":\"rgba(66, 165, 245, 0.16)\"},{\"from\":0,\"to\":10,\"color\":\"rgba(229, 115, 115, 0.16)\"},{\"from\":10,\"to\":20,\"color\":\"rgba(229, 115, 115, 0.33)\"},{\"from\":20,\"to\":30,\"color\":\"rgba(229, 115, 115, 0.5)\"},{\"from\":30,\"to\":40,\"color\":\"rgba(229, 115, 115, 0.66)\"},{\"from\":40,\"to\":50,\"color\":\"rgba(229, 115, 115, 0.83)\"},{\"from\":50,\"to\":60,\"color\":\"#e57373\"}],\"showUnitTitle\":true,\"colorPlate\":\"#cfd8dc\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"minorTicks\":2,\"valueInt\":3,\"valueDec\":1,\"highlightsWidth\":15,\"valueBox\":true,\"animation\":true,\"animationDuration\":1000,\"animationRule\":\"bounce\",\"colorNeedleShadowUp\":\"rgba(2, 255, 255, 0)\",\"colorNeedleShadowDown\":\"rgba(188, 143, 143, 0.78)\",\"units\":\"°C\",\"majorTicksCount\":12,\"numbersFont\":{\"family\":\"Roboto\",\"size\":20,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":28,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"size\":30,\"style\":\"normal\",\"weight\":\"normal\",\"shadowColor\":\"rgba(0, 0, 0, 0.49)\",\"color\":\"#444\"},\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\",\"unitTitle\":\"Temperature\",\"minValue\":-60},\"title\":\"Temperature radial gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Temperature radial gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'attributes_card', -'{"type":"latest","sizeX":7.5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}","controllerScript":"self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = ''tbDatasource'' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $(''#'' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $(''.tbDatasource-title'', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = ''table'' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $(''#'' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = ''labelCell'' + a;\n var cellId = ''cell'' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $(''#'' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $(''#'' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n self.ctx.valueCells[i].html(value);\n }\n } \n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css(''font-size'', datasoirceTitleFontSize+''px'');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css(''font-size'', valueFontSize+''px'');\n self.ctx.valueCells[i].css(''height'', valueFontSize*2.5+''px'');\n self.ctx.valueCells[i].css(''padding'', ''0px '' + valueFontSize + ''px'');\n self.ctx.labelCells[i].css(''font-size'', labelFontSize+''px'');\n self.ctx.labelCells[i].css(''height'', labelFontSize*2.5+''px'');\n self.ctx.labelCells[i].css(''padding'', ''0px '' + labelFontSize + ''px'');\n } \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"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\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Attributes card\"}"}', -'Attributes card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'digital_thermometer', -'{"type":"latest","sizeX":3,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < -60) {\\n\\tvalue = 60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":60,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":1,\"levelColors\":[\"#304ffe\",\"#7e57c2\",\"#ff4081\",\"#d32f2f\"],\"refreshAnimationType\":\"<>\",\"refreshAnimationTime\":700,\"startAnimationType\":\"<>\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":18},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"dashThickness\":1.5,\"decimals\":0,\"minValue\":-60,\"units\":\"°C\",\"gaugeColor\":\"#333333\",\"neonGlowBrightness\":35,\"gaugeType\":\"donut\"},\"title\":\"Digital thermometer\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital thermometer' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', -'raspberry_pi_gpio_control', -'{"type":"rpc","sizeX":6,"sizeY":10.5,"resources":[],"templateHtml":"
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n {{rpcErrorText}}\n \n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.switch-panel {\n margin: 0;\n height: 32px;\n width: 66px;\n min-width: 66px;\n}\n\n.switch-panel md-switch {\n margin: 0;\n width: 36px;\n min-width: 36px;\n}\n\n.switch-panel md-switch > div.md-container {\n margin: 0;\n}\n\n.switch-panel.col-0 md-switch {\n margin-left: 8px;\n margin-right: 4px;\n}\n\n.switch-panel.col-1 md-switch {\n margin-left: 4px;\n margin-right: 8px;\n}\n\n.gpio-row {\n height: 32px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.switch-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.switch-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n \n var i, gpio;\n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n scope.gpioList = [];\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false\n }\n );\n }\n\n scope.requestTimeout = settings.requestTimeout || 1000;\n\n scope.switchPanelBackgroundColor = settings.switchPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioStatusRequest = {\n method: \"getGpioStatus\",\n paramsBody: \"{}\"\n };\n \n if (settings.gpioStatusRequest) {\n scope.gpioStatusRequest.method = settings.gpioStatusRequest.method || scope.gpioStatusRequest.method;\n scope.gpioStatusRequest.paramsBody = settings.gpioStatusRequest.paramsBody || scope.gpioStatusRequest.paramsBody;\n }\n \n scope.gpioStatusChangeRequest = {\n method: \"setGpioStatus\",\n paramsBody: \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n };\n \n if (settings.gpioStatusChangeRequest) {\n scope.gpioStatusChangeRequest.method = settings.gpioStatusChangeRequest.method || scope.gpioStatusChangeRequest.method;\n scope.gpioStatusChangeRequest.paramsBody = settings.gpioStatusChangeRequest.paramsBody || scope.gpioStatusChangeRequest.paramsBody;\n }\n \n scope.parseGpioStatusFunction = \"return body[pin] === true;\";\n \n if (settings.parseGpioStatusFunction && settings.parseGpioStatusFunction.length > 0) {\n scope.parseGpioStatusFunction = settings.parseGpioStatusFunction;\n }\n \n scope.parseGpioStatusFunction = new Function(\"body, pin\", scope.parseGpioStatusFunction);\n \n function requestGpioStatus() {\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusRequest.method, \n scope.gpioStatusRequest.paramsBody, \n scope.requestTimeout)\n .then(\n function success(responseBody) {\n for (var g = 0; g < scope.gpioList.length; g++) {\n var gpio = scope.gpioList[g];\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled; \n }\n }\n );\n }\n \n function changeGpioStatus(gpio) {\n var pin = gpio.pin + '''';\n var enabled = !gpio.enabled;\n enabled = enabled === true ? ''true'' : ''false'';\n var paramsBody = scope.gpioStatusChangeRequest.paramsBody;\n var requestBody = JSON.parse(paramsBody.replace(\"\\\"{$pin}\\\"\", pin).replace(\"\\\"{$enabled}\\\"\", enabled));\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusChangeRequest.method, \n requestBody, scope.requestTimeout)\n .then(\n function success(responseBody) {\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled;\n }\n );\n }\n \n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n }\n\n scope.gpioClick = function($event, gpio) {\n changeGpioStatus(gpio);\n };\n\n requestGpioStatus(); \n \n self.onResize();\n}\n\nself.onResize = function() {\n var scope = self.ctx.$scope;\n var rowCount = scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n scope.prefferedRowHeight = prefferedRowHeight;\n var ratio = prefferedRowHeight/32;\n var switches = $(''md-switch'', self.ctx.$container);\n switches.css(''height'', 30*ratio+''px'');\n switches.css(''width'', 36*ratio+''px'');\n switches.css(''min-width'', 36*ratio+''px'');\n $(''.md-container'', switches).css(''height'', 24*ratio+''px'');\n $(''.md-container'', switches).css(''width'', 36*ratio+''px'');\n var bars = $(''.md-bar'', self.ctx.$container);\n bars.css(''height'', 14*ratio+''px'');\n bars.css(''width'', 34*ratio+''px'');\n var thumbs = $(''.md-thumb'', self.ctx.$container);\n thumbs.css(''height'', 20*ratio+''px'');\n thumbs.css(''width'', 20*ratio+''px'');\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio switches\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio switch\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\"]\n }\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n },\n \"switchPanelBackgroundColor\": {\n \"title\": \"Switches panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n },\n \"gpioStatusRequest\": {\n \"title\": \"GPIO status request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"getGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"gpioStatusChangeRequest\": {\n \"title\": \"GPIO status change request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"setGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"parseGpioStatusFunction\": {\n \"title\": \"Parse gpio status function\",\n \"type\": \"string\",\n \"default\": \"return body[pin] === true;\"\n } \n },\n \"required\": [\"gpioList\", \n \"requestTimeout\",\n \"switchPanelBackgroundColor\",\n \"gpioStatusRequest\",\n \"gpioStatusChangeRequest\",\n \"parseGpioStatusFunction\"]\n },\n \"form\": [\n \"gpioList\",\n \"requestTimeout\",\n {\n \"key\": \"switchPanelBackgroundColor\",\n \"type\": \"color\"\n },\n {\n \"key\": \"gpioStatusRequest\",\n \"items\": [\n \"gpioStatusRequest.method\",\n {\n \"key\": \"gpioStatusRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"gpioStatusChangeRequest\",\n \"items\": [\n \"gpioStatusChangeRequest.method\",\n {\n \"key\": \"gpioStatusChangeRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"parseGpioStatusFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"targetDeviceAliases\":[],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"parseGpioStatusFunction\":\"return body[pin] === true;\",\"gpioStatusChangeRequest\":{\"method\":\"setGpioStatus\",\"paramsBody\":\"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"},\"requestTimeout\":500,\"switchPanelBackgroundColor\":\"#008a00\",\"gpioStatusRequest\":{\"method\":\"getGpioStatus\",\"paramsBody\":\"{}\"},\"gpioList\":[{\"pin\":7,\"label\":\"GPIO 4 (GPCLK0)\",\"row\":3,\"col\":0,\"_uniqueKey\":0},{\"pin\":11,\"label\":\"GPIO 17\",\"row\":5,\"col\":0,\"_uniqueKey\":1},{\"pin\":12,\"label\":\"GPIO 18\",\"row\":5,\"col\":1,\"_uniqueKey\":2},{\"_uniqueKey\":3,\"pin\":13,\"label\":\"GPIO 27\",\"row\":6,\"col\":0},{\"_uniqueKey\":4,\"pin\":15,\"label\":\"GPIO 22\",\"row\":7,\"col\":0},{\"_uniqueKey\":5,\"pin\":16,\"label\":\"GPIO 23\",\"row\":7,\"col\":1},{\"_uniqueKey\":6,\"pin\":18,\"label\":\"GPIO 24\",\"row\":8,\"col\":1},{\"_uniqueKey\":7,\"pin\":22,\"label\":\"GPIO 25\",\"row\":10,\"col\":1},{\"_uniqueKey\":8,\"pin\":29,\"label\":\"GPIO 5\",\"row\":14,\"col\":0},{\"_uniqueKey\":9,\"pin\":31,\"label\":\"GPIO 6\",\"row\":15,\"col\":0},{\"_uniqueKey\":10,\"pin\":32,\"label\":\"GPIO 12\",\"row\":15,\"col\":1},{\"_uniqueKey\":11,\"pin\":33,\"label\":\"GPIO 13\",\"row\":16,\"col\":0},{\"_uniqueKey\":12,\"pin\":35,\"label\":\"GPIO 19\",\"row\":17,\"col\":0},{\"_uniqueKey\":13,\"pin\":36,\"label\":\"GPIO 16\",\"row\":17,\"col\":1},{\"_uniqueKey\":14,\"pin\":37,\"label\":\"GPIO 26\",\"row\":18,\"col\":0},{\"_uniqueKey\":15,\"pin\":38,\"label\":\"GPIO 20\",\"row\":18,\"col\":1},{\"_uniqueKey\":16,\"pin\":40,\"label\":\"GPIO 21\",\"row\":19,\"col\":1}]},\"title\":\"Raspberry Pi GPIO Control\"}"}', -'Raspberry Pi GPIO Control' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', -'basic_gpio_control', -'{"type":"rpc","sizeX":4,"sizeY":2,"resources":[],"templateHtml":"
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n {{rpcErrorText}}\n \n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.switch-panel {\n margin: 0;\n height: 32px;\n width: 66px;\n min-width: 66px;\n}\n\n.switch-panel md-switch {\n margin: 0;\n width: 36px;\n min-width: 36px;\n}\n\n.switch-panel md-switch > div.md-container {\n margin: 0;\n}\n\n.switch-panel.col-0 md-switch {\n padding-left: 8px;\n padding-right: 4px;\n}\n\n.switch-panel.col-1 md-switch {\n padding-left: 4px;\n padding-right: 8px;\n}\n\n.gpio-row {\n height: 32px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.switch-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.switch-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n \n var i, gpio;\n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n scope.gpioList = [];\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false\n }\n );\n }\n\n scope.requestTimeout = settings.requestTimeout || 1000;\n\n scope.switchPanelBackgroundColor = settings.switchPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioStatusRequest = {\n method: \"getGpioStatus\",\n paramsBody: \"{}\"\n };\n \n if (settings.gpioStatusRequest) {\n scope.gpioStatusRequest.method = settings.gpioStatusRequest.method || scope.gpioStatusRequest.method;\n scope.gpioStatusRequest.paramsBody = settings.gpioStatusRequest.paramsBody || scope.gpioStatusRequest.paramsBody;\n }\n \n scope.gpioStatusChangeRequest = {\n method: \"setGpioStatus\",\n paramsBody: \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n };\n \n if (settings.gpioStatusChangeRequest) {\n scope.gpioStatusChangeRequest.method = settings.gpioStatusChangeRequest.method || scope.gpioStatusChangeRequest.method;\n scope.gpioStatusChangeRequest.paramsBody = settings.gpioStatusChangeRequest.paramsBody || scope.gpioStatusChangeRequest.paramsBody;\n }\n \n scope.parseGpioStatusFunction = \"return body[pin] === true;\";\n \n if (settings.parseGpioStatusFunction && settings.parseGpioStatusFunction.length > 0) {\n scope.parseGpioStatusFunction = settings.parseGpioStatusFunction;\n }\n \n scope.parseGpioStatusFunction = new Function(\"body, pin\", scope.parseGpioStatusFunction);\n \n function requestGpioStatus() {\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusRequest.method, \n scope.gpioStatusRequest.paramsBody, \n scope.requestTimeout)\n .then(\n function success(responseBody) {\n for (var g = 0; g < scope.gpioList.length; g++) {\n var gpio = scope.gpioList[g];\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled; \n }\n }\n );\n }\n \n function changeGpioStatus(gpio) {\n var pin = gpio.pin + '''';\n var enabled = !gpio.enabled;\n enabled = enabled === true ? ''true'' : ''false'';\n var paramsBody = scope.gpioStatusChangeRequest.paramsBody;\n var requestBody = JSON.parse(paramsBody.replace(\"\\\"{$pin}\\\"\", pin).replace(\"\\\"{$enabled}\\\"\", enabled));\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusChangeRequest.method, \n requestBody, scope.requestTimeout)\n .then(\n function success(responseBody) {\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled;\n }\n );\n }\n \n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n }\n\n scope.gpioClick = function($event, gpio) {\n changeGpioStatus(gpio);\n };\n\n requestGpioStatus(); \n \n self.onResize();\n}\n\nself.onResize = function() {\n var scope = self.ctx.$scope;\n var rowCount = scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n scope.prefferedRowHeight = prefferedRowHeight;\n var ratio = prefferedRowHeight/32;\n var switches = $(''md-switch'', self.ctx.$container);\n switches.css(''height'', 30*ratio+''px'');\n switches.css(''width'', 36*ratio+''px'');\n switches.css(''min-width'', 36*ratio+''px'');\n $(''.md-container'', switches).css(''height'', 24*ratio+''px'');\n $(''.md-container'', switches).css(''width'', 36*ratio+''px'');\n var bars = $(''.md-bar'', self.ctx.$container);\n bars.css(''height'', 14*ratio+''px'');\n bars.css(''width'', 34*ratio+''px'');\n var thumbs = $(''.md-thumb'', self.ctx.$container);\n thumbs.css(''height'', 20*ratio+''px'');\n thumbs.css(''width'', 20*ratio+''px'');\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio switches\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio switch\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\"]\n }\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n },\n \"switchPanelBackgroundColor\": {\n \"title\": \"Switches panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n },\n \"gpioStatusRequest\": {\n \"title\": \"GPIO status request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"getGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"gpioStatusChangeRequest\": {\n \"title\": \"GPIO status change request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"setGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"parseGpioStatusFunction\": {\n \"title\": \"Parse gpio status function\",\n \"type\": \"string\",\n \"default\": \"return body[pin] === true;\"\n } \n },\n \"required\": [\"gpioList\", \n \"requestTimeout\",\n \"switchPanelBackgroundColor\",\n \"gpioStatusRequest\",\n \"gpioStatusChangeRequest\",\n \"parseGpioStatusFunction\"]\n },\n \"form\": [\n \"gpioList\",\n \"requestTimeout\",\n {\n \"key\": \"switchPanelBackgroundColor\",\n \"type\": \"color\"\n },\n {\n \"key\": \"gpioStatusRequest\",\n \"items\": [\n \"gpioStatusRequest.method\",\n {\n \"key\": \"gpioStatusRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"gpioStatusChangeRequest\",\n \"items\": [\n \"gpioStatusChangeRequest.method\",\n {\n \"key\": \"gpioStatusChangeRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"parseGpioStatusFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"targetDeviceAliases\":[],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"parseGpioStatusFunction\":\"return body[pin] === true;\",\"gpioStatusChangeRequest\":{\"method\":\"setGpioStatus\",\"paramsBody\":\"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"},\"requestTimeout\":500,\"switchPanelBackgroundColor\":\"#b71c1c\",\"gpioStatusRequest\":{\"method\":\"getGpioStatus\",\"paramsBody\":\"{}\"},\"gpioList\":[{\"pin\":1,\"label\":\"GPIO 1\",\"row\":0,\"col\":0,\"_uniqueKey\":0},{\"pin\":2,\"label\":\"GPIO 2\",\"row\":0,\"col\":1,\"_uniqueKey\":1},{\"pin\":3,\"label\":\"GPIO 3\",\"row\":1,\"col\":0,\"_uniqueKey\":2}]},\"title\":\"Basic GPIO Control\"}"}', -'Basic GPIO Control' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'horizontal_bar_justgage', -'{"type":"latest","sizeX":7,"sizeY":3,"resources":[],"templateHtml":"\n","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#999999\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":18,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#666666\"},\"neonGlowBrightness\":0,\"decimals\":0,\"dashThickness\":0,\"gaugeColor\":\"#eeeeee\",\"showTitle\":true,\"gaugeType\":\"horizontalBar\"},\"title\":\"Horizontal bar - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Horizontal bar - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'gauge_justgage', -'{"type":"latest","sizeX":4,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#999999\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":36,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#666666\"},\"neonGlowBrightness\":0,\"decimals\":0,\"dashThickness\":0,\"gaugeColor\":\"#eeeeee\",\"showTitle\":true,\"gaugeType\":\"arc\"},\"title\":\"Gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'lcd_gauge', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 180) {\\n\\tvalue = 180;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#babab2\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":1.5,\"decimals\":0,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"defaultColor\":\"#444444\",\"gaugeType\":\"arc\"},\"title\":\"LCD gauge\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'LCD gauge' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'openstreetmap', -'{"type":"latest","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''openstreet-map'', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Google Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"markersSettings\": {\n \"title\": \"Markers\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n }, \n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n }\n }\n }\n }\n },\n \"required\": [\n ]\n },\n \"form\": [\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"markersSettings\",\n \"items\": [\n \"markersSettings[].latKeyName\",\n \"markersSettings[].lngKeyName\",\n \"markersSettings[].showLabel\",\n \"markersSettings[].label\",\n \"markersSettings[].tooltipPattern\",\n {\n \"key\": \"markersSettings[].color\",\n \"type\": \"color\"\n },\n \"markersSettings[].useColorFunction\",\n {\n \"key\": \"markersSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"markersSettings[].markerImageSize\",\n \"markersSettings[].useMarkerImageFunction\",\n {\n \"key\": \"markersSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"markersSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"lat\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"lng\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.21553274887887564,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"markersSettings\":[{\"label\":\"First point\",\"color\":\"#1e88e5\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"useColorFunction\":true,\"colorFunction\":\"var temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix(''blue'', ''red'', amount = percent).toHexString();\\n}\\nreturn ''blue'';\"},{\"label\":\"Second point\",\"color\":\"#fdd835\",\"latKeyName\":\"lat\",\"lngKeyName\":\"lng\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${lat:7}
Longitude: ${lng:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"markerImageSize\":34,\"useMarkerImageFunction\":true,\"markerImages\":[\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDM3NSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ0NzA3IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgwIC03MTcuNzcgLTcxNy43NyAwIDcwOC4xOCAyMTY4KSI+CiAgIDxzdG9wIGlkPSJzdG9wNDcwOSIgc3RvcC1jb2xvcj0iIzg0ZDdmNSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDcxMSIgc3RvcC1jb2xvcj0iIzZhOTljZCIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NDY4OSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoNDU2LjIyIDAgMCAtNDU2LjIyIDQ4MC4wNyAzNDk0LjMpIj4KICAgPHN0b3AgaWQ9InN0b3A0NjkxIiBzdG9wLWNvbG9yPSIjY2FjOWM4IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A0NjkzIiBzdG9wLWNvbG9yPSIjZjZmNmY2IiBvZmZzZXQ9Ii43NTI2OSIvPgogICA8c3RvcCBpZD0ic3RvcDQ2OTUiIHN0b3AtY29sb3I9IiNkNGQzZDIiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDQ2NjkiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEzNDUuNSAwIDAgLTEzNDUuNSAzNS40MjcgMzA2NS45KSI+CiAgIDxzdG9wIGlkPSJzdG9wNDY3MSIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDY3MyIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIuMSIvPgogICA8c3RvcCBpZD0ic3RvcDQ2NzUiIHN0b3AtY29sb3I9IiNmZWZmZmYiIG9mZnNldD0iLjI0NzMxIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDY3NyIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NDU5MSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMCAtNzE3Ljc3IC03MTcuNzcgMCA3MDguMTggMjE2OCkiPgogICA8c3RvcCBpZD0ic3RvcDQ1OTMiIHN0b3AtY29sb3I9IiM1ZmM3ZjMiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDQ1OTUiIHN0b3AtY29sb3I9IiMyNzUyYTYiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDQ1NzMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiA0ODAuMDcgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNDU3NSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDU3NyIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A0NTc5IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MDk3LTMiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDk5LTEiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTM4Mi4zMiAxOTAuMDNjMCA0OC4yMzkgMTQ1Ljg5IDg3LjM5MSAzMjUuODkgODcuMzkxIDE3OS45MSAwIDMyNS44LTM5LjE1MiAzMjUuOC04Ny4zOTEgMC00OC4zMzItMTQ1Ljg5LTg3LjQ4LTMyNS44LTg3LjQ4LTE4MCAwLTMyNS44OSAzOS4xNDgtMzI1Ljg5IDg3LjQ4Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNDcwMy00IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNDcwNS05IiBkPSJtNTg1LjI5IDE1MzEuN2MtMzAuNDY1IDAtNTUuMTYgMjQuNjktNTUuMTYgNTUuMTZ2NTI4LjUyYzI4LjQzNyAzMS4xOCA2Ni45OTIgNTMuMDEgMTEwLjMyIDYwLjE0di01ODguNjZjMC0zMC40Ny0yNC42OTUtNTUuMTYtNTUuMTYtNTUuMTYiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0Njg1LTYiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0Njg3LTgiIGQ9Im01MzAuMTMgMjExNS40djM1NzQuMmMwIDMwLjQ2IDI0LjY5NSA1NS4xNiA1NS4xNiA1NS4xNnM1NS4xNi0yNC43IDU1LjE2LTU1LjE2di0zNTE0Yy00My4zMjgtNy4xMy04MS44ODMtMjguOTYtMTEwLjMyLTYwLjE0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNDY2NS02IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNDY2Ny00IiBkPSJtNzQ0LjkgNTk0MS45aC03My40NDJjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjItMzU2LjA3LTM0My4yLTM1Ni4wNy01OTMuNjUtMC4wMDQtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk2IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk2IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di00MjYxLjJjMjA5LjI5LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMThzLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOGMwIDI0MS42OSAxNDYuNzkgNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyNSAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NDIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjUxLTgiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjUzLTciIGQ9Im03MDguMTggOTU1Ljg5Yy0xMzUuNDggMC0yNTIuNTkgNDkuODY5LTMwOC4xNCAxMjIuMjQtMjIuMDA4IDI4LjY3LTM0LjM1OSA2MC44OC0zNC4zNTkgOTQuOTEgMCAxMTkuOTIgMTUzLjM0IDIxNy4xNCAzNDIuNSAyMTcuMTQgMTY0LjQgMCAzMDEuNzQtNzMuNDQgMzM0Ljg4LTE3MS4zOSA0Ljk4LTE0Ljc1IDcuNjEtMzAuMDYgNy42MS00NS43NSAwLTUzLjkyLTMwLjk5LTEwMy4yNC04Mi4yOTUtMTQxLjIxLTYyLjgxMi00Ni40NzgtMTU2LjA5LTc1LjkzOS0yNjAuMi03NS45MzkiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjM1LTEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjM3LTciIGQ9Im05NjguMzggMTAzMS44YzUxLjMwNSAzNy45NyA4Mi4yOTUgODcuMjkgODIuMjk1IDE0MS4yMXYtMC43MmMtMC4yOC01My42My0zMS4yMS0xMDIuNjktODIuMjk1LTE0MC40OW0tNTY4LjM0IDQ2LjNjLTIyLjAwOCAyOC42Ny0zNC4zNTkgNjAuODgtMzQuMzU5IDk0LjkxIDAtMzQuMDMgMTIuMzUxLTY2LjI0IDM0LjM1OS05NC45MW02NTAuNjMgOTQuOTFjMCAxNS42OS0yLjYzIDMxLTcuNjEgNDUuNzUgNC45MS0xNC41MyA3LjUzLTI5LjYgNy42MS00NS4wNHYtMC43MSIvPgogIDwvY2xpcFBhdGg+CiAgPHJhZGlhbEdyYWRpZW50IGlkPSJyYWRpYWxHcmFkaWVudDQ2MzktMiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGN5PSIwIiBjeD0iMCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCg1ODQuMTggMCAwIC01ODQuMTggNzA4LjE4IDg2Mi43NSkiIHI9IjEiPgogICA8c3RvcCBpZD0ic3RvcDQ2NDEtNyIgc3RvcC1jb2xvcj0iIzVmYzdmMyIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDY0My0yIiBzdG9wLWNvbG9yPSIjMjc1MmE2IiBvZmZzZXQ9IjEiLz4KICA8L3JhZGlhbEdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjE5LTIiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjIxLTYiIGQ9Im0xMDUwLjcgMTE3Mi4zdjAuNzIgMC43MS0wLjcxLTAuNzIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjAzLTEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjA1LTUiIGQ9Im0xMjkyLjQgODYyLjc1YzAtMzIyLjY0LTI2MS41NC01ODQuMTgtNTg0LjE3LTU4NC4xOHMtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4YzAgMzIyLjYzIDI2MS41NSA1ODQuMTggNTg0LjE4IDU4NC4xOHM1ODQuMTctMjYxLjU1IDU4NC4xNy01ODQuMTgiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NTg3LTAiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NTg5LTkiIGQ9Im00ODAuMDcgMTQwMC43djU4NS45NWMwIDQ5LjQ1IDE5IDk0LjY3IDUwLjA2NyAxMjguNzJ2LTUyOC41MmMwLTMwLjQ3IDI0LjY5NS01NS4xNiA1NS4xNi01NS4xNnM1NS4xNiAyNC42OSA1NS4xNiA1NS4xNnY1ODguNjZjMTAuMDk4IDEuNjcgMjAuNDUzIDIuNTMgMzEuMDA0IDIuNTNoNzMuNDM4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM5di01ODUuOTNjLTcwLjA4MiAyOS43Ni0xNDcuMTcgNDYuMjItMjI4LjEgNDYuMjItODAuOTM0IDAtMTU4LjAzLTE2LjQ2LTIyOC4xMS00Ni4yMiIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDQ1NjktNyIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDQ1NzEtMSIgZD0ibTkzNi4yOCAxOTg2LjZjMCAxMDUuMjctODYuMTIxIDE5MS4zOS0xOTEuMzkgMTkxLjM5aC03My40MzhjLTEwLjU1MSAwLTIwLjkwNi0wLjg2LTMxLjAwNC0yLjUzdjM1MTRjMCAzMC40Ni0yNC42OTUgNTUuMTYtNTUuMTYgNTUuMTZzLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTM1NzQuMmMtMzEuMDY3LTM0LjA1LTUwLjA2Ny03OS4yNy01MC4wNjctMTI4LjcydjM2NzUuM2MwIDEzLjQ2IDEuNDExIDI2LjYyIDQuMDkgMzkuMzEgMTguMjYyIDg2LjU3IDk1LjUwNCAxNTIuMDcgMTg3LjMgMTUyLjA3aDczLjQzOGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtMzY3NS4zIi8+CiAgPC9jbGlwUGF0aD4KIDwvZGVmcz4KIDxnIGlkPSJsYXllcjEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0zNjIuNzYgLTE4OC44NCkiPgogIDxnIGZpbGw9IiNmZmYiPgogICA8cGF0aCBpZD0icGF0aDQxODMiIGQ9Im00NTEuMjggOTM2Yy00OC44ODkgMC04OC41MjItMTAuNjMyLTg4LjUyMi0yMy43NSAwLTcuMDg2NCAxMS41NzEtMTMuNDQ5IDI5LjkxOC0xNy44IDAuMDM1NiAwLjAyOTggMC4wNjc0IDAuMDU4NSAwLjEwMjUgMC4wODk3LTE4LjIyIDQuMzM1LTI5LjcgMTAuNjYzLTI5LjcgMTcuNzEgMCAxMy4wNjkgMzkuNDg5IDIzLjY2NCA4OC4yMDEgMjMuNjY0czg4LjItMTAuNTk1IDg4LjItMjMuNjY0YzAtNy4wNDc0LTExLjQ4LTEzLjM3NS0yOS43LTE3LjcxIDAuMDM1LTAuMDMxMiAwLjA2NzUtMC4wNiAwLjEwMjUtMC4wODk3IDE4LjM0OCA0LjM1MSAyOS45MTkgMTAuNzEzIDI5LjkxOSAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjIgMjMuNzUiLz4KICAgPHBhdGggaWQ9InBhdGg0MTg1IiBkPSJtNDUxLjI4IDkzNS45MmMtNDguNzEyIDAtODguMjAxLTEwLjU5NS04OC4yMDEtMjMuNjY0IDAtNy4wNDc0IDExLjQ4LTEzLjM3NSAyOS43LTE3LjcxIDAuMDMzMyAwLjAyODkgMC4wNjk5IDAuMDYxMSAwLjEwMyAwLjA5MTQtMTguMDg5IDQuMzE3NC0yOS40ODIgMTAuNjExLTI5LjQ4MiAxNy42MTkgMCAxMy4wMjEgMzkuMzQ1IDIzLjU3OCA4Ny44OCAyMy41NzggNDguNTM2IDAgODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OCAwLTcuMDA3NC0xMS4zOTQtMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzI1LTAuMDMwMiAwLjA3LTAuMDYyNSAwLjEwMjUtMC4wOTE0IDE4LjIyIDQuMzM1IDI5LjcgMTAuNjYzIDI5LjcgMTcuNzEgMCAxMy4wNjktMzkuNDg5IDIzLjY2NC04OC4yIDIzLjY2NCIvPgogICA8cGF0aCBpZD0icGF0aDQxODciIGQ9Im00NTEuMjggOTM1LjgzYy00OC41MzUgMC04Ny44OC0xMC41NTctODcuODgtMjMuNTc4IDAtNy4wMDc0IDExLjM5My0xMy4zMDEgMjkuNDgyLTE3LjYxOSAwLjAzNTEgMC4wMjk3IDAuMDY3OSAwLjA1ODYgMC4xMDMgMC4wODk5LTE3Ljk1OSA0LjMwMjYtMjkuMjY1IDEwLjU2Mi0yOS4yNjUgMTcuNTI5IDAgMTIuOTc0IDM5LjIwMiAyMy40OTEgODcuNTYgMjMuNDkxczg3LjU1OS0xMC41MTggODcuNTU5LTIzLjQ5MWMwLTYuOTY3Mi0xMS4zMDUtMTMuMjI2LTI5LjI2NS0xNy41MjkgMC4wMzYyLTAuMDMxMiAwLjA2NzUtMC4wNjAxIDAuMTAzNzUtMC4wODk5IDE4LjA4OSA0LjMxNzQgMjkuNDgyIDEwLjYxMSAyOS40ODIgMTcuNjE5IDAgMTMuMDIxLTM5LjM0NSAyMy41NzgtODcuODggMjMuNTc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDE4OSIgZD0ibTQ1MS4yOCA5MzUuNzRjLTQ4LjM1OCAwLTg3LjU2LTEwLjUxOC04Ny41Ni0yMy40OTEgMC02Ljk2NzIgMTEuMzA2LTEzLjIyNiAyOS4yNjUtMTcuNTI5IDAuMDMzNyAwLjAyODggMC4wNzA0IDAuMDYxIDAuMTAzNjIgMC4wODk3LTE3LjgzIDQuMjg2Ny0yOS4wNDcgMTAuNTExLTI5LjA0NyAxNy40MzkgMCAxMi45MjYgMzkuMDU4IDIzLjQwNiA4Ny4yMzkgMjMuNDA2IDQ4LjE4IDAgODcuMjM5LTEwLjQ4IDg3LjIzOS0yMy40MDYgMC02LjkyNzgtMTEuMjE5LTEzLjE1Mi0yOS4wNDktMTcuNDM5IDAuMDMzNy0wLjAyODcgMC4wNzEyLTAuMDYxIDAuMTAzNzUtMC4wODk3IDE3Ljk2IDQuMzAyNiAyOS4yNjUgMTAuNTYyIDI5LjI2NSAxNy41MjkgMCAxMi45NzQtMzkuMjAxIDIzLjQ5MS04Ny41NTkgMjMuNDkxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDE5MSIgZD0ibTQ1MS4yOCA5MzUuNjZjLTQ4LjE4MSAwLTg3LjIzOS0xMC40OC04Ny4yMzktMjMuNDA2IDAtNi45Mjc4IDExLjIxOC0xMy4xNTIgMjkuMDQ3LTE3LjQzOSAwLjAzNTYgMC4wMzEyIDAuMDY4MyAwLjA2MDEgMC4xMDQgMC4wOTE0LTE3LjcwMSA0LjI2OS0yOC44MzEgMTAuNDU5LTI4LjgzMSAxNy4zNDggMCAxMi44NzkgMzguOTE0IDIzLjMyIDg2LjkxOCAyMy4zMiA0OC4wMDMgMCA4Ni45MTgtMTAuNDQxIDg2LjkxOC0yMy4zMiAwLTYuODg4Ni0xMS4xMy0xMy4wNzktMjguODMxLTE3LjM0OCAwLjAzNjMtMC4wMzEyIDAuMDY4OC0wLjA2MDEgMC4xMDM3NS0wLjA5MTQgMTcuODMgNC4yODY2IDI5LjA0OSAxMC41MTEgMjkuMDQ5IDE3LjQzOSAwIDEyLjkyNi0zOS4wNTkgMjMuNDA2LTg3LjIzOSAyMy40MDYiLz4KICAgPHBhdGggaWQ9InBhdGg0MTkzIiBkPSJtNDUxLjI4IDkzNS41N2MtNDguMDA0IDAtODYuOTE4LTEwLjQ0MS04Ni45MTgtMjMuMzIgMC02Ljg4ODYgMTEuMTI5LTEzLjA3OSAyOC44MzEtMTcuMzQ4IDAuMDM1NiAwLjAzMTIgMC4wNjg4IDAuMDYgMC4xMDQgMC4wOTAyLTE3LjU2OSA0LjI1MjYtMjguNjE0IDEwLjQxLTI4LjYxNCAxNy4yNTcgMCAxMi44MzEgMzguNzcxIDIzLjIzNCA4Ni41OTcgMjMuMjM0IDQ3LjgyNyAwIDg2LjU5Ny0xMC40MDMgODYuNTk3LTIzLjIzNCAwLTYuODQ3Ni0xMS4wNDUtMTMuMDA1LTI4LjYxNC0xNy4yNTcgMC4wMzUtMC4wMzAzIDAuMDY4Ny0wLjA1OSAwLjEwMzc1LTAuMDkwMiAxNy43MDEgNC4yNjkgMjguODMxIDEwLjQ1OSAyOC44MzEgMTcuMzQ4IDAgMTIuODc5LTM4LjkxNSAyMy4zMi04Ni45MTggMjMuMzIiLz4KICAgPHBhdGggaWQ9InBhdGg0MTk1IiBkPSJtNDUxLjI4IDkzNS40OWMtNDcuODI3IDAtODYuNTk3LTEwLjQwMy04Ni41OTctMjMuMjM0IDAtNi44NDc2IDExLjA0NS0xMy4wMDUgMjguNjE0LTE3LjI1NyAwLjAzMzYgMC4wMjk5IDAuMDcxMiAwLjA2MjUgMC4xMDQ1IDAuMDkxNC0xNy40NDEgNC4yMzQ5LTI4LjM5OCAxMC4zNTctMjguMzk4IDE3LjE2NiAwIDEyLjc4NCAzOC42MjcgMjMuMTQ3IDg2LjI3NyAyMy4xNDcgNDcuNjQ5IDAgODYuMjc3LTEwLjM2NCA4Ni4yNzctMjMuMTQ3IDAtNi44MDg2LTEwLjk1OC0xMi45MzEtMjguMzk5LTE3LjE2NiAwLjAzMzctMC4wMjg5IDAuMDcxMi0wLjA2MTUgMC4xMDUtMC4wOTE0IDE3LjU2OSA0LjI1MjUgMjguNjE0IDEwLjQxIDI4LjYxNCAxNy4yNTcgMCAxMi44MzEtMzguNzcgMjMuMjM0LTg2LjU5NyAyMy4yMzQiLz4KICAgPHBhdGggaWQ9InBhdGg0MTk3IiBkPSJtNDUxLjI4IDkzNS40Yy00Ny42NSAwLTg2LjI3Ny0xMC4zNjQtODYuMjc3LTIzLjE0NyAwLTYuODA4NiAxMC45NTctMTIuOTMxIDI4LjM5OC0xNy4xNjYgMC4wMzU2IDAuMDMwOCAwLjA2OTMgMC4wNTk1IDAuMTA0ODggMC4wOTA4LTE3LjMxIDQuMjE4Ny0yOC4xODIgMTAuMzA3LTI4LjE4MiAxNy4wNzUgMCAxMi43MzYgMzguNDg0IDIzLjA2MSA4NS45NTYgMjMuMDYxczg1Ljk1Ni0xMC4zMjUgODUuOTU2LTIzLjA2MWMwLTYuNzY4NS0xMC44NzEtMTIuODU2LTI4LjE4MS0xNy4wNzUgMC4wMzUtMC4wMzEyIDAuMDY4OC0wLjA2IDAuMTAzNzUtMC4wOTA4IDE3LjQ0MSA0LjIzNDkgMjguMzk5IDEwLjM1NyAyOC4zOTkgMTcuMTY2IDAgMTIuNzg0LTM4LjYyOCAyMy4xNDctODYuMjc3IDIzLjE0NyIvPgogICA8cGF0aCBpZD0icGF0aDQxOTkiIGQ9Im00NTEuMjggOTM1LjMxYy00Ny40NzIgMC04NS45NTYtMTAuMzI1LTg1Ljk1Ni0yMy4wNjEgMC02Ljc2ODUgMTAuODcyLTEyLjg1NiAyOC4xODItMTcuMDc1IDAuMDM1NyAwLjAzMDQgMC4wNjk5IDAuMDYwMSAwLjEwNTUgMC4wOTA0LTE3LjE4NCA0LjIwMjEtMjcuOTY3IDEwLjI1Ni0yNy45NjcgMTYuOTg1IDAgMTIuNjg4IDM4LjM0IDIyLjk3NiA4NS42MzUgMjIuOTc2IDQ3LjI5NCAwIDg1LjYzNi0xMC4yODggODUuNjM2LTIyLjk3NiAwLTYuNzI5LTEwLjc4NC0xMi43ODMtMjcuOTY4LTE2Ljk4NSAwLjAzNjItMC4wMzAzIDAuMDctMC4wNiAwLjEwNjI1LTAuMDkwNCAxNy4zMSA0LjIxODggMjguMTgxIDEwLjMwNyAyOC4xODEgMTcuMDc1IDAgMTIuNzM2LTM4LjQ4NCAyMy4wNjEtODUuOTU2IDIzLjA2MSIvPgogICA8cGF0aCBpZD0icGF0aDQyMDEiIGQ9Im00NTEuMjggOTM1LjIzYy00Ny4yOTUgMC04NS42MzUtMTAuMjg4LTg1LjYzNS0yMi45NzYgMC02LjcyOSAxMC43ODMtMTIuNzgzIDI3Ljk2Ny0xNi45ODUgMC4wMzM4IDAuMDI4OSAwLjA3MTggMC4wNjI1IDAuMTA1NSAwLjA5MTQtMTcuMDU0IDQuMTg0NS0yNy43NTEgMTAuMjA0LTI3Ljc1MSAxNi44OTQgMCAxMi42NDEgMzguMTk2IDIyLjg5IDg1LjMxNCAyMi44OXM4NS4zMTQtMTAuMjQ5IDg1LjMxNC0yMi44OWMwLTYuNjg5LTEwLjY5OC0xMi43MDktMjcuNzUxLTE2Ljg5NCAwLjAzMzctMC4wMjg5IDAuMDcxMi0wLjA2MjUgMC4xMDUtMC4wOTE0IDE3LjE4NCA0LjIwMjEgMjcuOTY4IDEwLjI1NiAyNy45NjggMTYuOTg1IDAgMTIuNjg4LTM4LjM0MSAyMi45NzYtODUuNjM2IDIyLjk3NiIvPgogICA8cGF0aCBpZD0icGF0aDQyMDMiIGQ9Im00NTEuMjggOTM1LjE0Yy00Ny4xMTggMC04NS4zMTQtMTAuMjQ5LTg1LjMxNC0yMi44OSAwLTYuNjg5IDEwLjY5Ny0xMi43MDkgMjcuNzUxLTE2Ljg5NCAwLjAzNTYgMC4wMjk4IDAuMDY5NyAwLjA1OTUgMC4xMDYgMC4wODk3LTE2LjkyNSA0LjE2OS0yNy41MzcgMTAuMTU0LTI3LjUzNyAxNi44MDQgMCAxMi41OTQgMzguMDUzIDIyLjgwNCA4NC45OTQgMjIuODA0IDQ2Ljk0IDAgODQuOTkzLTEwLjIxIDg0Ljk5My0yMi44MDQgMC02LjY0OTktMTAuNjExLTEyLjYzNS0yNy41MzYtMTYuODA0IDAuMDM2Mi0wLjAzMDMgMC4wNy0wLjA2IDAuMTA2MjUtMC4wODk3IDE3LjA1NCA0LjE4NDUgMjcuNzUxIDEwLjIwNCAyNy43NTEgMTYuODk0IDAgMTIuNjQxLTM4LjE5NiAyMi44OS04NS4zMTQgMjIuODkiLz4KICAgPHBhdGggaWQ9InBhdGg0MjA1IiBkPSJtNDUxLjI4IDkzNS4wNmMtNDYuOTQgMC04NC45OTQtMTAuMjEtODQuOTk0LTIyLjgwNCAwLTYuNjQ5OSAxMC42MTEtMTIuNjM1IDI3LjUzNy0xNi44MDQgMC4wMzU2IDAuMDMxMiAwLjA3MDMgMC4wNjAxIDAuMTA1ODcgMC4wOTE0LTE2Ljc5NiA0LjE1MTQtMjcuMzIyIDEwLjEwMi0yNy4zMjIgMTYuNzEyIDAgMTIuNTQ2IDM3LjkwOSAyMi43MTcgODQuNjczIDIyLjcxNyA0Ni43NjMgMCA4NC42NzMtMTAuMTcxIDg0LjY3My0yMi43MTcgMC02LjYwOTktMTAuNTI2LTEyLjU2MS0yNy4zMjItMTYuNzEyIDAuMDM2Mi0wLjAzMTIgMC4wNzEyLTAuMDYwMSAwLjEwNjI1LTAuMDkxNCAxNi45MjUgNC4xNjkgMjcuNTM2IDEwLjE1NCAyNy41MzYgMTYuODA0IDAgMTIuNTk0LTM4LjA1MiAyMi44MDQtODQuOTkzIDIyLjgwNCIvPgogICA8cGF0aCBpZD0icGF0aDQyMDciIGQ9Im00NTEuMjggOTM0Ljk3Yy00Ni43NjQgMC04NC42NzMtMTAuMTcxLTg0LjY3My0yMi43MTcgMC02LjYwOTkgMTAuNTI1LTEyLjU2MSAyNy4zMjItMTYuNzEyIDAuMDM2MSAwLjAyOTcgMC4wNzA5IDAuMDYgMC4xMDcgMC4wODk5LTE2LjY2OSA0LjEzNTItMjcuMTA4IDEwLjA1My0yNy4xMDggMTYuNjIyIDAgMTIuNDk5IDM3Ljc2NiAyMi42MzEgODQuMzUyIDIyLjYzMSA0Ni41ODcgMCA4NC4zNTItMTAuMTMzIDg0LjM1Mi0yMi42MzEgMC02LjU2OTktMTAuNDM5LTEyLjQ4Ny0yNy4xMDgtMTYuNjIyIDAuMDM2Mi0wLjAyOTkgMC4wNzEyLTAuMDYwMSAwLjEwNjI1LTAuMDg5OSAxNi43OTYgNC4xNTE0IDI3LjMyMiAxMC4xMDIgMjcuMzIyIDE2LjcxMiAwIDEyLjU0Ni0zNy45MSAyMi43MTctODQuNjczIDIyLjcxNyIvPgogICA8cGF0aCBpZD0icGF0aDQyMDkiIGQ9Im00NTEuMjggOTM0Ljg4Yy00Ni41ODYgMC04NC4zNTItMTAuMTMzLTg0LjM1Mi0yMi42MzEgMC02LjU2OTkgMTAuNDM5LTEyLjQ4NyAyNy4xMDgtMTYuNjIyIDAuMDM1NiAwLjAzMTIgMC4wNzA4IDAuMDYxNSAwLjEwNjM4IDAuMDkxMy0xNi41NDEgNC4xMTc2LTI2Ljg5NCAxMC0yNi44OTQgMTYuNTMxIDAgMTIuNDUxIDM3LjYyMiAyMi41NDUgODQuMDMxIDIyLjU0NXM4NC4wMy0xMC4wOTQgODQuMDMtMjIuNTQ1YzAtNi41MzEyLTEwLjM1Mi0xMi40MTQtMjYuODk0LTE2LjUzMSAwLjAzNjItMC4wMjk4IDAuMDcxMi0wLjA2IDAuMTA3NS0wLjA5MTMgMTYuNjY5IDQuMTM1MiAyNy4xMDggMTAuMDUzIDI3LjEwOCAxNi42MjIgMCAxMi40OTktMzcuNzY1IDIyLjYzMS04NC4zNTIgMjIuNjMxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDIxMSIgZD0ibTQ1MS4yOCA5MzQuOGMtNDYuNDA5IDAtODQuMDMxLTEwLjA5NC04NC4wMzEtMjIuNTQ1IDAtNi41MzEyIDEwLjM1My0xMi40MTQgMjYuODk0LTE2LjUzMSAwLjAzNjEgMC4wMjk3IDAuMDcxOCAwLjA2IDAuMTA3NSAwLjA4OTktMTYuNDE0IDQuMTAxNS0yNi42OCA5Ljk1MDEtMjYuNjggMTYuNDQxIDAgMTIuNDA0IDM3LjQ3OCAyMi40NTkgODMuNzEgMjIuNDU5czgzLjcxLTEwLjA1NSA4My43MS0yMi40NTljMC02LjQ5MTItMTAuMjY2LTEyLjM0LTI2LjY4LTE2LjQ0MSAwLjAzNS0wLjAyOTkgMC4wNzEyLTAuMDYwMSAwLjEwNjI1LTAuMDg5OSAxNi41NDEgNC4xMTc2IDI2Ljg5NCAxMCAyNi44OTQgMTYuNTMxIDAgMTIuNDUxLTM3LjYyMSAyMi41NDUtODQuMDMgMjIuNTQ1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDIxMyIgZD0ibTQ1MS4yOCA5MzQuNzFjLTQ2LjIzMiAwLTgzLjcxLTEwLjA1NS04My43MS0yMi40NTkgMC02LjQ5MTIgMTAuMjY2LTEyLjM0IDI2LjY4LTE2LjQ0MSAwLjAzNjEgMC4wMzEyIDAuMDcxOCAwLjA2MTUgMC4xMDc4NyAwLjA5MTItMTYuMjg4IDQuMDg0LTI2LjQ2NyA5Ljg5NzUtMjYuNDY3IDE2LjM1IDAgMTIuMzU2IDM3LjMzNSAyMi4zNzMgODMuMzkgMjIuMzczIDQ2LjA1NCAwIDgzLjM4OS0xMC4wMTYgODMuMzg5LTIyLjM3MyAwLTYuNDUyNi0xMC4xNzktMTIuMjY2LTI2LjQ2OC0xNi4zNSAwLjAzNjItMC4wMjk3IDAuMDcyNS0wLjA2IDAuMTA4NzUtMC4wOTEyIDE2LjQxNCA0LjEwMTUgMjYuNjggOS45NTAxIDI2LjY4IDE2LjQ0MSAwIDEyLjQwNC0zNy40NzkgMjIuNDU5LTgzLjcxIDIyLjQ1OSIvPgogICA8cGF0aCBpZD0icGF0aDQyMTUiIGQ9Im00NTEuMjggOTM0LjYyYy00Ni4wNTUgMC04My4zOS0xMC4wMTYtODMuMzktMjIuMzczIDAtNi40NTI2IDEwLjE3OS0xMi4yNjYgMjYuNDY3LTE2LjM1IDAuMDM2MSAwLjAzMDMgMC4wNzE4IDAuMDYwMSAwLjEwNzg4IDAuMDkxNC0xNi4xNTkgNC4wNjY0LTI2LjI1NCA5Ljg0NjEtMjYuMjU0IDE2LjI1OSAwIDEyLjMwOSAzNy4xOTEgMjIuMjg4IDgzLjA2OSAyMi4yODhzODMuMDY5LTkuOTc5IDgzLjA2OS0yMi4yODhjMC02LjQxMjYtMTAuMDk2LTEyLjE5Mi0yNi4yNTUtMTYuMjU5IDAuMDM2Mi0wLjAzMTIgMC4wNzI1LTAuMDYxMSAwLjEwNzUtMC4wOTE0IDE2LjI4OSA0LjA4NCAyNi40NjggOS44OTc1IDI2LjQ2OCAxNi4zNSAwIDEyLjM1Ni0zNy4zMzUgMjIuMzczLTgzLjM4OSAyMi4zNzMiLz4KICAgPHBhdGggaWQ9InBhdGg0MjE3IiBkPSJtNDUxLjI4IDkzNC41NGMtNDUuODc4IDAtODMuMDY5LTkuOTc5LTgzLjA2OS0yMi4yODggMC02LjQxMjYgMTAuMDk1LTEyLjE5MiAyNi4yNTQtMTYuMjU5IDAuMDM2MSAwLjAzMDMgMC4wNzIyIDAuMDYgMC4xMDgzNyAwLjA4OTgtMTYuMDMxIDQuMDQ4OS0yNi4wNDIgOS43OTY1LTI2LjA0MiAxNi4xNjkgMCAxMi4yNjEgMzcuMDQ3IDIyLjIwMSA4Mi43NDggMjIuMjAxIDQ1LjcgMCA4Mi43NDgtOS45NCA4Mi43NDgtMjIuMjAxIDAtNi4zNzI1LTEwLjAxMS0xMi4xMi0yNi4wNDItMTYuMTY5IDAuMDM2Mi0wLjAyOTggMC4wNzI1LTAuMDU5NSAwLjEwODc1LTAuMDg5OCAxNi4xNTkgNC4wNjY0IDI2LjI1NSA5Ljg0NjEgMjYuMjU1IDE2LjI1OSAwIDEyLjMwOS0zNy4xOTEgMjIuMjg4LTgzLjA2OSAyMi4yODgiLz4KICAgPHBhdGggaWQ9InBhdGg0MjE5IiBkPSJtNDUxLjI4IDkzNC40NWMtNDUuNzAxIDAtODIuNzQ4LTkuOTQtODIuNzQ4LTIyLjIwMSAwLTYuMzcyNSAxMC4wMTEtMTIuMTIgMjYuMDQyLTE2LjE2OSAwLjAzNjEgMC4wMzA0IDAuMDcyNyAwLjA2MTYgMC4xMDg4OCAwLjA5MTQtMTUuOTA2IDQuMDMxMi0yNS44MyA5Ljc0NDEtMjUuODMgMTYuMDc4IDAgMTIuMjE0IDM2LjkwNCAyMi4xMTUgODIuNDI3IDIyLjExNXM4Mi40MjctOS45MDA5IDgyLjQyNy0yMi4xMTVjMC02LjMzMzUtOS45MjM4LTEyLjA0Ni0yNS44My0xNi4wNzggMC4wMzYyLTAuMDI5OCAwLjA3MjUtMC4wNjEgMC4xMDg3NS0wLjA5MTQgMTYuMDMxIDQuMDQ4OSAyNi4wNDIgOS43OTY1IDI2LjA0MiAxNi4xNjkgMCAxMi4yNjEtMzcuMDQ4IDIyLjIwMS04Mi43NDggMjIuMjAxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDIyMSIgZD0ibTQ1MS4yOCA5MzQuMzdjLTQ1LjUyNCAwLTgyLjQyNy05LjkwMDktODIuNDI3LTIyLjExNSAwLTYuMzMzNSA5LjkyMzgtMTIuMDQ2IDI1LjgzLTE2LjA3OCAwLjAzNjIgMC4wMzAzIDAuMDcyNyAwLjA2MTUgMC4xMDkgMC4wOTEzLTE1Ljc3OSA0LjAxMzgtMjUuNjE4IDkuNjkxNS0yNS42MTggMTUuOTg2IDAgMTIuMTY2IDM2Ljc2IDIyLjAyOSA4Mi4xMDYgMjIuMDI5czgyLjEwNy05Ljg2MjggODIuMTA3LTIyLjAyOWMwLTYuMjk0OS05Ljg0LTExLjk3My0yNS42MTktMTUuOTg2IDAuMDM2Mi0wLjAyOTcgMC4wNzI1LTAuMDYxIDAuMTA4NzUtMC4wOTEzIDE1LjkwNiA0LjAzMTIgMjUuODMgOS43NDQxIDI1LjgzIDE2LjA3OCAwIDEyLjIxNC0zNi45MDQgMjIuMTE1LTgyLjQyNyAyMi4xMTUiLz4KICAgPHBhdGggaWQ9InBhdGg0MjIzIiBkPSJtNDUxLjI4IDkzNC4yOGMtNDUuMzQ2IDAtODIuMTA2LTkuODYyOC04Mi4xMDYtMjIuMDI5IDAtNi4yOTQ5IDkuODM5NC0xMS45NzMgMjUuNjE4LTE1Ljk4NiAwLjAzNjEgMC4wMzA0IDAuMDczMSAwLjA2MDEgMC4xMDkzNyAwLjA4OTktMTUuNjUyIDMuOTk3Ni0yNS40MDcgOS42NDE2LTI1LjQwNyAxNS44OTYgMCAxMi4xMTkgMzYuNjE3IDIxLjk0MiA4MS43ODYgMjEuOTQyczgxLjc4Ni05LjgyMzcgODEuNzg2LTIxLjk0MmMwLTYuMjU0OS05Ljc1NS0xMS44OTktMjUuNDA2LTE1Ljg5NiAwLjAzNjItMC4wMjk3IDAuMDcyNS0wLjA1OTUgMC4xMDg3NS0wLjA4OTkgMTUuNzc5IDQuMDEzOCAyNS42MTkgOS42OTE1IDI1LjYxOSAxNS45ODYgMCAxMi4xNjYtMzYuNzYxIDIyLjAyOS04Mi4xMDcgMjIuMDI5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDIyNSIgZD0ibTQ1MS4yOCA5MzQuMTljLTQ1LjE3IDAtODEuNzg2LTkuODIzNy04MS43ODYtMjEuOTQyIDAtNi4yNTQ5IDkuNzU0OS0xMS44OTkgMjUuNDA3LTE1Ljg5NiAwLjAzNjEgMC4wMzAzIDAuMDczNiAwLjA2MTUgMC4xMDk3NSAwLjA5MTQtMTUuNTI2IDMuOTc5OS0yNS4xOTYgOS41ODg5LTI1LjE5NiAxNS44MDUgMCAxMi4wNzEgMzYuNDczIDIxLjg1NiA4MS40NjUgMjEuODU2czgxLjQ2Ni05Ljc4NTIgODEuNDY2LTIxLjg1NmMwLTYuMjE2Mi05LjY3LTExLjgyNS0yNS4xOTYtMTUuODA1IDAuMDM2Mi0wLjAyOTkgMC4wNzM4LTAuMDYxMSAwLjExLTAuMDkxNCAxNS42NTEgMy45OTc2IDI1LjQwNiA5LjY0MTYgMjUuNDA2IDE1Ljg5NiAwIDEyLjExOS0zNi42MTYgMjEuOTQyLTgxLjc4NiAyMS45NDIiLz4KICAgPHBhdGggaWQ9InBhdGg0MjI3IiBkPSJtNDUxLjI4IDkzNC4xMWMtNDQuOTkyIDAtODEuNDY1LTkuNzg1Mi04MS40NjUtMjEuODU2IDAtNi4yMTYyIDkuNjctMTEuODI1IDI1LjE5Ni0xNS44MDUgMC4wMzYyIDAuMDMwMiAwLjA3MzggMC4wNjE1IDAuMTEwMzggMC4wOTEyLTE1LjM5OCAzLjk2MTQtMjQuOTg2IDkuNTM3Ni0yNC45ODYgMTUuNzE0IDAgMTIuMDI0IDM2LjMzIDIxLjc3IDgxLjE0NSAyMS43NyA0NC44MTQgMCA4MS4xNDQtOS43NDYxIDgxLjE0NC0yMS43NyAwLTYuMTc2Mi05LjU4NzUtMTEuNzUyLTI0Ljk4Ni0xNS43MTQgMC4wMzYyLTAuMDI5NyAwLjA3NS0wLjA2MSAwLjExMTI1LTAuMDkxMiAxNS41MjYgMy45Nzk5IDI1LjE5NiA5LjU4ODkgMjUuMTk2IDE1LjgwNSAwIDEyLjA3MS0zNi40NzQgMjEuODU2LTgxLjQ2NiAyMS44NTYiLz4KICAgPHBhdGggaWQ9InBhdGg0MjI5IiBkPSJtNDUxLjI4IDkzNC4wMmMtNDQuODE1IDAtODEuMTQ1LTkuNzQ2MS04MS4xNDUtMjEuNzcgMC02LjE3NjIgOS41ODc5LTExLjc1MiAyNC45ODYtMTUuNzE0IDAuMDM2MSAwLjAyODkgMC4wNzQyIDAuMDYwMSAwLjExMDM3IDAuMDkwNC0xNS4yNzMgMy45NDQ4LTI0Ljc3NSA5LjQ4NTgtMjQuNzc1IDE1LjYyNCAwIDExLjk3NiAzNi4xODYgMjEuNjg1IDgwLjgyNCAyMS42ODVzODAuODIzLTkuNzA5IDgwLjgyMy0yMS42ODVjMC02LjEzNzgtOS41MDI1LTExLjY3OS0yNC43NzUtMTUuNjI0IDAuMDM2Mi0wLjAzMDMgMC4wNzM4LTAuMDYxNSAwLjExLTAuMDkwNCAxNS4zOTkgMy45NjE0IDI0Ljk4NiA5LjUzNzYgMjQuOTg2IDE1LjcxNCAwIDEyLjAyNC0zNi4zMyAyMS43Ny04MS4xNDQgMjEuNzciLz4KICA8L2c+CiAgPGc+CiAgIDxwYXRoIGlkPSJwYXRoNDIzMSIgZD0ibTQ1MS4yOCA5MzMuOTRjLTQ0LjYzOCAwLTgwLjgyNC05LjcwOS04MC44MjQtMjEuNjg1IDAtNi4xMzc4IDkuNTAyNC0xMS42NzkgMjQuNzc1LTE1LjYyNCAwLjAzODYgMC4wMzA4IDAuMDcyMiAwLjA1OTUgMC4xMTEzOCAwLjA5MDctMTUuMTQ2IDMuOTI3OC0yNC41NjYgOS40MzUxLTI0LjU2NiAxNS41MzMgMCAxMS45MjkgMzYuMDQyIDIxLjU5OSA4MC41MDMgMjEuNTk5IDQ0LjQ2IDAgODAuNTAzLTkuNjY5OSA4MC41MDMtMjEuNTk5IDAtNi4wOTc2LTkuNDItMTEuNjA1LTI0LjU2Ni0xNS41MzMgMC4wMzg3LTAuMDMxMiAwLjA3MjUtMC4wNiAwLjExMTI1LTAuMDkwNyAxNS4yNzIgMy45NDQ4IDI0Ljc3NSA5LjQ4NTggMjQuNzc1IDE1LjYyNCAwIDExLjk3Ni0zNi4xODUgMjEuNjg1LTgwLjgyMyAyMS42ODUiIGZpbGw9IiNmZWZmZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0MjMzIiBkPSJtNDUxLjI4IDkzMy44NWMtNDQuNDYgMC04MC41MDMtOS42Njk5LTgwLjUwMy0yMS41OTkgMC02LjA5NzYgOS40Mi0xMS42MDUgMjQuNTY2LTE1LjUzMyAwLjAzNjEgMC4wMzAzIDAuMDc0NiAwLjA2MTUgMC4xMTEyNSAwLjA5MTQtMTUuMDIyIDMuOTA4Ni0yNC4zNTYgOS4zODI4LTI0LjM1NiAxNS40NDEgMCAxMS44ODEgMzUuODk4IDIxLjUxMyA4MC4xODIgMjEuNTEzIDQ0LjI4MyAwIDgwLjE4Mi05LjYzMTQgODAuMTgyLTIxLjUxMyAwLTYuMDU4Ni05LjMzMzgtMTEuNTMzLTI0LjM1Ni0xNS40NDEgMC4wMzYyLTAuMDI5OSAwLjA3NS0wLjA2MTEgMC4xMTEyNS0wLjA5MTQgMTUuMTQ2IDMuOTI3OCAyNC41NjYgOS40MzUxIDI0LjU2NiAxNS41MzMgMCAxMS45MjktMzYuMDQyIDIxLjU5OS04MC41MDMgMjEuNTk5IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDIzNSIgZD0ibTQ1MS4yOCA5MzMuNzdjLTQ0LjI4NCAwLTgwLjE4Mi05LjYzMTQtODAuMTgyLTIxLjUxMyAwLTYuMDU4NiA5LjMzNDUtMTEuNTMzIDI0LjM1Ni0xNS40NDEgMC4wMzY2IDAuMDI4NyAwLjA3NTIgMC4wNjE1IDAuMTExODcgMC4wOTAyLTE0Ljg5NiAzLjg5MjEtMjQuMTQ3IDkuMzMyNi0yNC4xNDcgMTUuMzUxIDAgMTEuODM0IDM1Ljc1NSAyMS40MjYgNzkuODYxIDIxLjQyNnM3OS44Ni05LjU5MjMgNzkuODYtMjEuNDI2YzAtNi4wMTg1LTkuMjUxMi0xMS40NTktMjQuMTQ2LTE1LjM1MSAwLjAzNjItMC4wMjg3IDAuMDc1LTAuMDYxNSAwLjExMTI1LTAuMDkwMiAxNS4wMjIgMy45MDg2IDI0LjM1NiA5LjM4MjggMjQuMzU2IDE1LjQ0MSAwIDExLjg4MS0zNS44OTkgMjEuNTEzLTgwLjE4MiAyMS41MTMiIGZpbGw9IiNmZWZlZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0MjM3IiBkPSJtNDUxLjI4IDkzMy42OGMtNDQuMTA2IDAtNzkuODYxLTkuNTkyMy03OS44NjEtMjEuNDI2IDAtNi4wMTg1IDkuMjUxNS0xMS40NTkgMjQuMTQ3LTE1LjM1MSAwLjAzODUgMC4wMzEyIDAuMDczMiAwLjA2MDEgMC4xMTE3NSAwLjA5MDktMTQuNzcgMy44NzU1LTIzLjkzOCA5LjI4MDItMjMuOTM4IDE1LjI2IDAgMTEuNzg2IDM1LjYxMSAyMS4zNCA3OS41NDEgMjEuMzQgNDMuOTI5IDAgNzkuNTQtOS41NTM3IDc5LjU0LTIxLjM0IDAtNS45OC05LjE2ODgtMTEuMzg1LTIzLjkzOS0xNS4yNiAwLjAzODctMC4wMzA4IDAuMDczNy0wLjA1OTYgMC4xMTI1LTAuMDkwOSAxNC44OTUgMy44OTIxIDI0LjE0NiA5LjMzMjYgMjQuMTQ2IDE1LjM1MSAwIDExLjgzNC0zNS43NTUgMjEuNDI2LTc5Ljg2IDIxLjQyNiIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQyMzkiIGQ9Im00NTEuMjggOTMzLjU5Yy00My45MjkgMC03OS41NDEtOS41NTM3LTc5LjU0MS0yMS4zNCAwLTUuOTggOS4xNjc5LTExLjM4NSAyMy45MzgtMTUuMjYgMC4wMzY2IDAuMDMwMyAwLjA3NjMgMC4wNjE1IDAuMTEyODggMC4wOTEzLTE0LjY0NiAzLjg1NjUtMjMuNzMgOS4yMjc2LTIzLjczIDE1LjE2OSAwIDExLjczOSAzNS40NjggMjEuMjU1IDc5LjIyIDIxLjI1NXM3OS4yMTktOS41MTYxIDc5LjIxOS0yMS4yNTVjMC01Ljk0MTQtOS4wODUtMTEuMzEyLTIzLjczLTE1LjE2OSAwLjAzNjMtMC4wMjk4IDAuMDc2My0wLjA2MSAwLjExMjUtMC4wOTEzIDE0Ljc3IDMuODc1NSAyMy45MzkgOS4yODAyIDIzLjkzOSAxNS4yNiAwIDExLjc4Ni0zNS42MTEgMjEuMzQtNzkuNTQgMjEuMzQiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0MjQxIiBkPSJtNDUxLjI4IDkzMy41MWMtNDMuNzUyIDAtNzkuMjItOS41MTYxLTc5LjIyLTIxLjI1NSAwLTUuOTQxNCA5LjA4NDUtMTEuMzEyIDIzLjczLTE1LjE2OSAwLjAzODUgMC4wMzEyIDAuMDczOCAwLjA1OTEgMC4xMTI3NSAwLjA5MDQtMTQuNTIyIDMuODM5OS0yMy41MjIgOS4xNzcyLTIzLjUyMiAxNS4wNzkgMCAxMS42OTEgMzUuMzI0IDIxLjE2OSA3OC44OTkgMjEuMTY5IDQzLjU3NCAwIDc4Ljg5OS05LjQ3NzYgNzguODk5LTIxLjE2OSAwLTUuOTAxNC05LjAwMTItMTEuMjM5LTIzLjUyMi0xNS4wNzkgMC4wMzg3LTAuMDMxMiAwLjA3MzctMC4wNTkxIDAuMTEyNS0wLjA5MDQgMTQuNjQ1IDMuODU2NSAyMy43MyA5LjIyNzYgMjMuNzMgMTUuMTY5IDAgMTEuNzM5LTM1LjQ2OCAyMS4yNTUtNzkuMjE5IDIxLjI1NSIgZmlsbD0iI2ZkZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQyNDMiIGQ9Im00NTEuMjggOTMzLjQyYy00My41NzUgMC03OC44OTktOS40Nzc2LTc4Ljg5OS0yMS4xNjkgMC01LjkwMTQgOS4wMDEtMTEuMjM5IDIzLjUyMi0xNS4wNzkgMC4wMzY2IDAuMDI5NyAwLjA3NjEgMC4wNjI1IDAuMTEzMjUgMC4wOTE0LTE0LjM5NSAzLjgyMDgtMjMuMzE1IDkuMTI1LTIzLjMxNSAxNC45ODcgMCAxMS42NDQgMzUuMTgxIDIxLjA4MyA3OC41NzkgMjEuMDgzczc4LjU3OC05LjQzOSA3OC41NzgtMjEuMDgzYzAtNS44NjIyLTguOTItMTEuMTY2LTIzLjMxNS0xNC45ODcgMC4wMzc1LTAuMDI4OSAwLjA3NjMtMC4wNjE2IDAuMTEzNzUtMC4wOTE0IDE0LjUyMSAzLjgzOTkgMjMuNTIyIDkuMTc3MiAyMy41MjIgMTUuMDc5IDAgMTEuNjkxLTM1LjMyNSAyMS4xNjktNzguODk5IDIxLjE2OSIgZmlsbD0iI2ZkZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQyNDUiIGQ9Im00NTEuMjggOTMzLjMzYy00My4zOTggMC03OC41NzktOS40MzktNzguNTc5LTIxLjA4MyAwLTUuODYyMiA4LjkyMDQtMTEuMTY2IDIzLjMxNS0xNC45ODcgMC4wMzg2IDAuMDMxMiAwLjA3NDMgMC4wNiAwLjExMzM3IDAuMDkwNy0xNC4yNzIgMy44MDQyLTIzLjEwNyA5LjA3MjgtMjMuMTA3IDE0Ljg5NiAwIDExLjU5NiAzNS4wMzcgMjAuOTk2IDc4LjI1NyAyMC45OTZzNzguMjU3LTkuMzk5OSA3OC4yNTctMjAuOTk2YzAtNS44MjM4LTguODM1LTExLjA5Mi0yMy4xMDgtMTQuODk2IDAuMDM4OC0wLjAzMDggMC4wNzUtMC4wNTk1IDAuMTEzNzUtMC4wOTA3IDE0LjM5NSAzLjgyMDggMjMuMzE1IDkuMTI1IDIzLjMxNSAxNC45ODcgMCAxMS42NDQtMzUuMTggMjEuMDgzLTc4LjU3OCAyMS4wODMiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0MjQ3IiBkPSJtNDUxLjI4IDkzMy4yNWMtNDMuMjIxIDAtNzguMjU3LTkuMzk5OS03OC4yNTctMjAuOTk2IDAtNS44MjM4IDguODM2LTExLjA5MiAyMy4xMDctMTQuODk2IDAuMDM3IDAuMDI4OSAwLjA3NzYgMC4wNjE1IDAuMTE0MjUgMC4wOTE0LTE0LjE0NiAzLjc4NTEtMjIuOTAxIDkuMDIxNC0yMi45MDEgMTQuODA1IDAgMTEuNTQ5IDM0Ljg5MyAyMC45MSA3Ny45MzcgMjAuOTEgNDMuMDQzIDAgNzcuOTM3LTkuMzYxMyA3Ny45MzctMjAuOTEgMC01Ljc4MzgtOC43NTUtMTEuMDItMjIuOTAxLTE0LjgwNSAwLjAzNjItMC4wMjk5IDAuMDc3NS0wLjA2MjUgMC4xMTM3NS0wLjA5MTQgMTQuMjcyIDMuODA0MiAyMy4xMDggOS4wNzI4IDIzLjEwOCAxNC44OTYgMCAxMS41OTYtMzUuMDM2IDIwLjk5Ni03OC4yNTcgMjAuOTk2IiBmaWxsPSIjZmRmZGZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI0OSIgZD0ibTQ1MS4yOCA5MzMuMTZjLTQzLjA0NCAwLTc3LjkzNy05LjM2MTMtNzcuOTM3LTIwLjkxIDAtNS43ODM4IDguNzU0OS0xMS4wMiAyMi45MDEtMTQuODA1IDAuMDM5IDAuMDMxMiAwLjA3NTEgMC4wNiAwLjExNDc1IDAuMDkwMi0xNC4wMjUgMy43Njg2LTIyLjY5NSA4Ljk2OTctMjIuNjk1IDE0LjcxNSAwIDExLjUwMSAzNC43NSAyMC44MjQgNzcuNjE2IDIwLjgyNHM3Ny42MTYtOS4zMjIzIDc3LjYxNi0yMC44MjRjMC01Ljc0NTEtOC42Ny0xMC45NDYtMjIuNjk1LTE0LjcxNSAwLjA0LTAuMDMwMyAwLjA3NjMtMC4wNTkgMC4xMTUtMC4wOTAyIDE0LjE0NiAzLjc4NTEgMjIuOTAxIDkuMDIxNCAyMi45MDEgMTQuODA1IDAgMTEuNTQ5LTM0Ljg5NCAyMC45MS03Ny45MzcgMjAuOTEiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg0MjUxIiBkPSJtNDUxLjI4IDkzMy4wOGMtNDIuODY2IDAtNzcuNjE2LTkuMzIyMy03Ny42MTYtMjAuODI0IDAtNS43NDUxIDguNjcwNC0xMC45NDYgMjIuNjk1LTE0LjcxNSAwLjAzNjYgMC4wMjk5IDAuMDc3NiAwLjA2MjUgMC4xMTQ3NSAwLjA5MTQtMTMuOSAzLjc1LTIyLjQ4OSA4LjkxNzUtMjIuNDg5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDYgMjAuNzM3IDc3LjI5NSAyMC43MzdzNzcuMjk2LTkuMjgzNyA3Ny4yOTYtMjAuNzM3YzAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40OS0xNC42MjQgMC4wMzc1LTAuMDI4OSAwLjA3ODgtMC4wNjE1IDAuMTE1LTAuMDkxNCAxNC4wMjUgMy43Njg2IDIyLjY5NSA4Ljk2OTcgMjIuNjk1IDE0LjcxNSAwIDExLjUwMS0zNC43NSAyMC44MjQtNzcuNjE2IDIwLjgyNCIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDQyNTMiIGQ9Im00NTEuMjggOTMyLjk5Yy00Mi42ODkgMC03Ny4yOTUtOS4yODM3LTc3LjI5NS0yMC43MzcgMC01LjcwNiA4LjU4ODgtMTAuODc0IDIyLjQ4OS0xNC42MjQgMC4wMzkgMC4wMzEyIDAuMDc2MSAwLjA1OTUgMC4xMTUxMyAwLjA5MDctMTMuNzc3IDMuNzMxNS0yMi4yODMgOC44NjUyLTIyLjI4MyAxNC41MzMgMCAxMS40MDYgMzQuNDYyIDIwLjY1MiA3Ni45NzQgMjAuNjUyczc2Ljk3NC05LjI0NjEgNzYuOTc0LTIwLjY1MmMwLTUuNjY3NS04LjUwNjItMTAuODAxLTIyLjI4NC0xNC41MzMgMC4wMzg3LTAuMDMxMiAwLjA3NjItMC4wNTk1IDAuMTE1LTAuMDkwNyAxMy45MDEgMy43NSAyMi40OSA4LjkxNzUgMjIuNDkgMTQuNjI0IDAgMTEuNDU0LTM0LjYwNiAyMC43MzctNzcuMjk2IDIwLjczNyIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDQyNTUiIGQ9Im00NTEuMjggOTMyLjljLTQyLjUxMiAwLTc2Ljk3NC05LjI0NjEtNzYuOTc0LTIwLjY1MiAwLTUuNjY3NSA4LjUwNjQtMTAuODAxIDIyLjI4My0xNC41MzMgMC4wMzkxIDAuMDMxMiAwLjA3NjggMC4wNjAxIDAuMTE1NzUgMC4wOTE0LTEzLjY1MSAzLjcxMzktMjIuMDc5IDguODEzOS0yMi4wNzkgMTQuNDQxIDAgMTEuMzU5IDM0LjMxOSAyMC41NjYgNzYuNjU0IDIwLjU2NiA0Mi4zMzQgMCA3Ni42NTMtOS4yMDc1IDc2LjY1My0yMC41NjYgMC01LjYyNzUtOC40MjYyLTEwLjcyOC0yMi4wNzgtMTQuNDQxIDAuMDM4Ny0wLjAzMTIgMC4wNzYzLTAuMDYwMSAwLjExNS0wLjA5MTQgMTMuNzc4IDMuNzMxNSAyMi4yODQgOC44NjUyIDIyLjI4NCAxNC41MzMgMCAxMS40MDYtMzQuNDYyIDIwLjY1Mi03Ni45NzQgMjAuNjUyIiBmaWxsPSIjZmNmY2ZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI1NyIgZD0ibTQ1MS4yOCA5MzIuODJjLTQyLjMzNSAwLTc2LjY1NC05LjIwNzUtNzYuNjU0LTIwLjU2NiAwLTUuNjI3NSA4LjQyNzItMTAuNzI4IDIyLjA3OS0xNC40NDEgMC4wMzcxIDAuMDI4NyAwLjA3OTEgMC4wNjI1IDAuMTE2MjUgMC4wOTEyLTEzLjUyOSAzLjY5NTQtMjEuODc0IDguNzYxMi0yMS44NzQgMTQuMzUgMCAxMS4zMTEgMzQuMTc1IDIwLjQ4IDc2LjMzMyAyMC40OCA0Mi4xNTcgMCA3Ni4zMzMtOS4xNjg5IDc2LjMzMy0yMC40OCAwLTUuNTg4OS04LjM0NS0xMC42NTUtMjEuODc0LTE0LjM1IDAuMDM2My0wLjAyODcgMC4wNzg4LTAuMDYyNSAwLjExNjI1LTAuMDkxMiAxMy42NTEgMy43MTM5IDIyLjA3OCA4LjgxMzkgMjIuMDc4IDE0LjQ0MSAwIDExLjM1OS0zNC4zMTkgMjAuNTY2LTc2LjY1MyAyMC41NjYiIGZpbGw9IiNmY2ZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg0MjU5IiBkPSJtNDUxLjI4IDkzMi43M2MtNDIuMTU4IDAtNzYuMzMzLTkuMTY4OS03Ni4zMzMtMjAuNDggMC01LjU4ODkgOC4zNDQ4LTEwLjY1NSAyMS44NzQtMTQuMzUgMC4wMzkgMC4wMzAzIDAuMDc3MSAwLjA2MDEgMC4xMTYyNSAwLjA5MTQtMTMuNDA1IDMuNjc2Mi0yMS42NyA4LjcwOS0yMS42NyAxNC4yNTkgMCAxMS4yNjQgMzQuMDMyIDIwLjM5NCA3Ni4wMTIgMjAuMzk0czc2LjAxMi05LjEyOTkgNzYuMDEyLTIwLjM5NGMwLTUuNTQ5OC04LjI2NS0xMC41ODItMjEuNjY5LTE0LjI1OSAwLjAzODctMC4wMzEyIDAuMDc2Mi0wLjA2MTEgMC4xMTYyNS0wLjA5MTQgMTMuNTI5IDMuNjk1NCAyMS44NzQgOC43NjEyIDIxLjg3NCAxNC4zNSAwIDExLjMxMS0zNC4xNzYgMjAuNDgtNzYuMzMzIDIwLjQ4IiBmaWxsPSIjZmJmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI2MSIgZD0ibTQ1MS4yOCA5MzIuNjVjLTQxLjk4IDAtNzYuMDEyLTkuMTI5OS03Ni4wMTItMjAuMzk0IDAtNS41NDk4IDguMjY0Ni0xMC41ODIgMjEuNjctMTQuMjU5IDAuMDM5NSAwLjAyOTggMC4wNzc2IDAuMDYgMC4xMTcxMiAwLjA4OTgtMTMuMjg0IDMuNjYwMi0yMS40NjYgOC42NTc4LTIxLjQ2NiAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4czc1LjY5Mi05LjA5MTQgNzUuNjkyLTIwLjMwOGMwLTUuNTExMi04LjE4MjUtMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wMzg3LTAuMDI5OCAwLjA3NzUtMC4wNiAwLjExNzUtMC4wODk4IDEzLjQwNCAzLjY3NjIgMjEuNjY5IDguNzA5IDIxLjY2OSAxNC4yNTkgMCAxMS4yNjQtMzQuMDMxIDIwLjM5NC03Ni4wMTIgMjAuMzk0IiBmaWxsPSIjZmJmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI2MyIgZD0ibTQ1MS4yOCA5MzIuNTZjLTQxLjgwMyAwLTc1LjY5MS05LjA5MTQtNzUuNjkxLTIwLjMwOCAwLTUuNTExMiA4LjE4MjEtMTAuNTA5IDIxLjQ2Ni0xNC4xNjkgMC4wMzk2IDAuMDMxMiAwLjA3ODEgMC4wNjE2IDAuMTE3NzUgMC4wOTE0LTEzLjE2MSAzLjY0MTEtMjEuMjYzIDguNjA1LTIxLjI2MyAxNC4wNzggMCAxMS4xNjkgMzMuNzQ1IDIwLjIyMSA3NS4zNzEgMjAuMjIxczc1LjM3LTkuMDUyMiA3NS4zNy0yMC4yMjFjMC01LjQ3MjYtOC4xMDEyLTEwLjQzNi0yMS4yNjItMTQuMDc4IDAuMDM4Ny0wLjAyOTggMC4wNzc1LTAuMDYwMSAwLjExNzUtMC4wOTE0IDEzLjI4NCAzLjY2MDIgMjEuNDY2IDguNjU3OCAyMS40NjYgMTQuMTY5IDAgMTEuMjE2LTMzLjg4OSAyMC4zMDgtNzUuNjkyIDIwLjMwOCIgZmlsbD0iI2ZiZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDQyNjUiIGQ9Im00NTEuMjggOTMyLjQ3Yy00MS42MjYgMC03NS4zNzEtOS4wNTIyLTc1LjM3MS0yMC4yMjEgMC01LjQ3MjYgOC4xMDE1LTEwLjQzNiAyMS4yNjMtMTQuMDc4IDAuMDM5IDAuMDMxMiAwLjA3ODEgMC4wNjE1IDAuMTE3NjMgMC4wOTEzLTEzLjAzOSAzLjYyMjYtMjEuMDYgOC41NTI4LTIxLjA2IDEzLjk4NiAwIDExLjEyIDMzLjYwMSAyMC4xMzYgNzUuMDUgMjAuMTM2czc1LjA0OS05LjAxNjEgNzUuMDQ5LTIwLjEzNmMwLTUuNDMzNi04LjAyLTEwLjM2NC0yMS4wNTktMTMuOTg2IDAuMDM4OC0wLjAyOTcgMC4wNzc1LTAuMDYgMC4xMTc1LTAuMDkxMyAxMy4xNjEgMy42NDExIDIxLjI2MiA4LjYwNSAyMS4yNjIgMTQuMDc4IDAgMTEuMTY5LTMzLjc0NSAyMC4yMjEtNzUuMzcgMjAuMjIxIiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI2NyIgZD0ibTQ1MS4yOCA5MzIuMzljLTQxLjQ0OSAwLTc1LjA1LTkuMDE2MS03NS4wNS0yMC4xMzYgMC01LjQzMzYgOC4wMjEtMTAuMzY0IDIxLjA2LTEzLjk4NiAwLjAzOSAwLjAzMDQgMC4wNzg2IDAuMDYxNiAwLjExODEyIDAuMDkxNC0xMi45MTcgMy42MDQtMjAuODU3IDguNS0yMC44NTcgMTMuODk1IDAgMTEuMDcyIDMzLjQ1NyAyMC4wNSA3NC43MjkgMjAuMDVzNzQuNzI5LTguOTc3NiA3NC43MjktMjAuMDVjMC01LjM5NS03Ljk0LTEwLjI5MS0yMC44NTgtMTMuODk1IDAuMDQtMC4wMjk4IDAuMDc4Ny0wLjA2MSAwLjExODc1LTAuMDkxNCAxMy4wMzkgMy42MjI2IDIxLjA1OSA4LjU1MjggMjEuMDU5IDEzLjk4NiAwIDExLjEyLTMzLjYgMjAuMTM2LTc1LjA0OSAyMC4xMzYiIGZpbGw9IiNmYWZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg0MjY5IiBkPSJtNDUxLjI4IDkzMi4zYy00MS4yNzIgMC03NC43MjktOC45Nzc2LTc0LjcyOS0yMC4wNSAwLTUuMzk1IDcuOTM5OS0xMC4yOTEgMjAuODU3LTEzLjg5NSAwLjAzOTYgMC4wMjk3IDAuMDc5MSAwLjA2MTUgMC4xMTg2MyAwLjA5MTItMTIuNzkzIDMuNTg1LTIwLjY1NSA4LjQ0ODgtMjAuNjU1IDEzLjgwNCAwIDExLjAyNSAzMy4zMTQgMTkuOTY0IDc0LjQwOCAxOS45NjRzNzQuNDA4LTguOTM5IDc0LjQwOC0xOS45NjRjMC01LjM1NjUtNy44NTg4LTEwLjIxOS0yMC42NTUtMTMuODA0IDAuMDQtMC4wMjk4IDAuMDc4Ny0wLjA2MTUgMC4xMTg3NS0wLjA5MTIgMTIuOTE4IDMuNjA0IDIwLjg1OCA4LjUgMjAuODU4IDEzLjg5NSAwIDExLjA3Mi0zMy40NTggMjAuMDUtNzQuNzI5IDIwLjA1IiBmaWxsPSIjZmFmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI3MSIgZD0ibTQ1MS4yOCA5MzIuMjJjLTQxLjA5NSAwLTc0LjQwOC04LjkzOS03NC40MDgtMTkuOTY0IDAtNS4zNTUgNy44NjE5LTEwLjIxOSAyMC42NTUtMTMuODA0IDAuMDM5NiAwLjAyOTkgMC4wNzkxIDAuMDYxMSAwLjExOTI1IDAuMDkxNC0xMi42NzMgMy41NjU5LTIwLjQ1MyA4LjM5Ni0yMC40NTMgMTMuNzEyIDAgMTAuOTc4IDMzLjE3IDE5Ljg3NyA3NC4wODcgMTkuODc3czc0LjA4OC04Ljg5OTkgNzQuMDg4LTE5Ljg3N2MwLTUuMzE2NC03Ljc4MTItMTAuMTQ2LTIwLjQ1NC0xMy43MTIgMC4wNC0wLjAzMDIgMC4wOC0wLjA2MTUgMC4xMTg3NS0wLjA5MTQgMTIuNzk2IDMuNTg1IDIwLjY1NSA4LjQ0NzIgMjAuNjU1IDEzLjgwNCAwIDExLjAyNS0zMy4zMTQgMTkuOTY0LTc0LjQwOCAxOS45NjQiIGZpbGw9IiNmYWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg0MjczIiBkPSJtNDUxLjI4IDkzMi4xM2MtNDAuOTE4IDAtNzQuMDg3LTguODk5OS03NC4wODctMTkuODc3IDAtNS4zMTY0IDcuNzgwMi0xMC4xNDYgMjAuNDUzLTEzLjcxMiAwLjAzOTUgMC4wMjk3IDAuMDc5NSAwLjA2MSAwLjExOTYyIDAuMDkxMy0xMi41NSAzLjU0NzQtMjAuMjUyIDguMzQzOC0yMC4yNTIgMTMuNjIxIDAgMTAuOTMgMzMuMDI2IDE5Ljc5MSA3My43NjcgMTkuNzkxIDQwLjc0IDAgNzMuNzY3LTguODYwOSA3My43NjctMTkuNzkxIDAtNS4yNzc0LTcuNzAyNS0xMC4wNzQtMjAuMjUyLTEzLjYyMSAwLjA0LTAuMDMwMyAwLjA4LTAuMDYxNSAwLjEyLTAuMDkxMyAxMi42NzIgMy41NjU5IDIwLjQ1NCA4LjM5NiAyMC40NTQgMTMuNzEyIDAgMTAuOTc4LTMzLjE3MSAxOS44NzctNzQuMDg4IDE5Ljg3NyIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDQyNzUiIGQ9Im00NTEuMjggOTMyLjA0Yy00MC43NCAwLTczLjc2Ny04Ljg2MDktNzMuNzY3LTE5Ljc5MSAwLTUuMjc3NCA3LjcwMTYtMTAuMDc0IDIwLjI1Mi0xMy42MjEgMC4wMzk1IDAuMDI5OSAwLjA4IDAuMDYxMSAwLjExOTYzIDAuMDkxNC0xMi40MzEgMy41MjgyLTIwLjA1MSA4LjI5MS0yMC4wNTEgMTMuNTMgMCAxMC44ODIgMzIuODgzIDE5LjcwNSA3My40NDYgMTkuNzA1czczLjQ0Ni04LjgyMjcgNzMuNDQ2LTE5LjcwNWMwLTUuMjM4OC03LjYyLTEwLjAwMi0yMC4wNTEtMTMuNTMgMC4wNC0wLjAzMDMgMC4wOC0wLjA2MTUgMC4xMi0wLjA5MTQgMTIuNTUgMy41NDc0IDIwLjI1MiA4LjM0MzggMjAuMjUyIDEzLjYyMSAwIDEwLjkzLTMzLjAyNiAxOS43OTEtNzMuNzY3IDE5Ljc5MSIgZmlsbD0iI2Y5ZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDQyNzciIGQ9Im00NTEuMjggOTMxLjk2Yy00MC41NjMgMC03My40NDYtOC44MjI3LTczLjQ0Ni0xOS43MDUgMC01LjIzODggNy42MjAxLTEwLjAwMiAyMC4wNTEtMTMuNTMgMC4wNCAwLjAyOTggMC4wODA1IDAuMDYxIDAuMTIwNSAwLjA5MDctMTIuMzEgMy41MTAyLTE5Ljg1MSA4LjIzODgtMTkuODUxIDEzLjQzOSAwIDEwLjgzNSAzMi43MzkgMTkuNjE5IDczLjEyNiAxOS42MTkgNDAuMzg2IDAgNzMuMTI2LTguNzgzNiA3My4xMjYtMTkuNjE5IDAtNS4yMDAyLTcuNTQxMi05LjkyODgtMTkuODUxLTEzLjQzOSAwLjA0LTAuMDI5NyAwLjA4MTMtMC4wNjEgMC4xMi0wLjA5MDcgMTIuNDMxIDMuNTI4MiAyMC4wNTEgOC4yOTEgMjAuMDUxIDEzLjUzIDAgMTAuODgyLTMyLjg4MiAxOS43MDUtNzMuNDQ2IDE5LjcwNSIgZmlsbD0iI2Y5ZjlmOSIvPgogICA8cGF0aCBpZD0icGF0aDQyNzkiIGQ9Im00NTEuMjggOTMxLjg3Yy00MC4zODYgMC03My4xMjYtOC43ODM2LTczLjEyNi0xOS42MTkgMC01LjIwMDIgNy41NDA1LTkuOTI4OCAxOS44NTEtMTMuNDM5IDAuMDQwMSAwLjAzMDMgMC4wODExIDAuMDYxNSAwLjEyMDYyIDAuMDkxNC0xMi4xODcgMy40OTExLTE5LjY1MSA4LjE4NjUtMTkuNjUxIDEzLjM0OCAwIDEwLjc4OCAzMi41OTYgMTkuNTM0IDcyLjgwNSAxOS41MzQgNDAuMjA4IDAgNzIuODA0LTguNzQ2MSA3Mi44MDQtMTkuNTM0IDAtNS4xNjExLTcuNDYzOC05Ljg1NjUtMTkuNjUxLTEzLjM0OCAwLjA0LTAuMDI5OSAwLjA4MTItMC4wNjExIDAuMTIxMjUtMC4wOTE0IDEyLjMxIDMuNTEwMiAxOS44NTEgOC4yMzg4IDE5Ljg1MSAxMy40MzkgMCAxMC44MzUtMzIuNzQgMTkuNjE5LTczLjEyNiAxOS42MTkiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg0MjgxIiBkPSJtNDUxLjI4IDkzMS43OWMtNDAuMjA5IDAtNzIuODA1LTguNzQ2MS03Mi44MDUtMTkuNTM0IDAtNS4xNjExIDcuNDYzOS05Ljg1NjUgMTkuNjUxLTEzLjM0OCAwLjA0MiAwLjAzMTIgMC4wNzk2IDAuMDU5IDAuMTIxNjMgMC4wOTEyLTEyLjA2OCAzLjQ3MjYtMTkuNDUyIDguMTMzOS0xOS40NTIgMTMuMjU2IDAgMTAuNzQgMzIuNDUyIDE5LjQ0OCA3Mi40ODQgMTkuNDQ4czcyLjQ4NC04LjcwNzUgNzIuNDg0LTE5LjQ0OGMwLTUuMTIyNS03LjM4NS05Ljc4MzgtMTkuNDUyLTEzLjI1NiAwLjA0MjUtMC4wMzIyIDAuMDgtMC4wNiAwLjEyMTI1LTAuMDkxMiAxMi4xODggMy40OTExIDE5LjY1MSA4LjE4NjUgMTkuNjUxIDEzLjM0OCAwIDEwLjc4OC0zMi41OTYgMTkuNTM0LTcyLjgwNCAxOS41MzQiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg0MjgzIiBkPSJtNDUxLjI4IDkzMS43Yy00MC4wMzIgMC03Mi40ODQtOC43MDc1LTcyLjQ4NC0xOS40NDggMC01LjEyMjUgNy4zODM3LTkuNzgzOCAxOS40NTItMTMuMjU2IDAuMDQgMC4wMzAzIDAuMDgxNSAwLjA2MTUgMC4xMjE2MiAwLjA5MTQtMTEuOTQ5IDMuNDUzNi0xOS4yNTIgOC4wOC0xOS4yNTIgMTMuMTY1IDAgMTAuNjkyIDMyLjMwOSAxOS4zNjEgNzIuMTYzIDE5LjM2MXM3Mi4xNjMtOC42NjkgNzIuMTYzLTE5LjM2MWMwLTUuMDg1LTcuMzAzOC05LjcxMTQtMTkuMjUyLTEzLjE2NSAwLjA0LTAuMDI5OSAwLjA4MTItMC4wNjExIDAuMTIxMjUtMC4wOTE0IDEyLjA2OCAzLjQ3MjYgMTkuNDUyIDguMTMzOSAxOS40NTIgMTMuMjU2IDAgMTAuNzQtMzIuNDUyIDE5LjQ0OC03Mi40ODQgMTkuNDQ4IiBmaWxsPSIjZjhmOGY4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI4NSIgZD0ibTQ1MS4yOCA5MzEuNjFjLTM5Ljg1NCAwLTcyLjE2My04LjY2OS03Mi4xNjMtMTkuMzYxIDAtNS4wODUgNy4zMDM4LTkuNzExNCAxOS4yNTItMTMuMTY1IDAuMDQgMC4wMjg4IDAuMDgyNSAwLjA2MTUgMC4xMjI1IDAuMDkxMy0xMS44MjggMy40MzUxLTE5LjA1NCA4LjAyNzQtMTkuMDU0IDEzLjA3NCAwIDEwLjY0NSAzMi4xNjUgMTkuMjc1IDcxLjg0MiAxOS4yNzVzNzEuODQyLTguNjI5OSA3MS44NDItMTkuMjc1YzAtNS4wNDY0LTcuMjI2Mi05LjYzODYtMTkuMDU0LTEzLjA3NCAwLjA0LTAuMDI5OCAwLjA4MjUtMC4wNjI1IDAuMTIyNS0wLjA5MTMgMTEuOTQ5IDMuNDUzNiAxOS4yNTIgOC4wOCAxOS4yNTIgMTMuMTY1IDAgMTAuNjkyLTMyLjMwOSAxOS4zNjEtNzIuMTYzIDE5LjM2MSIgZmlsbD0iI2Y4ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDQyODciIGQ9Im00NTEuMjggOTMxLjUzYy0zOS42NzcgMC03MS44NDItOC42Mjk5LTcxLjg0Mi0xOS4yNzUgMC01LjA0NjQgNy4yMjYtOS42Mzg2IDE5LjA1NC0xMy4wNzQgMC4wNDIgMC4wMzEyIDAuMDgwMSAwLjA1ODYgMC4xMjI2MyAwLjA5MTQtMTEuNzA4IDMuNDE2LTE4Ljg1NiA3Ljk3NS0xOC44NTYgMTIuOTgyIDAgMTAuNTk4IDMyLjAyMiAxOS4xODkgNzEuNTIyIDE5LjE4OXM3MS41MjItOC41OTEzIDcxLjUyMi0xOS4xODljMC01LjAwNzQtNy4xNDg4LTkuNTY2NC0xOC44NTYtMTIuOTgyIDAuMDQyNS0wLjAzMjcgMC4wOC0wLjA2MDEgMC4xMjI1LTAuMDkxNCAxMS44MjggMy40MzUxIDE5LjA1NCA4LjAyNzQgMTkuMDU0IDEzLjA3NCAwIDEwLjY0NS0zMi4xNjUgMTkuMjc1LTcxLjg0MiAxOS4yNzUiIGZpbGw9IiNmN2Y4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg0Mjg5IiBkPSJtNDUxLjI4IDkzMS40NGMtMzkuNSAwLTcxLjUyMi04LjU5MTMtNzEuNTIyLTE5LjE4OSAwLTUuMDA3NCA3LjE0ODQtOS41NjY0IDE4Ljg1Ni0xMi45ODIgMC4wNCAwLjAyODggMC4wODMgMC4wNjEgMC4xMjMgMC4wOTEzLTExLjU4OCAzLjM5NzUtMTguNjU4IDcuOTIyNC0xOC42NTggMTIuODkxIDAgMTAuNTUgMzEuODc4IDE5LjEwMyA3MS4yMDEgMTkuMTAzczcxLjItOC41NTI4IDcxLjItMTkuMTAzYzAtNC45Njg4LTcuMDctOS40OTM2LTE4LjY1OS0xMi44OTEgMC4wNC0wLjAzMDMgMC4wODM4LTAuMDYyNSAwLjEyMzc1LTAuMDkxMyAxMS43MDggMy40MTYgMTguODU2IDcuOTc1IDE4Ljg1NiAxMi45ODIgMCAxMC41OTgtMzIuMDIxIDE5LjE4OS03MS41MjIgMTkuMTg5IiBmaWxsPSIjZjdmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI5MSIgZD0ibTQ1MS4yOCA5MzEuMzVjLTM5LjMyMyAwLTcxLjIwMS04LjU1MjgtNzEuMjAxLTE5LjEwMyAwLTQuOTY4OCA3LjA2OTktOS40OTM2IDE4LjY1OC0xMi44OTEgMC4wNDI1IDAuMDMxMiAwLjA4MSAwLjA2MDEgMC4xMjM1IDAuMDkxNC0xMS40NyAzLjM3ODQtMTguNDYxIDcuODY5Ni0xOC40NjEgMTIuOCAwIDEwLjUwMiAzMS43MzQgMTkuMDE2IDcwLjg4IDE5LjAxNnM3MC44NzktOC41MTM2IDcwLjg3OS0xOS4wMTZjMC00LjkzMDEtNi45OTEyLTkuNDIxNC0xOC40NjEtMTIuOCAwLjA0MjUtMC4wMzEyIDAuMDgxMy0wLjA2MDEgMC4xMjM3NS0wLjA5MTQgMTEuNTg5IDMuMzk3NSAxOC42NTkgNy45MjI0IDE4LjY1OSAxMi44OTEgMCAxMC41NS0zMS44NzggMTkuMTAzLTcxLjIgMTkuMTAzIiBmaWxsPSIjZjZmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI5MyIgZD0ibTQ1MS4yOCA5MzEuMjdjLTM5LjE0NiAwLTcwLjg4LTguNTEzNi03MC44OC0xOS4wMTYgMC00LjkzMDEgNi45OTExLTkuNDIxNCAxOC40NjEtMTIuOCAwLjA0MDUgMC4wMjgyIDAuMDg0IDAuMDYxIDAuMTI0IDAuMDkwNy0xMS4zNDkgMy4zNTg5LTE4LjI2NCA3LjgxNzktMTguMjY0IDEyLjcwOSAwIDEwLjQ1NSAzMS41OSAxOC45MzEgNzAuNTU5IDE4LjkzMXM3MC41NTktOC40NzYgNzAuNTU5LTE4LjkzMWMwLTQuODkxMS02LjkxNjItOS4zNTAxLTE4LjI2NS0xMi43MDkgMC4wNC0wLjAyOTcgMC4wODM4LTAuMDYyNSAwLjEyMzc1LTAuMDkwNyAxMS40NyAzLjM3ODQgMTguNDYxIDcuODY5NiAxOC40NjEgMTIuOCAwIDEwLjUwMi0zMS43MzQgMTkuMDE2LTcwLjg3OSAxOS4wMTYiIGZpbGw9IiNmNmY2ZjciLz4KICAgPHBhdGggaWQ9InBhdGg0Mjk1IiBkPSJtNDUxLjI4IDkzMS4xOGMtMzguOTY5IDAtNzAuNTU5LTguNDc2LTcwLjU1OS0xOC45MzEgMC00Ljg5MTEgNi45MTUtOS4zNTAxIDE4LjI2NC0xMi43MDkgMC4wNDI1IDAuMDMxMiAwLjA4MjYgMC4wNjAxIDAuMTI1IDAuMDkxNC0xMS4yMzIgMy4zNDAyLTE4LjA2OCA3Ljc2NTEtMTguMDY4IDEyLjYxOCAwIDEwLjQwOCAzMS40NDcgMTguODQ1IDcwLjIzOCAxOC44NDUgMzguNzkyIDAgNzAuMjM4LTguNDM3NSA3MC4yMzgtMTguODQ1IDAtNC44NTI1LTYuODM2Mi05LjI3NzQtMTguMDY5LTEyLjYxOCAwLjA0MzgtMC4wMzEyIDAuMDgyNS0wLjA2MDEgMC4xMjUtMC4wOTE0IDExLjM0OSAzLjM1ODkgMTguMjY1IDcuODE3OSAxOC4yNjUgMTIuNzA5IDAgMTAuNDU1LTMxLjU5IDE4LjkzMS03MC41NTkgMTguOTMxIiBmaWxsPSIjZjZmNmY2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI5NyIgZD0ibTQ1MS4yOCA5MzEuMWMtMzguNzkyIDAtNzAuMjM4LTguNDM3NS03MC4yMzgtMTguODQ1IDAtNC44NTI1IDYuODM2NC05LjI3NzQgMTguMDY4LTEyLjYxOCAwLjA0MDEgMC4wMjg3IDAuMDg0NSAwLjA2MTUgMC4xMjUgMC4wOTEyLTExLjExMyAzLjMyMTMtMTcuODcyIDcuNzExNC0xNy44NzIgMTIuNTI2IDAgMTAuMzYgMzEuMzAzIDE4Ljc1OSA2OS45MTggMTguNzU5IDM4LjYxNCAwIDY5LjkxOC04LjM5ODkgNjkuOTE4LTE4Ljc1OSAwLTQuODE1LTYuNzYtOS4yMDUxLTE3Ljg3NC0xMi41MjYgMC4wNDEyLTAuMDI5NyAwLjA4NS0wLjA2MjUgMC4xMjUtMC4wOTEyIDExLjIzMiAzLjM0MDIgMTguMDY5IDcuNzY1MSAxOC4wNjkgMTIuNjE4IDAgMTAuNDA4LTMxLjQ0NiAxOC44NDUtNzAuMjM4IDE4Ljg0NSIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDQyOTkiIGQ9Im00NTEuMjggOTMxLjAxYy0zOC42MTQgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc1OTItOS4yMDUxIDE3Ljg3Mi0xMi41MjYgMC4wNDI1IDAuMDMxMiAwLjA4MyAwLjA2MDEgMC4xMjU1IDAuMDkxNC0xMC45OTUgMy4zMDI2LTE3LjY3NyA3LjY1ODYtMTcuNjc3IDEyLjQzNSAwIDEwLjMxMiAzMS4xNTkgMTguNjcyIDY5LjU5NyAxOC42NzIgMzguNDM3IDAgNjkuNTk3LTguMzU5OSA2OS41OTctMTguNjcyIDAtNC43NzY0LTYuNjgyNS05LjEzMjQtMTcuNjc4LTEyLjQzNSAwLjA0MjUtMC4wMzEyIDAuMDgyNS0wLjA2MDEgMC4xMjUtMC4wOTE0IDExLjExNCAzLjMyMTMgMTcuODc0IDcuNzExNCAxNy44NzQgMTIuNTI2IDAgMTAuMzYtMzEuMzA0IDE4Ljc1OS02OS45MTggMTguNzU5IiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDMwMSIgZD0ibTQ1MS4yOCA5MzAuOTJjLTM4LjQzOCAwLTY5LjU5Ny04LjM1OTktNjkuNTk3LTE4LjY3MiAwLTQuNzc2NCA2LjY4MjYtOS4xMzI0IDE3LjY3Ny0xMi40MzUgMC4wNDI1IDAuMDMxMiAwLjA4MzUgMC4wNiAwLjEyNiAwLjA5MTMtMTAuODc3IDMuMjgyOC0xNy40ODIgNy42MDY1LTE3LjQ4MiAxMi4zNDQgMCAxMC4yNjUgMzEuMDE2IDE4LjU4NiA2OS4yNzYgMTguNTg2czY5LjI3Ni04LjMyMTMgNjkuMjc2LTE4LjU4NmMwLTQuNzM3Mi02LjYwNS05LjA2MS0xNy40ODItMTIuMzQ0IDAuMDQyNS0wLjAzMTIgMC4wODM4LTAuMDYgMC4xMjYyNS0wLjA5MTMgMTAuOTk1IDMuMzAyNiAxNy42NzggNy42NTg2IDE3LjY3OCAxMi40MzUgMCAxMC4zMTItMzEuMTYgMTguNjcyLTY5LjU5NyAxOC42NzIiIGZpbGw9IiNmNWY1ZjUiLz4KICAgPHBhdGggaWQ9InBhdGg0MzAzIiBkPSJtNDUxLjI4IDkzMC44NGMtMzguMjYgMC02OS4yNzYtOC4zMjEzLTY5LjI3Ni0xOC41ODYgMC00LjczNzIgNi42MDU1LTkuMDYxIDE3LjQ4Mi0xMi4zNDQgMC4wNDMgMC4wMzEyIDAuMDg0IDAuMDYxIDAuMTI2NSAwLjA5MTItMTAuNzU4IDMuMjYzOC0xNy4yODkgNy41NTM4LTE3LjI4OSAxMi4yNTIgMCAxMC4yMTcgMzAuODczIDE4LjUgNjguOTU2IDE4LjVzNjguOTU2LTguMjgyOCA2OC45NTYtMTguNWMwLTQuNjk4OC02LjUzMTItOC45ODg4LTE3LjI4OS0xMi4yNTIgMC4wNDI1LTAuMDMwMyAwLjA4MzgtMC4wNiAwLjEyNjI1LTAuMDkxMiAxMC44NzggMy4yODI4IDE3LjQ4MiA3LjYwNjUgMTcuNDgyIDEyLjM0NCAwIDEwLjI2NS0zMS4wMTUgMTguNTg2LTY5LjI3NiAxOC41ODYiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzA1IiBkPSJtNDUxLjI4IDkzMC43NWMtMzguMDgzIDAtNjguOTU2LTguMjgyOC02OC45NTYtMTguNSAwLTQuNjk4OCA2LjUzMDgtOC45ODg4IDE3LjI4OS0xMi4yNTIgMC4wNDA1IDAuMDI5OSAwLjA4NjQgMC4wNjI1IDAuMTI2ODcgMC4wOTE0LTEwLjY0MiAzLjI0MzYtMTcuMDk1IDcuNS0xNy4wOTUgMTIuMTYxIDAgMTAuMTcgMzAuNzI5IDE4LjQxNSA2OC42MzUgMTguNDE1czY4LjYzNC04LjI0NTIgNjguNjM0LTE4LjQxNWMwLTQuNjYxMS02LjQ1MjUtOC45MTc1LTE3LjA5NS0xMi4xNjEgMC4wNDEyLTAuMDI4OSAwLjA4NzUtMC4wNjE1IDAuMTI3NS0wLjA5MTQgMTAuNzU4IDMuMjYzOCAxNy4yODkgNy41NTM4IDE3LjI4OSAxMi4yNTIgMCAxMC4yMTctMzAuODcyIDE4LjUtNjguOTU2IDE4LjUiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzA3IiBkPSJtNDUxLjI4IDkzMC42N2MtMzcuOTA2IDAtNjguNjM1LTguMjQ1Mi02OC42MzUtMTguNDE1IDAtNC42NjExIDYuNDUzMS04LjkxNzUgMTcuMDk1LTEyLjE2MSAwLjA0MyAwLjAzMTIgMC4wODUgMC4wNjEgMC4xMjggMC4wOTIyLTEwLjUyNCAzLjIyMzctMTYuOTAyIDcuNDQ2NC0xNi45MDIgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NiAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1Mi0xNi45MDItMTIuMDY5IDAuMDQzNy0wLjAzMTIgMC4wODUtMC4wNjEgMC4xMjc1LTAuMDkyMiAxMC42NDIgMy4yNDM2IDE3LjA5NSA3LjUgMTcuMDk1IDEyLjE2MSAwIDEwLjE3LTMwLjcyOSAxOC40MTUtNjguNjM0IDE4LjQxNSIgZmlsbD0iI2YzZjNmMyIvPgogICA8cGF0aCBpZD0icGF0aDQzMDkiIGQ9Im00NTEuMjggOTMwLjU4Yy0zNy43MjggMC02OC4zMTQtOC4yMDYxLTY4LjMxNC0xOC4zMjkgMC00LjYyMjUgNi4zNzc1LTguODQ1MiAxNi45MDItMTIuMDY5IDAuMDQzIDAuMDMwMyAwLjA4NSAwLjA2MDEgMC4xMjc4OCAwLjA5MTQtMTAuNDA3IDMuMjA1LTE2LjcwOSA3LjM5MzUtMTYuNzA5IDExLjk3OCAwIDEwLjA3NSAzMC40NDIgMTguMjQzIDY3Ljk5MyAxOC4yNDMgMzcuNTUyIDAgNjcuOTkzLTguMTY3NCA2Ny45OTMtMTguMjQzIDAtNC41ODQtNi4zMDEyLTguNzcyNS0xNi43MDktMTEuOTc4IDAuMDQyNS0wLjAzMTIgMC4wODUtMC4wNjExIDAuMTI3NS0wLjA5MTQgMTAuNTI1IDMuMjIzNiAxNi45MDIgNy40NDY0IDE2LjkwMiAxMi4wNjkgMCAxMC4xMjItMzAuNTg2IDE4LjMyOS02OC4zMTQgMTguMzI5IiBmaWxsPSIjZjNmM2YyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMxMSIgZD0ibTQ1MS4yOCA5MzAuNWMtMzcuNTUyIDAtNjcuOTkzLTguMTY3NC02Ny45OTMtMTguMjQzIDAtNC41ODQgNi4zMDE5LTguNzcyNSAxNi43MDktMTEuOTc4IDAuMDQzIDAuMDI5OCAwLjA4NTUgMC4wNjE1IDAuMTI4NSAwLjA5MTMtMTAuMjg5IDMuMTg1MS0xNi41MTcgNy4zNDE0LTE2LjUxNyAxMS44ODYgMCAxMC4wMjcgMzAuMjk4IDE4LjE1NiA2Ny42NzIgMTguMTU2czY3LjY3Mi04LjEyODkgNjcuNjcyLTE4LjE1NmMwLTQuNTQ2NC02LjIyNS04LjcwMTEtMTYuNTE2LTExLjg4NiAwLjA0MjUtMC4wMjk4IDAuMDg1LTAuMDYxNSAwLjEyODc1LTAuMDkxMyAxMC40MDggMy4yMDUgMTYuNzA5IDcuMzkzNSAxNi43MDkgMTEuOTc4IDAgMTAuMDc1LTMwLjQ0MSAxOC4yNDMtNjcuOTkzIDE4LjI0MyIgZmlsbD0iI2YyZjJmMiIvPgogICA8cGF0aCBpZD0icGF0aDQzMTMiIGQ9Im00NTEuMjggOTMwLjQxYy0zNy4zNzQgMC02Ny42NzItOC4xMjg5LTY3LjY3Mi0xOC4xNTYgMC00LjU0NDkgNi4yMjgtOC43MDExIDE2LjUxNy0xMS44ODYgMC4wNDI5IDAuMDI5OCAwLjA4NTkgMC4wNjEgMC4xMjkzNyAwLjA5MTQtMTAuMTc0IDMuMTY1LTE2LjMyNSA3LjI4NzUtMTYuMzI1IDExLjc5NSAwIDkuOTggMzAuMTU0IDE4LjA3IDY3LjM1MiAxOC4wNyAzNy4xOTcgMCA2Ny4zNTItOC4wODk4IDY3LjM1Mi0xOC4wNyAwLTQuNTA3NC02LjE1MjUtOC42Mjk5LTE2LjMyNS0xMS43OTUgMC4wNDI1LTAuMDMwNCAwLjA4NjItMC4wNjE2IDAuMTI4NzUtMC4wOTE0IDEwLjI5MSAzLjE4NTEgMTYuNTE2IDcuMzM5OSAxNi41MTYgMTEuODg2IDAgMTAuMDI3LTMwLjI5OCAxOC4xNTYtNjcuNjcyIDE4LjE1NiIgZmlsbD0iI2YyZjFmMSIvPgogICA8cGF0aCBpZD0icGF0aDQzMTUiIGQ9Im00NTEuMjggOTMwLjMyYy0zNy4xOTcgMC02Ny4zNTItOC4wODk4LTY3LjM1Mi0xOC4wNyAwLTQuNTA3NCA2LjE1MTQtOC42Mjk5IDE2LjMyNS0xMS43OTUgMC4wNDMgMC4wMzEyIDAuMDg2NCAwLjA2MSAwLjEyOTM4IDAuMDkyMy0xMC4wNTYgMy4xNDUtMTYuMTM0IDcuMjMzOS0xNi4xMzQgMTEuNzAzIDAgOS45MzI2IDMwLjAxMSAxNy45ODQgNjcuMDMxIDE3Ljk4NHM2Ny4wMy04LjA1MTMgNjcuMDMtMTcuOTg0YzAtNC40Njg4LTYuMDc3NS04LjU1NzYtMTYuMTM0LTExLjcwMyAwLjA0MjUtMC4wMzEyIDAuMDg2My0wLjA2MSAwLjEzLTAuMDkyMyAxMC4xNzIgMy4xNjUgMTYuMzI1IDcuMjg3NSAxNi4zMjUgMTEuNzk1IDAgOS45OC0zMC4xNTUgMTguMDctNjcuMzUyIDE4LjA3IiBmaWxsPSIjZjFmMWYxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMxNyIgZD0ibTQ1MS4yOCA5MzAuMjRjLTM3LjAyIDAtNjcuMDMxLTguMDUxMy02Ny4wMzEtMTcuOTg0IDAtNC40Njg4IDYuMDc3Ni04LjU1NzYgMTYuMTM0LTExLjcwMyAwLjA0MyAwLjAzMDMgMC4wODY5IDAuMDYxNSAwLjEyOTg3IDAuMDkxMy05LjkzOTkgMy4xMjUtMTUuOTQzIDcuMTgwMi0xNS45NDMgMTEuNjExIDAgOS44ODQ4IDI5Ljg2NyAxNy44OTcgNjYuNzEgMTcuODk3czY2LjcxLTguMDEyNyA2Ni43MS0xNy44OTdjMC00LjQzMTEtNi4wMDM4LTguNDg2NC0xNS45NDQtMTEuNjExIDAuMDQzOC0wLjAyOTcgMC4wODc1LTAuMDYxIDAuMTMtMC4wOTEzIDEwLjA1NiAzLjE0NSAxNi4xMzQgNy4yMzM5IDE2LjEzNCAxMS43MDMgMCA5LjkzMjYtMzAuMDEgMTcuOTg0LTY3LjAzIDE3Ljk4NCIgZmlsbD0iI2YwZjBmMCIvPgogICA8cGF0aCBpZD0icGF0aDQzMTkiIGQ9Im00NTEuMjggOTMwLjE1Yy0zNi44NDMgMC02Ni43MS04LjAxMjctNjYuNzEtMTcuODk3IDAtNC40MzExIDYuMDAzLTguNDg2NCAxNS45NDMtMTEuNjExIDAuMDQzNSAwLjAzMDQgMC4wODc0IDAuMDYxNiAwLjEzMDg4IDAuMDkxNC05LjgyNTIgMy4xMDY0LTE1Ljc1MyA3LjEyNzQtMTUuNzUzIDExLjUyIDAgOS44Mzc0IDI5LjcyMyAxNy44MTIgNjYuMzg5IDE3LjgxMnM2Ni4zODktNy45NzUxIDY2LjM4OS0xNy44MTJjMC00LjM5MjYtNS45Mjg4LTguNDEzNi0xNS43NTQtMTEuNTIgMC4wNDM3LTAuMDI5OCAwLjA4ODgtMC4wNjEgMC4xMzEyNS0wLjA5MTQgOS45NCAzLjEyNSAxNS45NDQgNy4xODAyIDE1Ljk0NCAxMS42MTEgMCA5Ljg4NDgtMjkuODY4IDE3Ljg5Ny02Ni43MSAxNy44OTciIGZpbGw9IiNmMGYwZWYiLz4KICAgPHBhdGggaWQ9InBhdGg0MzIxIiBkPSJtNDUxLjI4IDkzMC4wNmMtMzYuNjY2IDAtNjYuMzg5LTcuOTc1MS02Ni4zODktMTcuODEyIDAtNC4zOTI2IDUuOTI3Ni04LjQxMzYgMTUuNzUzLTExLjUyIDAuMDQ1NCAwLjAzMTIgMC4wODU1IDAuMDYgMC4xMzEzNyAwLjA5MTItOS43MTA1IDMuMDg2NS0xNS41NjQgNy4wNzM4LTE1LjU2NCAxMS40MjkgMCA5Ljc5IDI5LjU4IDE3LjcyNiA2Ni4wNjggMTcuNzI2czY2LjA2OC03LjkzNjEgNjYuMDY4LTE3LjcyNmMwLTQuMzU1LTUuODUyNS04LjM0MjItMTUuNTY0LTExLjQyOSAwLjA0NjItMC4wMzEyIDAuMDg2My0wLjA2IDAuMTMxMjUtMC4wOTEyIDkuODI1IDMuMTA2NCAxNS43NTQgNy4xMjc0IDE1Ljc1NCAxMS41MiAwIDkuODM3NC0yOS43MjQgMTcuODEyLTY2LjM4OSAxNy44MTIiIGZpbGw9IiNlZmVmZWYiLz4KICAgPHBhdGggaWQ9InBhdGg0MzIzIiBkPSJtNDUxLjI4IDkyOS45OGMtMzYuNDg5IDAtNjYuMDY4LTcuOTM2MS02Ni4wNjgtMTcuNzI2IDAtNC4zNTUgNS44NTMtOC4zNDIyIDE1LjU2NC0xMS40MjkgMC4wNDI5IDAuMDI5OSAwLjA4ODQgMC4wNjI1IDAuMTMxMzggMC4wOTI0LTkuNTk0MiAzLjA2NDktMTUuMzc1IDcuMDItMTUuMzc1IDExLjMzNiAwIDkuNzQyNiAyOS40MzcgMTcuNjQgNjUuNzQ4IDE3LjY0IDM2LjMxMiAwIDY1Ljc0OC03Ljg5NzUgNjUuNzQ4LTE3LjY0IDAtNC4zMTY0LTUuNzgtOC4yNzE1LTE1LjM3NS0xMS4zMzYgMC4wNDM3LTAuMDI5OSAwLjA4ODctMC4wNjI1IDAuMTMxMjUtMC4wOTI0IDkuNzExMiAzLjA4NjUgMTUuNTY0IDcuMDczOCAxNS41NjQgMTEuNDI5IDAgOS43OS0yOS41OCAxNy43MjYtNjYuMDY4IDE3LjcyNiIgZmlsbD0iI2VlZSIvPgogICA8cGF0aCBpZD0icGF0aDQzMjUiIGQ9Im00NTEuMjggOTI5Ljg5Yy0zNi4zMTIgMC02NS43NDgtNy44OTc1LTY1Ljc0OC0xNy42NCAwLTQuMzE2NCA1Ljc4MDQtOC4yNzE1IDE1LjM3NS0xMS4zMzYgMC4wNDM0IDAuMDMwMiAwLjA4ODggMC4wNjE1IDAuMTMyMjUgMC4wOTEyLTkuNDgxNCAzLjA0NjQtMTUuMTg2IDYuOTY2NC0xNS4xODYgMTEuMjQ1IDAgOS42OTQ5IDI5LjI5MiAxNy41NTQgNjUuNDI3IDE3LjU1NCAzNi4xMzQgMCA2NS40MjctNy44NTg4IDY1LjQyNy0xNy41NTQgMC00LjI3ODgtNS43MDM4LTguMTk4OC0xNS4xODYtMTEuMjQ1IDAuMDQzOC0wLjAyOTcgMC4wODg4LTAuMDYxIDAuMTMyNS0wLjA5MTIgOS41OTUgMy4wNjQ5IDE1LjM3NSA3LjAyIDE1LjM3NSAxMS4zMzYgMCA5Ljc0MjYtMjkuNDM2IDE3LjY0LTY1Ljc0OCAxNy42NCIgZmlsbD0iI2VlZWVlZCIvPgogICA8cGF0aCBpZD0icGF0aDQzMjciIGQ9Im00NTEuMjggOTI5LjgxYy0zNi4xMzQgMC02NS40MjctNy44NTg4LTY1LjQyNy0xNy41NTQgMC00LjI3ODggNS43MDQxLTguMTk4OCAxNS4xODYtMTEuMjQ1IDAuMDQ1NCAwLjAzMTIgMC4wODY5IDAuMDYwMSAwLjEzMjg3IDAuMDkxNC05LjM2NzIgMy4wMjYyLTE0Ljk5OCA2LjkxMzUtMTQuOTk4IDExLjE1NCAwIDkuNjQ3NSAyOS4xNDggMTcuNDY3IDY1LjEwNiAxNy40NjcgMzUuOTU3IDAgNjUuMTA2LTcuODE5OCA2NS4xMDYtMTcuNDY3IDAtNC4yNDAyLTUuNjMtOC4xMjc1LTE0Ljk5OC0xMS4xNTQgMC4wNDYzLTAuMDMxMiAwLjA4NzUtMC4wNjAxIDAuMTMyNS0wLjA5MTQgOS40ODI1IDMuMDQ2NCAxNS4xODYgNi45NjY0IDE1LjE4NiAxMS4yNDUgMCA5LjY5NDktMjkuMjkyIDE3LjU1NC02NS40MjcgMTcuNTU0IiBmaWxsPSIjZWRlZGVjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMyOSIgZD0ibTQ1MS4yOCA5MjkuNzJjLTM1Ljk1OCAwLTY1LjEwNi03LjgxOTgtNjUuMTA2LTE3LjQ2NyAwLTQuMjQwMiA1LjYzMDQtOC4xMjc1IDE0Ljk5OC0xMS4xNTQgMC4wNDM0IDAuMDMwMyAwLjA4OTcgMC4wNjI1IDAuMTMzMjUgMC4wOTIzLTkuMjUxIDMuMDA1NC0xNC44MSA2Ljg1ODktMTQuODEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNiAxNy4zODEgNjQuNzg2IDE3LjM4MXM2NC43ODYtNy43ODEyIDY0Ljc4Ni0xNy4zODFjMC00LjIwMjYtNS41Ni04LjA1NjEtMTQuODExLTExLjA2MiAwLjA0MzctMC4wMjk4IDAuMDktMC4wNjIgMC4xMzM3NS0wLjA5MjMgOS4zNjc1IDMuMDI2MiAxNC45OTggNi45MTM1IDE0Ljk5OCAxMS4xNTQgMCA5LjY0NzUtMjkuMTQ4IDE3LjQ2Ny02NS4xMDYgMTcuNDY3IiBmaWxsPSIjZWRlZGVjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMzMSIgZD0ibTQ1MS4yOCA5MjkuNjNjLTM1Ljc4IDAtNjQuNzg2LTcuNzgxMi02NC43ODYtMTcuMzgxIDAtNC4yMDI2IDUuNTU5NS04LjA1NjEgMTQuODEtMTEuMDYyIDAuMDQ1OSAwLjAzMTcgMC4wODg0IDAuMDYgMC4xMzM3NSAwLjA5MTItOS4xMzgxIDIuOTg1NC0xNC42MjQgNi44MDY2LTE0LjYyNCAxMC45NyAwIDkuNTUyOCAyOC44NjIgMTcuMjk1IDY0LjQ2NSAxNy4yOTUgMzUuNjAyIDAgNjQuNDY0LTcuNzQyMiA2NC40NjQtMTcuMjk1IDAtNC4xNjM2LTUuNDg1LTcuOTg0OS0xNC42MjQtMTAuOTcgMC4wNDYyLTAuMDMxMiAwLjA4ODctMC4wNTk1IDAuMTMzNzUtMC4wOTEyIDkuMjUxMiAzLjAwNTQgMTQuODExIDYuODU4OSAxNC44MTEgMTEuMDYyIDAgOS42MDAxLTI5LjAwNiAxNy4zODEtNjQuNzg2IDE3LjM4MSIgZmlsbD0iI2VjZWNlYiIvPgogICA8cGF0aCBpZD0icGF0aDQzMzMiIGQ9Im00NTEuMjggOTI5LjU1Yy0zNS42MDMgMC02NC40NjUtNy43NDIyLTY0LjQ2NS0xNy4yOTUgMC00LjE2MzYgNS40ODU0LTcuOTg0OSAxNC42MjQtMTAuOTcgMC4wNDQgMC4wMzAzIDAuMDkxNCAwLjA2MjUgMC4xMzQ3NSAwLjA5MjgtOS4wMjQ0IDIuOTYzOS0xNC40MzggNi43NTEtMTQuNDM4IDEwLjg3OCAwIDkuNTA0OSAyOC43MTggMTcuMjEgNjQuMTQ0IDE3LjIxczY0LjE0My03LjcwNTEgNjQuMTQzLTE3LjIxYzAtNC4xMjY1LTUuNDEyNS03LjkxMzYtMTQuNDM2LTEwLjg3OCAwLjA0MzgtMC4wMzAzIDAuMDktMC4wNjI1IDAuMTMzNzUtMC4wOTI4IDkuMTM4OCAyLjk4NTQgMTQuNjI0IDYuODA2NiAxNC42MjQgMTAuOTcgMCA5LjU1MjgtMjguODYyIDE3LjI5NS02NC40NjQgMTcuMjk1IiBmaWxsPSIjZWNlYmVhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMzNSIgZD0ibTQ1MS4yOCA5MjkuNDZjLTM1LjQyNiAwLTY0LjE0NC03LjcwNTEtNjQuMTQ0LTE3LjIxIDAtNC4xMjY1IDUuNDEzMS03LjkxMzYgMTQuNDM4LTEwLjg3OCAwLjA0NiAwLjAzMTIgMC4wODg5IDAuMDYwMSAwLjEzNDg4IDAuMDkxNC04LjkxMTEgMi45NDM5LTE0LjI1MiA2LjY5NzItMTQuMjUyIDEwLjc4NiAwIDkuNDU3NSAyOC41NzUgMTcuMTI0IDYzLjgyMyAxNy4xMjRzNjMuODIzLTcuNjY2IDYzLjgyMy0xNy4xMjRjMC00LjA4ODktNS4zNC03Ljg0MjItMTQuMjUxLTEwLjc4NiAwLjA0NS0wLjAzMTIgMC4wODg4LTAuMDYwMSAwLjEzNS0wLjA5MTQgOS4wMjM4IDIuOTYzOSAxNC40MzYgNi43NTEgMTQuNDM2IDEwLjg3OCAwIDkuNTA0OS0yOC43MTcgMTcuMjEtNjQuMTQzIDE3LjIxIiBmaWxsPSIjZWJlYmVhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMzNyIgZD0ibTQ1MS4yOCA5MjkuMzhjLTM1LjI0OCAwLTYzLjgyMy03LjY2Ni02My44MjMtMTcuMTI0IDAtNC4wODg5IDUuMzQwNC03Ljg0MjIgMTQuMjUyLTEwLjc4NiAwLjA0MzkgMC4wMjk3IDAuMDkyMiAwLjA2MjUgMC4xMzU3NSAwLjA5MTItOC43OTg5IDIuOTI0OS0xNC4wNjYgNi42NDUxLTE0LjA2NiAxMC42OTUgMCA5LjQxMDEgMjguNDMxIDE3LjAzOCA2My41MDIgMTcuMDM4czYzLjUwMi03LjYyNzUgNjMuNTAyLTE3LjAzOGMwLTQuMDQ5OC01LjI2NzUtNy43Ny0xNC4wNjYtMTAuNjk1IDAuMDQzNy0wLjAyODcgMC4wOTI1LTAuMDYxNSAwLjEzNjI1LTAuMDkxMiA4LjkxMTIgMi45NDM5IDE0LjI1MSA2LjY5NzIgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzQgMTcuMTI0LTYzLjgyMyAxNy4xMjQiIGZpbGw9IiNlYWVhZTkiLz4KICAgPHBhdGggaWQ9InBhdGg0MzM5IiBkPSJtNDUxLjI4IDkyOS4yOWMtMzUuMDcxIDAtNjMuNTAyLTcuNjI3NS02My41MDItMTcuMDM4IDAtNC4wNDk4IDUuMjY3Ni03Ljc3IDE0LjA2Ni0xMC42OTUgMC4wNDY0IDAuMDMxMiAwLjA5MDMgMC4wNjExIDAuMTM2MTIgMC4wOTI0LTguNjg1IDIuOTAzOC0xMy44ODIgNi41ODk4LTEzLjg4MiAxMC42MDIgMCA5LjM2MjIgMjguMjg4IDE2Ljk1MSA2My4xODIgMTYuOTUxczYzLjE4Mi03LjU4ODkgNjMuMTgyLTE2Ljk1MWMwLTQuMDEyOC01LjE5NzUtNy42OTg4LTEzLjg4Mi0xMC42MDIgMC4wNDYzLTAuMDMxMiAwLjA5LTAuMDYxMSAwLjEzNjI1LTAuMDkyNCA4Ljc5ODggMi45MjQ5IDE0LjA2NiA2LjY0NTEgMTQuMDY2IDEwLjY5NSAwIDkuNDEwMS0yOC40MyAxNy4wMzgtNjMuNTAyIDE3LjAzOCIgZmlsbD0iI2VhZWFlOCIvPgogICA8cGF0aCBpZD0icGF0aDQzNDEiIGQ9Im00NTEuMjggOTI5LjJjLTM0Ljg5NCAwLTYzLjE4Mi03LjU4ODktNjMuMTgyLTE2Ljk1MSAwLTQuMDEyOCA1LjE5NjgtNy42OTg4IDEzLjg4Mi0xMC42MDIgMC4wNDY0IDAuMDMxMiAwLjA5MDkgMC4wNjEgMC4xMzY3NSAwLjA5MTMtOC41NzIyIDIuODgzOC0xMy42OTggNi41Mzc2LTEzLjY5OCAxMC41MTEgMCA5LjMxNSAyOC4xNDQgMTYuODY1IDYyLjg2MSAxNi44NjVzNjIuODYtNy41NDk4IDYyLjg2LTE2Ljg2NWMwLTMuOTczNi01LjEyNS03LjYyNzUtMTMuNjk4LTEwLjUxMSAwLjA0NjItMC4wMzAzIDAuMDktMC4wNiAwLjEzNjI1LTAuMDkxMyA4LjY4NSAyLjkwMzggMTMuODgyIDYuNTg5OCAxMy44ODIgMTAuNjAyIDAgOS4zNjIyLTI4LjI4OCAxNi45NTEtNjMuMTgyIDE2Ljk1MSIgZmlsbD0iI2U5ZTllOCIvPgogICA8cGF0aCBpZD0icGF0aDQzNDMiIGQ9Im00NTEuMjggOTI5LjEyYy0zNC43MTcgMC02Mi44NjEtNy41NDk4LTYyLjg2MS0xNi44NjUgMC0zLjk3MzYgNS4xMjU1LTcuNjI3NSAxMy42OTgtMTAuNTExIDAuMDQ2NCAwLjAzMTIgMC4wOTE0IDAuMDYxIDAuMTM3NzUgMC4wOTIzLTguNDYxIDIuODYyOS0xMy41MTUgNi40ODMtMTMuNTE1IDEwLjQxOSAwIDkuMjY3NiAyOCAxNi43NzkgNjIuNTQgMTYuNzc5czYyLjUzOS03LjUxMTIgNjIuNTM5LTE2Ljc3OWMwLTMuOTM2LTUuMDUzOC03LjU1NjEtMTMuNTE0LTEwLjQxOSAwLjA0NjItMC4wMzEyIDAuMDkxMi0wLjA2MSAwLjEzNzUtMC4wOTIzIDguNTcyNSAyLjg4MzggMTMuNjk4IDYuNTM3NiAxMy42OTggMTAuNTExIDAgOS4zMTUtMjguMTQ0IDE2Ljg2NS02Mi44NiAxNi44NjUiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg0MzQ1IiBkPSJtNDUxLjI4IDkyOS4wM2MtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTUtMTAuNDE5IDAuMDQ1OSAwLjAzMTIgMC4wOTE3IDAuMDYxNiAwLjEzNzYzIDAuMDkyOS04LjM0OTYgMi44NDEzLTEzLjMzMiA2LjQyNzMtMTMuMzMyIDEwLjMyNiAwIDkuMjIwMiAyNy44NTYgMTYuNjk0IDYyLjIxOSAxNi42OTQgMzQuMzYyIDAgNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0IDAtMy44OTg5LTQuOTgyNS03LjQ4NDktMTMuMzMyLTEwLjMyNiAwLjA0NjMtMC4wMzEyIDAuMDkyNS0wLjA2MTYgMC4xMzg3NS0wLjA5MjkgOC40NiAyLjg2MjkgMTMuNTE0IDYuNDgzIDEzLjUxNCAxMC40MTkgMCA5LjI2NzYtMjcuOTk5IDE2Ljc3OS02Mi41MzkgMTYuNzc5IiBmaWxsPSIjZThlOGU2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM0NyIgZD0ibTQ1MS4yOCA5MjguOTVjLTM0LjM2MyAwLTYyLjIxOS03LjQ3MzYtNjIuMjE5LTE2LjY5NCAwLTMuODk4OSA0Ljk4MTktNy40ODQ5IDEzLjMzMi0xMC4zMjYgMC4wNDY0IDAuMDI5OCAwLjA5MjQgMC4wNiAwLjEzODc1IDAuMDkxMy04LjIzNzQgMi44MTk5LTEzLjE1IDYuMzczNS0xMy4xNSAxMC4yMzUgMCA5LjE3MjQgMjcuNzEzIDE2LjYwNyA2MS44OTggMTYuNjA3IDM0LjE4NiAwIDYxLjg5OC03LjQzNTEgNjEuODk4LTE2LjYwNyAwLTMuODYxNC00LjkxMTItNy40MTUtMTMuMTQ5LTEwLjIzNSAwLjA0NjItMC4wMzEyIDAuMDkxMy0wLjA2MTUgMC4xMzc1LTAuMDkxMyA4LjM1IDIuODQxMyAxMy4zMzIgNi40MjczIDEzLjMzMiAxMC4zMjYgMCA5LjIyMDItMjcuODU3IDE2LjY5NC02Mi4yMTkgMTYuNjk0IiBmaWxsPSIjZTdlN2U2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM0OSIgZD0ibTQ1MS4yOCA5MjguODZjLTM0LjE4NiAwLTYxLjg5OC03LjQzNTEtNjEuODk4LTE2LjYwNyAwLTMuODYxNCA0LjkxMjEtNy40MTUgMTMuMTUtMTAuMjM1IDAuMDQ2NCAwLjAzMTIgMC4wOTI4IDAuMDYxIDAuMTM5MTIgMC4wOTIyLTguMTI4IDIuNzk4OS0xMi45NjggNi4zMTg5LTEyLjk2OCAxMC4xNDMgMCA5LjEyMzUgMjcuNTY5IDE2LjUyMSA2MS41NzggMTYuNTIxIDM0LjAwOCAwIDYxLjU3OC03LjM5OCA2MS41NzgtMTYuNTIxIDAtMy44MjM4LTQuODQtNy4zNDM4LTEyLjk2OS0xMC4xNDMgMC4wNDYzLTAuMDMxMiAwLjA5MzctMC4wNjEgMC4xNC0wLjA5MjIgOC4yMzc1IDIuODE5OSAxMy4xNDkgNi4zNzM1IDEzLjE0OSAxMC4yMzUgMCA5LjE3MjQtMjcuNzEyIDE2LjYwNy02MS44OTggMTYuNjA3IiBmaWxsPSIjZTdlNmU1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM1MSIgZD0ibTQ1MS4yOCA5MjguNzdjLTM0LjAwOSAwLTYxLjU3OC03LjM5OC02MS41NzgtMTYuNTIxIDAtMy44MjM4IDQuODM5Ny03LjM0MzggMTIuOTY4LTEwLjE0MyAwLjA0NjQgMC4wMzA0IDAuMDkzMyAwLjA2MTYgMC4xMzk2MyAwLjA5MTQtOC4wMTU2IDIuNzc4OC0xMi43ODcgNi4yNjYxLTEyLjc4NyAxMC4wNTEgMCA5LjA3NzYgMjcuNDI2IDE2LjQzNSA2MS4yNTcgMTYuNDM1czYxLjI1Ny03LjM1NzQgNjEuMjU3LTE2LjQzNWMwLTMuNzg1MS00Ljc3MTItNy4yNzI1LTEyLjc4OC0xMC4wNTEgMC4wNDc1LTAuMDI5OCAwLjA5MzctMC4wNjEgMC4xNC0wLjA5MTQgOC4xMjg4IDIuNzk4OSAxMi45NjkgNi4zMTg5IDEyLjk2OSAxMC4xNDMgMCA5LjEyMzUtMjcuNTcgMTYuNTIxLTYxLjU3OCAxNi41MjEiIGZpbGw9IiNlNmU2ZTQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzUzIiBkPSJtNDUxLjI4IDkyOC42OWMtMzMuODMyIDAtNjEuMjU3LTcuMzU3NC02MS4yNTctMTYuNDM1IDAtMy43ODUxIDQuNzcxNS03LjI3MjUgMTIuNzg3LTEwLjA1MSAwLjA0NjQgMC4wMzEyIDAuMDk0MiAwLjA2MjUgMC4xNDA2MiAwLjA5MjItNy45MDYyIDIuNzU3OS0xMi42MDYgNi4yMTE1LTEyLjYwNiA5Ljk1OSAwIDkuMDI4OCAyNy4yODIgMTYuMzQ5IDYwLjkzNiAxNi4zNDlzNjAuOTM2LTcuMzE5OSA2MC45MzYtMTYuMzQ5YzAtMy43NDc1LTQuNy03LjIwMTEtMTIuNjA2LTkuOTU5IDAuMDQ2My0wLjAyOTcgMC4wOTM3LTAuMDYxIDAuMTQtMC4wOTIyIDguMDE2MiAyLjc3ODggMTIuNzg4IDYuMjY2MSAxMi43ODggMTAuMDUxIDAgOS4wNzc2LTI3LjQyNiAxNi40MzUtNjEuMjU3IDE2LjQzNSIgZmlsbD0iI2U1ZTVlMyIvPgogICA8cGF0aCBpZD0icGF0aDQzNTUiIGQ9Im00NTEuMjggOTI4LjZjLTMzLjY1NCAwLTYwLjkzNi03LjMxOTktNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NSA0LjcwMDEtNy4yMDExIDEyLjYwNi05Ljk1OSAwLjA0NjQgMC4wMzAzIDAuMDk0NyAwLjA2MTUgMC4xNDExMyAwLjA5MjgtNy43OTU0IDIuNzM2NC0xMi40MjcgNi4xNTYyLTEyLjQyNyA5Ljg2NjIgMCA4Ljk4MTUgMjcuMTM4IDE2LjI2MyA2MC42MTUgMTYuMjYzczYwLjYxNi03LjI4MTIgNjAuNjE2LTE2LjI2M2MwLTMuNzEtNC42MzEyLTcuMTI5OS0xMi40MjgtOS44NjYyIDAuMDQ2Mi0wLjAzMTIgMC4wOTUtMC4wNjI1IDAuMTQxMjUtMC4wOTI4IDcuOTA2MiAyLjc1NzkgMTIuNjA2IDYuMjExNSAxMi42MDYgOS45NTkgMCA5LjAyODgtMjcuMjgyIDE2LjM0OS02MC45MzYgMTYuMzQ5IiBmaWxsPSIjZTRlNGUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM1NyIgZD0ibTQ1MS4yOCA5MjguNTJjLTMzLjQ3NyAwLTYwLjYxNS03LjI4MTItNjAuNjE1LTE2LjI2MyAwLTMuNzEgNC42MzE0LTcuMTI5OSAxMi40MjctOS44NjYyIDAuMDQ2OSAwLjAyOTkgMC4wOTUzIDAuMDYxMSAwLjE0MTYyIDAuMDkxNC03LjY4NiAyLjcxNjItMTIuMjQ4IDYuMTAyNS0xMi4yNDggOS43NzQ5IDAgOC45MzM2IDI2Ljk5NSAxNi4xNzYgNjAuMjk1IDE2LjE3NnM2MC4yOTQtNy4yNDI2IDYwLjI5NC0xNi4xNzZjMC0zLjY3MjQtNC41NjEyLTcuMDU4Ni0xMi4yNDgtOS43NzQ5IDAuMDQ2My0wLjAzMDIgMC4wOTUtMC4wNjE1IDAuMTQxMjUtMC4wOTE0IDcuNzk2MiAyLjczNjQgMTIuNDI4IDYuMTU2MiAxMi40MjggOS44NjYyIDAgOC45ODE1LTI3LjEzOCAxNi4yNjMtNjAuNjE2IDE2LjI2MyIgZmlsbD0iI2U0ZTNlMiIvPgogICA8cGF0aCBpZD0icGF0aDQzNTkiIGQ9Im00NTEuMjggOTI4LjQzYy0zMy4zIDAtNjAuMjk1LTcuMjQyNi02MC4yOTUtMTYuMTc2IDAtMy42NzI0IDQuNTYyMS03LjA1ODYgMTIuMjQ4LTkuNzc0OSAwLjA0ODggMC4wMzIzIDAuMDkzMyAwLjA2MSAwLjE0MjUgMC4wOTIyLTcuNTc3NiAyLjY5MzktMTIuMDcgNi4wNDc5LTEyLjA3IDkuNjgyNiAwIDguODg2MiAyNi44NTEgMTYuMDkxIDU5Ljk3NCAxNi4wOTEgMzMuMTIyIDAgNTkuOTc0LTcuMjA1MSA1OS45NzQtMTYuMDkxIDAtMy42MzQ4LTQuNDkyNS02Ljk4ODgtMTIuMDctOS42ODI2IDAuMDQ4Ny0wLjAzMTIgMC4wOTM3LTAuMDYgMC4xNDI1LTAuMDkyMiA3LjY4NjIgMi43MTYyIDEyLjI0OCA2LjEwMjUgMTIuMjQ4IDkuNzc0OSAwIDguOTMzNi0yNi45OTQgMTYuMTc2LTYwLjI5NCAxNi4xNzYiIGZpbGw9IiNlM2UzZTEiLz4KICAgPHBhdGggaWQ9InBhdGg0MzYxIiBkPSJtNDUxLjI4IDkyOC4zNGMtMzMuMTIzIDAtNTkuOTc0LTcuMjA1MS01OS45NzQtMTYuMDkxIDAtMy42MzQ4IDQuNDkyMS02Ljk4ODggMTIuMDctOS42ODI2IDAuMDQ2NSAwLjAzMDMgMC4wOTYyIDAuMDYyNSAwLjE0MzEzIDAuMDkyOC03LjQ2NzggMi42NzI0LTExLjg5MiA1Ljk5MjItMTEuODkyIDkuNTg5OSAwIDguODM4OSAyNi43MDggMTYuMDA1IDU5LjY1MyAxNi4wMDVzNTkuNjUzLTcuMTY2IDU5LjY1My0xNi4wMDVjMC0zLjU5NzYtNC40MjM4LTYuOTE3NS0xMS44OTEtOS41ODk5IDAuMDQ2My0wLjAzMDMgMC4wOTYyLTAuMDYyNSAwLjE0MjUtMC4wOTI4IDcuNTc3NSAyLjY5MzkgMTIuMDcgNi4wNDc5IDEyLjA3IDkuNjgyNiAwIDguODg2Mi0yNi44NTIgMTYuMDkxLTU5Ljk3NCAxNi4wOTEiIGZpbGw9IiNlMmUyZTAiLz4KICAgPHBhdGggaWQ9InBhdGg0MzYzIiBkPSJtNDUxLjI4IDkyOC4yNmMtMzIuOTQ1IDAtNTkuNjUzLTcuMTY2LTU5LjY1My0xNi4wMDUgMC0zLjU5NzYgNC40MjQ0LTYuOTE3NSAxMS44OTItOS41ODk5IDAuMDQ4OSAwLjAzMTIgMC4wOTQyIDAuMDYwMSAwLjE0MzUgMC4wOTA5LTcuMzU4OSAyLjY1MjgtMTEuNzE1IDUuOTM5LTExLjcxNSA5LjQ5OSAwIDguNzkxIDI2LjU2NCAxNS45MTkgNTkuMzMyIDE1LjkxOXM1OS4zMzItNy4xMjggNTkuMzMyLTE1LjkxOWMwLTMuNTYtNC4zNTUtNi44NDYyLTExLjcxNC05LjQ5OSAwLjA0ODgtMC4wMzA4IDAuMDkzNy0wLjA1OTYgMC4xNDM3NS0wLjA5MDkgNy40Njc1IDIuNjcyNCAxMS44OTEgNS45OTIyIDExLjg5MSA5LjU4OTkgMCA4LjgzODktMjYuNzA4IDE2LjAwNS01OS42NTMgMTYuMDA1IiBmaWxsPSIjZTJlMWRmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM2NSIgZD0ibTQ1MS4yOCA5MjguMTdjLTMyLjc2OSAwLTU5LjMzMi03LjEyOC01OS4zMzItMTUuOTE5IDAtMy41NiA0LjM1NTktNi44NDYyIDExLjcxNS05LjQ5OSAwLjA0NjkgMC4wMzAzIDAuMDk3MiAwLjA2MyAwLjE0NDEyIDAuMDkyOC03LjI1MDUgMi42Mjk5LTExLjUzOCA1Ljg4MzgtMTEuNTM4IDkuNDA2MiAwIDguNzQzNiAyNi40MiAxNS44MzMgNTkuMDEyIDE1LjgzMyAzMi41OTEgMCA1OS4wMTItNy4wODg5IDU5LjAxMi0xNS44MzMgMC0zLjUyMjUtNC4yODc1LTYuNzc2NC0xMS41MzktOS40MDYyIDAuMDQ3NS0wLjAyOTggMC4wOTc1LTAuMDYyNSAwLjE0NS0wLjA5MjggNy4zNTg4IDIuNjUyOCAxMS43MTQgNS45MzkgMTEuNzE0IDkuNDk5IDAgOC43OTEtMjYuNTY0IDE1LjkxOS01OS4zMzIgMTUuOTE5IiBmaWxsPSIjZTFlMGRmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM2NyIgZD0ibTQ1MS4yOCA5MjguMDhjLTMyLjU5MSAwLTU5LjAxMi03LjA4ODktNTkuMDEyLTE1LjgzMyAwLTMuNTIyNSA0LjI4NzYtNi43NzY0IDExLjUzOC05LjQwNjIgMC4wNDkyIDAuMDMxMiAwLjA5NTYgMC4wNjEgMC4xNDUgMC4wOTIyLTcuMTQyMSAyLjYwODktMTEuMzYyIDUuODI5MS0xMS4zNjIgOS4zMTQgMCA4LjY5NjIgMjYuMjc3IDE1Ljc0NiA1OC42OTEgMTUuNzQ2czU4LjY5LTcuMDQ5OCA1OC42OS0xNS43NDZjMC0zLjQ4NDktNC4yMi02LjcwNTEtMTEuMzYyLTkuMzE0IDAuMDUtMC4wMzEyIDAuMDk2Mi0wLjA2MSAwLjE0NS0wLjA5MjIgNy4yNTEyIDIuNjI5OSAxMS41MzkgNS44ODM4IDExLjUzOSA5LjQwNjIgMCA4Ljc0MzYtMjYuNDIgMTUuODMzLTU5LjAxMiAxNS44MzMiIGZpbGw9IiNlMGUwZGUiLz4KICAgPHBhdGggaWQ9InBhdGg0MzY5IiBkPSJtNDUxLjI4IDkyOGMtMzIuNDE0IDAtNTguNjkxLTcuMDQ5OC01OC42OTEtMTUuNzQ2IDAtMy40ODQ5IDQuMjIwMi02LjcwNTEgMTEuMzYyLTkuMzE0IDAuMDQ2OSAwLjAzMDMgMC4wOTkxIDAuMDYyNSAwLjE0NiAwLjA5MjgtNy4wMzY2IDIuNTg2LTExLjE4OCA1Ljc3MzUtMTEuMTg4IDkuMjIxMiAwIDguNjQ4OSAyNi4xMzMgMTUuNjYgNTguMzcgMTUuNjZzNTguMzctNy4wMTEzIDU4LjM3LTE1LjY2YzAtMy40NDc4LTQuMTUxMi02LjYzNTItMTEuMTg4LTkuMjIxMiAwLjA0NjItMC4wMzAzIDAuMDk4Ny0wLjA2MjUgMC4xNDUtMC4wOTI4IDcuMTQyNSAyLjYwODkgMTEuMzYyIDUuODI5MSAxMS4zNjIgOS4zMTQgMCA4LjY5NjItMjYuMjc2IDE1Ljc0Ni01OC42OSAxNS43NDYiIGZpbGw9IiNlMGRmZGQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzcxIiBkPSJtNDUxLjI4IDkyNy45MWMtMzIuMjM3IDAtNTguMzctNy4wMTEzLTU4LjM3LTE1LjY2IDAtMy40NDc4IDQuMTUwOS02LjYzNTIgMTEuMTg4LTkuMjIxMiAwLjA0OTIgMC4wMzEyIDAuMDk2NiAwLjA2MTEgMC4xNDYgMC4wOTI0LTYuOTI3MiAyLjU2NDktMTEuMDEzIDUuNzE4OC0xMS4wMTMgOS4xMjg5IDAgOC42MDExIDI1Ljk4OSAxNS41NzUgNTguMDQ5IDE1LjU3NXM1OC4wNDktNi45NzQxIDU4LjA0OS0xNS41NzVjMC0zLjQxMDEtNC4wODUtNi41NjQtMTEuMDEyLTkuMTI4OSAwLjA0ODctMC4wMzEyIDAuMDk2Mi0wLjA2MTEgMC4xNDYyNS0wLjA5MjQgNy4wMzYyIDIuNTg2IDExLjE4OCA1Ljc3MzUgMTEuMTg4IDkuMjIxMiAwIDguNjQ4OS0yNi4xMzQgMTUuNjYtNTguMzcgMTUuNjYiIGZpbGw9IiNkZmRlZGMiLz4KICAgPHBhdGggaWQ9InBhdGg0MzczIiBkPSJtNDUxLjI4IDkyNy44M2MtMzIuMDYgMC01OC4wNDktNi45NzQxLTU4LjA0OS0xNS41NzUgMC0zLjQxMDEgNC4wODU1LTYuNTY0IDExLjAxMy05LjEyODkgMC4wNDk3IDAuMDMxMiAwLjA5NzYgMC4wNjE1IDAuMTQ3IDAuMDkyOC02LjgyMTkgMi41NDI0LTEwLjgzOSA1LjY2MzYtMTAuODM5IDkuMDM2MSAwIDguNTUzOCAyNS44NDYgMTUuNDg5IDU3LjcyOCAxNS40ODlzNTcuNzI4LTYuOTM1IDU3LjcyOC0xNS40ODljMC0zLjM3MjUtNC4wMTYyLTYuNDkzNi0xMC44MzktOS4wMzYxIDAuMDUtMC4wMzEyIDAuMDk3NS0wLjA2MTUgMC4xNDc1LTAuMDkyOCA2LjkyNzUgMi41NjQ5IDExLjAxMiA1LjcxODggMTEuMDEyIDkuMTI4OSAwIDguNjAxMS0yNS45OSAxNS41NzUtNTguMDQ5IDE1LjU3NSIgZmlsbD0iI2RlZGRkYiIvPgogICA8cGF0aCBpZD0icGF0aDQzNzUiIGQ9Im00NTEuMjggOTI3Ljc0Yy0zMS44ODMgMC01Ny43MjgtNi45MzUtNTcuNzI4LTE1LjQ4OSAwLTMuMzcyNSA0LjAxNy02LjQ5MzYgMTAuODM5LTkuMDM2MSAwLjA0OTIgMC4wMzEyIDAuMDk4NiAwLjA2MSAwLjE0Nzg4IDAuMDkyMi02LjcxNTIgMi41MjE1LTEwLjY2NiA1LjYwODktMTAuNjY2IDguOTQzOSAwIDguNTA2NCAyNS43MDIgMTUuNDAyIDU3LjQwOCAxNS40MDJzNTcuNDA4LTYuODk2IDU3LjQwOC0xNS40MDJjMC0zLjMzNS0zLjk1MTItNi40MjI0LTEwLjY2Ni04Ljk0MzkgMC4wNDg4LTAuMDMxMiAwLjA5NzUtMC4wNjEgMC4xNDc1LTAuMDkyMiA2LjgyMjUgMi41NDI1IDEwLjgzOSA1LjY2MzYgMTAuODM5IDkuMDM2MSAwIDguNTUzOC0yNS44NDYgMTUuNDg5LTU3LjcyOCAxNS40ODkiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg0Mzc3IiBkPSJtNDUxLjI4IDkyNy42NWMtMzEuNzA2IDAtNTcuNDA4LTYuODk2LTU3LjQwOC0xNS40MDIgMC0zLjMzNSAzLjk1MDgtNi40MjI0IDEwLjY2Ni04Ljk0MzkgMC4wNDk5IDAuMDMwMyAwLjA5ODYgMC4wNjE1IDAuMTQ4NSAwLjA5MjgtNi42MDk0IDIuNDk4Ni0xMC40OTQgNS41NTM4LTEwLjQ5NCA4Ljg1MTEgMCA4LjQ1OSAyNS41NTkgMTUuMzE2IDU3LjA4NyAxNS4zMTZzNTcuMDg3LTYuODU3NCA1Ny4wODctMTUuMzE2YzAtMy4yOTc0LTMuODgzOC02LjM1MjUtMTAuNDk0LTguODUxMSAwLjA1LTAuMDMxMiAwLjA5ODctMC4wNjI1IDAuMTQ4NzUtMC4wOTI4IDYuNzE1IDIuNTIxNSAxMC42NjYgNS42MDg5IDEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQtMjUuNzAyIDE1LjQwMi01Ny40MDggMTUuNDAyIiBmaWxsPSIjZGRkY2RhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM3OSIgZD0ibTQ1MS4yOCA5MjcuNTdjLTMxLjUyOCAwLTU3LjA4Ny02Ljg1NzQtNTcuMDg3LTE1LjMxNiAwLTMuMjk3NCAzLjg4NDgtNi4zNTI1IDEwLjQ5NC04Ljg1MTEgMC4wNDk3IDAuMDI5OSAwLjA5OTYgMC4wNjExIDAuMTQ4ODcgMC4wOTI0LTYuNTAyOSAyLjQ3NzUtMTAuMzIyIDUuNDk5LTEwLjMyMiA4Ljc1ODggMCA4LjQxMTEgMjUuNDE1IDE1LjIzIDU2Ljc2NiAxNS4yM3M1Ni43NjctNi44MTg4IDU2Ljc2Ny0xNS4yM2MwLTMuMjU5OC0zLjgyLTYuMjgxMi0xMC4zMjItOC43NTg4IDAuMDQ4Ny0wLjAzMTIgMC4wOTg3LTAuMDYyNSAwLjE0ODc1LTAuMDkyNCA2LjYxIDIuNDk4NiAxMC40OTQgNS41NTM4IDEwLjQ5NCA4Ljg1MTEgMCA4LjQ1OS0yNS41NTggMTUuMzE2LTU3LjA4NyAxNS4zMTYiIGZpbGw9IiNkY2RiZDkiLz4KICAgPHBhdGggaWQ9InBhdGg0MzgxIiBkPSJtNDUxLjI4IDkyNy40OGMtMzEuMzUxIDAtNTYuNzY2LTYuODE4OC01Ni43NjYtMTUuMjMgMC0zLjI1OTggMy44MTg5LTYuMjgxMiAxMC4zMjItOC43NTg4IDAuMDQ5OSAwLjAzMDMgMC4xMDA2MyAwLjA2MSAwLjE0OTg4IDAuMDkyMy02LjM5NzQgMi40NTUxLTEwLjE1MSA1LjQ0MzktMTAuMTUxIDguNjY2NSAwIDguMzYzOCAyNS4yNzEgMTUuMTQ0IDU2LjQ0NSAxNS4xNDRzNTYuNDQ2LTYuNzc5OCA1Ni40NDYtMTUuMTQ0YzAtMy4yMjI2LTMuNzUzOC02LjIxMTQtMTAuMTUxLTguNjY2NSAwLjA1LTAuMDMxMiAwLjEtMC4wNjIgMC4xNS0wLjA5MjMgNi41MDI1IDIuNDc3NSAxMC4zMjIgNS40OTkgMTAuMzIyIDguNzU4OCAwIDguNDExMS0yNS40MTYgMTUuMjMtNTYuNzY3IDE1LjIzIiBmaWxsPSIjZGJkYWQ4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM4MyIgZD0ibTQ1MS4yOCA5MjcuNGMtMzEuMTc0IDAtNTYuNDQ1LTYuNzc5OC01Ni40NDUtMTUuMTQ0IDAtMy4yMjI2IDMuNzUzNS02LjIxMTQgMTAuMTUxLTguNjY2NSAwLjA0OTkgMC4wMzAzIDAuMTAxMTIgMC4wNjE1IDAuMTUwODcgMC4wOTI4LTYuMjkzNCAyLjQzMjYtOS45ODE0IDUuMzg3Ny05Ljk4MTQgOC41NzM3IDAgOC4zMTY0IDI1LjEyOCAxNS4wNTggNTYuMTI1IDE1LjA1OCAzMC45OTYgMCA1Ni4xMjQtNi43NDEyIDU2LjEyNC0xNS4wNTggMC0zLjE4Ni0zLjY4NzUtNi4xNDExLTkuOTgxMi04LjU3MzggMC4wNS0wLjAzMTIgMC4xMDEyNS0wLjA2MjUgMC4xNTEyNS0wLjA5MjggNi4zOTc1IDIuNDU1MSAxMC4xNTEgNS40NDM5IDEwLjE1MSA4LjY2NjUgMCA4LjM2MzgtMjUuMjcyIDE1LjE0NC01Ni40NDYgMTUuMTQ0IiBmaWxsPSIjZGFkOWQ3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM4NSIgZD0ibTQ1MS4yOCA5MjcuMzFjLTMwLjk5NyAwLTU2LjEyNS02Ljc0MTItNTYuMTI1LTE1LjA1OCAwLTMuMTg2IDMuNjg4LTYuMTQxMSA5Ljk4MTQtOC41NzM4IDAuMDQ5OSAwLjAyOTggMC4xMDE2MyAwLjA2MSAwLjE1MTM4IDAuMDkyMy02LjE4ODUgMi40MTE2LTkuODEyIDUuMzMyNi05LjgxMiA4LjQ4MTUgMCA4LjI2ODUgMjQuOTg0IDE0Ljk3MyA1NS44MDQgMTQuOTczIDMwLjgxOSAwIDU1LjgwNC02LjcwNDIgNTUuODA0LTE0Ljk3MyAwLTMuMTQ4OS0zLjYyMzgtNi4wNjk5LTkuODEyNS04LjQ4MTUgMC4wNS0wLjAzMTIgMC4xMDI1LTAuMDYyNSAwLjE1MTI1LTAuMDkyMyA2LjI5MzggMi40MzI2IDkuOTgxMiA1LjM4NzggOS45ODEyIDguNTczOCAwIDguMzE2NC0yNS4xMjggMTUuMDU4LTU2LjEyNCAxNS4wNTgiIGZpbGw9IiNkOWQ4ZDYiLz4KICAgPHBhdGggaWQ9InBhdGg0Mzg3IiBkPSJtNDUxLjI4IDkyNy4yM2MtMzAuODIgMC01NS44MDQtNi43MDQyLTU1LjgwNC0xNC45NzMgMC0zLjE0ODkgMy42MjM1LTYuMDY5OSA5LjgxMi04LjQ4MTUgMC4wNTIzIDAuMDMxMiAwLjA5OTYgMC4wNjE2IDAuMTUxODcgMC4wOTI5LTYuMDg0NSAyLjM4ODYtOS42NDMgNS4yNzczLTkuNjQzIDguMzg4NiAwIDguMjIxMiAyNC44NDEgMTQuODg2IDU1LjQ4MyAxNC44ODZzNTUuNDgzLTYuNjY1IDU1LjQ4My0xNC44ODZjMC0zLjExMTQtMy41NTg4LTYtOS42NDI1LTguMzg4NiAwLjA1MTItMC4wMzEyIDAuMDk4Ny0wLjA2MTYgMC4xNTEyNS0wLjA5MjkgNi4xODg4IDIuNDExNiA5LjgxMjUgNS4zMzI2IDkuODEyNSA4LjQ4MTUgMCA4LjI2ODUtMjQuOTg1IDE0Ljk3My01NS44MDQgMTQuOTczIiBmaWxsPSIjZDhkN2Q2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM4OSIgZD0ibTQ1MS4yOCA5MjcuMTRjLTMwLjY0MyAwLTU1LjQ4My02LjY2NS01NS40ODMtMTQuODg2IDAtMy4xMTE0IDMuNTU4NS02IDkuNjQzLTguMzg4NiAwLjA1MDMgMC4wMjk4IDAuMTAzIDAuMDYyNSAwLjE1Mjg4IDAuMDkyMy01Ljk4MTUgMi4zNjYzLTkuNDc1MSA1LjIyMTEtOS40NzUxIDguMjk2NCAwIDguMTczOSAyNC42OTcgMTQuOCA1NS4xNjMgMTQuOCAzMC40NjUgMCA1NS4xNjItNi42MjU5IDU1LjE2Mi0xNC44IDAtMy4wNzUyLTMuNDkyNS01LjkzMDEtOS40NzUtOC4yOTY0IDAuMDUtMC4wMjk4IDAuMTAzNzUtMC4wNjI1IDAuMTUzNzUtMC4wOTIzIDYuMDgzOCAyLjM4ODYgOS42NDI1IDUuMjc3MyA5LjY0MjUgOC4zODg2IDAgOC4yMjEyLTI0Ljg0IDE0Ljg4Ni01NS40ODMgMTQuODg2IiBmaWxsPSIjZDdkNmQ1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM5MSIgZD0ibTQ1MS4yOCA5MjcuMDVjLTMwLjQ2NSAwLTU1LjE2My02LjYyNTktNTUuMTYzLTE0LjggMC0zLjA3NTIgMy40OTM2LTUuOTMwMSA5LjQ3NTEtOC4yOTY0IDAuMDUyOCAwLjAzMTIgMC4xMDE1IDAuMDYxNSAwLjE1Mzc1IDAuMDkyOC01Ljg3NzkgMi4zNDM4LTkuMzA4MSA1LjE2Ni05LjMwODEgOC4yMDM2IDAgOC4xMjY1IDI0LjU1MyAxNC43MTQgNTQuODQyIDE0LjcxNCAzMC4yODggMCA1NC44NDItNi41ODc0IDU0Ljg0Mi0xNC43MTQgMC0zLjAzNzYtMy40My01Ljg1OTktOS4zMDg4LTguMjAzNiAwLjA1MjUtMC4wMzEyIDAuMTAxMjUtMC4wNjE1IDAuMTUzNzUtMC4wOTI4IDUuOTgyNSAyLjM2NjMgOS40NzUgNS4yMjExIDkuNDc1IDguMjk2NCAwIDguMTczOS0yNC42OTYgMTQuOC01NS4xNjIgMTQuOCIgZmlsbD0iI2Q2ZDZkNCIvPgogICA8cGF0aCBpZD0icGF0aDQzOTMiIGQ9Im00NTEuMjggOTI2Ljk3Yy0zMC4yODkgMC01NC44NDItNi41ODc0LTU0Ljg0Mi0xNC43MTQgMC0zLjAzNzYgMy40MzAyLTUuODU5OSA5LjMwODEtOC4yMDM2IDAuMDUwNCAwLjAyOTcgMC4xMDQgMC4wNjI1IDAuMTU0MzcgMC4wOTIyLTUuNzc1NCAyLjMyMjctOS4xNDE2IDUuMTExNC05LjE0MTYgOC4xMTE0IDAgOC4wNzg2IDI0LjQxIDE0LjYyNyA1NC41MjEgMTQuNjI3czU0LjUyLTYuNTQ4OCA1NC41Mi0xNC42MjdjMC0zLTMuMzY2Mi01Ljc4ODYtOS4xNDEyLTguMTExNCAwLjA1LTAuMDI5NyAwLjEwMzc1LTAuMDYyNSAwLjE1Mzc1LTAuMDkyMiA1Ljg3ODggMi4zNDM4IDkuMzA4OCA1LjE2NiA5LjMwODggOC4yMDM2IDAgOC4xMjY1LTI0LjU1NCAxNC43MTQtNTQuODQyIDE0LjcxNCIgZmlsbD0iI2Q1ZDVkMyIvPgogICA8cGF0aCBpZD0icGF0aDQzOTUiIGQ9Im00NTEuMjggOTI2Ljg4Yy0zMC4xMTEgMC01NC41MjEtNi41NDg4LTU0LjUyMS0xNC42MjcgMC0zIDMuMzY2Mi01Ljc4ODYgOS4xNDE2LTguMTExNCAwLjA1MjggMC4wMzI4IDAuMTAzIDAuMDYyNSAwLjE1NTI1IDAuMDkzNy01LjY3MjQgMi4yOTg5LTguOTc2MSA1LjA1NTItOC45NzYxIDguMDE3NiAwIDguMDMxMiAyNC4yNjYgMTQuNTQxIDU0LjIgMTQuNTQxczU0LjItNi41MDk4IDU0LjItMTQuNTQxYzAtMi45NjI0LTMuMzAzOC01LjcxODgtOC45NzYyLTguMDE3NiAwLjA1MjUtMC4wMzEyIDAuMTAyNS0wLjA2MSAwLjE1NS0wLjA5MzcgNS43NzUgMi4zMjI3IDkuMTQxMiA1LjExMTQgOS4xNDEyIDguMTExNCAwIDguMDc4Ni0yNC40MSAxNC42MjctNTQuNTIgMTQuNjI3IiBmaWxsPSIjZDRkNGQyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM5NyIgZD0ibTQ1MS4yOCA5MjYuNzljLTI5LjkzNCAwLTU0LjItNi41MDk4LTU0LjItMTQuNTQxIDAtMi45NjI0IDMuMzAzOC01LjcxODggOC45NzYxLTguMDE3NiAwLjA1MjggMC4wMzEyIDAuMTAzNSAwLjA2MTYgMC4xNTYyNSAwLjA5MjktNS41NzA5IDIuMjc1OS04LjgxMTUgNC45OTg1LTguODExNSA3LjkyNDggMCA3Ljk4MzkgMjQuMTIyIDE0LjQ1NSA1My44NzkgMTQuNDU1czUzLjg3OS02LjQ3MTIgNTMuODc5LTE0LjQ1NWMwLTIuOTI2Mi0zLjI0MTItNS42NDg5LTguODExMi03LjkyNDggMC4wNTI1LTAuMDMxMiAwLjEwMzc1LTAuMDYxNiAwLjE1NjI1LTAuMDkyOSA1LjY3MjUgMi4yOTg5IDguOTc2MiA1LjA1NTIgOC45NzYyIDguMDE3NiAwIDguMDMxMi0yNC4yNjcgMTQuNTQxLTU0LjIgMTQuNTQxIiBmaWxsPSIjZDRkM2QxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM5OSIgZD0ibTQ1MS4yOCA5MjYuNzFjLTI5Ljc1NyAwLTUzLjg3OS02LjQ3MTItNTMuODc5LTE0LjQ1NSAwLTIuOTI2MiAzLjI0MDYtNS42NDg5IDguODExNS03LjkyNDggMC4wNTI4IDAuMDMxMiAwLjEwNCAwLjA2MSAwLjE1Njc1IDAuMDkyMy01LjQ3MDIgMi4yNTM5LTguNjQ3NSA0Ljk0MzktOC42NDc1IDcuODMyNSAwIDcuOTM2IDIzLjk3OSAxNC4zNyA1My41NTkgMTQuMzdzNTMuNTU4LTYuNDM0MSA1My41NTgtMTQuMzdjMC0yLjg4ODYtMy4xNzc1LTUuNTc4Ni04LjY0NzUtNy44MzI1IDAuMDUzOC0wLjAzMTIgMC4xMDUtMC4wNjEgMC4xNTc1LTAuMDkyMyA1LjU3IDIuMjc1OSA4LjgxMTIgNC45OTg1IDguODExMiA3LjkyNDggMCA3Ljk4MzktMjQuMTIyIDE0LjQ1NS01My44NzkgMTQuNDU1IiBmaWxsPSIjZDNkMmQwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQwMSIgZD0ibTQ1MS4yOCA5MjYuNjJjLTI5LjU4IDAtNTMuNTU5LTYuNDM0MS01My41NTktMTQuMzcgMC0yLjg4ODYgMy4xNzcyLTUuNTc4NiA4LjY0NzUtNy44MzI1IDAuMDUwMyAwLjAyOTcgMC4xMDY4OCAwLjA2NCAwLjE1NzYzIDAuMDkzNy01LjM2ODYgMi4yMy04LjQ4NDQgNC44ODYyLTguNDg0NCA3LjczODggMCA3Ljg4ODYgMjMuODM2IDE0LjI4NCA1My4yMzggMTQuMjg0czUzLjIzOC02LjM5NTEgNTMuMjM4LTE0LjI4NGMwLTIuODUyNS0zLjExNjItNS41MDg4LTguNDg1LTcuNzM4OCAwLjA1MTItMC4wMjk4IDAuMTA3NS0wLjA2NCAwLjE1NzUtMC4wOTM3IDUuNDcgMi4yNTM5IDguNjQ3NSA0Ljk0MzkgOC42NDc1IDcuODMyNSAwIDcuOTM2LTIzLjk3OCAxNC4zNy01My41NTggMTQuMzciIGZpbGw9IiNkMmQxY2YiLz4KICAgPHBhdGggaWQ9InBhdGg0NDAzIiBkPSJtNDUxLjI4IDkyNi41NGMtMjkuNDAyIDAtNTMuMjM4LTYuMzk1MS01My4yMzgtMTQuMjg0IDAtMi44NTI1IDMuMTE1OC01LjUwODggOC40ODQ0LTcuNzM4OCAwLjA1MjggMC4wMzEyIDAuMTA1NSAwLjA2MjUgMC4xNTgyNSAwLjA5MjItNS4yNjg1IDIuMjA3NS04LjMyMTggNC44MzE1LTguMzIxOCA3LjY0NjUgMCA3Ljg0MTIgMjMuNjkxIDE0LjE5NyA1Mi45MTcgMTQuMTk3IDI5LjIyNSAwIDUyLjkxNy02LjM1NiA1Mi45MTctMTQuMTk3IDAtMi44MTUtMy4wNTM4LTUuNDM5LTguMzIxMi03LjY0NjUgMC4wNTI1LTAuMDMxMiAwLjEwNS0wLjA2MSAwLjE1NzUtMC4wOTIyIDUuMzY4OCAyLjIzIDguNDg1IDQuODg2MiA4LjQ4NSA3LjczODggMCA3Ljg4ODYtMjMuODM2IDE0LjI4NC01My4yMzggMTQuMjg0IiBmaWxsPSIjZDFkMGNmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQwNSIgZD0ibTQ1MS4yOCA5MjYuNDVjLTI5LjIyNiAwLTUyLjkxNy02LjM1Ni01Mi45MTctMTQuMTk3IDAtMi44MTUgMy4wNTMyLTUuNDM5IDguMzIxOC03LjY0NjUgMC4wNTUxIDAuMDMyOCAwLjEwNCAwLjA2MTUgMC4xNTkyNSAwLjA5MzctNS4xNjggMi4xODQxLTguMTYwOCA0Ljc3NC04LjE2MDggNy41NTI4IDAgNy43OTQgMjMuNTQ5IDE0LjExMSA1Mi41OTcgMTQuMTExczUyLjU5Ny02LjMxNzMgNTIuNTk3LTE0LjExMWMwLTIuNzc4OC0yLjk5MjUtNS4zNjg2LTguMTYxMi03LjU1MjggMC4wNTUtMC4wMzIyIDAuMTAzNzUtMC4wNjEgMC4xNi0wLjA5MzcgNS4yNjc1IDIuMjA3NSA4LjMyMTIgNC44MzE1IDguMzIxMiA3LjY0NjUgMCA3Ljg0MTItMjMuNjkyIDE0LjE5Ny01Mi45MTcgMTQuMTk3IiBmaWxsPSIjZDBjZmNkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQwNyIgZD0ibTQ1MS4yOCA5MjYuMzZjLTI5LjA0OCAwLTUyLjU5Ny02LjMxNzMtNTIuNTk3LTE0LjExMSAwLTIuNzc4OCAyLjk5MjgtNS4zNjg2IDguMTYwOC03LjU1MjggMC4wNTMxIDAuMDMwMyAwLjEwNzM3IDAuMDYyNSAwLjE2MDEyIDAuMDkyOC01LjA2ODQgMi4xNjExLTcuOTk5NSA0LjcxODgtNy45OTk1IDcuNDYgMCA3Ljc0NjEgMjMuNDA0IDE0LjAyNSA1Mi4yNzUgMTQuMDI1czUyLjI3Ni02LjI3ODggNTIuMjc2LTE0LjAyNWMwLTIuNzQxMi0yLjkzMTItNS4yOTg5LTgtNy40NiAwLjA1MjUtMC4wMzAzIDAuMTA3NS0wLjA2MjUgMC4xNi0wLjA5MjggNS4xNjg4IDIuMTg0MSA4LjE2MTIgNC43NzQgOC4xNjEyIDcuNTUyOCAwIDcuNzk0LTIzLjU0OSAxNC4xMTEtNTIuNTk3IDE0LjExMSIgZmlsbD0iI2NmY2VjYyIvPgogICA8cGF0aCBpZD0icGF0aDQ0MDkiIGQ9Im00NTEuMjggOTI2LjI4Yy0yOC44NzEgMC01Mi4yNzUtNi4yNzg4LTUyLjI3NS0xNC4wMjUgMC0yLjc0MTIgMi45MzExLTUuMjk4OSA3Ljk5OTUtNy40NiAwLjA1MzIgMC4wMzEyIDAuMTA4MzggMC4wNjI1IDAuMTYxMTMgMC4wOTM3LTQuOTY4OCAyLjEzNzItNy44NDA0IDQuNjYxMS03Ljg0MDQgNy4zNjYyIDAgNy42OTg4IDIzLjI2MSAxMy45NCA1MS45NTUgMTMuOTRzNTEuOTU0LTYuMjQxMiA1MS45NTQtMTMuOTRjMC0yLjcwNTEtMi44NzEyLTUuMjI5LTcuODQtNy4zNjYyIDAuMDUzOC0wLjAzMTIgMC4xMDg3NS0wLjA2MjUgMC4xNjEyNS0wLjA5MzcgNS4wNjg4IDIuMTYxMSA4IDQuNzE4OCA4IDcuNDYgMCA3Ljc0NjEtMjMuNDA0IDE0LjAyNS01Mi4yNzYgMTQuMDI1IiBmaWxsPSIjY2VjZGNiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQxMSIgZD0ibTQ1MS4yOCA5MjYuMTljLTI4LjY5NCAwLTUxLjk1NS02LjI0MTItNTEuOTU1LTEzLjk0IDAtMi43MDUxIDIuODcxNi01LjIyOSA3Ljg0MDQtNy4zNjYyIDAuMDUzMiAwLjAyOTkgMC4xMDg4NyAwLjA2MjUgMC4xNjIxMyAwLjA5MjQtNC44NzA2IDIuMTEzOC03LjY4MTYgNC42MDY0LTcuNjgxNiA3LjI3MzkgMCA3LjY1MTQgMjMuMTE4IDEzLjg1NCA1MS42MzQgMTMuODU0IDI4LjUxNyAwIDUxLjYzNC02LjIwMjEgNTEuNjM0LTEzLjg1NCAwLTIuNjY3NS0yLjgxMTItNS4xNjAxLTcuNjgxMi03LjI3MzkgMC4wNTI1LTAuMDI5OSAwLjEwODc1LTAuMDYyNSAwLjE2MTI1LTAuMDkyNCA0Ljk2ODggMi4xMzcyIDcuODQgNC42NjExIDcuODQgNy4zNjYyIDAgNy42OTg4LTIzLjI2IDEzLjk0LTUxLjk1NCAxMy45NCIgZmlsbD0iI2NkY2NjYSIvPgogICA8cGF0aCBpZD0icGF0aDQ0MTMiIGQ9Im00NTEuMjggOTI2LjExYy0yOC41MTcgMC01MS42MzQtNi4yMDIxLTUxLjYzNC0xMy44NTQgMC0yLjY2NzUgMi44MTEtNS4xNjAxIDcuNjgxNi03LjI3MzkgMC4wNTUxIDAuMDMyNiAwLjEwNjg3IDAuMDYxNSAwLjE2MjQ5IDAuMDkzNy00Ljc3MjkgMi4wOTAyLTcuNTIyOSA0LjU0ODctNy41MjI5IDcuMTgwMSAwIDcuNjAzNSAyMi45NzQgMTMuNzY4IDUxLjMxMyAxMy43NjhzNTEuMzEzLTYuMTY0MSA1MS4zMTMtMTMuNzY4YzAtMi42MzE0LTIuNzUtNS4wODk5LTcuNTIzOC03LjE4MDEgMC4wNTYzLTAuMDMyMyAwLjEwNzUtMC4wNjExIDAuMTYzNzUtMC4wOTM3IDQuODcgMi4xMTM4IDcuNjgxMiA0LjYwNjQgNy42ODEyIDcuMjczOSAwIDcuNjUxNC0yMy4xMTggMTMuODU0LTUxLjYzNCAxMy44NTQiIGZpbGw9IiNjY2NiYzkiLz4KICAgPHBhdGggaWQ9InBhdGg0NDE1IiBkPSJtNDUxLjI4IDkyNi4wMmMtMjguMzM5IDAtNTEuMzEzLTYuMTY0MS01MS4zMTMtMTMuNzY4IDAtMi42MzE0IDIuNzUtNS4wODk5IDcuNTIyOS03LjE4MDEgMC4wNTMyIDAuMDMwMiAwLjExMDM4IDAuMDYyNSAwLjE2MzYzIDAuMDkyNy00LjY3NTIgMi4wNjc0LTcuMzY2MiA0LjQ5MjEtNy4zNjYyIDcuMDg3NCAwIDcuNTU2MSAyMi44MyAxMy42ODEgNTAuOTkzIDEzLjY4MXM1MC45OTMtNi4xMjUgNTAuOTkzLTEzLjY4MWMwLTIuNTk1Mi0yLjY5MTItNS4wMi03LjM2NjItNy4wODc0IDAuMDUyNS0wLjAzMDIgMC4xMS0wLjA2MjUgMC4xNjI1LTAuMDkyNyA0Ljc3MzggMi4wOTAyIDcuNTIzOCA0LjU0ODcgNy41MjM4IDcuMTgwMSAwIDcuNjAzNS0yMi45NzQgMTMuNzY4LTUxLjMxMyAxMy43NjgiIGZpbGw9IiNjYmM5YzgiLz4KICAgPHBhdGggaWQ9InBhdGg0NDE3IiBkPSJtNDUxLjI4IDkyNS45M2MtMjguMTYzIDAtNTAuOTkzLTYuMTI1LTUwLjk5My0xMy42ODEgMC0yLjU5NTIgMi42OTEtNS4wMiA3LjM2NjItNy4wODc0IDAuMDU1NiAwLjAzMjMgMC4xMDg4NyAwLjA2MjUgMC4xNjQ1IDAuMDkzNy00LjU3NzEgMi4wNDM0LTcuMjA5OSA0LjQzNi03LjIwOTkgNi45OTM2IDAgNy41MDg4IDIyLjY4NiAxMy41OTUgNTAuNjcyIDEzLjU5NSAyNy45ODUgMCA1MC42NzItNi4wODY1IDUwLjY3Mi0xMy41OTUgMC0yLjU1NzYtMi42MzI1LTQuOTUwMi03LjIxLTYuOTkzNiAwLjA1NS0wLjAzMTIgMC4xMDg3NS0wLjA2MTUgMC4xNjUtMC4wOTM3IDQuNjc1IDIuMDY3NCA3LjM2NjIgNC40OTIxIDcuMzY2MiA3LjA4NzQgMCA3LjU1NjEtMjIuODMgMTMuNjgxLTUwLjk5MyAxMy42ODEiIGZpbGw9IiNjYWM4YzciLz4KICAgPHBhdGggaWQ9InBhdGg0NDE5IiBkPSJtNDUxLjI4IDkyNS44NWMtMjcuOTg1IDAtNTAuNjcyLTYuMDg2NS01MC42NzItMTMuNTk1IDAtMi41NTc2IDIuNjMyOC00Ljk1MDIgNy4yMDk5LTYuOTkzNiAwLjA1NjMgMC4wMzEyIDAuMTA5ODcgMC4wNjI1IDAuMTY1NjIgMC4wOTM3LTQuNDgwNSAyLjAyLTcuMDU0OCA0LjM3ODQtNy4wNTQ4IDYuODk5OSAwIDcuNDYxNCAyMi41NDMgMTMuNTA5IDUwLjM1MSAxMy41MDlzNTAuMzUtNi4wNDc0IDUwLjM1LTEzLjUwOWMwLTIuNTIxNS0yLjU3MzgtNC44Nzk5LTcuMDU1LTYuODk5OSAwLjA1NjMtMC4wMzEyIDAuMTEtMC4wNjI1IDAuMTY2MjUtMC4wOTM3IDQuNTc3NSAyLjA0MzQgNy4yMSA0LjQzNiA3LjIxIDYuOTkzNiAwIDcuNTA4OC0yMi42ODYgMTMuNTk1LTUwLjY3MiAxMy41OTUiIGZpbGw9IiNjOWM3YzYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDIxIiBkPSJtNDUxLjI4IDkyNS43NmMtMjcuODA4IDAtNTAuMzUxLTYuMDQ3NC01MC4zNTEtMTMuNTA5IDAtMi41MjE1IDIuNTc0Mi00Ljg3OTkgNy4wNTQ4LTYuODk5OSAwLjA1NjEgMC4wMzEyIDAuMTEwNzUgMC4wNjI1IDAuMTY2NSAwLjA5MzctNC4zODUyIDEuOTk0Ni02LjkwMDQgNC4zMjIyLTYuOTAwNCA2LjgwNjEgMCA3LjQxMzYgMjIuMzk5IDEzLjQyMiA1MC4wMyAxMy40MjJzNTAuMDMtNi4wMDg3IDUwLjAzLTEzLjQyMmMwLTIuNDgzOS0yLjUxNS00LjgxMTUtNi45MDEyLTYuODA2MSAwLjA1NjMtMC4wMzEyIDAuMTExMjUtMC4wNjI1IDAuMTY2MjUtMC4wOTM3IDQuNDgxMiAyLjAyIDcuMDU1IDQuMzc4NCA3LjA1NSA2Ljg5OTkgMCA3LjQ2MTQtMjIuNTQyIDEzLjUwOS01MC4zNSAxMy41MDkiIGZpbGw9IiNjOGM2YzUiLz4KICAgPHBhdGggaWQ9InBhdGg0NDIzIiBkPSJtNDUxLjI4IDkyNS42N2MtMjcuNjMxIDAtNTAuMDMtNi4wMDg3LTUwLjAzLTEzLjQyMiAwLTIuNDgzOSAyLjUxNTEtNC44MTE1IDYuOTAwNC02LjgwNjEgMC4wNTYxIDAuMDMxMiAwLjExMTI1IDAuMDYyNSAwLjE2NzUgMC4wOTM3LTQuMjkwMSAxLjk3MTEtNi43NDcxIDQuMjY0Ni02Ljc0NzEgNi43MTI0IDAgNy4zNjYyIDIyLjI1NiAxMy4zMzcgNDkuNzEgMTMuMzM3czQ5LjcwOS01Ljk3MTIgNDkuNzA5LTEzLjMzN2MwLTIuNDQ3OC0yLjQ1NzUtNC43NDEyLTYuNzQ3NS02LjcxMjQgMC4wNTYzLTAuMDMxMiAwLjExMjUtMC4wNjI1IDAuMTY3NS0wLjA5MzcgNC4zODYyIDEuOTk0NiA2LjkwMTIgNC4zMjIyIDYuOTAxMiA2LjgwNjEgMCA3LjQxMzYtMjIuNCAxMy40MjItNTAuMDMgMTMuNDIyIiBmaWxsPSIjYzdjNWMzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQyNSIgZD0ibTQ1MS4yOCA5MjUuNTljLTI3LjQ1NCAwLTQ5LjcxLTUuOTcxMi00OS43MS0xMy4zMzcgMC0yLjQ0NzggMi40NTctNC43NDEyIDYuNzQ3MS02LjcxMjQgMC4wNTYxIDAuMDMxMiAwLjExMjI1IDAuMDYyNSAwLjE2ODM4IDAuMDkyMi00LjE5NDIgMS45NDg4LTYuNTk0OCA0LjIwOS02LjU5NDggNi42MjAxIDAgNy4zMTg5IDIyLjExMiAxMy4yNTEgNDkuMzg5IDEzLjI1MSAyNy4yNzYgMCA0OS4zODktNS45MzI2IDQ5LjM4OS0xMy4yNTEgMC0yLjQxMTEtMi40MDEyLTQuNjcxNC02LjU5NS02LjYyMDEgMC4wNTYzLTAuMDI5NyAwLjExMjUtMC4wNjEgMC4xNjc1LTAuMDkyMiA0LjI5IDEuOTcxMSA2Ljc0NzUgNC4yNjQ2IDYuNzQ3NSA2LjcxMjQgMCA3LjM2NjItMjIuMjU2IDEzLjMzNy00OS43MDkgMTMuMzM3IiBmaWxsPSIjYzZjNGMyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQyNyIgZD0ibTQ1MS4yOCA5MjUuNWMtMjcuMjc3IDAtNDkuMzg5LTUuOTMyNi00OS4zODktMTMuMjUxIDAtMi40MTExIDIuNDAwNS00LjY3MTQgNi41OTQ4LTYuNjIwMSAwLjA1NjEgMC4wMzEyIDAuMTEzMjUgMC4wNjQgMC4xNjk1IDAuMDkzNy00LjEwMDEgMS45MjM5LTYuNDQzNCA0LjE1MTQtNi40NDM0IDYuNTI2NCAwIDcuMjcxIDIxLjk2OCAxMy4xNjUgNDkuMDY4IDEzLjE2NSAyNy4wOTkgMCA0OS4wNjgtNS44OTQgNDkuMDY4LTEzLjE2NSAwLTIuMzc1LTIuMzQzOC00LjYwMjUtNi40NDM4LTYuNTI2NCAwLjA1NjItMC4wMjk3IDAuMTEzNzUtMC4wNjI1IDAuMTctMC4wOTM3IDQuMTkzOCAxLjk0ODggNi41OTUgNC4yMDkgNi41OTUgNi42MjAxIDAgNy4zMTg5LTIyLjExMyAxMy4yNTEtNDkuMzg5IDEzLjI1MSIgZmlsbD0iI2M0YzNjMSIvPgogICA8cGF0aCBpZD0icGF0aDQ0MjkiIGQ9Im00NTEuMjggOTI1LjQyYy0yNy4xIDAtNDkuMDY4LTUuODk0LTQ5LjA2OC0xMy4xNjUgMC0yLjM3NSAyLjM0MzItNC42MDI1IDYuNDQzNC02LjUyNjQgMC4wNTYxIDAuMDMxMiAwLjExMzc1IDAuMDY0IDAuMTY5ODcgMC4wOTM3LTQuMDA1NCAxLjg5OTktNi4yOTI1IDQuMDk1My02LjI5MjUgNi40MzI2IDAgNy4yMjM2IDIxLjgyNSAxMy4wNzkgNDguNzQ3IDEzLjA3OXM0OC43NDctNS44NTUgNDguNzQ3LTEzLjA3OWMwLTIuMzM3NC0yLjI4NzUtNC41MzI4LTYuMjkyNS02LjQzMjYgMC4wNTYzLTAuMDI5NyAwLjExMzc1LTAuMDYyNSAwLjE3LTAuMDkzNyA0LjEgMS45MjM5IDYuNDQzOCA0LjE1MTQgNi40NDM4IDYuNTI2NCAwIDcuMjcxLTIxLjk2OSAxMy4xNjUtNDkuMDY4IDEzLjE2NSIgZmlsbD0iI2MzYzJjMCIvPgogICA8cGF0aCBpZD0icGF0aDQ0MzEiIGQ9Im00NTEuMjggOTI1LjMzYy0yNi45MjIgMC00OC43NDctNS44NTUtNDguNzQ3LTEzLjA3OSAwLTIuMzM3NCAyLjI4NzEtNC41MzI4IDYuMjkyNS02LjQzMjYgMC4wNTY2IDAuMDMxMiAwLjExNTI1IDAuMDY0IDAuMTcxMzggMC4wOTM3LTMuOTEyNiAxLjg3NS02LjE0MzUgNC4wMzc2LTYuMTQzNSA2LjMzODkgMCA3LjE3NjIgMjEuNjgyIDEyLjk5MyA0OC40MjcgMTIuOTkzczQ4LjQyNy01LjgxNjQgNDguNDI3LTEyLjk5M2MwLTIuMzAxMi0yLjIzMTItNC40NjM5LTYuMTQzOC02LjMzODkgMC4wNTYzLTAuMDI5NyAwLjExNS0wLjA2MjUgMC4xNzEyNS0wLjA5MzcgNC4wMDUgMS44OTk5IDYuMjkyNSA0LjA5NTMgNi4yOTI1IDYuNDMyNiAwIDcuMjIzNi0yMS44MjQgMTMuMDc5LTQ4Ljc0NyAxMy4wNzkiIGZpbGw9IiNjMmMxYmYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDMzIiBkPSJtNDUxLjI4IDkyNS4yNWMtMjYuNzQ1IDAtNDguNDI3LTUuODE2NC00OC40MjctMTIuOTkzIDAtMi4zMDEyIDIuMjMwOS00LjQ2MzkgNi4xNDM1LTYuMzM4OSAwLjA1OTEgMC4wMzI4IDAuMTEzNzUgMC4wNjI1IDAuMTcyODcgMC4wOTUzLTMuODIwOCAxLjg1MDEtNS45OTU2IDMuOTc4NS01Ljk5NTYgNi4yNDM2IDAgNy4xMjg5IDIxLjUzOCAxMi45MDYgNDguMTA2IDEyLjkwNnM0OC4xMDYtNS43Nzc0IDQ4LjEwNi0xMi45MDZjMC0yLjI2NTEtMi4xNzM4LTQuMzkzNS01Ljk5NS02LjI0MzYgMC4wNTg4LTAuMDMyOCAwLjExMzc1LTAuMDYyNSAwLjE3MjUtMC4wOTUzIDMuOTEyNSAxLjg3NSA2LjE0MzggNC4wMzc2IDYuMTQzOCA2LjMzODkgMCA3LjE3NjItMjEuNjgyIDEyLjk5My00OC40MjcgMTIuOTkzIiBmaWxsPSIjYzFjMGJlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQzNSIgZD0ibTQ1MS4yOCA5MjUuMTZjLTI2LjU2OCAwLTQ4LjEwNi01Ljc3NzQtNDguMTA2LTEyLjkwNiAwLTIuMjY1MSAyLjE3NDktNC4zOTM1IDUuOTk1Ni02LjI0MzYgMC4wNTYxIDAuMDI5NyAwLjExNjc1IDAuMDYyNSAwLjE3MzM4IDAuMDkzNy0zLjcyODEgMS44MjQ2LTUuODQ3OCAzLjkyMTQtNS44NDc4IDYuMTQ5OSAwIDcuMDgxIDIxLjM5NCAxMi44MiA0Ny43ODUgMTIuODJzNDcuNzg0LTUuNzM4OCA0Ny43ODQtMTIuODJjMC0yLjIyODUtMi4xMi00LjMyNTItNS44NDc1LTYuMTQ5OSAwLjA1NjItMC4wMzEyIDAuMTE3NS0wLjA2NCAwLjE3Mzc1LTAuMDkzNyAzLjgyMTIgMS44NTAxIDUuOTk1IDMuOTc4NSA1Ljk5NSA2LjI0MzYgMCA3LjEyODktMjEuNTM4IDEyLjkwNi00OC4xMDYgMTIuOTA2IiBmaWxsPSIjYzBiZmJkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQzNyIgZD0ibTQ1MS4yOCA5MjUuMDdjLTI2LjM5MSAwLTQ3Ljc4NS01LjczODgtNDcuNzg1LTEyLjgyIDAtMi4yMjg1IDIuMTE5Ni00LjMyNTIgNS44NDc4LTYuMTQ5OSAwLjA1ODUgMC4wMzEyIDAuMTE1MjUgMC4wNjI1IDAuMTc0MjUgMC4wOTM3LTMuNjM2MiAxLjgwMTItNS43MDE2IDMuODYzOC01LjcwMTYgNi4wNTYxIDAgNy4wMzI4IDIxLjI1MSAxMi43MzUgNDcuNDY0IDEyLjczNSAyNi4yMTQgMCA0Ny40NjQtNS43MDIxIDQ3LjQ2NC0xMi43MzUgMC0yLjE5MjQtMi4wNjYyLTQuMjU0OS01LjcwMTItNi4wNTYxIDAuMDU4OC0wLjAzMTIgMC4xMTUtMC4wNjI1IDAuMTczNzUtMC4wOTM3IDMuNzI3NSAxLjgyNDYgNS44NDc1IDMuOTIxNCA1Ljg0NzUgNi4xNDk5IDAgNy4wODEtMjEuMzk0IDEyLjgyLTQ3Ljc4NCAxMi44MiIgZmlsbD0iI2JmYmViYyIvPgogICA8cGF0aCBpZD0icGF0aDQ0MzkiIGQ9Im00NTEuMjggOTI0Ljk5Yy0yNi4yMTMgMC00Ny40NjQtNS43MDIxLTQ3LjQ2NC0xMi43MzUgMC0yLjE5MjQgMi4wNjU0LTQuMjU0OSA1LjcwMTYtNi4wNTYxIDAuMDU5MSAwLjAzMTIgMC4xMTY3NSAwLjA2MjUgMC4xNzUzNyAwLjA5MzctMy41NDYgMS43NzU5LTUuNTU2MiAzLjgwNjEtNS41NTYyIDUuOTYyNCAwIDYuOTg0OSAyMS4xMDcgMTIuNjQ5IDQ3LjE0NCAxMi42NDlzNDcuMTQzLTUuNjY0IDQ3LjE0My0xMi42NDljMC0yLjE1NjItMi4wMS00LjE4NjUtNS41NTYyLTUuOTYyNCAwLjA1ODgtMC4wMzEyIDAuMTE2MjUtMC4wNjI1IDAuMTc2MjUtMC4wOTM3IDMuNjM1IDEuODAxMiA1LjcwMTIgMy44NjM4IDUuNzAxMiA2LjA1NjEgMCA3LjAzMjgtMjEuMjUgMTIuNzM1LTQ3LjQ2NCAxMi43MzUiIGZpbGw9IiNiZWJkYmIiLz4KICAgPHBhdGggaWQ9InBhdGg0NDQxIiBkPSJtNDUxLjI4IDkyNC45Yy0yNi4wMzcgMC00Ny4xNDQtNS42NjQtNDcuMTQ0LTEyLjY0OSAwLTIuMTU2MiAyLjAxMDItNC4xODY1IDUuNTU2Mi01Ljk2MjQgMC4wNTkgMC4wMzEyIDAuMTE3NjMgMC4wNjI1IDAuMTc2NzUgMC4wOTM3LTMuNDU1NiAxLjc1MjQtNS40MTIxIDMuNzQ4NS01LjQxMjEgNS44Njg2IDAgNi45Mzc1IDIwLjk2MyAxMi41NjIgNDYuODIzIDEyLjU2MiAyNS44NTkgMCA0Ni44MjMtNS42MjUgNDYuODIzLTEyLjU2MiAwLTIuMTIwMS0xLjk1NzUtNC4xMTYyLTUuNDEyNS01Ljg2ODYgMC4wNTg4LTAuMDMxMiAwLjExNzUtMC4wNjI1IDAuMTc2MjUtMC4wOTM3IDMuNTQ2MiAxLjc3NTkgNS41NTYyIDMuODA2MSA1LjU1NjIgNS45NjI0IDAgNi45ODQ5LTIxLjEwNiAxMi42NDktNDcuMTQzIDEyLjY0OSIgZmlsbD0iI2JkYmNiYSIvPgogICA8cGF0aCBpZD0icGF0aDQ0NDMiIGQ9Im00NTEuMjggOTI0LjgxYy0yNS44NTkgMC00Ni44MjMtNS42MjUtNDYuODIzLTEyLjU2MiAwLTIuMTIwMSAxLjk1NjUtNC4xMTYyIDUuNDEyMS01Ljg2ODYgMC4wNTkgMC4wMzIzIDAuMTE4NjMgMC4wNjM1IDAuMTc3NzUgMC4wOTQ4LTMuMzY1OCAxLjcyNjUtNS4yNjkxIDMuNjg5OS01LjI2OTEgNS43NzM5IDAgNi44OTAxIDIwLjgyIDEyLjQ3NiA0Ni41MDIgMTIuNDc2czQ2LjUwMi01LjU4NiA0Ni41MDItMTIuNDc2YzAtMi4wODQtMS45MDM4LTQuMDQ3NC01LjI2ODgtNS43NzM5IDAuMDU4OC0wLjAzMTIgMC4xMTg3NS0wLjA2MjUgMC4xNzc1LTAuMDk0OCAzLjQ1NSAxLjc1MjQgNS40MTI1IDMuNzQ4NSA1LjQxMjUgNS44Njg2IDAgNi45Mzc1LTIwLjk2NCAxMi41NjItNDYuODIzIDEyLjU2MiIgZmlsbD0iI2JjYmJiOSIvPgogICA8cGF0aCBpZD0icGF0aDQ0NDUiIGQ9Im00NTEuMjggOTI0LjczYy0yNS42ODIgMC00Ni41MDItNS41ODYtNDYuNTAyLTEyLjQ3NiAwLTIuMDg0IDEuOTAzNC00LjA0NzQgNS4yNjkxLTUuNzczOSAwLjA1OTUgMC4wMzEyIDAuMTE5NjMgMC4wNjI1IDAuMTc5MTMgMC4wOTM3LTMuMjc3NCAxLjcwMTEtNS4xMjc0IDMuNjMyOC01LjEyNzQgNS42ODAxIDAgNi44NDIyIDIwLjY3NiAxMi4zOSA0Ni4xODEgMTIuMzlzNDYuMTgtNS41NDc5IDQ2LjE4LTEyLjM5YzAtMi4wNDc0LTEuODUtMy45NzktNS4xMjc1LTUuNjgwMSAwLjA2LTAuMDMxMiAwLjEyLTAuMDYyNSAwLjE4LTAuMDkzNyAzLjM2NSAxLjcyNjUgNS4yNjg4IDMuNjg5OSA1LjI2ODggNS43NzM5IDAgNi44OTAxLTIwLjgyIDEyLjQ3Ni00Ni41MDIgMTIuNDc2IiBmaWxsPSIjYmJiYWI4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ0NyIgZD0ibTQ1MS4yOCA5MjQuNjRjLTI1LjUwNSAwLTQ2LjE4MS01LjU0NzktNDYuMTgxLTEyLjM5IDAtMi4wNDc0IDEuODUtMy45NzkgNS4xMjc0LTUuNjgwMSAwLjA1OTEgMC4wMzEyIDAuMTIwNjIgMC4wNjM5IDAuMTc5NzUgMC4wOTUxLTMuMTg5IDEuNjc2Mi00Ljk4NjQgMy41NzM4LTQuOTg2NCA1LjU4NSAwIDYuNzk0OSAyMC41MzIgMTIuMzA0IDQ1Ljg2IDEyLjMwNHM0NS44Ni01LjUwODggNDUuODYtMTIuMzA0YzAtMi4wMTEyLTEuNzk3NS0zLjkwODgtNC45ODYyLTUuNTg1IDAuMDU4OC0wLjAzMTIgMC4xMi0wLjA2MzkgMC4xNzg3NS0wLjA5NTEgMy4yNzc1IDEuNzAxMSA1LjEyNzUgMy42MzI4IDUuMTI3NSA1LjY4MDEgMCA2Ljg0MjItMjAuNjc2IDEyLjM5LTQ2LjE4IDEyLjM5IiBmaWxsPSIjYmFiOWI3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ0OSIgZD0ibTQ1MS4yOCA5MjQuNTZjLTI1LjMyOCAwLTQ1Ljg2LTUuNTA4OC00NS44Ni0xMi4zMDQgMC0yLjAxMTIgMS43OTc0LTMuOTA4OCA0Ljk4NjQtNS41ODUgMC4wNjIgMC4wMzI3IDAuMTE5NjIgMC4wNjI1IDAuMTgxMTIgMC4wOTM3LTMuMTAwNiAxLjY1MTQtNC44NDY4IDMuNTE2MS00Ljg0NjggNS40OTEyIDAgNi43NDc1IDIwLjM4OSAxMi4yMTkgNDUuNTQgMTIuMjE5czQ1LjUzOS01LjQ3MTIgNDUuNTM5LTEyLjIxOWMwLTEuOTc1MS0xLjc0NjItMy44Mzk5LTQuODQ2Mi01LjQ5MTIgMC4wNjEzLTAuMDMxMiAwLjExODc1LTAuMDYxIDAuMTgxMjUtMC4wOTM3IDMuMTg4OCAxLjY3NjIgNC45ODYyIDMuNTczOCA0Ljk4NjIgNS41ODUgMCA2Ljc5NDktMjAuNTMyIDEyLjMwNC00NS44NiAxMi4zMDQiIGZpbGw9IiNiOWI4YjYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDUxIiBkPSJtNDUxLjI4IDkyNC40N2MtMjUuMTUxIDAtNDUuNTQtNS40NzEyLTQ1LjU0LTEyLjIxOSAwLTEuOTc1MSAxLjc0NjEtMy44Mzk5IDQuODQ2OC01LjQ5MTIgMC4wNTk1IDAuMDMxMiAwLjEyMyAwLjA2MzUgMC4xODI2MyAwLjA5NTItMy4wMTMyIDEuNjI0NS00LjcwODUgMy40NTctNC43MDg1IDUuMzk2IDAgNi43MDAyIDIwLjI0NSAxMi4xMzIgNDUuMjE5IDEyLjEzMnM0NS4yMTgtNS40MzIxIDQ1LjIxOC0xMi4xMzJjMC0xLjkzOS0xLjY5NS0zLjc3MTUtNC43MDc1LTUuMzk2IDAuMDU4OC0wLjAzMTcgMC4xMjI1LTAuMDY0IDAuMTgyNS0wLjA5NTIgMy4xIDEuNjUxNCA0Ljg0NjIgMy41MTYxIDQuODQ2MiA1LjQ5MTIgMCA2Ljc0NzUtMjAuMzg4IDEyLjIxOS00NS41MzkgMTIuMjE5IiBmaWxsPSIjYjdiN2I1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ1MyIgZD0ibTQ1MS4yOCA5MjQuMzhjLTI0Ljk3NCAwLTQ1LjIxOS01LjQzMjEtNDUuMjE5LTEyLjEzMiAwLTEuOTM5IDEuNjk1Mi0zLjc3MTUgNC43MDg1LTUuMzk2IDAuMDYyIDAuMDMyMyAwLjEyMTUgMC4wNjIgMC4xODM2MiAwLjA5NDgtMi45MjcyIDEuNTk4Ni00LjU3MTQgMy4zOTg5LTQuNTcxNCA1LjMwMTIgMCA2LjY1MjQgMjAuMTAyIDEyLjA0NiA0NC44OTggMTIuMDQ2czQ0Ljg5OC01LjM5NCA0NC44OTgtMTIuMDQ2YzAtMS45MDI0LTEuNjQ1LTMuNzAyNi00LjU3MTItNS4zMDEyIDAuMDYxMy0wLjAzMjggMC4xMjEyNS0wLjA2MjUgMC4xODM3NS0wLjA5NDggMy4wMTI1IDEuNjI0NSA0LjcwNzUgMy40NTcgNC43MDc1IDUuMzk2IDAgNi43MDAyLTIwLjI0NCAxMi4xMzItNDUuMjE4IDEyLjEzMiIgZmlsbD0iI2I2YjZiNCIvPgogICA8cGF0aCBpZD0icGF0aDQ0NTUiIGQ9Im00NTEuMjggOTI0LjNjLTI0Ljc5NiAwLTQ0Ljg5OC01LjM5NC00NC44OTgtMTIuMDQ2IDAtMS45MDI0IDEuNjQ0MS0zLjcwMjYgNC41NzE0LTUuMzAxMiAwLjA2MiAwLjAzMTIgMC4xMjI1IDAuMDYyNSAwLjE4NSAwLjA5MzctMi44NDI4IDEuNTc1MS00LjQzNTUgMy4zNDEyLTQuNDM1NSA1LjIwNzUgMCA2LjYwNSAxOS45NTggMTEuOTYgNDQuNTc3IDExLjk2czQ0LjU3Ny01LjM1NSA0NC41NzctMTEuOTZjMC0xLjg2NjItMS41OTI1LTMuNjMyNC00LjQzNS01LjIwNzUgMC4wNjI1LTAuMDMxMiAwLjEyMjUtMC4wNjI1IDAuMTg1LTAuMDkzNyAyLjkyNjIgMS41OTg2IDQuNTcxMiAzLjM5ODkgNC41NzEyIDUuMzAxMiAwIDYuNjUyNC0yMC4xMDIgMTIuMDQ2LTQ0Ljg5OCAxMi4wNDYiIGZpbGw9IiNiNWI0YjMiLz4KICAgPHBhdGggaWQ9InBhdGg0NDU3IiBkPSJtNDUxLjI4IDkyNC4yMWMtMjQuNjE5IDAtNDQuNTc3LTUuMzU1LTQ0LjU3Ny0xMS45NiAwLTEuODY2MiAxLjU5MjgtMy42MzI0IDQuNDM1NS01LjIwNzUgMC4wNTk2IDAuMDMxMiAwLjEyNiAwLjA2NDkgMC4xODYgMC4wOTUyLTIuNzU3MiAxLjU0ODItNC4zMDEyIDMuMjgyMS00LjMwMTIgNS4xMTIyIDAgNi41NTc2IDE5LjgxNCAxMS44NzQgNDQuMjU3IDExLjg3NCAyNC40NDIgMCA0NC4yNTctNS4zMTU5IDQ0LjI1Ny0xMS44NzQgMC0xLjgzMDEtMS41NDM4LTMuNTY0LTQuMzAxMi01LjExMjIgMC4wNi0wLjAzMDQgMC4xMjYyNS0wLjA2NCAwLjE4NjI1LTAuMDk1MiAyLjg0MjUgMS41NzUxIDQuNDM1IDMuMzQxMiA0LjQzNSA1LjIwNzUgMCA2LjYwNS0xOS45NTggMTEuOTYtNDQuNTc3IDExLjk2IiBmaWxsPSIjYjRiM2IyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ1OSIgZD0ibTQ1MS4yOCA5MjQuMTNjLTI0LjQ0MiAwLTQ0LjI1Ny01LjMxNTktNDQuMjU3LTExLjg3NCAwLTEuODMwMSAxLjU0NC0zLjU2NCA0LjMwMTItNS4xMTIyIDAuMDY0NSAwLjAzMzYgMC4xMjI2MyAwLjA2MjUgMC4xODcgMC4wOTQ2LTIuNjcyOCAxLjUyMjUtNC4xNjc0IDMuMjIzNi00LjE2NzQgNS4wMTc2IDAgNi41MDk4IDE5LjY3MSAxMS43ODggNDMuOTM2IDExLjc4OHM0My45MzYtNS4yNzc4IDQzLjkzNi0xMS43ODhjMC0xLjc5NC0xLjQ5MzgtMy40OTUxLTQuMTY3NS01LjAxNzYgMC4wNjUtMC4wMzIxIDAuMTIyNS0wLjA2MSAwLjE4NzUtMC4wOTQ2IDIuNzU3NSAxLjU0ODIgNC4zMDEyIDMuMjgyMSA0LjMwMTIgNS4xMTIyIDAgNi41NTc2LTE5LjgxNCAxMS44NzQtNDQuMjU3IDExLjg3NCIgZmlsbD0iI2IzYjJiMSIvPgogICA8cGF0aCBpZD0icGF0aDQ0NjEiIGQ9Im00NTEuMjggOTI0LjA0Yy0yNC4yNjUgMC00My45MzYtNS4yNzc4LTQzLjkzNi0xMS43ODggMC0xLjc5NCAxLjQ5NDYtMy40OTUxIDQuMTY3NC01LjAxNzYgMC4wNjI1IDAuMDMxMiAwLjEyNjUgMC4wNjQgMC4xODkgMC4wOTUzLTIuNTkwNCAxLjQ5NjEtNC4wMzUxIDMuMTYzNi00LjAzNTEgNC45MjI0IDAgNi40NjI0IDE5LjUyNyAxMS43MDEgNDMuNjE1IDExLjcwMXM0My42MTQtNS4yMzg4IDQzLjYxNC0xMS43MDFjMC0xLjc1ODgtMS40NDUtMy40MjYyLTQuMDM1LTQuOTIyNCAwLjA2MjUtMC4wMzEyIDAuMTI2MjUtMC4wNjQgMC4xODg3NS0wLjA5NTMgMi42NzM4IDEuNTIyNSA0LjE2NzUgMy4yMjM2IDQuMTY3NSA1LjAxNzYgMCA2LjUwOTgtMTkuNjcgMTEuNzg4LTQzLjkzNiAxMS43ODgiIGZpbGw9IiNiMWIxYjAiLz4KICAgPHBhdGggaWQ9InBhdGg0NDYzIiBkPSJtNDUxLjI4IDkyMy45NWMtMjQuMDg4IDAtNDMuNjE1LTUuMjM4OC00My42MTUtMTEuNzAxIDAtMS43NTg4IDEuNDQ0OC0zLjQyNjIgNC4wMzUxLTQuOTIyNCAwLjA2MiAwLjAzMTIgMC4xMjc1IDAuMDYzNSAwLjE5IDAuMDk0Ny0yLjUwNzQgMS40NzAyLTMuOTA0OSAzLjEwNS0zLjkwNDkgNC44Mjc2IDAgNi40MTUgMTkuMzg0IDExLjYxNiA0My4yOTQgMTEuNjE2IDIzLjkxMSAwIDQzLjI5NC01LjIwMTIgNDMuMjk0LTExLjYxNiAwLTEuNzIyNi0xLjM5NzUtMy4zNTc0LTMuOTA1LTQuODI3NiAwLjA2MjUtMC4wMzEyIDAuMTI3NS0wLjA2MzUgMC4xOS0wLjA5NDcgMi41OSAxLjQ5NjEgNC4wMzUgMy4xNjM2IDQuMDM1IDQuOTIyNCAwIDYuNDYyNC0xOS41MjYgMTEuNzAxLTQzLjYxNCAxMS43MDEiIGZpbGw9IiNiMGIwYWYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDY1IiBkPSJtNDUxLjI4IDkyMy44N2MtMjMuOTExIDAtNDMuMjk0LTUuMjAxMi00My4yOTQtMTEuNjE2IDAtMS43MjI2IDEuMzk3NS0zLjM1NzQgMy45MDQ5LTQuODI3NiAwLjA2NDkgMC4wMzI3IDAuMTI1ODggMC4wNjI1IDAuMTkwODggMC4wOTUyLTIuNDI0OCAxLjQ0MzgtMy43NzQ5IDMuMDQ1OS0zLjc3NDkgNC43MzI0IDAgNi4zNjc2IDE5LjI0IDExLjUzIDQyLjk3NCAxMS41MyAyMy43MzMgMCA0Mi45NzMtNS4xNjIyIDQyLjk3My0xMS41MyAwLTEuNjg2NS0xLjM1LTMuMjg4Ni0zLjc3NS00LjczMjQgMC4wNjUtMC4wMzI3IDAuMTI2MjUtMC4wNjI1IDAuMTkxMjUtMC4wOTUyIDIuNTA3NSAxLjQ3MDIgMy45MDUgMy4xMDUgMy45MDUgNC44Mjc2IDAgNi40MTUtMTkuMzg0IDExLjYxNi00My4yOTQgMTEuNjE2IiBmaWxsPSIjYWZhZmFkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ2NyIgZD0ibTQ1MS4yOCA5MjMuNzhjLTIzLjczMyAwLTQyLjk3NC01LjE2MjItNDIuOTc0LTExLjUzIDAtMS42ODY1IDEuMzUwMS0zLjI4ODYgMy43NzQ5LTQuNzMyNCAwLjA2MyAwLjAzMTIgMC4xMzAzNyAwLjA2NDkgMC4xOTI4NyAwLjA5NjEtMi4zNDQyIDEuNDE2LTMuNjQ3IDIuOTg2NC0zLjY0NyA0LjYzNjIgMCA2LjMxOTkgMTkuMDk2IDExLjQ0NCA0Mi42NTMgMTEuNDQ0IDIzLjU1NiAwIDQyLjY1My01LjEyNCA0Mi42NTMtMTEuNDQ0IDAtMS42NDk5LTEuMzAyNS0zLjIyMDItMy42NDc1LTQuNjM2MiAwLjA2MjUtMC4wMzEyIDAuMTMtMC4wNjQ5IDAuMTkyNS0wLjA5NjEgMi40MjUgMS40NDM4IDMuNzc1IDMuMDQ1OSAzLjc3NSA0LjczMjQgMCA2LjM2NzYtMTkuMjQgMTEuNTMtNDIuOTczIDExLjUzIiBmaWxsPSIjYWVhZWFjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ2OSIgZD0ibTQ1MS4yOCA5MjMuN2MtMjMuNTU3IDAtNDIuNjUzLTUuMTI0LTQyLjY1My0xMS40NDQgMC0xLjY0OTkgMS4zMDI4LTMuMjIwMiAzLjY0Ny00LjYzNjIgMC4wNjU0IDAuMDMxMiAwLjEyODg4IDAuMDYyNSAwLjE5NDM4IDAuMDk0Ny0yLjI2NDggMS4zOTE2LTMuNTIwNSAyLjkyNjItMy41MjA1IDQuNTQxNSAwIDYuMjcyNSAxOC45NTMgMTEuMzU3IDQyLjMzMiAxMS4zNTdzNDIuMzMyLTUuMDg0OSA0Mi4zMzItMTEuMzU3YzAtMS42MTUyLTEuMjU2Mi0zLjE0OTktMy41Mi00LjU0MTUgMC4wNjUtMC4wMzIyIDAuMTI4NzUtMC4wNjM1IDAuMTkzNzUtMC4wOTQ3IDIuMzQ1IDEuNDE2IDMuNjQ3NSAyLjk4NjQgMy42NDc1IDQuNjM2MiAwIDYuMzE5OS0xOS4wOTcgMTEuNDQ0LTQyLjY1MyAxMS40NDQiIGZpbGw9IiNhZGFjYWIiLz4KICAgPHBhdGggaWQ9InBhdGg0NDcxIiBkPSJtNDUxLjI4IDkyMy42MWMtMjMuMzc5IDAtNDIuMzMyLTUuMDg0OS00Mi4zMzItMTEuMzU3IDAtMS42MTUyIDEuMjU1OC0zLjE0OTkgMy41MjA1LTQuNTQxNSAwLjA2NDkgMC4wMzE3IDAuMTI5ODcgMC4wNjQgMC4xOTUyNSAwLjA5NTItMi4xODUgMS4zNjUyLTMuMzk1IDIuODY3Ni0zLjM5NSA0LjQ0NjIgMCA2LjIyNTEgMTguODA5IDExLjI3MSA0Mi4wMTEgMTEuMjcxczQyLjAxLTUuMDQ2NCA0Mi4wMS0xMS4yNzFjMC0xLjU3ODYtMS4yMDg4LTMuMDgxLTMuMzk1LTQuNDQ2MiAwLjA2NjItMC4wMzEyIDAuMTMxMjUtMC4wNjM1IDAuMTk2MjUtMC4wOTUyIDIuMjYzOCAxLjM5MTYgMy41MiAyLjkyNjIgMy41MiA0LjU0MTUgMCA2LjI3MjUtMTguOTUyIDExLjM1Ny00Mi4zMzIgMTEuMzU3IiBmaWxsPSIjYWNhYmFhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ3MyIgZD0ibTQ1MS4yOCA5MjMuNTJjLTIzLjIwMiAwLTQyLjAxMS01LjA0NjQtNDIuMDExLTExLjI3MSAwLTEuNTc4NiAxLjIxLTMuMDgxIDMuMzk1LTQuNDQ2MiAwLjA2NTUgMC4wMzI2IDAuMTMxMzcgMC4wNjM5IDAuMTk2NzUgMC4wOTYxLTIuMTA2NCAxLjMzNzQtMy4yNzEgMi44MDc2LTMuMjcxIDQuMzUwMSAwIDYuMTc3MiAxOC42NjYgMTEuMTg1IDQxLjY5IDExLjE4NSAyMy4wMjUgMCA0MS42OS01LjAwNzggNDEuNjktMTEuMTg1IDAtMS41NDI1LTEuMTY1LTMuMDEyOC0zLjI3MTItNC4zNTAxIDAuMDY1LTAuMDMyMiAwLjEzMTI1LTAuMDYzNSAwLjE5NjI1LTAuMDk2MSAyLjE4NjIgMS4zNjUyIDMuMzk1IDIuODY3NiAzLjM5NSA0LjQ0NjIgMCA2LjIyNTEtMTguODA5IDExLjI3MS00Mi4wMSAxMS4yNzEiIGZpbGw9IiNhYWFhYTkiLz4KICAgPHBhdGggaWQ9InBhdGg0NDc1IiBkPSJtNDUxLjI4IDkyMy40NGMtMjMuMDI1IDAtNDEuNjktNS4wMDc4LTQxLjY5LTExLjE4NSAwLTEuNTQyNSAxLjE2NDYtMy4wMTI4IDMuMjcxLTQuMzUwMSAwLjA2NTUgMC4wMzEyIDAuMTMzMzcgMC4wNjQgMC4xOTg3NSAwLjA5NTMtMi4wMjkyIDEuMzExLTMuMTQ4OSAyLjc0NzUtMy4xNDg5IDQuMjU0OSAwIDYuMTI5OSAxOC41MjIgMTEuMDk5IDQxLjM3IDExLjA5OXM0MS4zNjktNC45Njg4IDQxLjM2OS0xMS4wOTljMC0xLjUwNzQtMS4xMi0yLjk0MzktMy4xNDg4LTQuMjU0OSAwLjA2NjItMC4wMzEyIDAuMTMzNzUtMC4wNjQgMC4xOTg3NS0wLjA5NTMgMi4xMDYyIDEuMzM3NCAzLjI3MTIgMi44MDc2IDMuMjcxMiA0LjM1MDEgMCA2LjE3NzItMTguNjY2IDExLjE4NS00MS42OSAxMS4xODUiIGZpbGw9IiNhOWE5YTgiLz4KICAgPHBhdGggaWQ9InBhdGg0NDc3IiBkPSJtNDUxLjI4IDkyMy4zNWMtMjIuODQ4IDAtNDEuMzctNC45Njg4LTQxLjM3LTExLjA5OSAwLTEuNTA3NCAxLjExOTYtMi45NDM5IDMuMTQ4OS00LjI1NDkgMC4wNjc5IDAuMDMyMiAwLjEzMTg3IDAuMDYzNSAwLjE5OTc1IDAuMDk2MS0xLjk1MjEgMS4yODIyLTMuMDI3OSAyLjY4NzUtMy4wMjc5IDQuMTU4OCAwIDYuMDgyNSAxOC4zNzggMTEuMDE0IDQxLjA0OSAxMS4wMTQgMjIuNjcgMCA0MS4wNDktNC45MzExIDQxLjA0OS0xMS4wMTQgMC0xLjQ3MTItMS4wNzYyLTIuODc2NS0zLjAyODgtNC4xNTg4IDAuMDY4Ny0wLjAzMjYgMC4xMzI1LTAuMDYzOSAwLjItMC4wOTYxIDIuMDI4OCAxLjMxMSAzLjE0ODggMi43NDc1IDMuMTQ4OCA0LjI1NDkgMCA2LjEyOTktMTguNTIyIDExLjA5OS00MS4zNjkgMTEuMDk5IiBmaWxsPSIjYThhOGE3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ3OSIgZD0ibTQ1MS4yOCA5MjMuMjdjLTIyLjY3MSAwLTQxLjA0OS00LjkzMTEtNDEuMDQ5LTExLjAxNCAwLTEuNDcxMiAxLjA3NTgtMi44NzY1IDMuMDI3OS00LjE1ODggMC4wNjU5IDAuMDMxMiAwLjEzMjc1IDAuMDYyNSAwLjE5ODI1IDAuMDkzNy0xLjg3NiAxLjI1NjQtMi45MDkyIDIuNjI2LTIuOTA5MiA0LjA2MTEgMCA2LjA0MTUgMTguMjM2IDEwLjkzNSA0MC43MzYgMTAuOTM1IDIyLjQ4OCAwIDQwLjcyNS00Ljg5MzUgNDAuNzI1LTEwLjkzNSAwLTEuNDM1MS0xLjAzMjUtMi44MDQ4LTIuOTA4OC00LjA2MDEgMC4wNjYyLTAuMDMxMiAwLjEzNS0wLjA2MzUgMC4yLTAuMDk0NyAxLjk1MjUgMS4yODIyIDMuMDI4OCAyLjY4NzUgMy4wMjg4IDQuMTU4OCAwIDYuMDgyNS0xOC4zNzkgMTEuMDE0LTQxLjA0OSAxMS4wMTQiIGZpbGw9IiNhN2E2YTUiLz4KICAgPHBhdGggaWQ9InBhdGg0NDgxIiBkPSJtNDU4IDkxMi4yNWMwIDAuOTk3NS0zLjAxMTggMS44MDUxLTYuNzI3IDEuODA1MS0zLjcxNTQgMC02LjcyNzEtMC44MDc2Mi02LjcyNzEtMS44MDUxIDAtMC45OTYxMiAzLjAxMTgtMS44MDUxIDYuNzI3MS0xLjgwNTEgMy43MTUyIDAgNi43MjcgMC44MDkgNi43MjcgMS44MDUxIiBmaWxsPSIjMTAwZjBkIi8+CiAgPC9nPgogIDxnIGlkPSJnNDU2NSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ1NjciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ1NjktNykiPgogICAgPHBhdGggaWQ9InBhdGg0NTgxIiBkPSJtOTM2LjI4IDE5ODYuNmMwIDEwNS4yNy04Ni4xMjEgMTkxLjM5LTE5MS4zOSAxOTEuMzloLTczLjQzOGMtMTAuNTUxIDAtMjAuOTA2LTAuODYtMzEuMDA0LTIuNTN2MzUxNGMwIDMwLjQ2LTI0LjY5NSA1NS4xNi01NS4xNiA1NS4xNnMtNTUuMTYtMjQuNy01NS4xNi01NS4xNnYtMzU3NC4yYy0zMS4wNjctMzQuMDUtNTAuMDY3LTc5LjI3LTUwLjA2Ny0xMjguNzJ2MzY3NS4zYzAgMTMuNDYgMS40MTEgMjYuNjIgNC4wOSAzOS4zMSAxOC4yNjIgODYuNTcgOTUuNTA0IDE1Mi4wNyAxODcuMyAxNTIuMDdoNzMuNDM4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di0zNjc1LjMiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ0NTczKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDU4MyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ1ODUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ1ODctMCkiPgogICAgPHBhdGggaWQ9InBhdGg0NTk3IiBkPSJtNDgwLjA3IDE0MDAuN3Y1ODUuOTVjMCA0OS40NSAxOSA5NC42NyA1MC4wNjcgMTI4Ljcydi01MjguNTJjMC0zMC40NyAyNC42OTUtNTUuMTYgNTUuMTYtNTUuMTZzNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2NTg4LjY2YzEwLjA5OCAxLjY3IDIwLjQ1MyAyLjUzIDMxLjAwNCAyLjUzaDczLjQzOGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOXYtNTg1LjkzYy03MC4wODIgMjkuNzYtMTQ3LjE3IDQ2LjIyLTIyOC4xIDQ2LjIyLTgwLjkzNCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjIiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ0NTkxKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDU5OSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2MDEiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2MDMtMSkiPgogICAgPHBhdGggaWQ9InBhdGg0NjEzIiBkPSJtMTI5Mi40IDg2Mi43NWMwLTMyMi42NC0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMThzLTU4NC4xOCAyNjEuNTQtNTg0LjE4IDU4NC4xOGMwIDMyMi42MyAyNjEuNTUgNTg0LjE4IDU4NC4xOCA1ODQuMThzNTg0LjE3LTI2MS41NSA1ODQuMTctNTg0LjE4IiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NDYzOS0yKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDYxNSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2MTciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2MTktMikiPgogICAgPHBhdGggaWQ9InBhdGg0NjI5IiBkPSJtMTA1MC43IDExNzIuM3YxLjQzLTAuNzEtMC43MiIgZmlsbD0idXJsKCNyYWRpYWxHcmFkaWVudDQ2MzktMikiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzQ2MzEiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IDM2Mi43NiA5MzYpIj4KICAgPGcgaWQ9Imc0NjMzIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg0NjM1LTEpIj4KICAgIDxwYXRoIGlkPSJwYXRoNDY0NSIgZD0ibTk2OC4zOCAxMDMxLjhjNTEuMzA1IDM3Ljk3IDgyLjI5NSA4Ny4yOSA4Mi4yOTUgMTQxLjIxdi0wLjcyYy0wLjI4LTUzLjYzLTMxLjIxLTEwMi42OS04Mi4yOTUtMTQwLjQ5bS01NjguMzQgNDYuM2MtMjIuMDA4IDI4LjY3LTM0LjM1OSA2MC44OC0zNC4zNTkgOTQuOTEgMC0zNC4wMyAxMi4zNTEtNjYuMjQgMzQuMzU5LTk0LjkxbTY1MC42MyA5NC45MWMwIDE1LjY5LTIuNjMgMzEtNy42MSA0NS43NSA0LjkxLTE0LjUzIDcuNTMtMjkuNiA3LjYxLTQ1LjA0di0wLjcxIiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NDYzOS0yKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDY0NyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2NDkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2NTEtOCkiPgogICAgPGcgaWQ9Imc0NjU1IiB0cmFuc2Zvcm09Im1hdHJpeCg2OTYuMiwwLDAsNDUzLjIsMzU5LjksOTQ1LjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNDY1NyIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFKRUFBQUJlQ0FZQUFBQWpadlpDQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBRnNoSlJFRlVlSnp0WGRlTzdMaXVKUTMvLzkvZWx3dVVlUjRrU290SnRpdDE5OHdRMkx0dFdWbExUQXJGLy9mL0Q2R2ZJUDZSVXYvNTlBT2p1Ui9IOFoyUzJLR21iQ3pFNDAvM3lMdVIvT0g2Q3RiM1lsbnllVlR0MzhJUWJkcVlzNEdUQ1I3VC90Y0huRDJRMzA0eGYzbDVFQ1UreTlWMkNORVh4bmQvZkJLcERBK2pNWko4ZDRsV25YVEtuUlpwYjdTMUF0enJvQmc1blh6VzhxdjJKT25MTExOSitUN2FqK01OT1lkMitnQ0ozMWlLUnJuQWJUc3I3Qkt4QTUrY3plWW53WEs5bkpQeS9lZXJJbU9VdHdMWkhRQ2UwLzU0QlVTaHZ0ekRIUEpOaHlTellndVJKdm5PNnh5Qzc0TEpEK2FuOUMxSnlycVZYUHZuYXYyOHlMb0lFRWsrUE5rbHozT2lqZktCU2NOTWdQdk9yaE5PQm50MGJsSHZxM3JQUi9YTkM1bmY1WFFCbUc2R2h2eDh2eVhwUXhXZTA2SHVjeUxsR2tjdmxLRXhpbTRHZVM0Z3ZyU2lTRUp1NEY4YzNkNlp2TEVQK2pIQzVzbXprM2JWQ0JFcSt5Mno2RXhlbWJKK0QwejdjWG5RR0xpS3RQZU55WnJreFd4aG9WUmNsVnhEMHNkMUdrZXJ3ZnE0bFVaMm9KN0J6Ukw1bmx0RG1FL0hSeUlkTUs1T2N1cDlKdk5kbUs1VS9pSW5RZ0QxWnhLaWgxZ2dpYmoyYVRoRE9FNUxtbm41Tk81eHBpa0FlWWRDUjc4NXY1ZXA0aXI0VUlrcnA2aFdZbEJjR09KZ2dBZVpSazIxVG9RZGk3b09nb25Jc3IzTnlWbWhEakt5SGJDU1htZVNEUlgzaTJBNng4aUxJSGlib1hlRGUyWDZ6Q3J0Z2FxSGhtSEZRUjB4K3BJZjA1aDV6b20yellvdG5QeENYUytTV1JGMUpCNGFEeXAzU0J6RndZRmVGU3MxbUl5cC9jTTZFVkp1OHA5eG5xdmZDdTRqUHR4eG1JTXN3SkF4R083WHBZNnptUGNqczZhT2pnWUZDNE5zWko0WmIxMU00Vmhta2trZzdhZ1Qyemkrd1Q1K2FKUVQ3U0hlT1VDOVQrZFZPdlU5bGZYUTlLY0ZMTDRSaGM3MDhYM3lNWTQ5clRJVWhyVDRqRG9UdEhWL0hJOGV1VHYxekVCRFpRYUhBY1NvVGhRWWduS3dsYXk2UWhJYmpybHRNTE13VlRHWUFUUnY1MUN2MVdOcHVaM3B1T2xFeXZxLzROQ2piS2N6VldNNHVOdlJPUkU2aFkrakFVazVUdVVBMWZBRGRCemxjbWppRzdGeXpJUWpYeWc4N2R5Q1k2M3E5UEdGMjR0MFZvOVEvd1huemJpUUFlbVI1NW5GOVhsbDRzc3IxRmdQWTlVeDdZZlFaRlBiUmdacFhuUXBOMk5LQU1iMlBWdEVaUURNNEY3RnpHQVhhTVJZd2RIdTZCdGZwK3QxTzEyZmsvQlF0SjM3eElXNE9ERkxvR2h5ZGxhYkV6a2ROMjBCbGlHUUFTSGpHNGltWWE1TFY4aUFVNFMyZXd2dmNHR1l4bk9vUEM5T3l4bXQ5S1grSXBMa3FhTFdBdkdEbkdaWFRNSlV5VTdVZzZDeTBHUUNBdWtSakNaUDdzNUc5T01vcThKM0pyQzh5RnBoUWxNQlI2Nmw5ZGhzclp1M3dKbjloVmx2R1k3TTZrRUR6RnRQVU16dk5QVHpWRU02Y3B4MUhYTUc1VjBvUkN3Q2JxTStzUTV1WVV4V2dkYXhWVWFCM0VoQmJCeW5UdGNWb3YweEhFdVpHUTRWUTlGR0NrWW44bHc4NFlNWVVDdkVWaFJ4aGlMTnA3ZERZcml0SktTL09TamZvVlJHTCtKQXFKOVFtYTJpNlprbWgyRHVjeCs0SDQ0RmN3TXdkcDh1RjRIVVkzTHhsT0ZnSFVSbzk5dDhlR01TMEZPNDZ6SGlXSVFXZ2x5ejFRWFFKMXNYd2RBWXp6WkhKMFFRcDNwbTFZbC9ncDZ2NTlCbnN3OCtmNjlHZGhZMHgxREcvM05vMm5jR1FJMzR5dUZGaUZnYXVGU2RJUUp4WnN4eU4zcm9zZFpsamxFRFpLY2NKNXRYblAzMm9GNUgzMEViQzRUNVRLdkIrSjNhMEpxeXR0Ukt1UGZyWlgwWDFJK3d1MEludndNZyt1N1E1QmVIQzFFQXRUaHRlK3hHVnJSNEhLa3daV21aS3hBd25pSVlnU0pabklUcENBZmZ5VEVTWk9SWjJYWHdjQW5BOTVKY3JwUFJaay9pQ1htM3daRXNrZ1krb0lEUnRNTVZBMGtSVkdFZHplZEZOTlk4aFdsL2FFYlNRYUxtaitjQ0RBQUo2MmhJR3pHYWdxWU9YZUhPOUlSa3kwTGVwWGRBOEpOaTdpcUhXYVhoNG10Zzl6WXRrM1YwampGMjMrVnd6QWpHMVhrR1V1VzArNm5iQXV6dy9ISTM4N2Z4U2lKMjUrRkJ4Q3hUSndvY3E0dWhMVlljaFRWNkJxb096eUdnSmtXVnpzY2x4K1crSmZKUUQ3eFM1cFYyZVRUTThEaldUbzNRNUtnRXU3RWIxcUxwTGxCNlI3Z0YzLzRnZG9tNi9GTmdkVE1KbmRDR01uYkliRmVJWWVWL0xPb0xOYkNtV3p0cWNEQ3Z2NmZmZnBJaGxSV285UjZpeW9CWXBPV05waTlISlJJQVlQeVZtZmtaVmdrbmdJQ3VwRHBTRjJmcUloaXBsUkVOajNWTExBZDNyN1VUWlFOY1h1RUQ1cXYyS2VsRXdJcGtyV0g0MzVLY2JwVDZMY3IxSy9YSTJ5amxOMVI4KzErZWNjV0QwNnlQT2tBTlo1TE03U1BJNWxpeE9rWEpmb2dRUFhxaEcyYWtPWGZnS0xDVUxZSDZaTnRqMFQvOVJqYmlXc0Y5OXRzL2hlNjJVZHhUSXU3OGhCM2NDWExSY2RLdElVeGRva3l1WXhJZDdYRS8yQUhHUEt0dUpFUHg1b0UwN3FoR3J0SUJCMERmc2c3aEJFSzQvREpibENUOUxaem1zMVN1bjJWOUZNM2RrR3dhWEtpK0FMQ1FBM0hqWDlQNUI5KzhXMkFzd0RKazN0WXFJQktCMTVsaDI2MVl6bXAwbFFuR0kyUHJtZkxYeTA5QmgzbThqUkY5aXFPOUMrVFJXbTJQN2ZrZ0dGaS9NSnFTWnhMK1VSdytKdGhFdjRtV2NRQ3dpUFpqZ0FlVUw1YmhxVFpjek5RRlFFZmRVZ3ZFcnI3UzQ5VUQrRGo1dnRZR1Y5Ky9SVmZMdjJLSm5hV3R5NTU5bmFWaEU2ODlRSDFDbGJxcHpVUWl1UEcvU2FDNWlpL2RQUlpPbk5JQUZnbWJSZjc1RFFIbDJhVXF5U3F0WlZacTBRbTJzWFZIVHdYK0xuZytKUmF2MXNQR2l3YUQ0OUltZk1WNU9xY2dWYWt6ZGFXL0t6ZEppaDMxQ2ZYcUl1NDRCZ05zbTlLRTJwb1o5ZkszK1pmUnRsZGR4dnRkdEg2eXdhRGFCa3JvQ0Q5YkVHU05FamdIaWo2WUs0UDRUSnFyK1oxeDBxb09LMUlSTnZQMUhOMXkrUXBjTTQxSW44aERSTW1NTm5RaHphb3I1bVluVDN1UW8zTWloaE1ackVEZU9BQndtdjJUazh4OVpsN2I5L0NlRGNNbmNTQkM2RHhDUHE3QkZ3YkxnL1JxdW51MHlxOTNkb2h6bGlacmR5S0dURmlXczllRjhKMEhRNWd1SmdXWHpDUkVFMWlIZ25CbXNZdHlseTVsV1BVZFJiWkRua0VzYmVYR0tUc3IvS3p4alp4L3J4bGZoZEladnVIWFQ0bXZxM1RHL2M3cTU4WGYxWWxnRnpyelZEeTVoemlnd2Q0UUdWeEdwdFFpdGM0VVFNSm15UUszQmJRTU45Q1BiTVBDYlJnRDlUbDdqU0d0ME9YR0s2TVBWUG45dEdKOWo2cXAxV2lsRThiSm1jZXMrZ3c1Q25JMW9XR2R5MkcramJIcDRHbmJRWVQyQjFsTFRQV3lBYUNOcHN5Q3ZVQThFdmpheDRhZDg0RFcwUHFtajg4QjQxMXVwOCtjOXovTFZMM1I2MFpFb05aNm0ycW1VMWZpQmlZdnhrWldUTHZRUnNNUE1KUXExVlNZeGw0U2JpSktXSWdQdG9jbm9RcDVjM0xXdmJGdFZOUWJLdGZCKytoOWwxWVJmVkprWm4wenkreUs5K3B1b3BCYklTR0UyaGdMVDF5TU1nUUUwRVRUL2xESFViYVUwWjk1ckpOeGQ0UFBUSG53Sk4zeDVpcGw2bWtiK1NqMEtkL2dMSWYza0ZWV1g2ZjNBZko2VHFneWVHWGI2MUg0NW5YSG5nZXUxdXNPUjF3aUFZbWpXMmYzWS9JY3loVXZtZXRmeXBFR0I5bFNLeUZXM05wanRpRyt3UkEzT2JkVks4cXJiditXbUh5bW5DeE5JV3JTaXorelBsVC8wSm96MmREdTEySHBlNDA2cUxqZzFsUGYxajNXaWo1TnFLanNBTUtkY0VSakdXUzlpSXB4T291RHRSNjJzSzVyR0hKMGNkTDNPd0I3SjEwdDUxcThlZ3JWMXFtUTluTVN2aXg3T2lZRisxOGZVUmt5aTdmY1YvSFZsQit5Ykp2akRzVXdFVkdYbFY1MFJRWFZWZFlkTTRtTlFtN2xDSThDbFF1elYxeVRTYjArUmlzeENiYmtuWVZXZlRWNXJDWEJURk54dHl3ZW1QR3pRazZxVEtDMUkwTzQzc3BiRUxHcy8zVVh1RjFKNzNEQ3VLNXdBemZmT1l2T0d1VmMwSjFZQXR5cm1NdDh2a09TUFBrb21iaFMwRTJPUHI2SkN4TXNJN2ZHSlAydTIwYlUzUGZkTDJUTWZlNm5QUmcyNGtzZjJHRVY4VWFveVRQNU0wMjRQak14d1lCRU0zT2Njc2Z1SFNNSjFQUnM2T2VCeUpXWS9HV1VIdFd3SlBBL0pDUWZMQ0VNK3pLT2xTMTI3b2hVWFdxTUdhdGtPaUJkejdNemxmMEEwV1ZQd3ZZODFEK0FPeFdMcG01cTRRSG9rbWFXRkVYaXRBS0xGUFAvMEhuWDZWWGU5RFJrUVR6a1lNRXc1Qlk2eWU3V1N5ZjFiUEdSSFBnMFBGMGd6QlEvd2JpMzQvUU00WGJXU3hCZmRhZGxGVkxhT05wbG5tS25ySUE3djY5MU02UlZMcCtnRXk1em9lQUxnbTlaNHBHSytScXlJYjlnbmMzOUdHcUFOZXRNRUNoVDBkYVRqczFOMUxsTk1SQTZUK1lnSWdDWURzZ2pGMDRaOThMYitsYm9rUFR4VlI1VE9EeHU1ZUJUWDRzWDArUlhGK1Y5WnZzWkxTNkFaTlZsblE3SVEvU3ZYKzdxSjBUMndhSEVDSWNXZ2RFSzQ1NTVvd21XeVk3WlZBNGRrZjI5dkhDaDFua2VjdVlPdUpyVGZib0htQ3ZrT2JvVlQ2dDY1TzRVM1FXUjYwekQ4aDV4TGVoc0tnOHdIQ3NtYzFJYWo0WVI2ZmJZcWRFYlMzbHN0aytHZlRpKzVpWVRyd3ZGRzRuZGQ5T0pCc0ttY2RmRmxZODlRMXZlMzdYTXpoeXh5N1NtR2F1TkpGNHBqakZzbmxISnh2N1ArazlnS3l4UmU4UVRIL3RrazQxVGpDdUVHRGlKUC85TnF1NUtVNlo3cURndytZTjBZUkE1UEVDMzV4clVzR1F2Y1NZMmYvTVU3OUtJTXFCY0ZXY1lvd0tjRkhFbUovTExHbGFFOFlnYnZkbG9zZFU5Ni9kYXE2NjBqM1A1dlM1alA1Rm9jVk1xRWhFNElOdjdReFFjZHRBOTdMempra2lJdzY1RXJVeWVpLytlVXozcmN5NzJXZTVVcjFmVlF4WGp0bmNyL2p5UWxJc2s0VWIvaVdObE9hWUhaRU9CMzRXS0R1ejlFRzRpYk9ncktIK3RQb1NaOHhCakhXSkJSN0xjSjI1ZXE4Ulh5MU9QYWtmeThNeVZ5NHcrczEzaldRbzI4S1UwcThPYkdVQ3doQm5td2FMdlIxS1BpUUhOUTRpSFpVWWt0QXZOQlRkZE9tamJxNGU5Tll1SEpYNlVwMU9oUm1CRUVUSjNUV0xEVFFtenlhZGlhNExlaEg1WDdYbWE3bktvZk8zTGp3WG01VGY0ZVpVQmxaS3NIbmpnVVZQeHROcUUyOGtQSXRvUGN4bW5qTHdONit3am8wc0xWdE93MG5wVXdReG15elNLcjVVSWpBM2pLeGVvbjNDYjMzZTFqQ2NFd1RvUEFTRFl0TzBaMThGR21rWHp4ZndGY0lIczBtK3FGeEhwTVdvaUl1YmlEazl1NE5FcjJqanlEVXJlWVJNa0lSRHJGSlZTUGVOR0JmOCtJT29Venc3Nm1XNXpKNmVjbzZ6ajF2V2VLL3A1VHN0M09Fcm14YVRpcDYwd0NaajRZbmtCMDh4a3ZCTVJ5ZFNadktoYWRsdlB4M2dRWEFkc1NiNDM3dHhiSm9Hdnk3VDM2ZHlhQ2wvS1QvREJYOE9TckFiNDA4VTRxZTc4YkZsY3lWZHhKQk9JQmxDYXJuRzZ2U2xKUkxyRm8xV21tK3VEbXpBc3VNdVFsWGdtelM3S2VsNDJPWkZ0bkszOXd6VnV5MXE0SUFrUEJDNklWY0lYUmR3RkxodVhoTEpZUU5sNStoZ3BlWFBLdFhpbDNYTVZFSi9FUkhMMFNyR3BnblZBd2ppelh2eXBKMk9GOWN4Rmk4Sk0wbmxIVTU1bjRxbE0yOGI0KzRNYU5EZlgzbHlWSXhlcW9Mci9HeHd6MTBkSStnR2RLQVhJVXRqZnFzMlZBNW8yUncrYWc3eGlubzBkamJIeWlrVURYcXRIczhySHlzVkJ0SnU3cU9DajVyK0IvWVJtZkc1VlVlaFFvYTBFUVRSV2hiSnpyNW1DV2VsTjUvUWJ6TGRhMzdscTZydmpxQzQzMTZ0dzJOU1dnNENMYmdZRW1ybFhxdGRCcU4yWHZRdmNpcDk1S3g0TVRRTzlhUzdJemtvMzN3NkNvQy9ocHFjUW5QNUZURmNHV0FHNTRtWGZzc0NlcFZ3cGRyYlI2UlY5Y3lkaEhMVTR1U1hjZjNBWW41T0NSQmR0MWR1ZE1penpoZHNKMkJYWm4raGdxQmZxT1AycnFPaFRVSFFqOC9UT1JLdHVwL1VJYlBqRUt2bHo1Q2VhVHBhcUxaWWp4RzlDRnBBejM1Wm5mc2pDS0NJWExidTV4MXE1aG1iQk1US1JtTXV6dFFrRVQyMWZ2L1A1akkzOXZxbmFVVmtud0p2eFhsL2RTLzFYU2RzK3IyL05kYUl6VVpqejQzSHR3MUNVbGVNNG5hbW9YYmFFc2d0VXVzR3BpeXVKMncvR0RUS2hzQWtvYlhDb1NySXBMZlBDWmk1SE1TQjhyNmg2MXcvblBmdURlVFd0aExMblBoa24wWUFZYjA1ZFZTRXNLQ1ZMU3FoSDZjUnZ6N3YreUtLYTlVeTQ3d2YxRnMzVWF5UDkvOEV0RUZaVll6S3BiU0ZpOXUvN3ZGNGd6OGZlUC9pTm52aDUrWktNNWhURXVkTmRsdVRqNmFUbjVGczFaWnVZRTlvR2xFRW5nc3BrSnF0eGZzV2xQdVJjMXZIb0ZXamZMQnZIbTZEdjNnUDB6c0g5RnVVd3NlT1JweXZjQ3BKOXgxamVTc3NZZ1BJaWJwZWg0L2FPNWh0Q1BtRTA2NkUwSThjaXlGUlBnOWlHS2dPT2pZb1hlVnJMeFdwY2QraTNXbWozSjhYNWFWWlVncjNabjROdGlpTlEwR0dKSXpvb01iMlcxLzd0UnFjWmR4VUo2UmJIZUpPWkR1NEdDN0xSa3RoNHBzbnNoMW1ack9rQXZtRFNYNlcvYUtGVmhNcTFCd081OEdoSDJkTWRNNzV1TzhZeHRjQWlzcE9acVhtMHVlbTRQYTl4eVZYVGVmQTJrRGw0MlhBd0h3Q1I2T041REhEWUhLYkk4elBHVmxyalhsdVIrSzFjNXlwZEFUeUNvUkpoa3lNRngyRXF1cXpwWDFtQ1FWY1N1ejF1SDJ0ckVPaHhtQTdSTUJHSjV0bjZJbHFvK0N5dzNoTlFoVm5hcm13UCtTTVVmbDUrU1ZHOFdXdkwwMEp2Z2hPdjFYU1U0cG1vbjREZG9QS25xL0dqT003ZmdYVllacFBvV2VOcHJYeXZhUFVyNEgrUHJqY201MHJaVUlQNUgzVHp5UVR3eHc2dlFCRHoyb1Y0TG5TT3J5MnpJU0VYRTBURllFWlczMkZudDFkaTdXdzJmZ00xZjZFT05xNFpBM2NldjZ4dDRaR3VBSE9ZR0JOd093SjJXR2prRW9uWVE0dUpEeWRDZ1VNY1B4VVlIWkEvc1FINjZiMjBSYnEzdHVGaVhvR2JyT3V4UG5zV2t4OHVma3RqQWJBTHZCQUJjM1Q5OU1BZ1NaeDIvYS91bzhiS1ZVTTE5aVVWM3o5T2J5ci9SeVdxbkRzYWcySk1OYzdRajFicnd2YmJydElMMVRRMnNXU2dIUk5XbThzMnY0WDFSTWVxWFdXZm8yaC92SmQrQWxTblFISVJqcENpMG9kUThZNWxDZW1GRG1KMUh5dWlHZ3JLam1jVE0zcUVlOFJxMlhRSnNBK3hxTCtxaXo4akxXc1B2VE5oaXJ5UlYxZTYwcTZiN3pYUWpGc3hpS25SUlJSK2N4YmozMTF1R0QrdjlpTDlsaU5FNzFLWG5sbTJxUlhyODJEekxIbjRMa1RnN202ZVpvWkVZd3hPbFdsS09SYWRpTE9LL0g3cks1VGk1UmV6bmE5V1RaYXZaZmhjWjR0eERtbjhyT3RFMWkwK2Z2NFZFdmpsaVN2K3BLeFdwNHpoQmM0UjdZamZTeThENkNTRGU4bzJoQ2VnOFduTUw1NFRnM1ZHUkN4Q3dod2NlSnZqUWt3UlZPYjdZaVN6eWxYaThRcDlXa24rRnIxYTd6dnBNOUc2OGhONVBBalJXRGNWVmhCSlhFVlJZc2dFZm95b0pBUlkrRmE1VitoNUR2S1h1RTlGcndCb2xmYkthZGRWbU9VNGVkeDJ5WlhHN0N1enE1bjljRHBQK210V2RiMURvekN2T3gzcHdmaFh1VTlGenlyaGQ1UGRBVTFNMnhkaXBXK1BIZHRlM1VHMzlQQnBVU2lSRlh1ZXNrK2xnbjVDUCtIYy91MjBuTHhGZUNhbWxtVTRVMDJJaUpocHg1Y2g2MWpGVXZmL0xNUVFrajhvV0lIRGcrMHFKdjY2MkxwTHo4NlZhbEg2SmJIWjlCN3FmOHh5MVc1eTc4N0pjUVJmNzJ3RWlYZVZWbEdmV1huM292UGZRdS9jcFhCM2RjRDZoY1NHYTJhaVI0YTZMalEwYm1Id1hxdlA4aDZZcXNvK3kwM1E5ZkJ2b1ZjQTlEclgwWHhzVHVNNlJ0RWIxS2h6SXFJcDQyaWErbmdSZzNFMjNyUytUQ1hPbzlnODRmbWZ0WGZvSG4yaTZaZDBTM0FVVG1WNmZtVFNuWTJrb09ta3l5QjZ1UlVSM0FTQ0piZ3RBbkpQNUYyaGY0SUoveXA5RDBDU2ZsZWRlVGdCY1ovUzBJbUlpSTdKZmViQUtaaktxaVJCZWVSWHdQVXZaa0F2VThWdFRqZVFNS1ExM0lnTVZ5THU0bXh5RUZXN2kvT1ZzTU1mdDQvTWVQOTJudkYzeUI2YWJ5VGpQdzBRL0VQMlJRYlFkdFZZNVpEaGNHeUZ5RURYWE9JQXpWb2d2ODBWNExCa3o2SmRhT0IvV0Z6U1hUOVo0RHI5MWR4blQyUlgzbjA1N2hSSisrbk9obzlkaU5vdkNUSFppejBEbTBsMkszNXF2ODkvOHVzcjFINDBtQWh2blFFRXBHdFkzRVdhL3FWRGRJOTEwNElVYTVnUlFvZk5rK1ZLUXhLNjNXZmp4MlV1QXU0L2tYaU5ycDYrRDF5SEtOL2c1VS9RRG02VUtOdTQxSytLOVlpbW9vek5LMlk5TWhFbnVzYjM4RnZucm5LbjlBRVQ3NTlHZDFpMVpDZ2lDaUtyMnBhUlpXazJVMU5mOW1BT2QxU2JIUEVrUVY5cnc0SDJyb0ZYU0xYLy8raTlGTzVDNktiNmtDa3dkcnJFb1NrbkVEdEdaSTZURURkT3hDSk54cWxzM0VCNUlpTGFhRjV1dGNsa2hadGxmZGtXQU5Id0M4QmcxNWovYUVGblo4cE0zUDdYMzhYa2x5QU9HTWMreHNJU3ZieXFRL2UvL3dQclJ0bllLOXcxL1FBQUFBQkpSVTVFcmtKZ2dnPT0iIHRyYW5zZm9ybT0ibWF0cml4KDEsMCwwLC0xLDAsMSkiIGhlaWdodD0iMSIgd2lkdGg9IjEiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiLz4KICAgIDwvZz4KICAgPC9nPgogIDwvZz4KICA8cGF0aCBpZD0icGF0aDQ2NTkiIGQ9Im00NTUuODcgMTg4Ljg0aC05LjE4MDJjLTIxLjczNyAwLTM5LjQyMyAxNy42ODUtMzkuNDIzIDM5LjQyMnY1MjMuMDdjLTI3LjM0IDE1LjY0NC00NC41MDkgNDQuNzc1LTQ0LjUwOSA3Ni44MjYgMCA0OC44MTEgMzkuNzExIDg4LjUyMiA4OC41MjIgODguNTIyIDQ4LjgxIDAgODguNTIyLTM5LjcxMiA4OC41MjItODguNTIyIDAtMzIuMDUyLTE3LjE2OS02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4NS0zOS40MjItMzkuNDIzLTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTIzIDEwLjc2NSAyMy45MjMgMjMuOTIydjUzMi42NWMyNi4xNjEgMTEuMTA2IDQ0LjUwOSAzNy4wMzEgNDQuNTA5IDY3LjI0MiAwIDQwLjMzLTMyLjY5MiA3My4wMjItNzMuMDIyIDczLjAyMi00MC4zMjkgMC03My4wMjItMzIuNjkyLTczLjAyMi03My4wMjIgMC0zMC4yMTEgMTguMzQ5LTU2LjEzNiA0NC41MDktNjcuMjQydi01MzIuNjVjMC0xMy4xNTggMTAuNzY2LTIzLjkyMiAyMy45MjMtMjMuOTIyaDkuMTgwMiIgZmlsbD0iIzEwMGYwZCIvPgogIDxnIGlkPSJnNDY2MSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2NjMiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2NjUtNikiPgogICAgPHBhdGggaWQ9InBhdGg0Njc5IiBkPSJtNzQ0LjkgNTk0MS45aC03My40NDJjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjItMzU2LjA3LTM0My4yLTM1Ni4wNy01OTMuNjUtMC4wMDQtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk2IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk2IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di00MjYxLjJjMjA5LjI5LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMThzLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOGMwIDI0MS42OSAxNDYuNzkgNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyNSAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NDIiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ0NjY5KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDY4MSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2ODMiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2ODUtNikiPgogICAgPHBhdGggaWQ9InBhdGg0Njk3IiBkPSJtNTMwLjEzIDIxMTUuNHYzNTc0LjJjMCAzMC40NiAyNC42OTUgNTUuMTYgNTUuMTYgNTUuMTZzNTUuMTYtMjQuNyA1NS4xNi01NS4xNnYtMzUxNGMtNDMuMzI4LTcuMTMtODEuODgzLTI4Ljk2LTExMC4zMi02MC4xNCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDQ2ODkpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc0Njk5IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAzNjIuNzYgOTM2KSI+CiAgIDxnIGlkPSJnNDcwMSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNDcwMy00KSI+CiAgICA8cGF0aCBpZD0icGF0aDQ3MTMiIGQ9Im01ODUuMjkgMTUzMS43Yy0zMC40NjUgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnY1MjguNTJjMjguNDM3IDMxLjE4IDY2Ljk5MiA1My4wMSAxMTAuMzIgNjAuMTR2LTU4OC42NmMwLTMwLjQ3LTI0LjY5NS01NS4xNi01NS4xNi01NS4xNiIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDQ3MDcpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc2MDkzIiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAzNjIuNzYgOTM2KSI+CiAgIDxnIGlkPSJnNjA5NSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNjA5Ny0zKSI+CiAgICA8ZyBpZD0iZzYxMDEiIHRyYW5zZm9ybT0ibWF0cml4KDY1OC4yLDAsMCwxODQuMiwzNzguOSw5OC45KSI+CiAgICAgPGltYWdlIGlkPSJpbWFnZTYxMDMiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBUklBQUFCTUNBWUFBQUNoeERnUUFBQUFCSE5DU1ZRSUNBZ0lmQWhraUFBQUdRSkpSRUZVZUp6dG5mdXZka2RWeDc4ejgveXNnc1ZTU2x0N295VTBoYWJTVk5BYWJWS1RrclFKalZoTTBYSXBwTmltQ0RYSXhhWVJCSWtWSlkwMGxsZ0VtdDVMYWNHYVlOUUVqVVlpVVFsR0V2MFB2S0QrL3N6NHc4eWF2V2J0TmJOblArZnluUE4ydnNtYjl6eDdybnVmTTUvelhXdlAzc2Q0N3dPR3V1WER5Ymxjd2Z2dXVsZmMra3gzWFdOZHg5amJycjYrKy9ndDNlTzJ4K3M3MTZYdlRsajQvcTI1cHIzOTFzcTBzV3AxdFo4N3JhNSt6SGZVQ2UzUFlxNUJYR2t6UUxKT3B4VWtYZjBkcE8wUlg1Y0JrcE1ORWp2cmNXaG9hR2lsQmtpR2hvWU9yTTNMZitTSEFBRC8vVC8vdCtlcERBME5uVGE5OXJMTEFBQWJPakNBTWpRMDFDc0NDR2tqS3d5Z0RBME4xWFQ1WmE5Umo4OUFRaHBBR1JvYUl0VUFRcXFDaERTQU1qVDAwdFZsbDE3U1ZXOFJKS1FCbEtHaGw0NTZBVUxxQmdscEFHVm82TXpWYXk2NWVLZDJxMEZDR2tBWkdqcHp0Q3RBU0R1RGhEU0FNalIwZW5YcHhSY2RTajhIQmdscEFHVm82UFRvc0FCQ09qU1FrQVpRaG9aT3JpNjU2TUlqNmZmUVFVSWlvQUFES2tORCs5WlJBWVIwWkNEaEdpNWxhT2o0ZGZHRkZ4emJXTWNDRXRJQXl0RFEwZXM0QVVJNlZwQ1FSdGd6TkhTNHV1akh6OS9yK0hzQkNkZHdLVU5EdTJ2ZkFDR1pQM3Z4RytIdHQ5NjY3M2tVT3NsUUdhOWFyTFFkcjFwY1hiYnJxeFl2dk9DOGxiTTdXajMreEJNdzMvajZDeUdFZ0JBQ2Z2a2R0KzE3VG9WT0lsQUdTQ3B0QjBoV2w2MEZ5VWtEeUplLzhpaU1NZkhmMTE5NFBvT0UvcjN6OWwvWjl4eG5PaWxRR1NDcHRCMGdXVjNXQTVJTHpuLzFUdk01U24zeFQ3NDBBY1FZQUlCNS9tdlBaWkFBY2FIUTV6dmUvYTU5enJlcWZVSmxnS1RTZG9Ca2RWa05KQ2NSSGdEd2hUOStKTVBERWtBSUtGOTc3cXNoaEZBQUJJZ25GTHlIRHdGM3Z1KzkrNXgvVThjTmxRR1NTdHNCa3RWbGZLenp6enQzcDNHUFF3LzkwY093eHNCWU96a1FCaFJqRE14elgzMDJlQkhhMEFuRzR4N0JSOURjL2F2djMrZjVMT280b0RKQVVtazdRTEs2N0x4eno5bHByT1BTZzMvNCtRUVFBMlBpSDV6Z1FDbGc4dXl6endRSkVRSUlnQXlSRUFLODkvREI0NFAzM0xQUDgrdlNVVUZsZ0tUU2RvQ2txK3lrd3dNQVB2dTV6OEVhQzV1QWtjT1lCQlFOSnVhWlo1NE9Ha1NDTDNNbUJKRWMvdmlBZXovMHdYMmViN2NPRXlvREpKVzJBeVRWc2xlLzZwVTc5WG5jZXVEM1BndFk3alFpVEFCTXpxTUdrNmVmZmlwb0VLRUZVNFBJTmdEZWV5QUVmT1REdjc3UDgxK3RnNEJsZ0tUU2RvQWtmMzFhd0VINjlHZCtGekFHMWxvNGc5MWc4dFJUVDRhMVRvUkRCSWgvQjlSdlk1M2YvTmhIOW5VOWR0WWFzQXlRVk5xK3hFRnk3amxuNzlSdVgvckViMzg2Z3NFbFNNQWNEQ1pQUHZGNG1OeUhWM01pS2tSaWd3SWlJY1EvTFJ5OHgvMzNmWHdQbCtmZ1dvTEtBRW1sN1VzTUpLY05IS1Q3Zit1VGNlR0QzWGxaQVpOcXpvUkEwZ01SQUlVYnFVR0UrZ0NBVDl4LzN4NHUxK0dLdzJXQXBOTDJEQWZKYVFVSDZXUDMzVitBUUlNSkJ3bUFSWmdVSUhuODhjZUNoQWlBeFpCR1FnVEFEQ1RlVCtYZWUzem1VNS9jejFVOFpQM25ELzUzMzFNQU1FQ2kxbHNxN3dESmFZY0c2Y01mL1hoZStDWUJvblFVYlpoSWtBQ293a1FGeVVGQ0dnMGlRQUtUaiswZStKMVBIZjlWUFdMdEF5NERKRXE5cFhJeHoxZWQvWW9kWjNSeWRlOXZmRFFDQVlDMXBncVR3d2h4TWtnZWUrelJzUFl1elM1dWhDQkM3WHlxKytEdlA3Q0hTMzA4T21xNERKQW85UnBsNS96WVdZY3ptUk9vdTMvdFhsZ09pdVF1V2lBQjFyc1NTeHZUQkV5Szk1RklpUFJLZzBpdjd2ckFoekowSG5yd0Q3cmJuUWFkOWJJZlZvK2ZsTkRvVE5XWkRBeXVPKy8rUUliRGtuaGtZSzJGQnpKTUZ1VURRaHJDdzJlWWNEVmZiTVRkeUZxRkhhQnk1MTMzWU9zOUh2NzhnK3NIUEVXcUFRWVlrT25WSzE4aXNORDAzdmZmRGRjRER3VEU1ZWU3WUZNMlRtdVhYTW1DdXQrUXRnWUk4N1l4ck9uVkhYZmVGVjJLRDNqa0N3L3RQTzVwVkFzeXBETWRObWVmOWZKOVQrSEU2VjEzM0Fsais5MkgzL3FjODFpcmdKQnpMTDNhKzZzV2wzVDd1OStYYmRtWEhubDR6N001R2VxQmpkUi8vTmNQam1BbXl4cFEyRjIzM2Y0ZU9QYkU3WEZvVndDZGVKQnczWGI3ZTNLNDlQaFh2cmp2Nlp3cXZlSkhYN2J2S1F4MTZPM3ZlT2Y2TUVUb0lPRFoxY1YwZzhSYXUzTjRZNjJCOTFnVjNzaHhaZkwzMXR0dWg5OXVFVUxBMDA4OHV0Tzhob1pPZ243aDF0dlNIUk9YanhsanV2SWdOZTBLQkdDNi9idEdWWkFRMVlJRjNNcUVLNGZPV2dBNWE3SE5tZVhwMlorVzN2cTJYNHBmQkkrdEQzaisyU2Y3SnpzMGRNeTYrWlpmQkFBNGF3RGxEZ2hYdm1YTGJ1UDJTdDcrWFN0KyszZEpHU1RXbUhoTHlGckFBejcwTFg1alRIeDR6MXBZNytIUnQ2Y2dqdVB6bU43NzZGY1cydEtGOWQ3RE9nZS8zV2I0a0c1NjY5dXlpL25UNTUvdE9vK2hvYVBVVzI2K3BiaFZTeEJ4ZWQrSDY3NlY2NndGeEw2UUhta2IwbnFsUGNESHRUSEdBbmI1TjM4a1UxekFKa1RyUkp2TGVpWVBlSGpmMTA2NkVvczRyZ1FHSHlPV0FjNTZiUDBFcDdmY2ZFdmU0K0s5eHpkZmZLRTUzNkdodzlBTk45NEVhOHRIN1drUkx6a1IrbmxlNDBaYW05RmFxbTFHVzVMYzJUb0xiZkxDOTNIekNSRERHMVJBazA5T3VKSmFiZ09ZWU5MclNvdzFNNWhJVjhJcXoyQkM4NEsxdU9IR202WlhTWHFQdi9qbWk0c1hiV2lvUjlmZmNHTmErTFlOa1NUTmpVaUlTR2x1aEVORVUydDdmRTFMcnhPUTJzU1FKcm9TNi9Xblc0MHhPVmVpdVpMWkpGaVl4SE1sNUVwNDByVUdFd2tNc1BJYVRMSmpXWUFKemRnaWZ2UHB4VTViNy9HdHYvcnp4dVVkR3ByMHM5Zi9mQUVLQUgwUXFZUTBIQ0lrN2tacUVPRmEyaG92dGJRMVhwUDI5Ty9HWkJmQllBS1dLMEhwS21vaERvRHNTdWczUHM5bkFDVk1aSWdqNFRPRFNWcitTODVrNHh4Q0NHcVlRekN4enNWUUJ4RW1lUWRmK3VIWWVvK1EzTlRmZk9zdk8zK3NoczVrL2RSMVB4Y1hYYzV4MkJJVWR2cHQzUXBuNkU1TWp4TlpnZ2lwNS9tYVhMZnlzSjU4aFFCcDZXRTkwcVpzVkljSlVJWTR0RGhyTUxFcEw5RURreno1QldjQ1FNMlpaS2V5MmFSZGZWczFad0lBM2hpRUVBcDNRcGVEeWh5QWJaclBULy9NOWJIUDRDUDRFUEQzZi92WGZUOTlRNmRXMTc3cE9saG44M01sQkJFT0VBRFZVQ2FYQ1lqdzI3dzFpQmdyMjllZFNDOUVXay84eXJ4SXp4Ty9KT3B6WTR3cEZqNlFTTmlBQ2QwTzdvRUpnR2FZUS9lN1d6a1Q1eHdRUWpVQkN3RGJCQUVaNmdBV01EN2ZHdWJ1eEtVTDRjblZJTG9UNHh4TWNsWmI3d0h2NFl5RE5SWStlRno3cHV0eWVPYVRlL21IYi8vZFFYNXVoL2FvTjE3N1pnQnBVY0xzREJDcXJ5VlZhNkZNVVdidDNJWEVUcHM1a1o1d3BnY2lyYnlJREtQa08xczMyU0x4RUFkWWRDWU9pTzZrQVJOakxRSXdTOERTcENNMzRpc0d0QVNzUzJHSzZrNHNZTHlKNzFFUjdvUytvVE4zNGhDQndxQWpnV0lTR0lEb1VEaFFET0tZQkJTLzlYRFdJVmlMTjE3NzV0d3V2cGJCNHgrLzgrMmRmckNIamtaWFhYME5ySmwrNHhNSThyczdFa0JvVGZRQWhPcjE1a0p5SHl0ZENJQVpSRGhBZU52YWJkN0Yxd1VBWFU1RWU0djhCSkk0RStZaTZtRU9MZEphQWhiQWxQOVE3dVlBeUhkME5IZGl0Z2FXWG1rQUJpQmo0SXhSM1FsZDZNRExXTGhqMGk3WUdsQ01jekFoRkE3RkpZaDQ5czNJVVBFbVFpU0ZQUVlHc0M0OWNSbkxmK0thbjh4UTRlY01BTi85cCsrc1h3bERYYnJ5RFZjWEM3QUhIa0RwUHFndXo0RUFXSFlnc1dFM1FHTDFlUzVFOXQzclF2ZzRtZ3ZoYldWaTFmQnhHemtSN2UvYWJDakpTUjBFTStVUU5KaFFTQ0ZESFRCQUlBUTExSW5yTjRaUXZlNGtoeHRzMFJmaGpqRUlOb1UwRWlqcGYzNk1nRktFUEt5ZU55WURCVURUcFFEb2dvcUZRL0RUZXh4ODhManE2bXNLc05DMUNON2pYNzczejgyRjhsTFg2NjU0UFlBSkNnQ0tSWlRCWUUwQmdMWHc0R05vN29QYXQwS1lQSFlQUUdLSHEzSWgrUnlVTnJ1R01zVjREQ0lTb0ZPYjVFaHNPb0hpMWk4UGRheUhnOG12WTVTaERweUJveGRFTjl4SkVPNmtDRjJZTzRtYmUwMStveHFGTzNTUmw0RGlyRVB3b1hBb05CWWRBNUNnTWdFRllMa1crbVlRVk5LZG5nd1Y1K0o1Q3FkaTRSQXNBMFFLZ1RTd09PZXlZM0dwUHppSEs5OXdkV3pMNEZKOFRuMS8vMSsvaHpOTmw3LzJpbUlCQWloZ0FHQldMaDBIbFhGdzVIcEsyRUo5U25qRU5vMEVhcXhjM0xKZHlvSEVKZ2NEQ0QvZWt3dWhhMUM3dmR2S2g3VCt1bDRlenhoc3JMVjVrZE15SnBIZER6NUJocmtUNXh4c3NQbU5hc0RrVHB5enhhc1p5V0ZNSVZFODBhQUFaVnJvSlZETTFreHpGQTZsQ0htb1BjdWgwRW0zWEFvUTU1Q2g0aXkyUGl4Q0JYUWU2YmNRN1VrQlVBVUxnSHdIQ0pnRFkvcmJRanBBNlBNVlYxNVZ2SUtRUDlQRWI5bHJ6enJSc1gvL3QrL1B5bmJWSlpkZW5oY0xsM1pMVVpieDdkb3RZTVM2YzJnQXFEb09xaXRkQjdXVllZdGFSNEVIOVY5ekg3bmNUa2xVT3RZS1lYZy9Td0RKN1FWQXBybk5iKzN1OG5kcnFJeTdFQTZVTWtjQ3dORUNaZTlqemJrVDQrQk5nS0dYUmRNM01hNzUrSXBHRTl2SmNNZTV5V0dzQVFxRlBOeWhSSWhKOEtSeEVsQUFaSmRpZkFLSkx4MEpRWVdEWmdhVjJEQkRKVHVSRUpLajhQQXBiT05Bb0x0SCtaMjIzTEdRYTFMZ0FtQVJNRVdiQ2p4a0dlK0g2M1ZYdlA1QUw2MGlhUUNSejNMMEFJVy94cThIR0x3Tmh3YUFMbkRFOXZPY0IzMjk1RHh5bnhWNGFPWGNmZVQ2MGxuczZFQnlmNTBBNFgzMy9zRndHcVBJa2ZEQitjdWNjNUJCeDJsaHAzREhPR1NnNU1VcGdNTERIYUFQS0xQd0pTMVltOElXK2lFaWwwSkE0ZWRBbjdsTEFWQ0hpbk54RTFvYUs0OExwRmNWV0RnWEhZWjBLK0QxRTFqSXNSUWdjQzRuc2psY0FPVG5oNElWemtNQWhzNmJybVUrVm53dFlLSThmTmtMRGdrbStVdW5KdFdWaUdkTCtGNEV6WTBBeThEZ2JXV1l3dnVxT1E2cVd3Tkg3bGZBWStZc1pEL2kyUmgrQjRibVVYTWZkTjd5cWQzREJBaGR5OTQ3TXJsdkFROWV0bkdibEdOZ1A2elo4aWV3eUdTc1NmWjlMVkI0eUVOQUFWZ094Y2JieFpTVWxXNGpYa3oyT2JrVUJ4ZHpLV0c2RmN4ZlJMMUo0UnM0TkJTb0FGUDRRMS9YM0lvR0ZxcEhlMUlrTUR3N0Y1NW5vUTFLUzREaGRXSi9MS3hSWU1OVmc4ZGh2WW0rOWlTcDZsVFlVeDc4dlJtRkc2bkFRbzYxRmhwRjNVNXdVQnNPaHRMaDZIVTBlTVR6N0hjZi9IUHJWbTYrcmpzNGtIZ3QrZ0VDTnJaejhUeWpJekVzeDhDMnJjK0F3bzkzQUNYZjRlRWhEd0JuYlBGM2NyaEx5WXVKdXhRMkYrNCt5S1hRUlNTWEFrQ0ZDaURDSHdZVmgrUXMvT1JLQU9Sa0xRQUVjWHh5TEF3c3dKUzRGYTZGNEpLZEM4cG5tMlFvWTcwT0VMNzQ4NWliT1JTMEJ5WjdYdytoUFVNRmxBdS9KZTFONDlMUlNQandGL25JeFEvVWs2OXh2TjJnVWRSdGdJUHFTZGN4OVYyR0xid1A2VHppV1Azd29QT3F1bzgwYml1Skt2dGNDeERlSHdjSUhjL0pWcHBNTDFCNHlFTkFtZVZRS0N5UlNkbmtVZ2dvUU51bEFNaFFrVzZENW04dEEwZDYyVFIzS2pRR2R4aFZ0eEk2d1JMS01rZmpMTUNGMnBKa01yVklvSW9FcXl6ZnlESUZIalhYb2IyU29laDRwV3B2OU5MY2lvU0tWU0RDanhlN0tsdVF5WXQvSFRUS3RuWmUxZ0FISFpNNUQycXZoUzI4ck9aYStCTzZhOXpITkFlcjlEa2xVZWxhU3JmUjQwRDRzUmphR0F1UGtCMERYUndKbEF3T0JoUVlreGQxb001RHVzdVRnQUpNb1lNTlU2NkVYTW9HYVVFZU1sU29INWxUY1pnZ29ya1ZEU3hnYllDMGJ3WllEUmYrdVZqRUFqTFVEeCtESHdQbWNKQlBiV3Voakd4VFk4VlNEa1VMVnpScEFKRnQ1ZE9yV3RnaTI5VmdFWS9OZ1ZGOFZxQkJuemswWk4yMTRJaGpzMXZReXI2UFdmMEtQSXErV0x1RHVBL2VwbkErRllnUVFHYnVCTFNQeEpwcGp3Z0RDb0dEQTRYREpMc1Y1bEp5dSt4UzRxRGNwZVFMSDZZTmFVVXVCZWlDQ21oZkNPWlFvWGJ4QWdod2tDT2dPMENZZ3dWUUhFczhDQUF6MXdMb2NLRzZRQXlMZVBtRzFaR1E0ZTFpWXg3S3pHRkRVdUdoT0JUZzhISWpVclZjaVhRZ1FQc3VEbjl2aHdZS1hsKys2NVQzclFHaktGZWdrZXV6UlVOMStLS2ZIV3U0RHVwSGcwK1A4NkMrZXVGQjUxWnpIM3dPV3VpU3J3TURTRkVQSmFSeWFHTWNMZjRFRkpRdXhORTNXWVE5TTVjQ05GMUsvSnhDaHJSdzEwSUZtRUFBWUxxakJPUy8rcWVCaGNLTmZNejZLbGlvRFQ4Zk9sYURDNVMyRWpDOGpvUU1yOGRCQTB5d2tjZDVQOU1BYzBoc3ZRNFVxUnA0cERRd2FDcUFrQnZQWWFNQkF0QWhBZFFjU2lzSjJ3YU1CZzM2YkpWamNXNmw0NkE2TlhBQTRsWXpPMTdMZVFEb2dzZlV0Mzc3dG13N0J3Zi83TngwVFdaMUdFQjR1eUpIRWhkL3lFQUJNQXQ3dUVzQmtLSEN5MnRRb1dUakVsU0FldmdEWUxyenM3RkFDRTIzQXRUQndqZmpBUkVzc2F6TXNkVGdBbmJjQ0pEVUFNUDdjSDUrak5jdEFKR2VHZExxYTNDSjEyTmIxT0hxQmNaYXRRRERvVkNyMzNZcGRWQUFjMWp3ZWozQUFOQ0VCakIzRzd4T3kzSEV0bk53VUo4ejF4RW5JOXhEMjNuUS9EVjQ1TEtLQXdHd0dMNU1ZODNyYkp4MStXLzlGcUZMWHVpVFN3R1FvVUkvaUR6MEFTYUhJa01mb0E0VjJGaTJsRk9CVFVCSmVRaDZOMG92V0RUSFFuT21jVFRYRXNzbTUwSjl5N1owanZ5NFVRQUJ0b2hib05sQTJSZXlrRmlWOEduVjFkUzd4NlEzVHdMb2NORVNzelhIQVV5UWtHV2FHNUd3NEczV0FDT1cxNkZCeDJXZk1sU2gvMlhmUzY0ajlqbmZkVXJYUXd0YjV1MTNoMGR1Yit2dENYQWI2MnpjU3A0WCs5eGhrRXNCVU9SU2Nua0lSWkpTUW9XKzFxQUNDcTFDQU5LdVQyL1NvaGR1aFMvYVhjRUNJSWRjTmt3N0xscHc0Wi9YQW9iM0lmc0dLcUNKSC9LWE1uUnA1VVlJSGEyWGVSL0dUdGFXV3BBeFNxalRjaUdBRGdsWlQ0T0xWY3FYZ0tIVnJZVW84WHdtdDBIbDZoZ0NYTDNnb0xHV0VxWjVuZ3BNYXZEUXltVHVveGIrbEE4OTBqNFNPMTNRdzRZS1VBOS82R3RmOU4vblZoeGNGU3pSNnBzQ0xQR2tmWjVUQ3k2eEhHazhIUUs5Z05IRzR2VjVYM3djWHNkaHZ2aFZoMUZ4SGMzYnZFdDlOdFNmSjZtQVJXbmZDblVLY0N4QUJKakRJdFp0T3d5dGJDbEVVY2VTYmlOMmxOdXNCUWVOMzNJZDhuaVA4NkE1YS9EZ1gvUDVHbGFYNnN4eUpCcFU4djZQRHFqQXhJV3psRlBoL1ZuUmQxNjBkRHVYeXIzUFlBa01MSnBqQWFaRlRVNUl1cFo0RVdpZmgrSmNsTENJdEFRWWZpd3YwSTJBaGdJYlBuN1JOcmRwMy9iVjJ2SE14Rkc3RVZMVGxTZ0FtYjNFZU1HbDhJMXhMVkR3dGoyd0FIU1hVZFJkQ1EzNm4rYzRwbU5UcUJLUDFjRXhhOU1BaUV5WThqb3liQ25LVkZEcDhPQjFOL1RiSXVjdkZLaFlXQVRQRjdydVZLWUZINEhpUTc5YjRYVjZ3UUpnZXI4SndPQVN3U0pkQzdBZUxrREZVVlFjRE0ySnp6dGZ6eVRwWnVUWExlRHcrWERWZHFJZWRtNUVxbnRQU2NXOWFEdGxPUmppR0xyN2tQMjJRQUdzZ3dXZkd3Y0c3M3N0TlBnY1pJS1U1clFyT0FBOVpNbGZLd25UVnA4MWVQQjYvSncyZWNNTy9jWkhHeXFBSHY1UVBhcGJoQzlMYmlWK0FOQUdDeDByWElDMU1kY1JwaHdMdVpaWVp4MWNZcHMwSndFWTU5aUM2NEFNdjl1amdZYXVkVDYrNEQ1a3VZUlByVjNaNTdydzVTQ3lTajVrS21zblc3WFBMWWhva0FCUUlHa05MUGg0TFdERXczVm9jS2ZCNTFuTGNlUjJDbERXZ21NK2h6cEFaTTZEWDdOcU9NWEF0N0dXSGhqell0L0hmQThGTFd6ajJEczk2TUczaFJDb2RCRUpCZ3BZbGh3THdGeEF4YlZRblI2NEZHMFhBQlByOUVNR3FJUXNMSnprc0luMVN5M3RZT1h6bHRyMUtkKzE2czJWMU54TFQ2Z2pON3BwZ0pCZjE4SWY2U3lBT2l6NDF6MHVJeDVmQnczK1dhc2pReFd0SGdjSHphWFhkZkM1OHpxcUs1cmxhZUs4V0k3RVpoaUg0TVhkbENtRWlPVnM4ZE5PV0JZQ1VaMWR3Y0xieU0xZlczRlhoTU5sbHZoVTRGSzBuZVU1ZE1BVWZRcklsRzZKL2JDeTMvd3FiTkw1NUM4RlFqaDArUGlVNzZndC9oWVNqbW8zYTAyMVhhNjVYRHVtUUtVM1B3SlpUem9LSUlPQzkxczZteElXdkh3Tk1MVC9lNkVCb0pyanlPMHFqa1BydHhjYzgvcTF1ME4yMW5aamJKeFVkQUVwSkRIMFdzSlltZHhLa2ZSRTI2MnNBUXVzQUFIUHNhQmMvSVl2TE9aYWVCM051ZkQvZXdGVDFCV1FLY3ZtK1E4NDVQTlZIYzNVU2Y1eXl5Z3dnMDVzRENrdFh3SlVjaWFWaFgxUVJ3S3NjQ1dWcDRkbFhpUjFXcllWODY4QlFzNm5GeFN4WElkRldiWWVHSzMvbDZBQllKYmpvTEpXQ05RS1Y3VDJTNjVqTmk4ekhkdlFlMFBqd3ZVWktpWk1JVXZOclJ3bVdJci9yWkliOFJNRW1uQ0pYd0E0T0dEVXV0WDhSaDAweGR3Z0FNR0FZeUVYdFoyMVRSMURrd1JSMGVTWTNRaXBsWXd0UUZBMFduWWxHaHkwTWV0NUZMNndyRkpldnoxOEdNQm8xbDl3Ry9UL0hHQjF4MUZ2MCs4NjB1Um1vQUpTamlRWStzMXBNbFNBRWl6SWE1UmVUMGlmOVRBb3RwK0hHeklVaXZYTE8wSkZmZW9qSlhCcmNBRndhSURSeWpJVTJPc1R0ZnI4L0l0Mm1HQVQ4ellpbEJHd2FDWmVIYlVwNGNDWDA5eGxISjBiQWZvZFNhdWU5ZzZUeGR2QUVqQUtJR1M5MmhQR2VxaFRCMGt2TUdwMXRiekdVdithMjVpZjk3TGptSmZYd1RHMVozVUtWMlN4TWF5VFlKQVc3UFIwNjBIQW9qa1dnTUVoZmMyVHQ5SzEwTGo4TTNjdWdPWWFEZ1lZYlN5bkhKTmZhN0NaSlZNcndDbmE1N3E4M0JYbkpsWGJUNktOZTV4YUFvd0VRVzZudWhMaFBHWVFhUU5DZm0wWDZpd2Q2M1VZQUxxZ1VRK1YrcUFoKzJuZlZqNFlPRmhWQU1CbXMzSEZ3dlkreElvbXNXSUhzTVQvMllLaHhHU1JCMm5BeFpRTGxqc1hPbDZGaTNBdlJka0NZSXFYTmZPRjF3Qk4wYjRDRFBtWkE0ZVgxUktwR2dSYWlkTWxxRXo5SG02NEl4ZDZUVFY0QUZBVHRLMndSdnRjQTRUODNBMktXRkN0cDRVazhWejZYY3hTWG1PcGJTdC9JNzh2aHdFT0svSTAvdzk1Y0d6L2FGZkY2Z0FBQUFCSlJVNUVya0pnZ2c9PSIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogPC9nPgo8L3N2Zz4K\",\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDM3NSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ1MTM5IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxZS03IC0yNjEzLjcgLTI2MTMuNyAtMWUtNyAyNjUyLjQgMzk0Mi44KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTE0MSIgc3RvcC1jb2xvcj0iI2M3ZTQ0YiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTE0MyIgc3RvcC1jb2xvcj0iIzYwYmE2NSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTEyMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoNDU2LjIyIDAgMCAtNDU2LjIyIDI0MjQuMyAzNDk0LjMpIj4KICAgPHN0b3AgaWQ9InN0b3A1MTIzIiBzdG9wLWNvbG9yPSIjY2FjOWM4IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1MTI1IiBzdG9wLWNvbG9yPSIjZjZmNmY2IiBvZmZzZXQ9Ii43NTI2OSIvPgogICA8c3RvcCBpZD0ic3RvcDUxMjciIHN0b3AtY29sb3I9IiNkNGQzZDIiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDUxMDEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEzNDUuNSAwIDAgLTEzNDUuNSAxOTc5LjYgMzA2NS45KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTEwMyIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTEwNSIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIuMSIvPgogICA8c3RvcCBpZD0ic3RvcDUxMDciIHN0b3AtY29sb3I9IiNmZWZmZmYiIG9mZnNldD0iLjI0NzMxIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTEwOSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTAyMyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMWUtNyAtMjYxMy43IC0yNjEzLjcgLTFlLTcgMjY1Mi40IDM5NDIuOCkiPgogICA8c3RvcCBpZD0ic3RvcDUwMjUiIHN0b3AtY29sb3I9IiNhNmQ3MWMiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDUwMjciIHN0b3AtY29sb3I9IiMyNjgwM2EiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDUwMDUiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiAyNDI0LjMgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNTAwNyIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTAwOSIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A1MDExIiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MTA5LTkiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MTExLTQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTIxOTguMiAxOTAuMDNjMCA2Ny4yMzEgMjAzLjMxIDEyMS43NyA0NTQuMTQgMTIxLjc3czQ1NC4xNC01NC41MzkgNDU0LjE0LTEyMS43N2MwLTY3LjMyLTIwMy4zMS0xMjEuODYtNDU0LjE0LTEyMS44NnMtNDU0LjE0IDU0LjU0My00NTQuMTQgMTIxLjg2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTEzNS03IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTEzNy04IiBkPSJtMjUyOS41IDE1MzEuN2MtMzAuNDYgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnYyMjc4LjhjMjguNDQgMzEuMTggNjcgNTMgMTEwLjMzIDYwLjE0di0yMzM4LjljMC0zMC40Ny0yNC43LTU1LjE2LTU1LjE3LTU1LjE2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTExNy0wIiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTExOS0zIiBkPSJtMjQ3NC4zIDM4NjUuNnYxODIzLjljMCAzMC40NiAyNC43IDU1LjE2IDU1LjE2IDU1LjE2IDMwLjQ3IDAgNTUuMTctMjQuNyA1NS4xNy01NS4xNnYtMTc2My44Yy00My4zMy03LjE0LTgxLjg5LTI4Ljk2LTExMC4zMy02MC4xNCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDUwOTctNiIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDUwOTktMyIgZD0ibTI2ODkuMSA1OTQxLjloLTczLjQ0Yy0xNTQuMzcgMC0yNzkuOTYtMTI1LjU5LTI3OS45Ni0yNzkuOTZ2LTQyMDUuNWMtMjE4LjExLTExNi4yMi0zNTYuMDctMzQzLjIyLTM1Ni4wNy01OTMuNyAwLTM3MC45NiAzMDEuNzktNjcyLjc1IDY3Mi43NS02NzIuNzUgMzcwLjk1IDAgNjcyLjc0IDMwMS43OSA2NzIuNzQgNjcyLjc1IDAgMjUwLjQ4LTEzNy45NSA0NzcuNDgtMzU2LjA3IDU5My43djQyMDUuNWMwIDE1NC4zNy0xMjUuNTkgMjc5Ljk2LTI3OS45NSAyNzkuOTZtMC04OC41OGMxMDUuMjYgMCAxOTEuMzgtODYuMTIgMTkxLjM4LTE5MS4zOHYtNDI2MS4yYzIwOS4yOS04OC44NSAzNTYuMDctMjk2LjI1IDM1Ni4wNy01MzcuOTQgMC0zMjIuNjMtMjYxLjU0LTU4NC4xOC01ODQuMTctNTg0LjE4LTMyMi42NCAwLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOCAwIDI0MS42OSAxNDYuNzkgNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyIDE5MS4zOCAxOTEuMzkgMTkxLjM4aDczLjQ0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTA4My01IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTA4NS01IiBkPSJtMjY1Mi40IDk1NS44OWMtMTg5LjE2IDAtMzQyLjUgOTcuMjE5LTM0Mi41IDIxNy4xNSAwIDExOS45MiAxNTMuMzQgMjE3LjE0IDM0Mi41IDIxNy4xNCAxMzkuNjUgMCAyNTkuNzctNTIuOTkgMzEzLjA5LTEyOC45OSAxOC45LTI2Ljk0IDI5LjQtNTYuNzYgMjkuNC04OC4xNSAwLTY2LjY3LTQ3LjQtMTI2LjMzLTEyMS45OS0xNjYuMTYtNTkuNTgtMzEuODEtMTM2LjUxLTUwLjk4OS0yMjAuNS01MC45ODkiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1MDY3LTQiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1MDY5LTciIGQ9Im0yODcyLjkgMTAwNi45Yzc0LjU5IDM5LjgzIDEyMS45OSA5OS40OSAxMjEuOTkgMTY2LjE2di0xLjAyYy0wLjQ4LTY2LjI2LTQ3Ljc2LTEyNS41MS0xMjEuOTktMTY1LjE0bTEyMS45OSAxNjYuMTZjMCAzMS4zOS0xMC41IDYxLjIxLTI5LjQgODguMTUgMTguNy0yNi42NSAyOS4xOC01Ni4xMyAyOS40LTg3LjE0di0xLjAxIi8+CiAgPC9jbGlwUGF0aD4KICA8cmFkaWFsR3JhZGllbnQgaWQ9InJhZGlhbEdyYWRpZW50NTA3MS02IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY3k9IjAiIGN4PSIwIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDU4NC4xOCAwIDAgLTU4NC4xOCAyNjUyLjQgODYyLjc1KSIgcj0iMSI+CiAgIDxzdG9wIGlkPSJzdG9wNTA3My01IiBzdG9wLWNvbG9yPSIjYTZkNzFjIiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1MDc1LTYiIHN0b3AtY29sb3I9IiMyNjgwM2EiIG9mZnNldD0iMSIvPgogIDwvcmFkaWFsR3JhZGllbnQ+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDUwNTEtOSIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDUwNTMtMyIgZD0ibTI5OTQuOSAxMTcydjEuMDIgMS4wMS0xLjAxLTEuMDIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1MDM1LTIiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1MDM3LTUiIGQ9Im0zMjM2LjUgODYyLjc1YzAtMzIyLjY0LTI2MS41NC01ODQuMTgtNTg0LjE3LTU4NC4xOC0zMjIuNjQgMC01ODQuMTggMjYxLjU0LTU4NC4xOCA1ODQuMTggMCAzMjIuNjMgMjYxLjU0IDU4NC4xOCA1ODQuMTggNTg0LjE4IDMyMi42MyAwIDU4NC4xNy0yNjEuNTUgNTg0LjE3LTU4NC4xOCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDUwMTktNCIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDUwMjEtMyIgZD0ibTI4ODAuNSAxNDAwLjdjLTcwLjA4IDI5Ljc2LTE0Ny4xNyA0Ni4yMi0yMjguMSA0Ni4yMi04MC45NCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjJ2MjMzNi4yYzAgNDkuNDQgMTkgOTQuNjYgNTAuMDYgMTI4Ljcydi0yMjc4LjhjMC0zMC40NyAyNC43LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ3IDAgNTUuMTcgMjQuNjkgNTUuMTcgNTUuMTZ2MjMzOC45YzEwLjA5IDEuNjYgMjAuNDUgMi41MiAzMSAyLjUyaDczLjQ0YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di0yMzM2LjIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1MDAxLTgiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1MDAzLTYiIGQ9Im0yODgwLjUgMzczNi45YzAgMTA1LjI2LTg2LjEyIDE5MS4zOC0xOTEuMzggMTkxLjM4aC03My40NGMtMTAuNTUgMC0yMC45MS0wLjg2LTMxLTIuNTJ2MTc2My44YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNyA1NS4xNi0zMC40NiAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTE4MjMuOWMtMzEuMDYtMzQuMDYtNTAuMDYtNzkuMjgtNTAuMDYtMTI4LjcydjE5MjVjMCAxMDUuMjYgODYuMTIgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDRjMTA1LjI2IDAgMTkxLjM4LTg2LjEyIDE5MS4zOC0xOTEuMzh2LTE5MjUiLz4KICA8L2NsaXBQYXRoPgogPC9kZWZzPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI1Ny4wNiAtMjAwLjk3KSI+CiAgPGcgZmlsbD0iI2ZmZiI+CiAgIDxwYXRoIGlkPSJwYXRoNDcxNSIgZD0ibTM0NS41OCA5NDguMTRjLTQ4Ljg5IDAtODguNTIyLTEwLjYzMi04OC41MjItMjMuNzUgMC03LjA4NjQgMTEuNTcxLTEzLjQ0OSAyOS45MTktMTcuOCAwLjAzNSAwLjAyOTggMC4wNjc1IDAuMDU4NSAwLjEwMjUgMC4wODk3LTE4LjIyIDQuMzM1LTI5LjcgMTAuNjYzLTI5LjcgMTcuNzEgMCAxMy4wNjkgMzkuNDg5IDIzLjY2NCA4OC4yMDEgMjMuNjY0IDQ4LjcxMSAwIDg4LjIwMS0xMC41OTUgODguMjAxLTIzLjY2NCAwLTcuMDQ3NC0xMS40ODEtMTMuMzc1LTI5LjcwMS0xNy43MSAwLjAzNS0wLjAzMTIgMC4wNjctMC4wNiAwLjEwMjUtMC4wODk3IDE4LjM0OCA0LjM1MSAyOS45MTkgMTAuNzEzIDI5LjkxOSAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjEgMjMuNzUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzE3IiBkPSJtMzQ1LjU4IDk0OC4wNWMtNDguNzEyIDAtODguMjAxLTEwLjU5NS04OC4yMDEtMjMuNjY0IDAtNy4wNDc0IDExLjQ4LTEzLjM3NSAyOS43LTE3LjcxIDAuMDMzNyAwLjAyODkgMC4wNyAwLjA2MTEgMC4xMDI1IDAuMDkxNC0xOC4wODkgNC4zMTc0LTI5LjQ4MiAxMC42MTEtMjkuNDgyIDE3LjYxOSAwIDEzLjAyMSAzOS4zNDYgMjMuNTc4IDg3Ljg4MSAyMy41NzhzODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OGMwLTcuMDA3NC0xMS4zOTQtMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzMtMC4wMzAyIDAuMDctMC4wNjI1IDAuMTAyNS0wLjA5MTQgMTguMjIgNC4zMzUgMjkuNzAxIDEwLjY2MyAyOS43MDEgMTcuNzEgMCAxMy4wNjktMzkuNDkgMjMuNjY0LTg4LjIwMSAyMy42NjQiLz4KICAgPHBhdGggaWQ9InBhdGg0NzE5IiBkPSJtMzQ1LjU4IDk0Ny45NmMtNDguNTM1IDAtODcuODgxLTEwLjU1Ny04Ny44ODEtMjMuNTc4IDAtNy4wMDc0IDExLjM5NC0xMy4zMDEgMjkuNDgyLTE3LjYxOSAwLjAzNjIgMC4wMjk3IDAuMDY4NyAwLjA1ODYgMC4xMDM3NSAwLjA4OTktMTcuOTYgNC4zMDI2LTI5LjI2NSAxMC41NjItMjkuMjY1IDE3LjUyOSAwIDEyLjk3NCAzOS4yMDEgMjMuNDkxIDg3LjU2IDIzLjQ5MSA0OC4zNTggMCA4Ny41NTktMTAuNTE4IDg3LjU1OS0yMy40OTEgMC02Ljk2NzItMTEuMzA1LTEzLjIyNi0yOS4yNjUtMTcuNTI5IDAuMDM2LTAuMDMxMiAwLjA2OS0wLjA2MDEgMC4xMDM4LTAuMDg5OSAxOC4wODkgNC4zMTc0IDI5LjQ4MiAxMC42MTEgMjkuNDgyIDE3LjYxOSAwIDEzLjAyMS0zOS4zNDUgMjMuNTc4LTg3Ljg4IDIzLjU3OCIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjEiIGQ9Im0zNDUuNTggOTQ3Ljg4Yy00OC4zNTkgMC04Ny41Ni0xMC41MTgtODcuNTYtMjMuNDkxIDAtNi45NjcyIDExLjMwNS0xMy4yMjYgMjkuMjY1LTE3LjUyOSAwLjAzMjUgMC4wMjg4IDAuMDcgMC4wNjEgMC4xMDM3NSAwLjA4OTctMTcuODMgNC4yODY2LTI5LjA0OCAxMC41MTEtMjkuMDQ4IDE3LjQzOSAwIDEyLjkyNiAzOS4wNTggMjMuNDA2IDg3LjIzOSAyMy40MDYgNDguMTggMCA4Ny4yMzktMTAuNDggODcuMjM5LTIzLjQwNiAwLTYuOTI3OC0xMS4yMTktMTMuMTUyLTI5LjA0OC0xNy40MzkgMC4wMzMtMC4wMjg3IDAuMDctMC4wNjEgMC4xMDI1LTAuMDg5NyAxNy45NiA0LjMwMjYgMjkuMjY1IDEwLjU2MiAyOS4yNjUgMTcuNTI5IDAgMTIuOTc0LTM5LjIwMSAyMy40OTEtODcuNTU5IDIzLjQ5MSIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjMiIGQ9Im0zNDUuNTggOTQ3Ljc5Yy00OC4xODEgMC04Ny4yMzktMTAuNDgtODcuMjM5LTIzLjQwNiAwLTYuOTI3OCAxMS4yMTgtMTMuMTUyIDI5LjA0OC0xNy40MzkgMC4wMzUgMC4wMzEyIDAuMDY3NSAwLjA2MDEgMC4xMDM3NSAwLjA5MTQtMTcuNzAxIDQuMjY5LTI4LjgzMSAxMC40NTktMjguODMxIDE3LjM0OCAwIDEyLjg3OSAzOC45MTUgMjMuMzIgODYuOTE5IDIzLjMyIDQ4LjAwMyAwIDg2LjkxOC0xMC40NDEgODYuOTE4LTIzLjMyIDAtNi44ODg2LTExLjEzLTEzLjA3OS0yOC44MzEtMTcuMzQ4IDAuMDM2LTAuMDMxMiAwLjA2OS0wLjA2MDEgMC4xMDUtMC4wOTE0IDE3LjgyOSA0LjI4NjYgMjkuMDQ4IDEwLjUxMSAyOS4wNDggMTcuNDM5IDAgMTIuOTI2LTM5LjA1OSAyMy40MDYtODcuMjM5IDIzLjQwNiIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjUiIGQ9Im0zNDUuNTggOTQ3LjcxYy00OC4wMDQgMC04Ni45MTktMTAuNDQxLTg2LjkxOS0yMy4zMiAwLTYuODg4NiAxMS4xMy0xMy4wNzkgMjguODMxLTE3LjM0OCAwLjAzNSAwLjAzMTIgMC4wNjg3IDAuMDYgMC4xMDM3NSAwLjA5MDItMTcuNTY5IDQuMjUyNi0yOC42MTQgMTAuNDEtMjguNjE0IDE3LjI1NyAwIDEyLjgzMSAzOC43NyAyMy4yMzQgODYuNTk4IDIzLjIzNCA0Ny44MjYgMCA4Ni41OTgtMTAuNDAzIDg2LjU5OC0yMy4yMzQgMC02Ljg0NzYtMTEuMDQ2LTEzLjAwNS0yOC42MTUtMTcuMjU3IDAuMDM1LTAuMDMwMyAwLjA2OS0wLjA1OSAwLjEwMzctMC4wOTAyIDE3LjcwMSA0LjI2OSAyOC44MzEgMTAuNDU5IDI4LjgzMSAxNy4zNDggMCAxMi44NzktMzguOTE1IDIzLjMyLTg2LjkxOCAyMy4zMiIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjciIGQ9Im0zNDUuNTggOTQ3LjYyYy00Ny44MjggMC04Ni41OTgtMTAuNDAzLTg2LjU5OC0yMy4yMzQgMC02Ljg0NzYgMTEuMDQ1LTEzLjAwNSAyOC42MTQtMTcuMjU3IDAuMDMzNyAwLjAyOTkgMC4wNzEyIDAuMDYyNSAwLjEwNSAwLjA5MTQtMTcuNDQxIDQuMjM0OS0yOC4zOTkgMTAuMzU3LTI4LjM5OSAxNy4xNjYgMCAxMi43ODQgMzguNjI4IDIzLjE0NyA4Ni4yNzggMjMuMTQ3IDQ3LjY0OSAwIDg2LjI3Ni0xMC4zNjQgODYuMjc2LTIzLjE0NyAwLTYuODA4Ni0xMC45NTgtMTIuOTMxLTI4LjM5OS0xNy4xNjYgMC4wMzQtMC4wMjg5IDAuMDcxLTAuMDYxNSAwLjEwNS0wLjA5MTQgMTcuNTY5IDQuMjUyNSAyOC42MTUgMTAuNDEgMjguNjE1IDE3LjI1NyAwIDEyLjgzMS0zOC43NzEgMjMuMjM0LTg2LjU5OCAyMy4yMzQiLz4KICAgPHBhdGggaWQ9InBhdGg0NzI5IiBkPSJtMzQ1LjU4IDk0Ny41M2MtNDcuNjUgMC04Ni4yNzgtMTAuMzY0LTg2LjI3OC0yMy4xNDcgMC02LjgwODYgMTAuOTU4LTEyLjkzMSAyOC4zOTktMTcuMTY2IDAuMDM1IDAuMDMwOCAwLjA2ODcgMC4wNTk1IDAuMTA1IDAuMDkwOC0xNy4zMTEgNC4yMTg3LTI4LjE4MiAxMC4zMDctMjguMTgyIDE3LjA3NSAwIDEyLjczNiAzOC40ODQgMjMuMDYxIDg1Ljk1NiAyMy4wNjEgNDcuNDcxIDAgODUuOTU1LTEwLjMyNSA4NS45NTUtMjMuMDYxIDAtNi43Njg1LTEwLjg2OC0xMi44NTYtMjguMTgxLTE3LjA3NSAwLjAzNS0wLjAzMTIgMC4wNjktMC4wNiAwLjEwMzgtMC4wOTA4IDE3LjQ0MSA0LjIzNDkgMjguMzk5IDEwLjM1NyAyOC4zOTkgMTcuMTY2IDAgMTIuNzg0LTM4LjYyOCAyMy4xNDctODYuMjc2IDIzLjE0NyIvPgogICA8cGF0aCBpZD0icGF0aDQ3MzEiIGQ9Im0zNDUuNTggOTQ3LjQ1Yy00Ny40NzIgMC04NS45NTYtMTAuMzI1LTg1Ljk1Ni0yMy4wNjEgMC02Ljc2ODUgMTAuODcxLTEyLjg1NiAyOC4xODItMTcuMDc1IDAuMDM1IDAuMDMwNCAwLjA2ODcgMC4wNjAxIDAuMTA1IDAuMDkwNC0xNy4xODQgNC4yMDIxLTI3Ljk2NiAxMC4yNTYtMjcuOTY2IDE2Ljk4NSAwIDEyLjY4OCAzOC4zNCAyMi45NzYgODUuNjM1IDIyLjk3NnM4NS42MzUtMTAuMjg4IDg1LjYzNS0yMi45NzZjMC02LjcyOS0xMC43ODQtMTIuNzgzLTI3Ljk2OC0xNi45ODUgMC4wMzYtMC4wMzAzIDAuMDctMC4wNiAwLjEwNjItMC4wOTA0IDE3LjMxNCA0LjIxODggMjguMTgxIDEwLjMwNyAyOC4xODEgMTcuMDc1IDAgMTIuNzM2LTM4LjQ4NCAyMy4wNjEtODUuOTU1IDIzLjA2MSIvPgogICA8cGF0aCBpZD0icGF0aDQ3MzMiIGQ9Im0zNDUuNTggOTQ3LjM2Yy00Ny4yOTUgMC04NS42MzUtMTAuMjg4LTg1LjYzNS0yMi45NzYgMC02LjcyOSAxMC43ODItMTIuNzgzIDI3Ljk2Ni0xNi45ODUgMC4wMzM3IDAuMDI4OSAwLjA3MTIgMC4wNjI1IDAuMTA1IDAuMDkxNC0xNy4wNTQgNC4xODQ1LTI3Ljc1MSAxMC4yMDQtMjcuNzUxIDE2Ljg5MyAwIDEyLjY0MSAzOC4xOTYgMjIuODkgODUuMzE1IDIyLjg5IDQ3LjExOCAwIDg1LjMxNC0xMC4yNDkgODUuMzE0LTIyLjg5IDAtNi42ODktMTAuNjk4LTEyLjcwOS0yNy43NTEtMTYuODk0IDAuMDM0LTAuMDI4OSAwLjA3Mi0wLjA2MjUgMC4xMDUtMC4wOTE0IDE3LjE4NCA0LjIwMjEgMjcuOTY4IDEwLjI1NiAyNy45NjggMTYuOTg1IDAgMTIuNjg4LTM4LjM0IDIyLjk3Ni04NS42MzUgMjIuOTc2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDczNSIgZD0ibTM0NS41OCA5NDcuMjhjLTQ3LjExOSAwLTg1LjMxNS0xMC4yNDktODUuMzE1LTIyLjg5IDAtNi42ODkgMTAuNjk4LTEyLjcwOSAyNy43NTEtMTYuODk0IDAuMDM2MiAwLjAyOTggMC4wNyAwLjA1OTUgMC4xMDYyNSAwLjA4OTctMTYuOTI1IDQuMTY5LTI3LjUzNiAxMC4xNTQtMjcuNTM2IDE2LjgwNCAwIDEyLjU5NCAzOC4wNTIgMjIuODA0IDg0Ljk5NCAyMi44MDQgNDYuOTQgMCA4NC45OTQtMTAuMjEgODQuOTk0LTIyLjgwNCAwLTYuNjQ5OS0xMC42MTItMTIuNjM1LTI3LjUzOC0xNi44MDQgMC4wMzYtMC4wMzAzIDAuMDctMC4wNiAwLjEwNjMtMC4wODk3IDE3LjA1NCA0LjE4NDUgMjcuNzUxIDEwLjIwNCAyNy43NTEgMTYuODk0IDAgMTIuNjQxLTM4LjE5NiAyMi44OS04NS4zMTQgMjIuODkiLz4KICAgPHBhdGggaWQ9InBhdGg0NzM3IiBkPSJtMzQ1LjU4IDk0Ny4xOWMtNDYuOTQxIDAtODQuOTk0LTEwLjIxLTg0Ljk5NC0yMi44MDQgMC02LjY0OTkgMTAuNjExLTEyLjYzNSAyNy41MzYtMTYuODA0IDAuMDM1IDAuMDMxMiAwLjA3IDAuMDYwMSAwLjEwNjI1IDAuMDkxNC0xNi43OTYgNC4xNTE0LTI3LjMyMiAxMC4xMDItMjcuMzIyIDE2LjcxMiAwIDEyLjU0NiAzNy45MSAyMi43MTcgODQuNjc0IDIyLjcxNyA0Ni43NjMgMCA4NC42NzMtMTAuMTcxIDg0LjY3My0yMi43MTcgMC02LjYwOTktMTAuNTI2LTEyLjU2MS0yNy4zMjItMTYuNzEyIDAuMDM2LTAuMDMxMiAwLjA3MS0wLjA2MDEgMC4xMDYyLTAuMDkxNCAxNi45MjUgNC4xNjkgMjcuNTM4IDEwLjE1NCAyNy41MzggMTYuODA0IDAgMTIuNTk0LTM4LjA1NCAyMi44MDQtODQuOTk0IDIyLjgwNCIvPgogICA8cGF0aCBpZD0icGF0aDQ3MzkiIGQ9Im0zNDUuNTggOTQ3LjFjLTQ2Ljc2NCAwLTg0LjY3NC0xMC4xNzEtODQuNjc0LTIyLjcxNyAwLTYuNjA5OSAxMC41MjYtMTIuNTYxIDI3LjMyMi0xNi43MTIgMC4wMzYyIDAuMDI5NyAwLjA3IDAuMDYgMC4xMDYyNSAwLjA4OTktMTYuNjY5IDQuMTM1Mi0yNy4xMDggMTAuMDUzLTI3LjEwOCAxNi42MjIgMCAxMi40OTkgMzcuNzY2IDIyLjYzMSA4NC4zNTIgMjIuNjMxczg0LjM1MS0xMC4xMzMgODQuMzUxLTIyLjYzMWMwLTYuNTY5OS0xMC40MzktMTIuNDg3LTI3LjEwOC0xNi42MjIgMC4wMzYtMC4wMjk5IDAuMDcxLTAuMDYwMSAwLjEwNjMtMC4wODk5IDE2Ljc5NiA0LjE1MTQgMjcuMzIyIDEwLjEwMiAyNy4zMjIgMTYuNzEyIDAgMTIuNTQ2LTM3LjkxIDIyLjcxNy04NC42NzMgMjIuNzE3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDc0MSIgZD0ibTM0NS41OCA5NDcuMDJjLTQ2LjU4NiAwLTg0LjM1Mi0xMC4xMzMtODQuMzUyLTIyLjYzMSAwLTYuNTY5OSAxMC40MzktMTIuNDg3IDI3LjEwOC0xNi42MjIgMC4wMzYyIDAuMDMxMiAwLjA3MTIgMC4wNjE1IDAuMTA3NSAwLjA5MTMtMTYuNTQxIDQuMTE3Ni0yNi44OTQgMTAtMjYuODk0IDE2LjUzMSAwIDEyLjQ1MSAzNy42MjEgMjIuNTQ1IDg0LjAzMSAyMi41NDUgNDYuNDA5IDAgODQuMDMxLTEwLjA5NCA4NC4wMzEtMjIuNTQ1IDAtNi41MzEyLTEwLjM1NC0xMi40MTQtMjYuODk1LTE2LjUzMSAwLjAzNi0wLjAyOTggMC4wNzEtMC4wNiAwLjEwNzUtMC4wOTEzIDE2LjY2OSA0LjEzNTIgMjcuMTA4IDEwLjA1MyAyNy4xMDggMTYuNjIyIDAgMTIuNDk5LTM3Ljc2NSAyMi42MzEtODQuMzUxIDIyLjYzMSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NDMiIGQ9Im0zNDUuNTggOTQ2LjkzYy00Ni40MSAwLTg0LjAzMS0xMC4wOTQtODQuMDMxLTIyLjU0NSAwLTYuNTMxMiAxMC4zNTItMTIuNDE0IDI2Ljg5NC0xNi41MzEgMC4wMzUgMC4wMjk3IDAuMDcxMiAwLjA2IDAuMTA3NSAwLjA4OTktMTYuNDE1IDQuMTAxNS0yNi42ODEgOS45NTAxLTI2LjY4MSAxNi40NDEgMCAxMi40MDQgMzcuNDc5IDIyLjQ1OSA4My43MTEgMjIuNDU5IDQ2LjIzMSAwIDgzLjcxLTEwLjA1NSA4My43MS0yMi40NTkgMC02LjQ5MTItMTAuMjY2LTEyLjM0LTI2LjY4LTE2LjQ0MSAwLjAzNS0wLjAyOTkgMC4wNzEtMC4wNjAxIDAuMTA2Mi0wLjA4OTkgMTYuNTQxIDQuMTE3NiAyNi44OTUgMTAgMjYuODk1IDE2LjUzMSAwIDEyLjQ1MS0zNy42MjIgMjIuNTQ1LTg0LjAzMSAyMi41NDUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzQ1IiBkPSJtMzQ1LjU4IDk0Ni44NGMtNDYuMjMyIDAtODMuNzExLTEwLjA1NS04My43MTEtMjIuNDU5IDAtNi40OTEyIDEwLjI2Ni0xMi4zNCAyNi42ODEtMTYuNDQxIDAuMDM1IDAuMDMxMiAwLjA3MTIgMC4wNjE1IDAuMTA3NSAwLjA5MTItMTYuMjg5IDQuMDg0LTI2LjQ2OCA5Ljg5NzUtMjYuNDY4IDE2LjM1IDAgMTIuMzU2IDM3LjMzNSAyMi4zNzMgODMuMzkgMjIuMzczczgzLjM4OS0xMC4wMTYgODMuMzg5LTIyLjM3M2MwLTYuNDUyNi0xMC4xNzktMTIuMjY2LTI2LjQ2Ni0xNi4zNSAwLjAzNS0wLjAyOTcgMC4wNzEtMC4wNiAwLjEwNzUtMC4wOTEyIDE2LjQxNCA0LjEwMTUgMjYuNjggOS45NTAxIDI2LjY4IDE2LjQ0MSAwIDEyLjQwNC0zNy40NzkgMjIuNDU5LTgzLjcxIDIyLjQ1OSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NDciIGQ9Im0zNDUuNTggOTQ2Ljc2Yy00Ni4wNTUgMC04My4zOS0xMC4wMTYtODMuMzktMjIuMzczIDAtNi40NTI2IDEwLjE3OS0xMi4yNjYgMjYuNDY4LTE2LjM1IDAuMDM1IDAuMDMwMyAwLjA3MTIgMC4wNjAxIDAuMTA3NSAwLjA5MTQtMTYuMTU5IDQuMDY2NC0yNi4yNTQgOS44NDYxLTI2LjI1NCAxNi4yNTkgMCAxMi4zMDkgMzcuMTkgMjIuMjg4IDgzLjA2OSAyMi4yODggNDUuODc4IDAgODMuMDY5LTkuOTc5IDgzLjA2OS0yMi4yODggMC02LjQxMjYtMTAuMDk2LTEyLjE5Mi0yNi4yNTUtMTYuMjU5IDAuMDM2LTAuMDMxMiAwLjA3Mi0wLjA2MTEgMC4xMDg4LTAuMDkxNCAxNi4yODggNC4wODQgMjYuNDY2IDkuODk3NSAyNi40NjYgMTYuMzUgMCAxMi4zNTYtMzcuMzM0IDIyLjM3My04My4zODkgMjIuMzczIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc0OSIgZD0ibTM0NS41OCA5NDYuNjdjLTQ1Ljg3OSAwLTgzLjA2OS05Ljk3OS04My4wNjktMjIuMjg4IDAtNi40MTI2IDEwLjA5NS0xMi4xOTIgMjYuMjU0LTE2LjI1OSAwLjAzNjIgMC4wMzAzIDAuMDcyNSAwLjA2IDAuMTA4NzUgMC4wODk4LTE2LjAzMSA0LjA0ODktMjYuMDQyIDkuNzk2NS0yNi4wNDIgMTYuMTY5IDAgMTIuMjYxIDM3LjA0OCAyMi4yMDEgODIuNzQ5IDIyLjIwMSA0NS43IDAgODIuNzQ4LTkuOTQgODIuNzQ4LTIyLjIwMSAwLTYuMzcyNS0xMC4wMTEtMTIuMTItMjYuMDQyLTE2LjE2OSAwLjAzNi0wLjAyOTggMC4wNzItMC4wNTk1IDAuMTA4Ny0wLjA4OTggMTYuMTU5IDQuMDY2NCAyNi4yNTUgOS44NDYxIDI2LjI1NSAxNi4yNTkgMCAxMi4zMDktMzcuMTkxIDIyLjI4OC04My4wNjkgMjIuMjg4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDc1MSIgZD0ibTM0NS41OCA5NDYuNTljLTQ1LjcwMSAwLTgyLjc0OS05Ljk0LTgyLjc0OS0yMi4yMDEgMC02LjM3MjUgMTAuMDExLTEyLjEyIDI2LjA0Mi0xNi4xNjkgMC4wMzYzIDAuMDMwNCAwLjA3MjUgMC4wNjE2IDAuMTA4NzUgMC4wOTE0LTE1LjkwNiA0LjAzMTItMjUuODMgOS43NDQxLTI1LjgzIDE2LjA3OCAwIDEyLjIxNCAzNi45MDQgMjIuMTE1IDgyLjQyOCAyMi4xMTUgNDUuNTIzIDAgODIuNDI2LTkuOTAwOSA4Mi40MjYtMjIuMTE1IDAtNi4zMzM1LTkuOTIzNy0xMi4wNDYtMjUuODMtMTYuMDc4IDAuMDM2LTAuMDI5OCAwLjA3NC0wLjA2MSAwLjEwODgtMC4wOTE0IDE2LjAzMSA0LjA0ODkgMjYuMDQyIDkuNzk2NSAyNi4wNDIgMTYuMTY5IDAgMTIuMjYxLTM3LjA0OCAyMi4yMDEtODIuNzQ4IDIyLjIwMSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NTMiIGQ9Im0zNDUuNTggOTQ2LjVjLTQ1LjUyNCAwLTgyLjQyOC05LjkwMDktODIuNDI4LTIyLjExNSAwLTYuMzMzNSA5LjkyMzgtMTIuMDQ2IDI1LjgzLTE2LjA3OCAwLjAzNjIgMC4wMzAzIDAuMDcyNSAwLjA2MTUgMC4xMDg3NSAwLjA5MTMtMTUuNzc5IDQuMDEzNy0yNS42MTkgOS42OTE0LTI1LjYxOSAxNS45ODYgMCAxMi4xNjYgMzYuNzYxIDIyLjAyOSA4Mi4xMDggMjIuMDI5IDQ1LjM0NiAwIDgyLjEwNi05Ljg2MjggODIuMTA2LTIyLjAyOSAwLTYuMjk0OS05Ljg0LTExLjk3My0yNS42MTktMTUuOTg2IDAuMDM2LTAuMDI5NyAwLjA3Mi0wLjA2MSAwLjEwODctMC4wOTEzIDE1LjkwNiA0LjAzMTIgMjUuODMgOS43NDQxIDI1LjgzIDE2LjA3OCAwIDEyLjIxNC0zNi45MDQgMjIuMTE1LTgyLjQyNiAyMi4xMTUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzU1IiBkPSJtMzQ1LjU4IDk0Ni40MWMtNDUuMzQ2IDAtODIuMTA4LTkuODYyOC04Mi4xMDgtMjIuMDI5IDAtNi4yOTQ5IDkuODQtMTEuOTczIDI1LjYxOS0xNS45ODYgMC4wMzYzIDAuMDMwNCAwLjA3MzggMC4wNjAxIDAuMTEgMC4wODk5LTE1LjY1MiAzLjk5NzYtMjUuNDA4IDkuNjQxNi0yNS40MDggMTUuODk2IDAgMTIuMTE5IDM2LjYxNiAyMS45NDIgODEuNzg2IDIxLjk0MiA0NS4xNjkgMCA4MS43ODUtOS44MjM3IDgxLjc4NS0yMS45NDIgMC02LjI1NDktOS43NTM4LTExLjg5OS0yNS40MDYtMTUuODk2IDAuMDM2LTAuMDI5NyAwLjA3Mi0wLjA1OTUgMC4xMDg4LTAuMDg5OSAxNS43NzkgNC4wMTM4IDI1LjYxOSA5LjY5MTUgMjUuNjE5IDE1Ljk4NiAwIDEyLjE2Ni0zNi43NiAyMi4wMjktODIuMTA2IDIyLjAyOSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NTciIGQ9Im0zNDUuNTggOTQ2LjMzYy00NS4xNyAwLTgxLjc4Ni05LjgyMzctODEuNzg2LTIxLjk0MiAwLTYuMjU0OSA5Ljc1NS0xMS44OTkgMjUuNDA4LTE1Ljg5NiAwLjAzNjIgMC4wMzAzIDAuMDcyNSAwLjA2MTUgMC4xMDg3NSAwLjA5MTQtMTUuNTI2IDMuOTc5OS0yNS4xOTUgOS41ODg5LTI1LjE5NSAxNS44MDUgMCAxMi4wNzEgMzYuNDcyIDIxLjg1NiA4MS40NjUgMjEuODU2IDQ0Ljk5MSAwIDgxLjQ2NS05Ljc4NTIgODEuNDY1LTIxLjg1NiAwLTYuMjE2Mi05LjY3LTExLjgyNS0yNS4xOTYtMTUuODA1IDAuMDM2LTAuMDI5OSAwLjA3NC0wLjA2MTEgMC4xMS0wLjA5MTQgMTUuNjUyIDMuOTk3NiAyNS40MDYgOS42NDE2IDI1LjQwNiAxNS44OTYgMCAxMi4xMTktMzYuNjE2IDIxLjk0Mi04MS43ODUgMjEuOTQyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc1OSIgZD0ibTM0NS41OCA5NDYuMjRjLTQ0Ljk5MiAwLTgxLjQ2NS05Ljc4NTItODEuNDY1LTIxLjg1NiAwLTYuMjE2MiA5LjY2ODgtMTEuODI1IDI1LjE5NS0xNS44MDUgMC4wMzYzIDAuMDMwMiAwLjA3NSAwLjA2MTUgMC4xMTEyNSAwLjA5MTItMTUuMzk5IDMuOTYxNC0yNC45ODYgOS41Mzc3LTI0Ljk4NiAxNS43MTQgMCAxMi4wMjQgMzYuMzMgMjEuNzcgODEuMTQ1IDIxLjc3IDQ0LjgxNCAwIDgxLjE0NC05Ljc0NjEgODEuMTQ0LTIxLjc3IDAtNi4xNzYyLTkuNTg3NS0xMS43NTItMjQuOTg2LTE1LjcxNCAwLjAzOC0wLjAyOTcgMC4wNzUtMC4wNjEgMC4xMTEyLTAuMDkxMiAxNS41MjYgMy45Nzk5IDI1LjE5NiA5LjU4ODkgMjUuMTk2IDE1LjgwNSAwIDEyLjA3MS0zNi40NzQgMjEuODU2LTgxLjQ2NSAyMS44NTYiLz4KICAgPHBhdGggaWQ9InBhdGg0NzYxIiBkPSJtMzQ1LjU4IDk0Ni4xNmMtNDQuODE1IDAtODEuMTQ1LTkuNzQ2MS04MS4xNDUtMjEuNzcgMC02LjE3NjIgOS41ODc1LTExLjc1MiAyNC45ODYtMTUuNzE0IDAuMDM2MiAwLjAyODkgMC4wNzM3IDAuMDYwMSAwLjExIDAuMDkwNC0xNS4yNzIgMy45NDQ4LTI0Ljc3NSA5LjQ4NTgtMjQuNzc1IDE1LjYyNCAwIDExLjk3NiAzNi4xODYgMjEuNjg1IDgwLjgyNCAyMS42ODVzODAuODIzLTkuNzA5IDgwLjgyMy0yMS42ODVjMC02LjEzNzgtOS41MDI1LTExLjY3OS0yNC43NzUtMTUuNjI0IDAuMDM2LTAuMDMwMyAwLjA3NC0wLjA2MTUgMC4xMS0wLjA5MDQgMTUuMzk5IDMuOTYxNCAyNC45ODYgOS41Mzc2IDI0Ljk4NiAxNS43MTQgMCAxMi4wMjQtMzYuMzMgMjEuNzctODEuMTQ0IDIxLjc3Ii8+CiAgPC9nPgogIDxnPgogICA8cGF0aCBpZD0icGF0aDQ3NjMiIGQ9Im0zNDUuNTggOTQ2LjA3Yy00NC42MzggMC04MC44MjQtOS43MDktODAuODI0LTIxLjY4NSAwLTYuMTM3OCA5LjUwMjUtMTEuNjc5IDI0Ljc3NS0xNS42MjQgMC4wMzg4IDAuMDMwOCAwLjA3MjUgMC4wNTk1IDAuMTExMjUgMC4wOTA3LTE1LjE0NiAzLjkyNzgtMjQuNTY2IDkuNDM1MS0yNC41NjYgMTUuNTMzIDAgMTEuOTI5IDM2LjA0MiAyMS41OTkgODAuNTA0IDIxLjU5OSA0NC40NiAwIDgwLjUwMy05LjY2OTkgODAuNTAzLTIxLjU5OSAwLTYuMDk3Ni05LjQyLTExLjYwNS0yNC41NjYtMTUuNTMzIDAuMDM5LTAuMDMxMiAwLjA3Mi0wLjA2IDAuMTExMy0wLjA5MDcgMTUuMjcyIDMuOTQ0OCAyNC43NzUgOS40ODU4IDI0Ljc3NSAxNS42MjQgMCAxMS45NzYtMzYuMTg1IDIxLjY4NS04MC44MjMgMjEuNjg1IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc2NSIgZD0ibTM0NS41OCA5NDUuOThjLTQ0LjQ2MSAwLTgwLjUwNC05LjY2OTktODAuNTA0LTIxLjU5OSAwLTYuMDk3NiA5LjQyLTExLjYwNSAyNC41NjYtMTUuNTMzIDAuMDM2MiAwLjAzMDMgMC4wNzUgMC4wNjE1IDAuMTExMjUgMC4wOTE0LTE1LjAyMiAzLjkwODYtMjQuMzU2IDkuMzgyNy0yNC4zNTYgMTUuNDQxIDAgMTEuODgxIDM1Ljg5OSAyMS41MTMgODAuMTgyIDIxLjUxM3M4MC4xODEtOS42MzE0IDgwLjE4MS0yMS41MTNjMC02LjA1ODYtOS4zMzM3LTExLjUzMy0yNC4zNTYtMTUuNDQxIDAuMDM2LTAuMDI5OSAwLjA3NS0wLjA2MTEgMC4xMTEyLTAuMDkxNCAxNS4xNDYgMy45Mjc4IDI0LjU2NiA5LjQzNTEgMjQuNTY2IDE1LjUzMyAwIDExLjkyOS0zNi4wNDIgMjEuNTk5LTgwLjUwMyAyMS41OTkiIGZpbGw9IiNmZWZmZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0NzY3IiBkPSJtMzQ1LjU4IDk0NS45Yy00NC4yODQgMC04MC4xODItOS42MzE0LTgwLjE4Mi0yMS41MTMgMC02LjA1ODYgOS4zMzM4LTExLjUzMyAyNC4zNTYtMTUuNDQxIDAuMDM2MiAwLjAyODcgMC4wNzUgMC4wNjE1IDAuMTExMjUgMC4wOTAyLTE0Ljg5NSAzLjg5MjEtMjQuMTQ2IDkuMzMyNi0yNC4xNDYgMTUuMzUxIDAgMTEuODM0IDM1Ljc1NSAyMS40MjYgNzkuODYxIDIxLjQyNnM3OS44NjEtOS41OTIzIDc5Ljg2MS0yMS40MjZjMC02LjAxODUtOS4yNTEyLTExLjQ1OS0yNC4xNDgtMTUuMzUxIDAuMDM2LTAuMDI4NyAwLjA3NS0wLjA2MTUgMC4xMTEzLTAuMDkwMiAxNS4wMjIgMy45MDg2IDI0LjM1NiA5LjM4MjggMjQuMzU2IDE1LjQ0MSAwIDExLjg4MS0zNS44OTkgMjEuNTEzLTgwLjE4MSAyMS41MTMiIGZpbGw9IiNmZWZlZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0NzY5IiBkPSJtMzQ1LjU4IDk0NS44MWMtNDQuMTA2IDAtNzkuODYxLTkuNTkyMy03OS44NjEtMjEuNDI2IDAtNi4wMTg1IDkuMjUxMi0xMS40NTkgMjQuMTQ2LTE1LjM1MSAwLjAzODcgMC4wMzEyIDAuMDczOCAwLjA2MDEgMC4xMTI1IDAuMDkwOS0xNC43NyAzLjg3NTUtMjMuOTM5IDkuMjgwMi0yMy45MzkgMTUuMjYgMCAxMS43ODYgMzUuNjExIDIxLjM0IDc5LjU0MSAyMS4zNCA0My45MjkgMCA3OS41NC05LjU1MzcgNzkuNTQtMjEuMzQgMC01Ljk4LTkuMTY3NS0xMS4zODUtMjMuOTM5LTE1LjI2IDAuMDM5LTAuMDMwOCAwLjA3NC0wLjA1OTYgMC4xMTI1LTAuMDkwOSAxNC44OTYgMy44OTIxIDI0LjE0OCA5LjMzMjYgMjQuMTQ4IDE1LjM1MSAwIDExLjgzNC0zNS43NTUgMjEuNDI2LTc5Ljg2MSAyMS40MjYiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzcxIiBkPSJtMzQ1LjU4IDk0NS43M2MtNDMuOTMgMC03OS41NDEtOS41NTM3LTc5LjU0MS0yMS4zNCAwLTUuOTggOS4xNjg4LTExLjM4NSAyMy45MzktMTUuMjYgMC4wMzYyIDAuMDMwMyAwLjA3NjIgMC4wNjE1IDAuMTEyNSAwLjA5MTMtMTQuNjQ1IDMuODU2NS0yMy43MyA5LjIyNzYtMjMuNzMgMTUuMTY5IDAgMTEuNzM5IDM1LjQ2OCAyMS4yNTUgNzkuMjIgMjEuMjU1IDQzLjc1MSAwIDc5LjIxOS05LjUxNjEgNzkuMjE5LTIxLjI1NSAwLTUuOTQxNC05LjA4MzctMTEuMzEyLTIzLjczLTE1LjE2OSAwLjAzNi0wLjAyOTggMC4wNzYtMC4wNjEgMC4xMTI1LTAuMDkxMyAxNC43NzEgMy44NzU1IDIzLjkzOSA5LjI4MDIgMjMuOTM5IDE1LjI2IDAgMTEuNzg2LTM1LjYxMSAyMS4zNC03OS41NCAyMS4zNCIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NzMiIGQ9Im0zNDUuNTggOTQ1LjY0Yy00My43NTIgMC03OS4yMi05LjUxNjEtNzkuMjItMjEuMjU1IDAtNS45NDE0IDkuMDg1LTExLjMxMiAyMy43My0xNS4xNjkgMC4wMzg4IDAuMDMxMiAwLjA3MzggMC4wNTkxIDAuMTEyNSAwLjA5MDQtMTQuNTIxIDMuODM5OS0yMy41MjIgOS4xNzcyLTIzLjUyMiAxNS4wNzkgMCAxMS42OTEgMzUuMzI1IDIxLjE2OSA3OC45IDIxLjE2OSA0My41NzQgMCA3OC44OTktOS40Nzc2IDc4Ljg5OS0yMS4xNjkgMC01LjkwMTQtOS4wMDEyLTExLjIzOS0yMy41MjItMTUuMDc5IDAuMDM5LTAuMDMxMiAwLjA3NC0wLjA1OTEgMC4xMTI1LTAuMDkwNCAxNC42NDYgMy44NTY1IDIzLjczIDkuMjI3NiAyMy43MyAxNS4xNjkgMCAxMS43MzktMzUuNDY4IDIxLjI1NS03OS4yMTkgMjEuMjU1IiBmaWxsPSIjZmRmZWZlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc3NSIgZD0ibTM0NS41OCA5NDUuNTVjLTQzLjU3NSAwLTc4LjktOS40Nzc2LTc4LjktMjEuMTY5IDAtNS45MDE0IDkuMDAxMi0xMS4yMzkgMjMuNTIyLTE1LjA3OSAwLjAzNzUgMC4wMjk3IDAuMDc3NSAwLjA2MjUgMC4xMTM3NSAwLjA5MTQtMTQuMzk1IDMuODIwOC0yMy4zMTUgOS4xMjUtMjMuMzE1IDE0Ljk4NyAwIDExLjY0NCAzNS4xODEgMjEuMDgzIDc4LjU3OSAyMS4wODNzNzguNTc4LTkuNDM5IDc4LjU3OC0yMS4wODNjMC01Ljg2MjItOC45Mi0xMS4xNjYtMjMuMzE1LTE0Ljk4NyAwLjAzOC0wLjAyODkgMC4wNzYtMC4wNjE2IDAuMTEzNy0wLjA5MTQgMTQuNTIxIDMuODM5OSAyMy41MjIgOS4xNzcyIDIzLjUyMiAxNS4wNzkgMCAxMS42OTEtMzUuMzI1IDIxLjE2OS03OC44OTkgMjEuMTY5IiBmaWxsPSIjZmRmZWZlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc3NyIgZD0ibTM0NS41OCA5NDUuNDdjLTQzLjM5OCAwLTc4LjU3OS05LjQzOS03OC41NzktMjEuMDgzIDAtNS44NjIyIDguOTItMTEuMTY2IDIzLjMxNS0xNC45ODcgMC4wMzg3IDAuMDMxMiAwLjA3NSAwLjA2IDAuMTEzNzUgMC4wOTA3LTE0LjI3MSAzLjgwNDItMjMuMTA4IDkuMDcyOC0yMy4xMDggMTQuODk2IDAgMTEuNTk2IDM1LjAzNiAyMC45OTYgNzguMjU4IDIwLjk5NiA0My4yMiAwIDc4LjI1OC05LjM5OTkgNzguMjU4LTIwLjk5NiAwLTUuODIzOC04LjgzNjMtMTEuMDkyLTIzLjEwOS0xNC44OTYgMC4wMzktMC4wMzA4IDAuMDc1LTAuMDU5NSAwLjExMzgtMC4wOTA3IDE0LjM5NSAzLjgyMDggMjMuMzE1IDkuMTI1IDIzLjMxNSAxNC45ODcgMCAxMS42NDQtMzUuMTggMjEuMDgzLTc4LjU3OCAyMS4wODMiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0Nzc5IiBkPSJtMzQ1LjU4IDk0NS4zOGMtNDMuMjIxIDAtNzguMjU4LTkuMzk5OS03OC4yNTgtMjAuOTk2IDAtNS44MjM4IDguODM2Mi0xMS4wOTIgMjMuMTA4LTE0Ljg5NiAwLjAzNjIgMC4wMjg5IDAuMDc3NSAwLjA2MTUgMC4xMTM3NSAwLjA5MTQtMTQuMTQ2IDMuNzg1MS0yMi45MDEgOS4wMjE0LTIyLjkwMSAxNC44MDUgMCAxMS41NDkgMzQuODk0IDIwLjkxIDc3LjkzOCAyMC45MSA0My4wNDMgMCA3Ny45MzYtOS4zNjEzIDc3LjkzNi0yMC45MSAwLTUuNzgzOC04Ljc1NS0xMS4wMi0yMi45MDEtMTQuODA1IDAuMDM2LTAuMDI5OSAwLjA3OC0wLjA2MjUgMC4xMTM3LTAuMDkxNCAxNC4yNzIgMy44MDQyIDIzLjEwOSA5LjA3MjggMjMuMTA5IDE0Ljg5NiAwIDExLjU5Ni0zNS4wMzggMjAuOTk2LTc4LjI1OCAyMC45OTYiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg0NzgxIiBkPSJtMzQ1LjU4IDk0NS4zYy00My4wNDQgMC03Ny45MzgtOS4zNjEzLTc3LjkzOC0yMC45MSAwLTUuNzgzOCA4Ljc1NS0xMS4wMiAyMi45MDEtMTQuODA1IDAuMDM4NyAwLjAzMTIgMC4wNzYyIDAuMDYgMC4xMTUgMC4wOTAyLTE0LjAyNSAzLjc2ODYtMjIuNjk1IDguOTY5Ny0yMi42OTUgMTQuNzE1IDAgMTEuNTAxIDM0Ljc1IDIwLjgyNCA3Ny42MTYgMjAuODI0IDQyLjg2NSAwIDc3LjYxNS05LjMyMjMgNzcuNjE1LTIwLjgyNCAwLTUuNzQ1MS04LjY3LTEwLjk0Ni0yMi42OTUtMTQuNzE1IDAuMDQtMC4wMzAzIDAuMDc2LTAuMDU5IDAuMTE1LTAuMDkwMiAxNC4xNDYgMy43ODUxIDIyLjkwMSA5LjAyMTQgMjIuOTAxIDE0LjgwNSAwIDExLjU0OS0zNC44OTQgMjAuOTEtNzcuOTM2IDIwLjkxIiBmaWxsPSIjZmRmZGZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc4MyIgZD0ibTM0NS41OCA5NDUuMjFjLTQyLjg2NiAwLTc3LjYxNi05LjMyMjMtNzcuNjE2LTIwLjgyNCAwLTUuNzQ1MSA4LjY3LTEwLjk0NiAyMi42OTUtMTQuNzE1IDAuMDM2MiAwLjAyOTkgMC4wNzc1IDAuMDYyNSAwLjExNSAwLjA5MTQtMTMuOTAxIDMuNzUtMjIuNDkgOC45MTc1LTIyLjQ5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDggMjAuNzM3IDc3LjI5NiAyMC43MzcgNDIuNjg5IDAgNzcuMjk1LTkuMjgzNyA3Ny4yOTUtMjAuNzM3IDAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40OS0xNC42MjQgMC4wMzgtMC4wMjg5IDAuMDc5LTAuMDYxNSAwLjExNS0wLjA5MTQgMTQuMDI1IDMuNzY4NiAyMi42OTUgOC45Njk3IDIyLjY5NSAxNC43MTUgMCAxMS41MDEtMzQuNzUgMjAuODI0LTc3LjYxNSAyMC44MjQiIGZpbGw9IiNmY2ZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg0Nzg1IiBkPSJtMzQ1LjU4IDk0NS4xMmMtNDIuNjg5IDAtNzcuMjk2LTkuMjgzNy03Ny4yOTYtMjAuNzM3IDAtNS43MDYgOC41ODg4LTEwLjg3NCAyMi40OS0xNC42MjQgMC4wMzg3IDAuMDMxMiAwLjA3NjMgMC4wNTk1IDAuMTE1IDAuMDkwNy0xMy43NzggMy43MzE1LTIyLjI4NCA4Ljg2NTItMjIuMjg0IDE0LjUzMyAwIDExLjQwNiAzNC40NjIgMjAuNjUyIDc2Ljk3NSAyMC42NTIgNDIuNTExIDAgNzYuOTc0LTkuMjQ2MSA3Ni45NzQtMjAuNjUyIDAtNS42Njc1LTguNTA2Mi0xMC44MDEtMjIuMjg0LTE0LjUzMyAwLjA0LTAuMDMxMiAwLjA3Ni0wLjA1OTUgMC4xMTUtMC4wOTA3IDEzLjkwMSAzLjc1IDIyLjQ5IDguOTE3NSAyMi40OSAxNC42MjQgMCAxMS40NTQtMzQuNjA2IDIwLjczNy03Ny4yOTUgMjAuNzM3IiBmaWxsPSIjZmNmZGZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc4NyIgZD0ibTM0NS41OCA5NDUuMDRjLTQyLjUxMiAwLTc2Ljk3NS05LjI0NjEtNzYuOTc1LTIwLjY1MiAwLTUuNjY3NSA4LjUwNjItMTAuODAxIDIyLjI4NC0xNC41MzMgMC4wMzg3IDAuMDMxMiAwLjA3NjIgMC4wNjAxIDAuMTE2MjUgMC4wOTE0LTEzLjY1MiAzLjcxMzktMjIuMDc5IDguODEzOS0yMi4wNzkgMTQuNDQxIDAgMTEuMzU5IDM0LjMxOSAyMC41NjYgNzYuNjU0IDIwLjU2NiA0Mi4zMzQgMCA3Ni42NTQtOS4yMDc1IDc2LjY1NC0yMC41NjYgMC01LjYyNzUtOC40Mjc1LTEwLjcyOC0yMi4wNzktMTQuNDQxIDAuMDM5LTAuMDMxMiAwLjA3Ni0wLjA2MDEgMC4xMTUtMC4wOTE0IDEzLjc3OCAzLjczMTUgMjIuMjg0IDguODY1MiAyMi4yODQgMTQuNTMzIDAgMTEuNDA2LTM0LjQ2MiAyMC42NTItNzYuOTc0IDIwLjY1MiIgZmlsbD0iI2ZjZmNmZCIvPgogICA8cGF0aCBpZD0icGF0aDQ3ODkiIGQ9Im0zNDUuNTggOTQ0Ljk1Yy00Mi4zMzUgMC03Ni42NTQtOS4yMDc1LTc2LjY1NC0yMC41NjYgMC01LjYyNzUgOC40MjYyLTEwLjcyOCAyMi4wNzktMTQuNDQxIDAuMDM2MiAwLjAyODcgMC4wNzg3IDAuMDYyNSAwLjExNSAwLjA5MTItMTMuNTI5IDMuNjk1NC0yMS44NzQgOC43NjEyLTIxLjg3NCAxNC4zNSAwIDExLjMxMSAzNC4xNzYgMjAuNDggNzYuMzM0IDIwLjQ4czc2LjMzMy05LjE2ODkgNzYuMzMzLTIwLjQ4YzAtNS41ODg5LTguMzQ1LTEwLjY1NS0yMS44NzQtMTQuMzUgMC4wMzYtMC4wMjg3IDAuMDc5LTAuMDYyNSAwLjExNjMtMC4wOTEyIDEzLjY1MSAzLjcxMzkgMjIuMDc5IDguODEzOSAyMi4wNzkgMTQuNDQxIDAgMTEuMzU5LTM0LjMyIDIwLjU2Ni03Ni42NTQgMjAuNTY2IiBmaWxsPSIjZmNmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc5MSIgZD0ibTM0NS41OCA5NDQuODdjLTQyLjE1OCAwLTc2LjMzNC05LjE2ODktNzYuMzM0LTIwLjQ4IDAtNS41ODg5IDguMzQ1LTEwLjY1NSAyMS44NzQtMTQuMzUgMC4wNCAwLjAzMDMgMC4wNzc1IDAuMDYwMSAwLjExNzUgMC4wOTE0LTEzLjQwNSAzLjY3NjItMjEuNjcgOC43MDktMjEuNjcgMTQuMjU5IDAgMTEuMjY0IDM0LjAzMSAyMC4zOTQgNzYuMDEyIDIwLjM5NCA0MS45OCAwIDc2LjAxMS05LjEyOTkgNzYuMDExLTIwLjM5NCAwLTUuNTQ5OC04LjI2NS0xMC41ODItMjEuNjY5LTE0LjI1OSAwLjAzOS0wLjAzMTIgMC4wNzYtMC4wNjExIDAuMTE2Mi0wLjA5MTQgMTMuNTI5IDMuNjk1NCAyMS44NzQgOC43NjEyIDIxLjg3NCAxNC4zNSAwIDExLjMxMS0zNC4xNzUgMjAuNDgtNzYuMzMzIDIwLjQ4IiBmaWxsPSIjZmJmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc5MyIgZD0ibTM0NS41OCA5NDQuNzhjLTQxLjk4MSAwLTc2LjAxMi05LjEyOTktNzYuMDEyLTIwLjM5NCAwLTUuNTQ5OCA4LjI2NS0xMC41ODIgMjEuNjctMTQuMjU5IDAuMDM4NyAwLjAyOTggMC4wNzc1IDAuMDYgMC4xMTYyNSAwLjA4OTgtMTMuMjg0IDMuNjYwMi0yMS40NjUgOC42NTc4LTIxLjQ2NSAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4czc1LjY5MS05LjA5MTQgNzUuNjkxLTIwLjMwOGMwLTUuNTExMi04LjE4MjUtMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wMzktMC4wMjk4IDAuMDc4LTAuMDYgMC4xMTc1LTAuMDg5OCAxMy40MDQgMy42NzYyIDIxLjY2OSA4LjcwOSAyMS42NjkgMTQuMjU5IDAgMTEuMjY0LTM0LjAzMSAyMC4zOTQtNzYuMDExIDIwLjM5NCIgZmlsbD0iI2ZiZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDQ3OTUiIGQ9Im0zNDUuNTggOTQ0LjY5Yy00MS44MDQgMC03NS42OTEtOS4wOTE0LTc1LjY5MS0yMC4zMDggMC01LjUxMTIgOC4xODEyLTEwLjUwOSAyMS40NjUtMTQuMTY5IDAuMDQgMC4wMzEyIDAuMDc4NyAwLjA2MTYgMC4xMTc1IDAuMDkxNC0xMy4xNjEgMy42NDExLTIxLjI2MiA4LjYwNS0yMS4yNjIgMTQuMDc4IDAgMTEuMTY5IDMzLjc0NSAyMC4yMjEgNzUuMzcxIDIwLjIyMXM3NS4zNy05LjA1MjIgNzUuMzctMjAuMjIxYzAtNS40NzI2LTguMTAxMy0xMC40MzYtMjEuMjYyLTE0LjA3OCAwLjA0LTAuMDI5OCAwLjA3OC0wLjA2MDEgMC4xMTc1LTAuMDkxNCAxMy4yODQgMy42NjAyIDIxLjQ2NiA4LjY1NzggMjEuNDY2IDE0LjE2OSAwIDExLjIxNi0zMy44ODkgMjAuMzA4LTc1LjY5MSAyMC4zMDgiIGZpbGw9IiNmYmZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg0Nzk3IiBkPSJtMzQ1LjU4IDk0NC42MWMtNDEuNjI2IDAtNzUuMzcxLTkuMDUyMi03NS4zNzEtMjAuMjIxIDAtNS40NzI2IDguMTAxMi0xMC40MzYgMjEuMjYyLTE0LjA3OCAwLjA0IDAuMDMxMiAwLjA3ODcgMC4wNjE1IDAuMTE3NSAwLjA5MTMtMTMuMDM5IDMuNjIyNi0yMS4wNTkgOC41NTI4LTIxLjA1OSAxMy45ODYgMCAxMS4xMiAzMy42IDIwLjEzNiA3NS4wNSAyMC4xMzYgNDEuNDQ5IDAgNzUuMDQ5LTkuMDE2MSA3NS4wNDktMjAuMTM2IDAtNS40MzM2LTguMDItMTAuMzY0LTIxLjA1OS0xMy45ODYgMC4wMzktMC4wMjk3IDAuMDc5LTAuMDYgMC4xMTc1LTAuMDkxMyAxMy4xNjEgMy42NDExIDIxLjI2MiA4LjYwNSAyMS4yNjIgMTQuMDc4IDAgMTEuMTY5LTMzLjc0NCAyMC4yMjEtNzUuMzcgMjAuMjIxIiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc5OSIgZD0ibTM0NS41OCA5NDQuNTJjLTQxLjQ1IDAtNzUuMDUtOS4wMTYxLTc1LjA1LTIwLjEzNiAwLTUuNDMzNiA4LjAyLTEwLjM2NCAyMS4wNTktMTMuOTg2IDAuMDQgMC4wMzA0IDAuMDc4OCAwLjA2MTYgMC4xMTg3NSAwLjA5MTQtMTIuOTE4IDMuNjA0LTIwLjg1OCA4LjUtMjAuODU4IDEzLjg5NSAwIDExLjA3MiAzMy40NTggMjAuMDUgNzQuNzMgMjAuMDUgNDEuMjcxIDAgNzQuNzI5LTguOTc3NiA3NC43MjktMjAuMDUgMC01LjM5NS03Ljk0LTEwLjI5MS0yMC44NTgtMTMuODk1IDAuMDQtMC4wMjk4IDAuMDc5LTAuMDYxIDAuMTE4OC0wLjA5MTQgMTMuMDM5IDMuNjIyNiAyMS4wNTkgOC41NTI4IDIxLjA1OSAxMy45ODYgMCAxMS4xMi0zMy42IDIwLjEzNi03NS4wNDkgMjAuMTM2IiBmaWxsPSIjZmFmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDgwMSIgZD0ibTM0NS41OCA5NDQuNDRjLTQxLjI3MiAwLTc0LjczLTguOTc3Ni03NC43My0yMC4wNSAwLTUuMzk1IDcuOTQtMTAuMjkxIDIwLjg1OC0xMy44OTUgMC4wNCAwLjAyOTcgMC4wNzg4IDAuMDYxNSAwLjExODc1IDAuMDkxMi0xMi43OTQgMy41ODUtMjAuNjU1IDguNDQ4OC0yMC42NTUgMTMuODA0IDAgMTEuMDI1IDMzLjMxNCAxOS45NjQgNzQuNDA5IDE5Ljk2NCA0MS4wOTQgMCA3NC40MDgtOC45MzkgNzQuNDA4LTE5Ljk2NCAwLTUuMzU2NS03Ljg1ODgtMTAuMjE5LTIwLjY1NS0xMy44MDQgMC4wNC0wLjAyOTggMC4wOC0wLjA2MTUgMC4xMTg3LTAuMDkxMiAxMi45MTggMy42MDQgMjAuODU4IDguNSAyMC44NTggMTMuODk1IDAgMTEuMDcyLTMzLjQ1OCAyMC4wNS03NC43MjkgMjAuMDUiIGZpbGw9IiNmYWZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg0ODAzIiBkPSJtMzQ1LjU4IDk0NC4zNWMtNDEuMDk1IDAtNzQuNDA5LTguOTM5LTc0LjQwOS0xOS45NjQgMC01LjM1NSA3Ljg2MTItMTAuMjE5IDIwLjY1NS0xMy44MDQgMC4wNCAwLjAyOTkgMC4wNzg3IDAuMDYxMSAwLjExODc1IDAuMDkxNC0xMi42NzIgMy41NjU5LTIwLjQ1MiA4LjM5Ni0yMC40NTIgMTMuNzEyIDAgMTAuOTc4IDMzLjE3IDE5Ljg3NyA3NC4wODggMTkuODc3czc0LjA4OC04Ljg5OTkgNzQuMDg4LTE5Ljg3N2MwLTUuMzE2NC03Ljc4MTMtMTAuMTQ2LTIwLjQ1NC0xMy43MTIgMC4wNC0wLjAzMDIgMC4wOC0wLjA2MTUgMC4xMTg4LTAuMDkxNCAxMi43OTYgMy41ODUgMjAuNjU1IDguNDQ3MiAyMC42NTUgMTMuODA0IDAgMTEuMDI1LTMzLjMxNCAxOS45NjQtNzQuNDA4IDE5Ljk2NCIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDQ4MDUiIGQ9Im0zNDUuNTggOTQ0LjI2Yy00MC45MTggMC03NC4wODgtOC44OTk5LTc0LjA4OC0xOS44NzcgMC01LjMxNjQgNy43OC0xMC4xNDYgMjAuNDUyLTEzLjcxMiAwLjA0IDAuMDI5NyAwLjA4IDAuMDYxIDAuMTIgMC4wOTEzLTEyLjU1IDMuNTQ3NC0yMC4yNTIgOC4zNDM4LTIwLjI1MiAxMy42MjEgMCAxMC45MyAzMy4wMjYgMTkuNzkxIDczLjc2OCAxOS43OTEgNDAuNzQgMCA3My43NjYtOC44NjA5IDczLjc2Ni0xOS43OTEgMC01LjI3NzQtNy43MDEyLTEwLjA3NC0yMC4yNTItMTMuNjIxIDAuMDQtMC4wMzAzIDAuMDgtMC4wNjE1IDAuMTItMC4wOTEzIDEyLjY3MiAzLjU2NTkgMjAuNDU0IDguMzk2IDIwLjQ1NCAxMy43MTIgMCAxMC45NzgtMzMuMTcgMTkuODc3LTc0LjA4OCAxOS44NzciIGZpbGw9IiNmYWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODA3IiBkPSJtMzQ1LjU4IDk0NC4xOGMtNDAuNzQxIDAtNzMuNzY4LTguODYwOS03My43NjgtMTkuNzkxIDAtNS4yNzc0IDcuNzAyNS0xMC4wNzQgMjAuMjUyLTEzLjYyMSAwLjA0IDAuMDI5OSAwLjA4IDAuMDYxMSAwLjEyIDAuMDkxNC0xMi40MzEgMy41MjgyLTIwLjA1MSA4LjI5MS0yMC4wNTEgMTMuNTMgMCAxMC44ODIgMzIuODgyIDE5LjcwNSA3My40NDYgMTkuNzA1IDQwLjU2MyAwIDczLjQ0NS04LjgyMjcgNzMuNDQ1LTE5LjcwNSAwLTUuMjM4OC03LjYxODgtMTAuMDAyLTIwLjA1MS0xMy41MyAwLjA0LTAuMDMwMyAwLjA4MS0wLjA2MTUgMC4xMi0wLjA5MTQgMTIuNTUxIDMuNTQ3NCAyMC4yNTIgOC4zNDM4IDIwLjI1MiAxMy42MjEgMCAxMC45My0zMy4wMjYgMTkuNzkxLTczLjc2NiAxOS43OTEiIGZpbGw9IiNmOWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODA5IiBkPSJtMzQ1LjU4IDk0NC4wOWMtNDAuNTY0IDAtNzMuNDQ2LTguODIyNy03My40NDYtMTkuNzA1IDAtNS4yMzg4IDcuNjItMTAuMDAyIDIwLjA1MS0xMy41MyAwLjA0IDAuMDI5OCAwLjA4IDAuMDYxIDAuMTIgMC4wOTA3LTEyLjMxIDMuNTEwMi0xOS44NTEgOC4yMzg4LTE5Ljg1MSAxMy40MzkgMCAxMC44MzUgMzIuNzQgMTkuNjE5IDczLjEyNiAxOS42MTkgNDAuMzg1IDAgNzMuMTI1LTguNzgzNiA3My4xMjUtMTkuNjE5IDAtNS4yMDAyLTcuNTQxMy05LjkyODgtMTkuODUxLTEzLjQzOSAwLjA0LTAuMDI5NyAwLjA4MS0wLjA2MSAwLjEyLTAuMDkwNyAxMi40MzIgMy41MjgyIDIwLjA1MSA4LjI5MSAyMC4wNTEgMTMuNTMgMCAxMC44ODItMzIuODgyIDE5LjcwNS03My40NDUgMTkuNzA1IiBmaWxsPSIjZjlmOWY5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgxMSIgZD0ibTM0NS41OCA5NDRjLTQwLjM4NiAwLTczLjEyNi04Ljc4MzYtNzMuMTI2LTE5LjYxOSAwLTUuMjAwMiA3LjU0MTItOS45Mjg4IDE5Ljg1MS0xMy40MzkgMC4wNCAwLjAzMDMgMC4wODEzIDAuMDYxNSAwLjEyMTI1IDAuMDkxNC0xMi4xODggMy40OTExLTE5LjY1MSA4LjE4NjUtMTkuNjUxIDEzLjM0OCAwIDEwLjc4OCAzMi41OTYgMTkuNTM0IDcyLjgwNSAxOS41MzRzNzIuODA0LTguNzQ2MSA3Mi44MDQtMTkuNTM0YzAtNS4xNjExLTcuNDYzNy05Ljg1NjUtMTkuNjUxLTEzLjM0OCAwLjA0LTAuMDMxMiAwLjA4MS0wLjA2MTEgMC4xMjEyLTAuMDkxNCAxMi4zMSAzLjUxMDIgMTkuODUxIDguMjM4OCAxOS44NTEgMTMuNDM5IDAgMTAuODM1LTMyLjc0IDE5LjYxOS03My4xMjUgMTkuNjE5IiBmaWxsPSIjZjlmOWY5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgxMyIgZD0ibTM0NS41OCA5NDMuOTJjLTQwLjIwOSAwLTcyLjgwNS04Ljc0NjEtNzIuODA1LTE5LjUzNCAwLTUuMTYxMSA3LjQ2MzgtOS44NTY1IDE5LjY1MS0xMy4zNDggMC4wNDEyIDAuMDMxMiAwLjA3ODggMC4wNTkgMC4xMjEyNSAwLjA5MTItMTIuMDY4IDMuNDcyNi0xOS40NTEgOC4xMzM5LTE5LjQ1MSAxMy4yNTYgMCAxMC43NCAzMi40NTEgMTkuNDQ4IDcyLjQ4NCAxOS40NDggNDAuMDMxIDAgNzIuNDg0LTguNzA3NSA3Mi40ODQtMTkuNDQ4IDAtNS4xMjI1LTcuMzg1LTkuNzgzOC0xOS40NTItMTMuMjU2IDAuMDQzLTAuMDMyMiAwLjA4LTAuMDYgMC4xMjEzLTAuMDkxMiAxMi4xODggMy40OTExIDE5LjY1MSA4LjE4NjUgMTkuNjUxIDEzLjM0OCAwIDEwLjc4OC0zMi41OTUgMTkuNTM0LTcyLjgwNCAxOS41MzQiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg0ODE1IiBkPSJtMzQ1LjU4IDk0My44M2MtNDAuMDMyIDAtNzIuNDg0LTguNzA3NS03Mi40ODQtMTkuNDQ4IDAtNS4xMjI1IDcuMzgzOC05Ljc4MzggMTkuNDUxLTEzLjI1NiAwLjA0IDAuMDMwMyAwLjA4MTMgMC4wNjE1IDAuMTIxMjUgMC4wOTE0LTExLjk0OSAzLjQ1MzYtMTkuMjUyIDguMDgtMTkuMjUyIDEzLjE2NSAwIDEwLjY5MiAzMi4zMDkgMTkuMzYxIDcyLjE2NCAxOS4zNjEgMzkuODU0IDAgNzIuMTYzLTguNjY5IDcyLjE2My0xOS4zNjEgMC01LjA4NS03LjMwMzgtOS43MTE0LTE5LjI1Mi0xMy4xNjUgMC4wNC0wLjAyOTkgMC4wODEtMC4wNjExIDAuMTIxMi0wLjA5MTQgMTIuMDY4IDMuNDcyNiAxOS40NTIgOC4xMzM5IDE5LjQ1MiAxMy4yNTYgMCAxMC43NC0zMi40NTIgMTkuNDQ4LTcyLjQ4NCAxOS40NDgiIGZpbGw9IiNmOGY4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg0ODE3IiBkPSJtMzQ1LjU4IDk0My43NWMtMzkuODU1IDAtNzIuMTY0LTguNjY5LTcyLjE2NC0xOS4zNjEgMC01LjA4NSA3LjMwMzgtOS43MTE0IDE5LjI1Mi0xMy4xNjUgMC4wNCAwLjAyODggMC4wODI1IDAuMDYxNSAwLjEyMjUgMC4wOTEzLTExLjgyOCAzLjQzNTEtMTkuMDU0IDguMDI3NC0xOS4wNTQgMTMuMDc0IDAgMTAuNjQ1IDMyLjE2NSAxOS4yNzUgNzEuODQyIDE5LjI3NSAzOS42NzYgMCA3MS44NDEtOC42Mjk5IDcxLjg0MS0xOS4yNzUgMC01LjA0NjQtNy4yMjUtOS42Mzg2LTE5LjA1NC0xMy4wNzQgMC4wNC0wLjAyOTggMC4wODItMC4wNjI1IDAuMTIyNS0wLjA5MTMgMTEuOTQ5IDMuNDUzNiAxOS4yNTIgOC4wOCAxOS4yNTIgMTMuMTY1IDAgMTAuNjkyLTMyLjMwOSAxOS4zNjEtNzIuMTYzIDE5LjM2MSIgZmlsbD0iI2Y4ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MTkiIGQ9Im0zNDUuNTggOTQzLjY2Yy0zOS42NzggMC03MS44NDItOC42Mjk5LTcxLjg0Mi0xOS4yNzUgMC01LjA0NjQgNy4yMjYyLTkuNjM4NiAxOS4wNTQtMTMuMDc0IDAuMDQyNSAwLjAzMTIgMC4wOCAwLjA1ODYgMC4xMjI1IDAuMDkxNC0xMS43MDggMy40MTYtMTguODU2IDcuOTc1LTE4Ljg1NiAxMi45ODIgMCAxMC41OTggMzIuMDIxIDE5LjE4OSA3MS41MjIgMTkuMTg5IDM5LjUgMCA3MS41MjEtOC41OTEzIDcxLjUyMS0xOS4xODkgMC01LjAwNzQtNy4xNDg3LTkuNTY2NC0xOC44NTYtMTIuOTgyIDAuMDQzLTAuMDMyNyAwLjA4LTAuMDYwMSAwLjEyMjUtMC4wOTE0IDExLjgyOSAzLjQzNTEgMTkuMDU0IDguMDI3NCAxOS4wNTQgMTMuMDc0IDAgMTAuNjQ1LTMyLjE2NSAxOS4yNzUtNzEuODQxIDE5LjI3NSIgZmlsbD0iI2Y3ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MjEiIGQ9Im0zNDUuNTggOTQzLjU3Yy0zOS41MDEgMC03MS41MjItOC41OTEzLTcxLjUyMi0xOS4xODkgMC01LjAwNzQgNy4xNDg4LTkuNTY2NCAxOC44NTYtMTIuOTgyIDAuMDQgMC4wMjg4IDAuMDgzNyAwLjA2MSAwLjEyMzc1IDAuMDkxMy0xMS41ODkgMy4zOTc1LTE4LjY1OSA3LjkyMjQtMTguNjU5IDEyLjg5MSAwIDEwLjU1IDMxLjg3OCAxOS4xMDMgNzEuMjAxIDE5LjEwM3M3MS4yLTguNTUyOCA3MS4yLTE5LjEwM2MwLTQuOTY4OC03LjA3LTkuNDkzNi0xOC42NTktMTIuODkxIDAuMDQtMC4wMzAzIDAuMDg0LTAuMDYyNSAwLjEyMzgtMC4wOTEzIDExLjcwOCAzLjQxNiAxOC44NTYgNy45NzUgMTguODU2IDEyLjk4MiAwIDEwLjU5OC0zMi4wMjEgMTkuMTg5LTcxLjUyMSAxOS4xODkiIGZpbGw9IiNmN2Y3ZjciLz4KICAgPHBhdGggaWQ9InBhdGg0ODIzIiBkPSJtMzQ1LjU4IDk0My40OWMtMzkuMzI0IDAtNzEuMjAxLTguNTUyOC03MS4yMDEtMTkuMTAzIDAtNC45Njg4IDcuMDctOS40OTM2IDE4LjY1OS0xMi44OTEgMC4wNDI1IDAuMDMxMiAwLjA4MTIgMC4wNjAxIDAuMTIzNzUgMC4wOTE0LTExLjQ3IDMuMzc4NC0xOC40NjEgNy44Njk2LTE4LjQ2MSAxMi44IDAgMTAuNTAyIDMxLjczNCAxOS4wMTYgNzAuODggMTkuMDE2IDM5LjE0NSAwIDcwLjg4LTguNTEzNiA3MC44OC0xOS4wMTYgMC00LjkzMDEtNi45OTI1LTkuNDIxNC0xOC40NjEtMTIuOCAwLjA0MS0wLjAzMTIgMC4wOC0wLjA2MDEgMC4xMjI1LTAuMDkxNCAxMS41ODkgMy4zOTc1IDE4LjY1OSA3LjkyMjQgMTguNjU5IDEyLjg5MSAwIDEwLjU1LTMxLjg3OCAxOS4xMDMtNzEuMiAxOS4xMDMiIGZpbGw9IiNmNmY3ZjciLz4KICAgPHBhdGggaWQ9InBhdGg0ODI1IiBkPSJtMzQ1LjU4IDk0My40Yy0zOS4xNDYgMC03MC44OC04LjUxMzYtNzAuODgtMTkuMDE2IDAtNC45MzAxIDYuOTkxMi05LjQyMTQgMTguNDYxLTEyLjggMC4wNCAwLjAyODIgMC4wODM3IDAuMDYxIDAuMTIzNzUgMC4wOTA3LTExLjM0OSAzLjM1ODktMTguMjY1IDcuODE3OS0xOC4yNjUgMTIuNzA5IDAgMTAuNDU1IDMxLjU5MSAxOC45MzEgNzAuNTYgMTguOTMxczcwLjU1OS04LjQ3NiA3MC41NTktMTguOTMxYzAtNC44OTExLTYuOTE1LTkuMzUwMS0xOC4yNjUtMTIuNzA5IDAuMDQtMC4wMjk3IDAuMDg0LTAuMDYyNSAwLjEyNS0wLjA5MDcgMTEuNDY5IDMuMzc4NCAxOC40NjEgNy44Njk2IDE4LjQ2MSAxMi44IDAgMTAuNTAyLTMxLjczNSAxOS4wMTYtNzAuODggMTkuMDE2IiBmaWxsPSIjZjZmNmY3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgyNyIgZD0ibTM0NS41OCA5NDMuMzJjLTM4Ljk2OSAwLTcwLjU2LTguNDc2LTcwLjU2LTE4LjkzMSAwLTQuODkxMSA2LjkxNjItOS4zNTAxIDE4LjI2NS0xMi43MDkgMC4wNDI1IDAuMDMxMiAwLjA4MjUgMC4wNjAxIDAuMTI1IDAuMDkxNC0xMS4yMzIgMy4zNDAyLTE4LjA2OSA3Ljc2NTEtMTguMDY5IDEyLjYxOCAwIDEwLjQwOCAzMS40NDYgMTguODQ1IDcwLjIzOSAxOC44NDUgMzguNzkxIDAgNzAuMjM4LTguNDM3NSA3MC4yMzgtMTguODQ1IDAtNC44NTI1LTYuODM2My05LjI3NzQtMTguMDY4LTEyLjYxOCAwLjA0My0wLjAzMTIgMC4wODEtMC4wNjAxIDAuMTIzNy0wLjA5MTQgMTEuMzUgMy4zNTg5IDE4LjI2NSA3LjgxNzkgMTguMjY1IDEyLjcwOSAwIDEwLjQ1NS0zMS41OSAxOC45MzEtNzAuNTU5IDE4LjkzMSIgZmlsbD0iI2Y2ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4MjkiIGQ9Im0zNDUuNTggOTQzLjIzYy0zOC43OTIgMC03MC4yMzktOC40Mzc1LTcwLjIzOS0xOC44NDUgMC00Ljg1MjUgNi44MzYyLTkuMjc3NCAxOC4wNjktMTIuNjE4IDAuMDQgMC4wMjg3IDAuMDgzNyAwLjA2MTUgMC4xMjUgMC4wOTEyLTExLjExNCAzLjMyMTMtMTcuODcyIDcuNzExNC0xNy44NzIgMTIuNTI2IDAgMTAuMzYgMzEuMzAyIDE4Ljc1OSA2OS45MTggMTguNzU5IDM4LjYxNCAwIDY5LjkxOC04LjM5ODkgNjkuOTE4LTE4Ljc1OSAwLTQuODE1LTYuNzYtOS4yMDUxLTE3Ljg3NC0xMi41MjYgMC4wNDEtMC4wMjk3IDAuMDg1LTAuMDYyNSAwLjEyNjMtMC4wOTEyIDExLjIzMSAzLjM0MDIgMTguMDY4IDcuNzY1MSAxOC4wNjggMTIuNjE4IDAgMTAuNDA4LTMxLjQ0NiAxOC44NDUtNzAuMjM4IDE4Ljg0NSIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4MzEiIGQ9Im0zNDUuNTggOTQzLjE0Yy0zOC42MTUgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc1ODgtOS4yMDUxIDE3Ljg3Mi0xMi41MjYgMC4wNDI1IDAuMDMxMiAwLjA4MjUgMC4wNjAxIDAuMTI1IDAuMDkxNC0xMC45OTUgMy4zMDI2LTE3LjY3OCA3LjY1ODYtMTcuNjc4IDEyLjQzNSAwIDEwLjMxMiAzMS4xNiAxOC42NzIgNjkuNTk4IDE4LjY3MiAzOC40MzYgMCA2OS41OTYtOC4zNTk5IDY5LjU5Ni0xOC42NzIgMC00Ljc3NjQtNi42ODI1LTkuMTMyNC0xNy42NzgtMTIuNDM1IDAuMDQzLTAuMDMxMiAwLjA4Mi0wLjA2MDEgMC4xMjUtMC4wOTE0IDExLjExNCAzLjMyMTMgMTcuODc0IDcuNzExNCAxNy44NzQgMTIuNTI2IDAgMTAuMzYtMzEuMzA0IDE4Ljc1OS02OS45MTggMTguNzU5IiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgzMyIgZD0ibTM0NS41OCA5NDMuMDZjLTM4LjQzOCAwLTY5LjU5OC04LjM1OTktNjkuNTk4LTE4LjY3MiAwLTQuNzc2NCA2LjY4MjUtOS4xMzI0IDE3LjY3OC0xMi40MzUgMC4wNDI1IDAuMDMxMiAwLjA4MzcgMC4wNiAwLjEyNjI1IDAuMDkxMy0xMC44NzggMy4yODI4LTE3LjQ4MiA3LjYwNjUtMTcuNDgyIDEyLjM0NCAwIDEwLjI2NSAzMS4wMTYgMTguNTg2IDY5LjI3NiAxOC41ODZzNjkuMjc2LTguMzIxMyA2OS4yNzYtMTguNTg2YzAtNC43MzcyLTYuNjA2Mi05LjA2MS0xNy40ODQtMTIuMzQ0IDAuMDQzLTAuMDMxMiAwLjA4NC0wLjA2IDAuMTI2Mi0wLjA5MTMgMTAuOTk1IDMuMzAyNiAxNy42NzggNy42NTg2IDE3LjY3OCAxMi40MzUgMCAxMC4zMTItMzEuMTYgMTguNjcyLTY5LjU5NiAxOC42NzIiIGZpbGw9IiNmNWY1ZjUiLz4KICAgPHBhdGggaWQ9InBhdGg0ODM1IiBkPSJtMzQ1LjU4IDk0Mi45N2MtMzguMjYgMC02OS4yNzYtOC4zMjEzLTY5LjI3Ni0xOC41ODYgMC00LjczNzIgNi42MDUtOS4wNjEgMTcuNDgyLTEyLjM0NCAwLjA0MjUgMC4wMzEyIDAuMDgzNyAwLjA2MSAwLjEyNjI1IDAuMDkxMi0xMC43NTggMy4yNjM4LTE3LjI4OSA3LjU1MzgtMTcuMjg5IDEyLjI1MiAwIDEwLjIxNyAzMC44NzIgMTguNSA2OC45NTYgMTguNSAzOC4wODMgMCA2OC45NTUtOC4yODI4IDY4Ljk1NS0xOC41IDAtNC42OTg4LTYuNTMxMy04Ljk4ODgtMTcuMjg5LTEyLjI1MiAwLjA0My0wLjAzMDMgMC4wODQtMC4wNiAwLjEyNjMtMC4wOTEyIDEwLjg3OCAzLjI4MjggMTcuNDg0IDcuNjA2NSAxNy40ODQgMTIuMzQ0IDAgMTAuMjY1LTMxLjAxNiAxOC41ODYtNjkuMjc2IDE4LjU4NiIgZmlsbD0iI2Y0ZjRmNCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MzciIGQ9Im0zNDUuNTggOTQyLjg5Yy0zOC4wODQgMC02OC45NTYtOC4yODI4LTY4Ljk1Ni0xOC41IDAtNC42OTg4IDYuNTMxMi04Ljk4ODggMTcuMjg5LTEyLjI1MiAwLjA0MTIgMC4wMjk5IDAuMDg2MiAwLjA2MjUgMC4xMjc1IDAuMDkxNC0xMC42NDIgMy4yNDM2LTE3LjA5NSA3LjUtMTcuMDk1IDEyLjE2MSAwIDEwLjE3IDMwLjcyOSAxOC40MTUgNjguNjM1IDE4LjQxNSAzNy45MDUgMCA2OC42MzQtOC4yNDUyIDY4LjYzNC0xOC40MTUgMC00LjY2MTEtNi40NTI1LTguOTE3NS0xNy4wOTUtMTIuMTYxIDAuMDQxLTAuMDI4OSAwLjA4OC0wLjA2MTUgMC4xMjc1LTAuMDkxNCAxMC43NTggMy4yNjM4IDE3LjI4OSA3LjU1MzggMTcuMjg5IDEyLjI1MiAwIDEwLjIxNy0zMC44NzIgMTguNS02OC45NTUgMTguNSIgZmlsbD0iI2Y0ZjRmNCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MzkiIGQ9Im0zNDUuNTggOTQyLjhjLTM3LjkwNiAwLTY4LjYzNS04LjI0NTItNjguNjM1LTE4LjQxNSAwLTQuNjYxMSA2LjQ1MjUtOC45MTc1IDE3LjA5NS0xMi4xNjEgMC4wNDI1IDAuMDMxMiAwLjA4NSAwLjA2MSAwLjEyNzUgMC4wOTIyLTEwLjUyNCAzLjIyMzYtMTYuOTAxIDcuNDQ2NC0xNi45MDEgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NSAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1Mi0xNi45MDItMTIuMDY5IDAuMDQ0LTAuMDMxMiAwLjA4NS0wLjA2MSAwLjEyNzUtMC4wOTIyIDEwLjY0MiAzLjI0MzYgMTcuMDk1IDcuNSAxNy4wOTUgMTIuMTYxIDAgMTAuMTctMzAuNzI5IDE4LjQxNS02OC42MzQgMTguNDE1IiBmaWxsPSIjZjNmM2YzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg0MSIgZD0ibTM0NS41OCA5NDIuNzFjLTM3LjcyOSAwLTY4LjMxNC04LjIwNjEtNjguMzE0LTE4LjMyOSAwLTQuNjIyNSA2LjM3NzUtOC44NDUyIDE2LjkwMS0xMi4wNjkgMC4wNDI1IDAuMDMwMyAwLjA4NSAwLjA2MDEgMC4xMjc1IDAuMDkxNC0xMC40MDggMy4yMDUtMTYuNzA5IDcuMzkzNS0xNi43MDkgMTEuOTc4IDAgMTAuMDc1IDMwLjQ0MSAxOC4yNDMgNjcuOTk0IDE4LjI0MyAzNy41NTEgMCA2Ny45OTMtOC4xNjc0IDY3Ljk5My0xOC4yNDMgMC00LjU4NC02LjMwMTMtOC43NzI1LTE2LjcwOS0xMS45NzggMC4wNDMtMC4wMzEyIDAuMDg1LTAuMDYxMSAwLjEyNzUtMC4wOTE0IDEwLjUyNSAzLjIyMzYgMTYuOTAyIDcuNDQ2NCAxNi45MDIgMTIuMDY5IDAgMTAuMTIyLTMwLjU4NSAxOC4zMjktNjguMzE0IDE4LjMyOSIgZmlsbD0iI2YzZjNmMiIvPgogICA8cGF0aCBpZD0icGF0aDQ4NDMiIGQ9Im0zNDUuNTggOTQyLjYzYy0zNy41NTIgMC02Ny45OTQtOC4xNjc0LTY3Ljk5NC0xOC4yNDMgMC00LjU4NCA2LjMwMTItOC43NzI1IDE2LjcwOS0xMS45NzggMC4wNDM3IDAuMDI5OCAwLjA4NjMgMC4wNjE1IDAuMTI4NzUgMC4wOTEzLTEwLjI4OSAzLjE4NTEtMTYuNTE2IDcuMzQxMy0xNi41MTYgMTEuODg2IDAgMTAuMDI3IDMwLjI5OCAxOC4xNTYgNjcuNjcyIDE4LjE1NnM2Ny42NzEtOC4xMjg5IDY3LjY3MS0xOC4xNTZjMC00LjU0NjQtNi4yMjUtOC43MDExLTE2LjUxNi0xMS44ODYgMC4wNDMtMC4wMjk4IDAuMDg2LTAuMDYxNSAwLjEyODctMC4wOTEzIDEwLjQwOCAzLjIwNSAxNi43MDkgNy4zOTM1IDE2LjcwOSAxMS45NzggMCAxMC4wNzUtMzAuNDQxIDE4LjI0My02Ny45OTMgMTguMjQzIiBmaWxsPSIjZjJmMmYyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg0NSIgZD0ibTM0NS41OCA5NDIuNTRjLTM3LjM3NSAwLTY3LjY3Mi04LjEyODktNjcuNjcyLTE4LjE1NiAwLTQuNTQ0OSA2LjIyNzUtOC43MDExIDE2LjUxNi0xMS44ODYgMC4wNDI1IDAuMDI5OCAwLjA4NjMgMC4wNjEgMC4xMjg3NSAwLjA5MTQtMTAuMTcyIDMuMTY1LTE2LjMyNCA3LjI4NzUtMTYuMzI0IDExLjc5NSAwIDkuOTggMzAuMTU0IDE4LjA3IDY3LjM1MSAxOC4wNyAzNy4xOTYgMCA2Ny4zNTEtOC4wODk4IDY3LjM1MS0xOC4wNyAwLTQuNTA3NC02LjE1MTItOC42Mjk5LTE2LjMyNS0xMS43OTUgMC4wNDMtMC4wMzA0IDAuMDg2LTAuMDYxNiAwLjEyODgtMC4wOTE0IDEwLjI5MSAzLjE4NTEgMTYuNTE2IDcuMzM5OSAxNi41MTYgMTEuODg2IDAgMTAuMDI3LTMwLjI5OCAxOC4xNTYtNjcuNjcxIDE4LjE1NiIgZmlsbD0iI2YyZjFmMSIvPgogICA8cGF0aCBpZD0icGF0aDQ4NDciIGQ9Im0zNDUuNTggOTQyLjQ2Yy0zNy4xOTggMC02Ny4zNTEtOC4wODk4LTY3LjM1MS0xOC4wNyAwLTQuNTA3NCA2LjE1MTItOC42Mjk5IDE2LjMyNC0xMS43OTUgMC4wNDM4IDAuMDMxMiAwLjA4NzUgMC4wNjEgMC4xMyAwLjA5MjMtMTAuMDU2IDMuMTQ1LTE2LjEzNCA3LjIzMzktMTYuMTM0IDExLjcwMyAwIDkuOTMyNiAzMC4wMTEgMTcuOTg0IDY3LjAzMSAxNy45ODRzNjcuMDMtOC4wNTEzIDY3LjAzLTE3Ljk4NGMwLTQuNDY4OC02LjA3NzUtOC41NTc2LTE2LjEzNC0xMS43MDMgMC4wNDQtMC4wMzEyIDAuMDg2LTAuMDYxIDAuMTMtMC4wOTIzIDEwLjE3NCAzLjE2NSAxNi4zMjUgNy4yODc1IDE2LjMyNSAxMS43OTUgMCA5Ljk4LTMwLjE1NSAxOC4wNy02Ny4zNTEgMTguMDciIGZpbGw9IiNmMWYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODQ5IiBkPSJtMzQ1LjU4IDk0Mi4zN2MtMzcuMDIgMC02Ny4wMzEtOC4wNTEzLTY3LjAzMS0xNy45ODQgMC00LjQ2ODggNi4wNzc1LTguNTU3NiAxNi4xMzQtMTEuNzAzIDAuMDQzOCAwLjAzMDMgMC4wODc1IDAuMDYxNSAwLjEzIDAuMDkxMy05Ljk0IDMuMTI1LTE1Ljk0MiA3LjE4MDItMTUuOTQyIDExLjYxMSAwIDkuODg0OCAyOS44NjYgMTcuODk3IDY2LjcxIDE3Ljg5NyAzNi44NDMgMCA2Ni43MS04LjAxMjcgNjYuNzEtMTcuODk3IDAtNC40MzExLTYuMDAzOC04LjQ4NjQtMTUuOTQ0LTExLjYxMSAwLjA0NC0wLjAyOTcgMC4wODgtMC4wNjEgMC4xMy0wLjA5MTMgMTAuMDU2IDMuMTQ1IDE2LjEzNCA3LjIzMzkgMTYuMTM0IDExLjcwMyAwIDkuOTMyNi0zMC4wMSAxNy45ODQtNjcuMDMgMTcuOTg0IiBmaWxsPSIjZjBmMGYwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg1MSIgZD0ibTM0NS41OCA5NDIuMjhjLTM2Ljg0NCAwLTY2LjcxLTguMDEyNy02Ni43MS0xNy44OTcgMC00LjQzMTEgNi4wMDI1LTguNDg2NCAxNS45NDItMTEuNjExIDAuMDQzOCAwLjAzMDQgMC4wODc1IDAuMDYxNiAwLjEzMTI1IDAuMDkxNC05LjgyNSAzLjEwNjQtMTUuNzU0IDcuMTI3NC0xNS43NTQgMTEuNTIgMCA5LjgzNzQgMjkuNzI0IDE3LjgxMiA2Ni4zOSAxNy44MTIgMzYuNjY1IDAgNjYuMzg5LTcuOTc1MSA2Ni4zODktMTcuODEyIDAtNC4zOTI2LTUuOTI4Ny04LjQxMzYtMTUuNzUyLTExLjUyIDAuMDQzLTAuMDI5OCAwLjA4OC0wLjA2MSAwLjEzLTAuMDkxNCA5Ljk0IDMuMTI1IDE1Ljk0NCA3LjE4MDIgMTUuOTQ0IDExLjYxMSAwIDkuODg0OC0yOS44NjggMTcuODk3LTY2LjcxIDE3Ljg5NyIgZmlsbD0iI2YwZjBlZiIvPgogICA8cGF0aCBpZD0icGF0aDQ4NTMiIGQ9Im0zNDUuNTggOTQyLjJjLTM2LjY2NiAwLTY2LjM5LTcuOTc1MS02Ni4zOS0xNy44MTIgMC00LjM5MjYgNS45Mjg4LTguNDEzNiAxNS43NTQtMTEuNTIgMC4wNDUgMC4wMzEyIDAuMDg1IDAuMDYgMC4xMzEyNSAwLjA5MTItOS43MTEyIDMuMDg2NS0xNS41NjQgNy4wNzM4LTE1LjU2NCAxMS40MjkgMCA5Ljc5IDI5LjU4IDE3LjcyNiA2Ni4wNjkgMTcuNzI2IDM2LjQ4OCAwIDY2LjA2OC03LjkzNjEgNjYuMDY4LTE3LjcyNiAwLTQuMzU1LTUuODUyNS04LjM0MjItMTUuNTYyLTExLjQyOSAwLjA0NS0wLjAzMTIgMC4wODUtMC4wNiAwLjEzMTItMC4wOTEyIDkuODIzOCAzLjEwNjQgMTUuNzUyIDcuMTI3NCAxNS43NTIgMTEuNTIgMCA5LjgzNzQtMjkuNzI0IDE3LjgxMi02Ni4zODkgMTcuODEyIiBmaWxsPSIjZWZlZmVmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg1NSIgZD0ibTM0NS41OCA5NDIuMTFjLTM2LjQ4OSAwLTY2LjA2OS03LjkzNjEtNjYuMDY5LTE3LjcyNiAwLTQuMzU1IDUuODUyNS04LjM0MjIgMTUuNTY0LTExLjQyOSAwLjA0MjUgMC4wMjk5IDAuMDg3NSAwLjA2MjUgMC4xMzEyNSAwLjA5MjQtOS41OTM4IDMuMDY0OS0xNS4zNzQgNy4wMi0xNS4zNzQgMTEuMzM2IDAgOS43NDI2IDI5LjQzNiAxNy42NCA2NS43NDggMTcuNjQgMzYuMzExIDAgNjUuNzQ4LTcuODk3NSA2NS43NDgtMTcuNjQgMC00LjMxNjQtNS43OC04LjI3MTUtMTUuMzc1LTExLjMzNiAwLjA0NC0wLjAyOTkgMC4wODktMC4wNjI1IDAuMTMyNS0wLjA5MjQgOS43MSAzLjA4NjUgMTUuNTYyIDcuMDczOCAxNS41NjIgMTEuNDI5IDAgOS43OS0yOS41OCAxNy43MjYtNjYuMDY4IDE3LjcyNiIgZmlsbD0iI2VlZSIvPgogICA8cGF0aCBpZD0icGF0aDQ4NTciIGQ9Im0zNDUuNTggOTQyLjAzYy0zNi4zMTEgMC02NS43NDgtNy44OTc1LTY1Ljc0OC0xNy42NCAwLTQuMzE2NCA1Ljc4LTguMjcxNSAxNS4zNzQtMTEuMzM2IDAuMDQzNyAwLjAzMDIgMC4wODg4IDAuMDYxNSAwLjEzMjUgMC4wOTEyLTkuNDgyNSAzLjA0NjQtMTUuMTg2IDYuOTY2NC0xNS4xODYgMTEuMjQ1IDAgOS42OTQ5IDI5LjI5MiAxNy41NTQgNjUuNDI4IDE3LjU1NCAzNi4xMzQgMCA2NS40MjYtNy44NTg4IDY1LjQyNi0xNy41NTQgMC00LjI3ODgtNS43MDM3LTguMTk4OC0xNS4xODYtMTEuMjQ1IDAuMDQ0LTAuMDI5NyAwLjA5LTAuMDYxIDAuMTMyNS0wLjA5MTIgOS41OTUgMy4wNjQ5IDE1LjM3NSA3LjAyIDE1LjM3NSAxMS4zMzYgMCA5Ljc0MjYtMjkuNDM2IDE3LjY0LTY1Ljc0OCAxNy42NCIgZmlsbD0iI2VlZWVlZCIvPgogICA8cGF0aCBpZD0icGF0aDQ4NTkiIGQ9Im0zNDUuNTggOTQxLjk0Yy0zNi4xMzUgMC02NS40MjgtNy44NTg4LTY1LjQyOC0xNy41NTQgMC00LjI3ODggNS43MDM4LTguMTk4OCAxNS4xODYtMTEuMjQ1IDAuMDQ1IDAuMDMxMiAwLjA4NjMgMC4wNjAxIDAuMTMyNSAwLjA5MTQtOS4zNjc1IDMuMDI2Mi0xNC45OTggNi45MTM1LTE0Ljk5OCAxMS4xNTQgMCA5LjY0NzUgMjkuMTQ5IDE3LjQ2NyA2NS4xMDYgMTcuNDY3IDM1Ljk1NiAwIDY1LjEwNi03LjgxOTggNjUuMTA2LTE3LjQ2NyAwLTQuMjQwMi01LjYzMTItOC4xMjc1LTE0Ljk5OS0xMS4xNTQgMC4wNDYtMC4wMzEyIDAuMDg4LTAuMDYwMSAwLjEzMjUtMC4wOTE0IDkuNDgyNSAzLjA0NjQgMTUuMTg2IDYuOTY2NCAxNS4xODYgMTEuMjQ1IDAgOS42OTQ5LTI5LjI5MiAxNy41NTQtNjUuNDI2IDE3LjU1NCIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDQ4NjEiIGQ9Im0zNDUuNTggOTQxLjg1Yy0zNS45NTggMC02NS4xMDYtNy44MTk4LTY1LjEwNi0xNy40NjcgMC00LjI0MDIgNS42My04LjEyNzUgMTQuOTk4LTExLjE1NCAwLjA0MzcgMC4wMzAzIDAuMDkgMC4wNjI1IDAuMTMzNzUgMC4wOTIzLTkuMjUxMiAzLjAwNTQtMTQuODExIDYuODU4OS0xNC44MTEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNiAxNy4zODEgNjQuNzg2IDE3LjM4MXM2NC43ODUtNy43ODEyIDY0Ljc4NS0xNy4zODFjMC00LjIwMjYtNS41Ni04LjA1NjEtMTQuODExLTExLjA2MiAwLjA0NC0wLjAyOTggMC4wOTEtMC4wNjIgMC4xMzM4LTAuMDkyMyA5LjM2NzUgMy4wMjYyIDE0Ljk5OSA2LjkxMzUgMTQuOTk5IDExLjE1NCAwIDkuNjQ3NS0yOS4xNSAxNy40NjctNjUuMTA2IDE3LjQ2NyIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDQ4NjMiIGQ9Im0zNDUuNTggOTQxLjc3Yy0zNS43OCAwLTY0Ljc4Ni03Ljc4MTItNjQuNzg2LTE3LjM4MSAwLTQuMjAyNiA1LjU2LTguMDU2MSAxNC44MTEtMTEuMDYyIDAuMDQ1IDAuMDMxNyAwLjA4NzUgMC4wNiAwLjEzMzc1IDAuMDkxMi05LjEzODggMi45ODU0LTE0LjYyNCA2LjgwNjYtMTQuNjI0IDEwLjk3IDAgOS41NTI4IDI4Ljg2MSAxNy4yOTUgNjQuNDY1IDE3LjI5NSAzNS42MDMgMCA2NC40NjQtNy43NDIyIDY0LjQ2NC0xNy4yOTUgMC00LjE2MzYtNS40ODUtNy45ODQ5LTE0LjYyNC0xMC45NyAwLjA0Ni0wLjAzMTIgMC4wODktMC4wNTk1IDAuMTMzNy0wLjA5MTIgOS4yNTEzIDMuMDA1NCAxNC44MTEgNi44NTg5IDE0LjgxMSAxMS4wNjIgMCA5LjYwMDEtMjkuMDA1IDE3LjM4MS02NC43ODUgMTcuMzgxIiBmaWxsPSIjZWNlY2ViIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg2NSIgZD0ibTM0NS41OCA5NDEuNjhjLTM1LjYwNCAwLTY0LjQ2NS03Ljc0MjItNjQuNDY1LTE3LjI5NSAwLTQuMTYzNiA1LjQ4NS03Ljk4NDkgMTQuNjI0LTEwLjk3IDAuMDQzOCAwLjAzMDMgMC4wOTEzIDAuMDYyNSAwLjEzMzc1IDAuMDkyOC05LjAyMzggMi45NjM5LTE0LjQzNiA2Ljc1MS0xNC40MzYgMTAuODc4IDAgOS41MDQ5IDI4LjcxOCAxNy4yMSA2NC4xNDQgMTcuMjEgMzUuNDI1IDAgNjQuMTQ0LTcuNzA1MSA2NC4xNDQtMTcuMjEgMC00LjEyNjUtNS40MTM3LTcuOTEzNi0xNC40MzgtMTAuODc5IDAuMDQ0LTAuMDI4OSAwLjA5MS0wLjA2MTEgMC4xMzM4LTAuMDkxNCA5LjEzODcgMi45ODU0IDE0LjYyNCA2LjgwNjYgMTQuNjI0IDEwLjk3IDAgOS41NTI4LTI4Ljg2MSAxNy4yOTUtNjQuNDY0IDE3LjI5NSIgZmlsbD0iI2VjZWJlYSIvPgogICA8cGF0aCBpZD0icGF0aDQ4NjciIGQ9Im0zNDUuNTggOTQxLjZjLTM1LjQyNiAwLTY0LjE0NC03LjcwNTEtNjQuMTQ0LTE3LjIxIDAtNC4xMjY1IDUuNDEyNS03LjkxMzYgMTQuNDM2LTEwLjg3OCAwLjA0NjMgMC4wMzEyIDAuMDkgMC4wNjAxIDAuMTM1IDAuMDkxNC04LjkxMTIgMi45NDM5LTE0LjI1MSA2LjY5NzItMTQuMjUxIDEwLjc4NiAwIDkuNDU3NSAyOC41NzUgMTcuMTI0IDYzLjgyNCAxNy4xMjQgMzUuMjQ4IDAgNjMuODIzLTcuNjY2IDYzLjgyMy0xNy4xMjQgMC00LjA4ODktNS4zNC03Ljg0MjItMTQuMjUxLTEwLjc4NiAwLjA0Ni0wLjAzMTIgMC4wODktMC4wNjE1IDAuMTM1LTAuMDkyOCA5LjAyMzggMi45NjUyIDE0LjQzOCA2Ljc1MjQgMTQuNDM4IDEwLjg3OSAwIDkuNTA0OS0yOC43MTkgMTcuMjEtNjQuMTQ0IDE3LjIxIiBmaWxsPSIjZWJlYmVhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg2OSIgZD0ibTM0NS41OCA5NDEuNTFjLTM1LjI0OSAwLTYzLjgyNC03LjY2Ni02My44MjQtMTcuMTI0IDAtNC4wODg5IDUuMzQtNy44NDIyIDE0LjI1MS0xMC43ODYgMC4wNDM3IDAuMDI5NyAwLjA5MjUgMC4wNjI1IDAuMTM2MjUgMC4wOTEyLTguNzk4OCAyLjkyNDktMTQuMDY2IDYuNjQ1Mi0xNC4wNjYgMTAuNjk1IDAgOS40MTAxIDI4LjQzIDE3LjAzOCA2My41MDIgMTcuMDM4IDM1LjA3MSAwIDYzLjUwMS03LjYyNzUgNjMuNTAxLTE3LjAzOCAwLTQuMDQ5OC01LjI2NzUtNy43Ny0xNC4wNjYtMTAuNjk1IDAuMDQ0LTAuMDI4NyAwLjA5Mi0wLjA2MTUgMC4xMzYyLTAuMDkxMiA4LjkxMTMgMi45NDM5IDE0LjI1MSA2LjY5NzIgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzUgMTcuMTI0LTYzLjgyMyAxNy4xMjQiIGZpbGw9IiNlYWVhZTkiLz4KICAgPHBhdGggaWQ9InBhdGg0ODcxIiBkPSJtMzQ1LjU4IDk0MS40MmMtMzUuMDcyIDAtNjMuNTAyLTcuNjI3NS02My41MDItMTcuMDM4IDAtNC4wNDk4IDUuMjY3NS03Ljc3IDE0LjA2Ni0xMC42OTUgMC4wNDYyIDAuMDMxMiAwLjA5IDAuMDYxMSAwLjEzNjI1IDAuMDkyNC04LjY4NSAyLjkwMzgtMTMuODgyIDYuNTg5OC0xMy44ODIgMTAuNjAyIDAgOS4zNjIyIDI4LjI4OCAxNi45NTEgNjMuMTgyIDE2Ljk1MXM2My4xODEtNy41ODg5IDYzLjE4MS0xNi45NTFjMC00LjAxMjgtNS4xOTc1LTcuNjk4OC0xMy44ODItMTAuNjAyIDAuMDQ2LTAuMDMxMiAwLjA5LTAuMDYxMSAwLjEzNjMtMC4wOTI0IDguNzk4NyAyLjkyNDkgMTQuMDY2IDYuNjQ1MSAxNC4wNjYgMTAuNjk1IDAgOS40MTAxLTI4LjQzIDE3LjAzOC02My41MDEgMTcuMDM4IiBmaWxsPSIjZWFlYWU4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDg3MyIgZD0ibTM0NS41OCA5NDEuMzRjLTM0Ljg5NSAwLTYzLjE4Mi03LjU4ODktNjMuMTgyLTE2Ljk1MSAwLTQuMDEyOCA1LjE5NzUtNy42OTg4IDEzLjg4Mi0xMC42MDIgMC4wNDYyIDAuMDMxMiAwLjA5IDAuMDYxIDAuMTM2MjUgMC4wOTEzLTguNTcyNSAyLjg4MzgtMTMuNjk4IDYuNTM3Ni0xMy42OTggMTAuNTExIDAgOS4zMTUgMjguMTQ0IDE2Ljg2NSA2Mi44NjEgMTYuODY1IDM0LjcxNiAwIDYyLjg2LTcuNTQ5OCA2Mi44Ni0xNi44NjUgMC0zLjk3MzYtNS4xMjUtNy42Mjc1LTEzLjY5OC0xMC41MTEgMC4wNDYtMC4wMzAzIDAuMDktMC4wNiAwLjEzNjItMC4wOTEzIDguNjg1IDIuOTAzOCAxMy44ODIgNi41ODk4IDEzLjg4MiAxMC42MDIgMCA5LjM2MjItMjguMjg4IDE2Ljk1MS02My4xODEgMTYuOTUxIiBmaWxsPSIjZTllOWU4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDg3NSIgZD0ibTM0NS41OCA5NDEuMjVjLTM0LjcxOCAwLTYyLjg2MS03LjU0OTgtNjIuODYxLTE2Ljg2NSAwLTMuOTczNiA1LjEyNS03LjYyNzUgMTMuNjk4LTEwLjUxMSAwLjA0NjIgMC4wMzEyIDAuMDkxMyAwLjA2MSAwLjEzNzUgMC4wOTIzLTguNDYgMi44NjI5LTEzLjUxNCA2LjQ4My0xMy41MTQgMTAuNDE5IDAgOS4yNjc2IDI4IDE2Ljc3OSA2Mi41NCAxNi43NzlzNjIuNTQtNy41MTEyIDYyLjU0LTE2Ljc3OWMwLTMuOTM2LTUuMDU1LTcuNTU2MS0xMy41MTUtMTAuNDE5IDAuMDQ2LTAuMDMxMiAwLjA5MS0wLjA2MSAwLjEzNzUtMC4wOTIzIDguNTcyNSAyLjg4MzggMTMuNjk4IDYuNTM3NiAxMy42OTggMTAuNTExIDAgOS4zMTUtMjguMTQ0IDE2Ljg2NS02Mi44NiAxNi44NjUiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg0ODc3IiBkPSJtMzQ1LjU4IDk0MS4xNmMtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTQtMTAuNDE5IDAuMDQ2MiAwLjAzMTIgMC4wOTI1IDAuMDYxNiAwLjEzODc1IDAuMDkyOS04LjM1IDIuODQxMy0xMy4zMzIgNi40MjczLTEzLjMzMiAxMC4zMjYgMCA5LjIyMDIgMjcuODU2IDE2LjY5NCA2Mi4yMiAxNi42OTQgMzQuMzYzIDAgNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0IDAtMy44OTg5LTQuOTgyNS03LjQ4NDktMTMuMzMyLTEwLjMyNiAwLjA0Ny0wLjAzMTIgMC4wOTItMC4wNjE2IDAuMTM4OC0wLjA5MjkgOC40NiAyLjg2MjkgMTMuNTE1IDYuNDgzIDEzLjUxNSAxMC40MTkgMCA5LjI2NzYtMjggMTYuNzc5LTYyLjU0IDE2Ljc3OSIgZmlsbD0iI2U4ZThlNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4NzkiIGQ9Im0zNDUuNTggOTQxLjA4Yy0zNC4zNjQgMC02Mi4yMi03LjQ3MzYtNjIuMjItMTYuNjk0IDAtMy44OTg5IDQuOTgyNS03LjQ4NDkgMTMuMzMyLTEwLjMyNiAwLjA0NjIgMC4wMjk4IDAuMDkxMyAwLjA2IDAuMTM3NSAwLjA5MTMtOC4yMzYyIDIuODE5OS0xMy4xNDkgNi4zNzM1LTEzLjE0OSAxMC4yMzUgMCA5LjE3MjQgMjcuNzEyIDE2LjYwNyA2MS44OTkgMTYuNjA3IDM0LjE4NSAwIDYxLjg5OC03LjQzNTEgNjEuODk4LTE2LjYwNyAwLTMuODYxNC00LjkxMTMtNy40MTUtMTMuMTQ5LTEwLjIzNSAwLjA0Ni0wLjAzMTIgMC4wOTItMC4wNjE1IDAuMTM3NS0wLjA5MTMgOC4zNSAyLjg0MTMgMTMuMzMyIDYuNDI3MyAxMy4zMzIgMTAuMzI2IDAgOS4yMjAyLTI3Ljg1NiAxNi42OTQtNjIuMjE5IDE2LjY5NCIgZmlsbD0iI2U3ZTdlNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4ODEiIGQ9Im0zNDUuNTggOTQwLjk5Yy0zNC4xODYgMC02MS44OTktNy40MzUxLTYxLjg5OS0xNi42MDcgMC0zLjg2MTQgNC45MTI1LTcuNDE1IDEzLjE0OS0xMC4yMzUgMC4wNDc1IDAuMDMxMiAwLjA5MzcgMC4wNjEgMC4xNCAwLjA5MjItOC4xMjYyIDIuNzk4OS0xMi45NjkgNi4zMTk5LTEyLjk2OSAxMC4xNDMgMCA5LjEyMzUgMjcuNTcgMTYuNTIxIDYxLjU3OSAxNi41MjEgMzQuMDA4IDAgNjEuNTc4LTcuMzk4IDYxLjU3OC0xNi41MjEgMC0zLjgyMzgtNC44NC03LjM0MzgtMTIuOTY5LTEwLjE0MyAwLjA0Ni0wLjAzMTIgMC4wOTQtMC4wNjEgMC4xNC0wLjA5MjIgOC4yMzc1IDIuODE5OSAxMy4xNDkgNi4zNzM1IDEzLjE0OSAxMC4yMzUgMCA5LjE3MjQtMjcuNzEyIDE2LjYwNy02MS44OTggMTYuNjA3IiBmaWxsPSIjZTdlNmU1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDg4MyIgZD0ibTM0NS41OCA5NDAuOTFjLTM0LjAwOSAwLTYxLjU3OS03LjM5OC02MS41NzktMTYuNTIxIDAtMy44MjI4IDQuODQyNS03LjM0MzggMTIuOTY5LTEwLjE0MyAwLjA0NjMgMC4wMzA0IDAuMDkzNyAwLjA2MTYgMC4xNCAwLjA5MTQtOC4wMTYyIDIuNzc4OC0xMi43ODggNi4yNjYxLTEyLjc4OCAxMC4wNTEgMCA5LjA3NzYgMjcuNDI2IDE2LjQzNSA2MS4yNTggMTYuNDM1IDMzLjgzMSAwIDYxLjI1Ni03LjM1NzQgNjEuMjU2LTE2LjQzNSAwLTMuNzg1MS00Ljc3MTItNy4yNzI1LTEyLjc4Ni0xMC4wNTEgMC4wNDYtMC4wMjk4IDAuMDkzLTAuMDYxIDAuMTM4Ny0wLjA5MTQgOC4xMjg4IDIuNzk4OSAxMi45NjkgNi4zMTg5IDEyLjk2OSAxMC4xNDMgMCA5LjEyMzUtMjcuNTcgMTYuNTIxLTYxLjU3OCAxNi41MjEiIGZpbGw9IiNlNmU2ZTQiLz4KICAgPHBhdGggaWQ9InBhdGg0ODg1IiBkPSJtMzQ1LjU4IDk0MC44MmMtMzMuODMxIDAtNjEuMjU4LTcuMzU3NC02MS4yNTgtMTYuNDM1IDAtMy43ODUxIDQuNzcxMi03LjI3MjUgMTIuNzg4LTEwLjA1MSAwLjA0NjIgMC4wMzEyIDAuMDkzNyAwLjA2MjUgMC4xNCAwLjA5MjItNy45MDYyIDIuNzU3OS0xMi42MDYgNi4yMTE1LTEyLjYwNiA5Ljk1OSAwIDkuMDI4OCAyNy4yODEgMTYuMzQ5IDYwLjkzNiAxNi4zNDkgMzMuNjU0IDAgNjAuOTM2LTcuMzE5OSA2MC45MzYtMTYuMzQ5IDAtMy43NDc1LTQuNzAxMi03LjIwMTEtMTIuNjA4LTkuOTU5IDAuMDQ2LTAuMDI5NyAwLjA5NC0wLjA2MSAwLjE0MTMtMC4wOTIyIDguMDE1IDIuNzc4OCAxMi43ODYgNi4yNjYxIDEyLjc4NiAxMC4wNTEgMCA5LjA3NzYtMjcuNDI1IDE2LjQzNS02MS4yNTYgMTYuNDM1IiBmaWxsPSIjZTVlNWUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg4NyIgZD0ibTM0NS41OCA5NDAuNzNjLTMzLjY1NSAwLTYwLjkzNi03LjMxOTktNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NSA0LjctNy4yMDExIDEyLjYwNi05Ljk1OSAwLjA0NjIgMC4wMzAzIDAuMDk1IDAuMDYxNSAwLjE0MTI1IDAuMDkyOC03Ljc5NjIgMi43MzY0LTEyLjQyOCA2LjE1NjItMTIuNDI4IDkuODY2MiAwIDguOTgxNSAyNy4xMzkgMTYuMjYzIDYwLjYxNiAxNi4yNjMgMzMuNDc2IDAgNjAuNjE1LTcuMjgxMiA2MC42MTUtMTYuMjYzIDAtMy43MS00LjYzMTMtNy4xMjk5LTEyLjQyOC05Ljg2NjIgMC4wNDYtMC4wMzEyIDAuMDk1LTAuMDYyNSAwLjE0MTItMC4wOTI4IDcuOTA2MyAyLjc1NzkgMTIuNjA4IDYuMjExNSAxMi42MDggOS45NTkgMCA5LjAyODgtMjcuMjgyIDE2LjM0OS02MC45MzYgMTYuMzQ5IiBmaWxsPSIjZTRlNGUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg4OSIgZD0ibTM0NS41OCA5NDAuNjVjLTMzLjQ3OCAwLTYwLjYxNi03LjI4MTItNjAuNjE2LTE2LjI2MyAwLTMuNzEgNC42MzEyLTcuMTI5OSAxMi40MjgtOS44NjYyIDAuMDQ2MiAwLjAyOTkgMC4wOTUgMC4wNjExIDAuMTQxMjUgMC4wOTE0LTcuNjg2MiAyLjcxNjItMTIuMjQ4IDYuMTAyNS0xMi4yNDggOS43NzQ5IDAgOC45MzM2IDI2Ljk5NSAxNi4xNzYgNjAuMjk1IDE2LjE3NiAzMy4yOTkgMCA2MC4yOTQtNy4yNDI2IDYwLjI5NC0xNi4xNzYgMC0zLjY3MjQtNC41NjEyLTcuMDU4Ni0xMi4yNDgtOS43NzQ5IDAuMDQ2LTAuMDMwMiAwLjA5NS0wLjA2MTUgMC4xNDEzLTAuMDkxNCA3Ljc5NjIgMi43MzY0IDEyLjQyOCA2LjE1NjIgMTIuNDI4IDkuODY2MiAwIDguOTgxNS0yNy4xMzkgMTYuMjYzLTYwLjYxNSAxNi4yNjMiIGZpbGw9IiNlNGUzZTIiLz4KICAgPHBhdGggaWQ9InBhdGg0ODkxIiBkPSJtMzQ1LjU4IDk0MC41NmMtMzMuMyAwLTYwLjI5NS03LjI0MjYtNjAuMjk1LTE2LjE3NiAwLTMuNjcyNCA0LjU2MTItNy4wNTg2IDEyLjI0OC05Ljc3NDkgMC4wNDg4IDAuMDMyMyAwLjA5MzcgMC4wNjEgMC4xNDI1IDAuMDkyMi03LjU3NzUgMi42OTM5LTEyLjA2OSA2LjA0NzktMTIuMDY5IDkuNjgyNiAwIDguODg2MiAyNi44NSAxNi4wOTEgNTkuOTc0IDE2LjA5MSAzMy4xMjMgMCA1OS45NzQtNy4yMDUxIDU5Ljk3NC0xNi4wOTEgMC0zLjYzNDgtNC40OTI1LTYuOTg4OC0xMi4wNy05LjY4MjYgMC4wNDktMC4wMzEyIDAuMDk0LTAuMDYgMC4xNDI1LTAuMDkyMiA3LjY4NjMgMi43MTYyIDEyLjI0OCA2LjEwMjUgMTIuMjQ4IDkuNzc0OSAwIDguOTMzNi0yNi45OTUgMTYuMTc2LTYwLjI5NCAxNi4xNzYiIGZpbGw9IiNlM2UzZTEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODkzIiBkPSJtMzQ1LjU4IDk0MC40OGMtMzMuMTI0IDAtNTkuOTc0LTcuMjA1MS01OS45NzQtMTYuMDkxIDAtMy42MzQ4IDQuNDkxMi02Ljk4ODggMTIuMDY5LTkuNjgyNiAwLjA0NjIgMC4wMzAzIDAuMDk2MiAwLjA2MjUgMC4xNDM3NSAwLjA5MjgtNy40Njc1IDIuNjcyNC0xMS44OTIgNS45OTIyLTExLjg5MiA5LjU4OTkgMCA4LjgzODkgMjYuNzA4IDE2LjAwNSA1OS42NTQgMTYuMDA1IDMyLjk0NSAwIDU5LjY1My03LjE2NiA1OS42NTMtMTYuMDA1IDAtMy41OTc2LTQuNDIzOC02LjkxNzUtMTEuODkxLTkuNTg5OSAwLjA0Ni0wLjAzMDMgMC4wOTYtMC4wNjI1IDAuMTQyNS0wLjA5MjggNy41Nzc1IDIuNjkzOSAxMi4wNyA2LjA0NzkgMTIuMDcgOS42ODI2IDAgOC44ODYyLTI2Ljg1MSAxNi4wOTEtNTkuOTc0IDE2LjA5MSIgZmlsbD0iI2UyZTJlMCIvPgogICA8cGF0aCBpZD0icGF0aDQ4OTUiIGQ9Im0zNDUuNTggOTQwLjM5Yy0zMi45NDYgMC01OS42NTQtNy4xNjYtNTkuNjU0LTE2LjAwNSAwLTMuNTk3NiA0LjQyNS02LjkxNzUgMTEuODkyLTkuNTg5OSAwLjA0ODggMC4wMzEyIDAuMDkzNyAwLjA2MDEgMC4xNDI1IDAuMDkwOS03LjM1ODggMi42NTI4LTExLjcxNCA1LjkzOS0xMS43MTQgOS40OTkgMCA4Ljc5MSAyNi41NjQgMTUuOTE5IDU5LjMzMiAxNS45MTlzNTkuMzMxLTcuMTI4IDU5LjMzMS0xNS45MTljMC0zLjU2LTQuMzU1LTYuODQ2Mi0xMS43MTQtOS40OTkgMC4wNDktMC4wMzA4IDAuMDk0LTAuMDU5NiAwLjE0MzctMC4wOTA5IDcuNDY3NSAyLjY3MjQgMTEuODkxIDUuOTkyMiAxMS44OTEgOS41ODk5IDAgOC44Mzg5LTI2LjcwOCAxNi4wMDUtNTkuNjUzIDE2LjAwNSIgZmlsbD0iI2UyZTFkZiIvPgogICA8cGF0aCBpZD0icGF0aDQ4OTciIGQ9Im0zNDUuNTggOTQwLjNjLTMyLjc2OSAwLTU5LjMzMi03LjEyOC01OS4zMzItMTUuOTE5IDAtMy41NiA0LjM1NS02Ljg0NjIgMTEuNzE0LTkuNDk5IDAuMDQ3NSAwLjAzMDMgMC4wOTc1IDAuMDYzIDAuMTQ1IDAuMDkyOC03LjI1MTIgMi42Mjk4LTExLjUzOSA1Ljg4MzctMTEuNTM5IDkuNDA2MiAwIDguNzQzNiAyNi40MjEgMTUuODMzIDU5LjAxMiAxNS44MzNzNTkuMDExLTcuMDg4OSA1OS4wMTEtMTUuODMzYzAtMy41MjI1LTQuMjg3NS02Ljc3NjQtMTEuNTM5LTkuNDA2MiAwLjA0OC0wLjAyOTggMC4wOTgtMC4wNjI1IDAuMTQ1LTAuMDkyOCA3LjM1ODcgMi42NTI4IDExLjcxNCA1LjkzOSAxMS43MTQgOS40OTkgMCA4Ljc5MS0yNi41NjQgMTUuOTE5LTU5LjMzMSAxNS45MTkiIGZpbGw9IiNlMWUwZGYiLz4KICAgPHBhdGggaWQ9InBhdGg0ODk5IiBkPSJtMzQ1LjU4IDk0MC4yMmMtMzIuNTkxIDAtNTkuMDEyLTcuMDg4OS01OS4wMTItMTUuODMzIDAtMy41MjI1IDQuMjg3NS02Ljc3NjQgMTEuNTM5LTkuNDA2MiAwLjA0ODcgMC4wMzEyIDAuMDk1IDAuMDYxIDAuMTQ1IDAuMDkyMi03LjE0MjUgMi42MDg5LTExLjM2MiA1LjgyOTEtMTEuMzYyIDkuMzE0IDAgOC42OTYyIDI2LjI3NiAxNS43NDYgNTguNjkxIDE1Ljc0NiAzMi40MTQgMCA1OC42OS03LjA0OTggNTguNjktMTUuNzQ2IDAtMy40ODQ5LTQuMjItNi43MDUxLTExLjM2Mi05LjMxNCAwLjA1LTAuMDMxMiAwLjA5Ni0wLjA2MSAwLjE0NS0wLjA5MjIgNy4yNTEyIDIuNjI5OSAxMS41MzkgNS44ODM4IDExLjUzOSA5LjQwNjIgMCA4Ljc0MzYtMjYuNDIgMTUuODMzLTU5LjAxMSAxNS44MzMiIGZpbGw9IiNlMGUwZGUiLz4KICAgPHBhdGggaWQ9InBhdGg0OTAxIiBkPSJtMzQ1LjU4IDk0MC4xM2MtMzIuNDE1IDAtNTguNjkxLTcuMDQ5OC01OC42OTEtMTUuNzQ2IDAtMy40ODQ5IDQuMjItNi43MDUxIDExLjM2Mi05LjMxNCAwLjA0NjMgMC4wMzAzIDAuMDk4NyAwLjA2MjUgMC4xNDUgMC4wOTI4LTcuMDM2MiAyLjU4Ni0xMS4xODYgNS43NzM1LTExLjE4NiA5LjIyMTIgMCA4LjY0ODkgMjYuMTMyIDE1LjY2IDU4LjM3IDE1LjY2IDMyLjIzNiAwIDU4LjM3LTcuMDExMyA1OC4zNy0xNS42NiAwLTMuNDQ3OC00LjE1MTMtNi42MzUyLTExLjE4OC05LjIyMTIgMC4wNDYtMC4wMzAzIDAuMDk5LTAuMDYyNSAwLjE0NS0wLjA5MjggNy4xNDI1IDIuNjA4OSAxMS4zNjIgNS44MjkxIDExLjM2MiA5LjMxNCAwIDguNjk2Mi0yNi4yNzYgMTUuNzQ2LTU4LjY5IDE1Ljc0NiIgZmlsbD0iI2UwZGZkZCIvPgogICA8cGF0aCBpZD0icGF0aDQ5MDMiIGQ9Im0zNDUuNTggOTQwLjA1Yy0zMi4yMzggMC01OC4zNy03LjAxMTMtNTguMzctMTUuNjYgMC0zLjQ0NzggNC4xNS02LjYzNTIgMTEuMTg2LTkuMjIxMiAwLjA1IDAuMDMxMiAwLjA5NzUgMC4wNjExIDAuMTQ3NSAwLjA5MjQtNi45Mjg4IDIuNTY0OS0xMS4wMTQgNS43MTg4LTExLjAxNCA5LjEyODkgMCA4LjYwMTEgMjUuOTkgMTUuNTc1IDU4LjA1IDE1LjU3NSAzMi4wNTkgMCA1OC4wNDktNi45NzQxIDU4LjA0OS0xNS41NzUgMC0zLjQxMDEtNC4wODUtNi41NjQtMTEuMDEyLTkuMTI4OSAwLjA0OS0wLjAzMTIgMC4wOTYtMC4wNjExIDAuMTQ2My0wLjA5MjQgNy4wMzYyIDIuNTg2IDExLjE4OCA1Ljc3MzUgMTEuMTg4IDkuMjIxMiAwIDguNjQ4OS0yNi4xMzQgMTUuNjYtNTguMzcgMTUuNjYiIGZpbGw9IiNkZmRlZGMiLz4KICAgPHBhdGggaWQ9InBhdGg0OTA1IiBkPSJtMzQ1LjU4IDkzOS45NmMtMzIuMDYgMC01OC4wNS02Ljk3NDEtNTguMDUtMTUuNTc1IDAtMy40MTAxIDQuMDg1LTYuNTY0IDExLjAxNC05LjEyODkgMC4wNDg3IDAuMDMxMiAwLjA5NjIgMC4wNjE1IDAuMTQ2MjUgMC4wOTI4LTYuODIxMiAyLjU0MjUtMTAuODM5IDUuNjYzNi0xMC44MzkgOS4wMzYxIDAgOC41NTM4IDI1Ljg0NiAxNS40ODkgNTcuNzI5IDE1LjQ4OXM1Ny43MjgtNi45MzUgNTcuNzI4LTE1LjQ4OWMwLTMuMzcyNS00LjAxNjMtNi40OTM2LTEwLjgzOS05LjAzNjEgMC4wNS0wLjAzMTIgMC4wOTctMC4wNjE1IDAuMTQ3NS0wLjA5MjggNi45Mjc1IDIuNTY0OSAxMS4wMTIgNS43MTg4IDExLjAxMiA5LjEyODkgMCA4LjYwMTEtMjUuOTkgMTUuNTc1LTU4LjA0OSAxNS41NzUiIGZpbGw9IiNkZWRkZGIiLz4KICAgPHBhdGggaWQ9InBhdGg0OTA3IiBkPSJtMzQ1LjU4IDkzOS44N2MtMzEuODgyIDAtNTcuNzI5LTYuOTM1LTU3LjcyOS0xNS40ODkgMC0zLjM3MjUgNC4wMTc1LTYuNDkzNiAxMC44MzktOS4wMzYxIDAuMDUgMC4wMzEyIDAuMDk4NyAwLjA2MSAwLjE0NzUgMC4wOTIyLTYuNzE1IDIuNTIxNS0xMC42NjYgNS42MDg5LTEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQgMjUuNzAyIDE1LjQwMiA1Ny40MDkgMTUuNDAyIDMxLjcwNSAwIDU3LjQwOC02Ljg5NiA1Ny40MDgtMTUuNDAyIDAtMy4zMzUtMy45NTEzLTYuNDIyNC0xMC42NjYtOC45NDM5IDAuMDQ5LTAuMDMxMiAwLjA5OS0wLjA2MSAwLjE0NzUtMC4wOTIyIDYuODIyNSAyLjU0MjUgMTAuODM5IDUuNjYzNiAxMC44MzkgOS4wMzYxIDAgOC41NTM4LTI1Ljg0NSAxNS40ODktNTcuNzI4IDE1LjQ4OSIgZmlsbD0iI2RkZGNkYSIvPgogICA8cGF0aCBpZD0icGF0aDQ5MDkiIGQ9Im0zNDUuNTggOTM5Ljc5Yy0zMS43MDYgMC01Ny40MDktNi44OTYtNTcuNDA5LTE1LjQwMiAwLTMuMzM1IDMuOTUxMi02LjQyMjQgMTAuNjY2LTguOTQzOSAwLjA1IDAuMDMwMyAwLjA5ODcgMC4wNjE1IDAuMTQ4NzUgMC4wOTI4LTYuNjEgMi40OTg2LTEwLjQ5NCA1LjU1MzgtMTAuNDk0IDguODUxMSAwIDguNDU5IDI1LjU1OSAxNS4zMTYgNTcuMDg4IDE1LjMxNiAzMS41MjggMCA1Ny4wODYtNi44NTc0IDU3LjA4Ni0xNS4zMTYgMC0zLjI5NzQtMy44ODM3LTYuMzUyNS0xMC40OTQtOC44NTExIDAuMDUtMC4wMzEyIDAuMDk5LTAuMDYyNSAwLjE0ODctMC4wOTI4IDYuNzE1IDIuNTIxNSAxMC42NjYgNS42MDg5IDEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQtMjUuNzAyIDE1LjQwMi01Ny40MDggMTUuNDAyIiBmaWxsPSIjZGRkY2RhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDkxMSIgZD0ibTM0NS41OCA5MzkuN2MtMzEuNTI5IDAtNTcuMDg4LTYuODU3NC01Ny4wODgtMTUuMzE2IDAtMy4yOTc0IDMuODgzOC02LjM1MjUgMTAuNDk0LTguODUxMSAwLjA1IDAuMDI5OSAwLjEwMjUgMC4wNjM1IDAuMTUyNSAwLjA5MzctNi41MDUgMi40NzYxLTEwLjMyNCA1LjQ5NDYtMTAuMzI0IDguNzUzNSAwIDguNDE1IDI1LjQxNCAxNS4yMzMgNTYuNzY4IDE1LjIzM3M1Ni43NjgtNi44MTc5IDU2Ljc2OC0xNS4yMzNjMC0zLjI1ODktMy44Mi02LjI3ODktMTAuMzI4LTguNzU0OSAwLjA1LTAuMDMxMiAwLjEtMC4wNjI1IDAuMTUtMC4wOTI0IDYuNjEgMi40OTg2IDEwLjQ5NCA1LjU1MzggMTAuNDk0IDguODUxMSAwIDguNDU5LTI1LjU1OSAxNS4zMTYtNTcuMDg2IDE1LjMxNiIgZmlsbD0iI2RjZGJkOSIvPgogICA8cGF0aCBpZD0icGF0aDQ5MTMiIGQ9Im0zNTIuMyA5MjQuMzljMCAwLjk5NzUtMy4wMTEyIDEuODA1MS02LjcyNjIgMS44MDUxLTMuNzE2MiAwLTYuNzI3NS0wLjgwNzYyLTYuNzI3NS0xLjgwNTEgMC0wLjk5NjEyIDMuMDExMi0xLjgwNTEgNi43Mjc1LTEuODA1MSAzLjcxNSAwIDYuNzI2MiAwLjgwOSA2LjcyNjIgMS44MDUxIiBmaWxsPSIjMTAwZjBkIi8+CiAgPC9nPgogIDxnIGlkPSJnNDk5NyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzQ5OTkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUwMDEtOCkiPgogICAgPHBhdGggaWQ9InBhdGg1MDEzIiBkPSJtMjg4MC41IDM3MzYuOWMwIDEwNS4yNi04Ni4xMiAxOTEuMzgtMTkxLjM4IDE5MS4zOGgtNzMuNDRjLTEwLjU1IDAtMjAuOTEtMC44Ni0zMS0yLjUydjE3NjMuOGMwIDMwLjQ2LTI0LjcgNTUuMTYtNTUuMTcgNTUuMTYtMzAuNDYgMC01NS4xNi0yNC43LTU1LjE2LTU1LjE2di0xODIzLjljLTMxLjA2LTM0LjA2LTUwLjA2LTc5LjI4LTUwLjA2LTEyOC43MnYxOTI1YzAgMTA1LjI2IDg2LjEyIDE5MS4zOCAxOTEuMzkgMTkxLjM4aDczLjQ0YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di0xOTI1IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTAwNSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzUwMTUiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IDE0LjAzMiA5NDguMTQpIj4KICAgPGcgaWQ9Imc1MDE3IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1MDE5LTQpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTAyOSIgZD0ibTI4ODAuNSAxNDAwLjdjLTcwLjA4IDI5Ljc2LTE0Ny4xNyA0Ni4yMi0yMjguMSA0Ni4yMi04MC45NCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjJ2MjMzNi4yYzAgNDkuNDQgMTkgOTQuNjYgNTAuMDYgMTI4Ljcydi0yMjc4LjhjMC0zMC40NyAyNC43LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ3IDAgNTUuMTcgMjQuNjkgNTUuMTcgNTUuMTZ2MjMzOC45YzEwLjA5IDEuNjYgMjAuNDUgMi41MiAzMSAyLjUyaDczLjQ0YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di0yMzM2LjIiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ1MDIzKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNTAzMSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzUwMzMiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUwMzUtMikiPgogICAgPHBhdGggaWQ9InBhdGg1MDQ1IiBkPSJtMzIzNi41IDg2Mi43NWMwLTMyMi42NC0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4IDAgMzIyLjYzIDI2MS41NCA1ODQuMTggNTg0LjE4IDU4NC4xOCAzMjIuNjMgMCA1ODQuMTctMjYxLjU1IDU4NC4xNy01ODQuMTgiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1MDcxLTYpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1MDQ3IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAxNC4wMzIgOTQ4LjE0KSI+CiAgIDxnIGlkPSJnNTA0OSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTA1MS05KSI+CiAgICA8cGF0aCBpZD0icGF0aDUwNjEiIGQ9Im0yOTk0LjkgMTE3MnYyLjAzLTEuMDEtMS4wMiIgZmlsbD0idXJsKCNyYWRpYWxHcmFkaWVudDUwNzEtNikiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzUwNjMiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IDE0LjAzMiA5NDguMTQpIj4KICAgPGcgaWQ9Imc1MDY1IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1MDY3LTQpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTA3NyIgZD0ibTI4NzIuOSAxMDA2LjljNzQuNTkgMzkuODMgMTIxLjk5IDk5LjQ5IDEyMS45OSAxNjYuMTZ2LTEuMDJjLTAuNDgtNjYuMjYtNDcuNzYtMTI1LjUxLTEyMS45OS0xNjUuMTRtMTIxLjk5IDE2Ni4xNmMwIDMxLjM5LTEwLjUgNjEuMjEtMjkuNCA4OC4xNSAxOC43LTI2LjY1IDI5LjE4LTU2LjEzIDI5LjQtODcuMTR2LTEuMDEiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1MDcxLTYpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1MDc5IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAxNC4wMzIgOTQ4LjE0KSI+CiAgIDxnIGlkPSJnNTA4MSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTA4My01KSI+CiAgICA8ZyBpZD0iZzUwODciIHRyYW5zZm9ybT0ibWF0cml4KDY5Ni4yLDAsMCw0NTMuMiwyMzAzLjksOTQ1LjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNTA4OSIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFKRUFBQUJlQ0FZQUFBQWpadlpDQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBSGMxSlJFRlVlSnpOWGQyYTZ5aU1sTngrLzdmZHZabGp0QmNncVNRRWR0THA3dVdiT1ozWUlEQVVwUi9BNGYvOTczK0UvaUR4cCtSOFN0QWZKL25RS1B6RllKNlgvUHZsS3ArUCtzRVB1dVJQcHNEZnBDYXZ6cGpmNlp5elNmdlJDcDR3eFNwTDIvVEJld1QwRjRoN3JhWHZ0UEJKbVU4eFhaWE82MGRBSktQcnBIekNPMkM5QTVEN01uOUZXZnVXL1FSbzFvRGhVZmF6TnNCSG1jakJJZUZCWTVObFBLUXM3dGVkZEFlOHU0NzlLOVBwdTlCOXdpQXhDNmUvZUYvQ3ZVK3gwM25KZjk4V1lnTU1qVm9OMnZKNmRRTUJicktMeWg2a3Z6TEEzeHNvcHByQ2o4ZnlWOVZXMTc4THB2T1M2KzNDUUR6OWV6RlFqOEdFQU53T2VOdktYU1Y1QVBCUHBXK3pEeEdWclJ4anRSdjA3NERuM1hhZi85N3d6bmp4ZVE4bU5vMGNyaGVaK2VIVTRGRDd1a3l1b3N6NTduUjhRSEgzb3RXQ2ZOWUdLUUFtUlNWQ1hNcXMyaU9MejAvU2VlMWNJRWhNUkR4Y2JpSHZPNzJDQXlwUzJ6a2x1Qlo1dGM3NUtoWit4aW01MHlvd3Y1Mlc2dVIxdnF1QVVOczJjeE9lcXFsOHFZTXNqcDEvZnRaVFo2TUhUQ1QrOStEeFFhSWhUWlNaZ2NZMWJOVnJLbStWamhmTm9teEovTGFmOXFycjhuQmVXM29Pb01uRkdaK3lvYzJ4SFRjRDlOZzcwOEZYQytxd1N0dGs2bklJRWlhVnM1OXNUeTdid3owRkh6N2hieG5ZNzlnYnQ2Nzd3N284TDdKWXpDUlM4WHlmYm8yOGcyVkZjNURPZjYxbUlwM3RwcmFTMnNuUXc4R0pnZWFMME51b3huQVZtR1orM3doZWd1V1BJOXp2bWw2eUtWc3lrWDNTdmovU1BTbmEwNEtzOEhsOHFWaHlDallxZUM1UVlRRWdtTmV5U0ZBWkZtZ3N5blRiS0xja2NwbDlLNGhyeHlTaG5vZUQ5VWxpZWhVZjd3WU5jOC9XeG5PMnBYU2NvNTNUeE0wUVJNSUVwbFFGZ3Vsc1dGU0Vtc1ExSytiTVFuNnZRWUFJaFFidmpkMWdOTU04NVYxQktsd0RScHp1VFMyclV4bUt1aW56bmZSVTlpN2ZFK040ZVUyQnhCd041bFRPMWhjZ2t5UXF3UElONUJJUm5WZTd6TTFtRW1MdWlPUmg2MWhoOE1iNndFdFFRNEdoRnA2M2d4R0F5ekh6SXN4R0pETlVvb2Y0L3krOXpFeFRnZHBGZDlub1ZXRytZK1FSeTV6WnAycGpIOXMxZUxwS1pRT2xndlFVYW1Od2g3TW5FZ1pueFI1Ni8wZ0R5U1RSYU9QY3pKUnVhR2pMTWkrajUzZmg5cXFidjJXay9IMXJ1S050b3l3VEoyc2RhMUptS3VvWEI1YzBaQ3FtczhrL1UxbEVIUlFpRHA3SU5yTmJkS1ZMQjZlNUExOE84a1lld2J3Yk1qaURkWjZIRmNubFVFT1Y2bERuejZkOUFERzcxcmxzL3A0OHFxSmNnMERMenU5dWs4MDB5OFg2bkpsbTkzOHdrWXhnSWx2Rmh6TWhNYWZtVEZnU2tqR1NsOGdVbHlFYTRKeEZwTVo3UFRuL05rMFpLOFBwbDN6N2xQWUJ4SGtpaGJMRnRlZ2RWVDBkb3orcm1KTUJSTlJBQWFDa05vaDRQZEg5NzR2cGZWT2FzdEFSSDArajFHTGxtSGlJWVJoa2pUOXEwd3dLN0krYVl6dVpmdkVlVVdlNDFiaXY1dEFXSnNod1A0eW5wNTdobkMzN3RYdTU4ZXV4dk5lS2kyaFRCZEFBZVRnVFhjNUFTVlJyUkdlVHkrMGFRY041eEduUUdHYS9IL1dRLzZtQWhUeDJiSUF4QVdzeDhDc1Y5NVM1Zm5LRDFqdHBhd3ROQTc4Q1MxeElueG1vQU15aWpuajlzR3NJTVB2TEEwUkJFak1kSThpSHdvOHh5Z2NSdGVHNWRhYXFqZTZzd1MzaUxUbFNnUW5uUnd3MVBCbjRpdHlMaHYxKzJyVDlsU1dSYnNkY0lMSitxR0RMUEdBZnphZGdZVHFzWFMzdDhuQUdjcnljUWhjMVlXSVdBMFlBQmJDVXFpcFZaWjFvZW5YcVZEb3JzZW5QQTJRR3Rsb01yS1F5UFM4QXFweVRhNFlxYjRhTTM2U21oM3VmZDlZUkVZWURFUVFNZVNXQmJwYVkyU0xMMWI0VHBSRzdEM2FQSUZBWGNsbnpNcDBpRnhHd3lVRVM3UjN4MkUyUEo4bFFjVjVGQjVjUWovZ0JlbFVJUEcyRXFSK2hrcTM4d2FacEZJQlR4MkFYQXEzT0l1OFBxTGRxaTB0dFpHc1Raa3R2WnFtMWR6WXhTOG9UMUZRSUxzYmVFL2dlZ1JPdk4yakxLU1JFSWpiVEc3bG5sbzFuR2F0eENJU2VaMVFqc2JvSVJtK283UVNnTEI4ZkplZW5rRi9MYUp2ek5leUVYZnFVbHF2VXd5djVaNXNRbm0raGtpbzVSRHpaUTVsUllqUjZBTWtBRTZ4YUFKNDdXR0Y1Uk1qalJKMWQrbTFkTWtXMEhKU0JNejdMdklwUEJQRW11RGNQT29JbFJZQlNMS3JxdG9xQk10M205RnZiUXBhMnpxTENQajF6bytjblJEWFV5K0ZrakhHbnlEQ2pYUnNpMXVCTUx0L2dTNVRaamU2elNSdEJ3S0VaNFRtWXhFWmZPK1hMS21rZXdHTjhCR2VWaWNuZ0hwSGZ6N013MjJVNFA3Q05GU3VWVHd2cGlTRzc4aUJOeGtlUVZ4bTRzcERQUmE3NWU2WGVNZ3R4ZVc4c1k5QkZZTjFPb1FHaHZIamJpT2lnVStnL3VvVG9HUFpPRTRnRndTTjR4QUJORFRIR09raElFcGdJYkNkZjhmY01xdUtxTVF1THdPbGVhZmdYTWxibDkwa2VndVJWcVRmM054bml3QlcyVnM0SEJUeXVNM041QjA2bkVDOXkxV0NVV0krNllTS3RSNng5NXZ1b1RzekJhQkNMN3pkQ3pWb01MbEdVcFprNUE4N3l6bHlSR2FmQjUzbnI2NXhrYys4bjB3NDQxYjNNMmxWeTgrQUkxM3lzSWxqODNoaW54RVljZWxOWk1ObERHVUQyblZXZFhiYW1GZDM1d1NEb1NrRkN1K1p3djU0c01BNTJETnBTOXRDTHdjL0hqN0tNN09ST2FZdVVuN0tBVnJXdEc3TnpFcEc3SzdYVXk4OXUrQ1JuWXhzUkhXQW8reFNmQTQ4T3FHeVVhOTZUNkJwYlA4aWdheXY2cEp1VytoZTloamFQTjBLR1d1dUp3MExzMms2WlRtSkl3c0ZvV01VbVFqTm0wTjdLcWJhaFBwc3FHeWRjV2VCNHRuT1N6MW1vbUozc0RENTM2d2ZqV0lUYnAyY1pEcWl1Z1RmWGFEQlJtUHhqS3dqYVFjd3kyUy9NSGdyQXRhOEd3dlNqMlVOSnhSR0JYUVM2TUd6a1REMldnYkRxMEczMGVsUHVwOVBPM25xbS9qaTUyTmtWbnd2bDlTNzhncXhuK1NiMUJleERDRlpRWjBTdXRuRExhOCsybU4weWxqL0FOdXAyRVlOeHhDQ0pBckMwSU5wZE9rZXFDR3UxUlRjQ0txbmJEU1BsOUYxV2VnTElOUU90RGVWWUxtK004YzAwRVJ4Nk5TZVljdUx5ZE1rYzE4bXFkanNUNlNqMTFJVG9iSFFOdGNYRTNMMnZ3MkkvRW13bDFCL1Q0RnRmWEFZV0FTbTZIdWV0Uzg5WFBqZzhQckxlSzZOZXFkQWJsL203cWR6enZNeGJYQnNEbXZPc3JOTDZHZnRsTkpTSkhHQWVFOEpyWU9uS3JOWjBQQytJSndrSm5TVGRvdEVGVmlZaDRtaUR3SllURzgwSUhMK3VvQ3Z0bGZTQTZER0VEc2hBTlhVWmpiOXNIMkhaWGZvclZZYnBmdlZGRjM2RWRnd1J5MGZWdHBLdmNiNmVnTU1ENjNCUVhRZ29sa1o2WEZXSTZGUmhseEI5S1hoRUJ5T2hQWUhEQTRvS3JOaDh6SXUyZ00yeFFrV1pYSlVBTjRSaXVLc0NhazVWaC82MHEvOE9TQ2VRaEZaZW9JTFc1YXBsaVYyYnZEZTVCTXRjNkREMjBYcEVtRTRaSjJDWnhKWVhqa0UvT2tnV2VSNU1oY1kxSXpOcGZ2aWNCMW9QQWhBSkdPYldSOXJVTkd1aVBKUTFQYWVvakpncTF2cU5WTlcxQ2xMc041MWhlUzR6dWR6STJGa2Rac1BacjdsY3JNUHpYeVRrYTNQS1dDZHVTVlczL0pLT1VlWjVHMGRRVmFMcUM5bEJqTWxjSGVFZ1lrUDFTaTkvQkpVWXU3QmlqM29mVXcydTFZQjhjdWYxNDQzNWVmSXNVdVZBaU9BZWFoam9ORTNRMXBsa0JPa0VBVXBKb1BMVUNJSG00UUlob2JQSmYxT1FrWW02T3k1a3F5bk0ycmowaEtOVmF1TWdhMWxuWU14STBPU21vU0lWNXoxbDRLcTR5ZDRSc1MwWDNpd3A3UVdvTGpROFo2MjJjRlRwM1pjdnhOcHhJR1Y2d05WV2ozS0xTR0NkNk1uaGl4a3l3NGV0SDZuT1VzMEZGbU8zaWFJTFAyTTJmek1XWW5maVp6dHB0cDJ3Q3hSMHNRclh0YmpJbW14NmIwT1JudGhKdTdUYjgvT2c5UGR5eWMxOXluR21QQ1Y0dW9mTFdUT0FFaXRWZGhITmF0THJFdDBLY3JucUdDTWcxRldMQ21xRGxiNVVlT0JabWRXSEpQYkJKOUJtY3phUlk1YzBHTXhwWjRFM3RmVHMzb1hCcStEYjFmT0lpVkttT0gxamF4dzhGZHNsUm9QeU13UFZyRlF0WjF4d0grdlVpTFdDN1JTNmlGVWRzYXEwTmxqRzBSelpSWTF1OGZzVVZVNitwdmx4OEd2Tk1aTjFrOXFqSzFubmJ2UTJTUGtPQjFYVnZFSnFVOVp4WWIxU2VQL3FQUWxzVk9ldGdWdGJpcmd4emVvWG9sUGtIelZtT3FodmdjaXM1RlFucm1hd2hzUVNBZ3lteXlmOVhCc0hRQkZSZU1FVjdQNjFzdGlRZkI1Tzg3d3k4SzhPN0hmVFhWWDVQclp0M2t2TktaK2FCU3ZHMGU4KzNXZVFIU0UveWxEbXd5MkhHRUxvQzhDc1ROU3o5VVZZUHljcTBCSzBUL1NGRDdNTkJDRUJlUFlPbk1SQWVqOHhGTkdLS1B6cCt6WW9aOENwVENIZ096YlNkMUxsWFlVazVjZFVPdWFvRjFudlhQcHNPK205UHVwWlpvTzFNcWFvU21PZGZkUlBFVmpGSjlqQnlDMEk2ZTU5TjZGeHUrb1hLOU5BSElmVGl4a0FJZWp5dXhIcjdGUFpPams4NEtjd2s0dElSRWV4YVd0RnpuYi9tNmM5NGlETjlhOWMvN2oxanhKVmVBOU9qSld5VjJxb1loaTluaGRVRlVndDFDVUwrd21QRTZGTnBLckdtdExNRGdrMmpReHh3ejNycm5oNkFZUStISHRYNEJQSWFMcktEUjJHTXlBWGhNVDVNdVR0VEJXVEw1dlU2VTdGNFVMd0s4bEx4QmFWZHM2R2xkYm55SlRIVzdqSGZKZ1d5RldzbjlWSFh3dkc0MFhLTzBNWGdKeStuMGowVUNMUlFiNlc1dnVNT0hoYlBNYmJ6cFdGQ1NTQlNVekZzUUkxTHFVRVZXZVB3Y1p1SmdOUTFma3dXZGd3Wi9JZzZWYmJWZi9kcWJzNytOemRYLzhtUjFEK1ExWmtwL2pPb0Y2bTJLa1YyVVV1aWd1c2N4bk1IN2JRWnBVb3V0cXYzNVhCbUVUNmFKNGlEUVpTLzdJeFRGWXZ2cUtmUWdBMDVwczRrK1FUVGZwdlJUS1JmZHFVTjljL3lVbmJQR05Yck5NbjdLWHNhenl2VGFoYXJTZWFHYVBpNHBnSEJwcUlxZ1ZXM0V3VzE3OXlQUnpVWUFUUUFKVTRRL2ExTTBHbXdPaXlSNi90dXJpWEZZMXJJN29KZEVxakRQTHhGQzFEZmRCSDArQUV1VW5CN0FMTm56bWQ4VE5KSlB0aHN4R2NQOGRCVDFOVjF0Y1JGTGJaSiszSnppQlRkakwyQ2UzczN2alpxRHQ2NW1XSldnQnBDY09NWWhlbHJCVmV2cEFHTThlRFZCWHRvdGdZWXlMYXNRL1dNelBRSDcxTlpwbDg4Ty9aY3JKbmltd091S1BJQStxcnFpSmNDMmR4Q01NR0dMTHBjblJoekZjNmVweUl4bDdabnNmRU5ISEdPTVFIbWJnTFBJWlJoL0dhSmpLZjJ3STVtZW0wc2NIR0pnU29zaGprNWRnSFM2d3NHT2pwK3RpN2FiVjBzaUxFSFdEaVVaOFpHSkdwZVBwY2VXZStDcS81THhMUnQzOUVWWVZiWnJXOHFzVTJaSjFxSG4rcElTM1MzWFp5VmpoTTFjWEhybC9hUmhFYzdKY2l1MHdtcE9lelJkVmtYSVA0YjdubE1uMzRVUG8rT09lM3Z1Sy9tbWVlUWpZaXhZcThsL015UWJZUWpiY3BnRXc4ckJFWktoOHJPbFU3MnRJQ1N6amhnYW9PRzJPTUJLMVRockJqMkVUMFZVQ05RYVkyTklKRWJsVVJCaTJqYlFTemxXYVFyajcxZHIwR2d2M0xBT08xZVVDWGlubGZaMkNwMklxZHJYUk41UXJHQXdiQytycnlZbE50SFM5czNydWQ5c2lkcng4YVM5aWFRV0JrSTVqWXZvOU80MnhnWjQvUGVSWlZWR0FiVUg3VGdpNmtZR0VFd00rcWN3Zk9UL0tTOXhkV1dIbFZzN3FKY3B3ZGF0Yyt5NEk2UnA5WDhuQ1JLUWN0Y1lHVmFENUZhM1VQZWJZOWxrU29jYmQ5RkJqOU9oc2dlamIzcnJLMmRrWlJSS2pIMW8vYlpuTlNLQzVoeExQN2tVZWdGL3d6L21BTnRtV0hoazlyc0x0cWx2WE5EQlhmdWx2eHJFMi9rZC92VksvVlVFTzRXUitybXNvcTB0VmIzaTNRTlEyVE9rUk4raWQ4eWNNcDhzKzNaVWhYUlYrRFFnd3N1cXdCWU5LVi80NGNmL2pBYU1CZWh2M3hqeDk4Qk44Z1VXNkFrTGpzeUZZZzI5cEFMNmQzclprVlJyWTRubTZ1M3Y0eHE3MUtiZ1JpbHNmbHBJcExHaGp4OXVDaUgzVGt3RllYMkx4RTQvMUUyUVpSdDU5SEk2eXBBMWpSMFBZQUpUNlViUUUzTnNKNzF1S0NmYlRPQlJNRitlL1F5bHJtL2RWWG9QWmUyK1lsRGxSaHlQZkpmbEd6Z2VLMldYdXpuUUhFeDdQblJaWUNRUmJwMXZwd29rS0FWR2dzd0xMMGhVdDJXMlhhOWpFczdYVGtyYXMvWUJxTk05bURqdnY1MEs0dVJiQW9BTkdZaWRvNGVtVmtZQTQzVXo2L2xtd0NlcFpXYkhhM3pyWktxMFhZMHJiaGVXWGRaU0RUajFWNEdPRFYrNmxqRURMNnZPcXUyOGlKNW10bUc3bDhJb3ZyU1ZlVnRqMjJzZERYMEEvYTJMN3RnNjBWcVBZOEdDbmpPNEdCcnV2VHZnY2FIK3dyNlNsbHNwVlhGcGhuTTRnQjc1dHJUOUorbSt4emFiTDlkRmNvTXROczc3Z3E2aVBHZEF6dmVxNER2ZVFEeXZ1QnJ5YmUwMFM2Tjh5bjRTWE9oQjE0WFo2L2dsaklER3NpVlROTVRab0ZEOXVRY1F6UjBXTlRjMGNvYkFVWk13TnRwVXVJdnJReFZsZnNIVk5vN1BtaUtSZzdFK3Q0bFN6ZXRZYzA3ZXJiM3J0OTF4QjRadkNnZlRwbHRkZnpOWXIyWWUyTnBRMXRnZlhHcjdvQUF6V0pMT2pNRllLTnZoa2tXaUtoT2IyWjBqcmRlaE5JS2ZXQXJBb08xN0g2bktvS1ViUDZneWFpbmRocFB5anJGemxZOVVXUUVnZmtLYUFxS2JFdGUwblRpMDJYY3ZFZUx0WXF6SHh3d3lwYzhZTTZEaGFmL2NZNmJzVVNDNDVtbEJQVzRBWlRuWjJhMmpqSncrWTJrNERqcFRiSWlOVWNNTERLTmt3eXZmVkQyYy9OWkZkOXhQN1F4NWdaREFhN2h4SThDcnNiRm54eGFiaWVTNVdqOUlLcWVaaHlWR3NWNWJyenZseTFESk81NktPRzd6S1NYRFp1alVYN3htMmZDRFMzaDNSeU05enpkdXNTeVNuMmN1MyttcjBBcE5GY1haM1hONlFKVWRqY2p5amxGTHZwUDIzVWdrbW8zcGwrOTlmUkNIWkhLVE4zOXQwclpMS0MrQlJJbnFjN2JxdkJIM0lBT25TUTQrdGw4SVhsOHdhYy90c2N0WnBEdVZoS3Yrc2JQWTBSckt3dWt3aWRYVjBNaGxCQVFOaTNiMEJ6TjEwSDFVRkNJMjhMMzUxOVdtaGdkT2xqZ3dPNzBRM3pKRkRnSnJaK244UDlPVW1vODdzSjdaTGJ2S0h0a1dWWFhwem54NFMyMG1wTENGRS8vdXpUTTc0SkpMNU1xTi92YmVqYlpkMSswckxtNFEwaUdkdGpuUXI5dEVlRFVSVjdTc0JqVjIzSjA0cVorZ2Q4dkF3WTdRQjlKRk54aEY1YjZEWXZNcVdvbHZhS0lyWm5JM1NUbnZCYWJXSDZ2WFRWdXJCcWkxQThJOCtFZmVmWGN3MU1SUDlvdU42cC92emF2clJUUzIyUHdFWUt3dUdkaVc2UFpjQUtqOHdpcEQ5VDFjTUE4QXBpZHV2Y1hzQVF2RFJuTEFVRE5wMUlZS0RUSG1IUFJHNUFocWRLN0lZZTNQL2ZWTGR2WHQvUzY3Rk1wT2xZQW8vK3hITFIzb2tiNzExbGVaa20zYkM1ckU1a09yZVZORm80dkxNK3pDTCthaGxWUm5HN2hxczh0R2xXMFd1Tkg4VnRzazdhRWJEMldKQXZkaFBLblFLSWdpQ0xaZTdTSjdjV3ZST0lySXFzWWtNOUlWTW9FMTFFQVNCUk9mcUNhalFCM0JzWDIrNmhmWHlRcnZ6SExZamVKbmQ0VHBGT2M4eTZvYXd2bGhJcktrZGpCbGo4aDRZVmFyanhua2VJdmJreExMRjYzTTlkMlVQYVJQeEZ4L0FyaldyWWt6OTAxZFV4WWJjbEpTZHpIc3Vid2dHMTI3NnpZZVJSUHN4ckFJQ2liZk9NV1IyN0oxV1hRWTlMUXFCQlI2UlJRMEJDd0JGWCtSdW90ck5udllDQmZGVS9QdmZVSEZOYmJoY0o2YThPQ1NLSEFNL2lIcDJ1REN2TGpZeXBQaDVyZWZHeVA3aXJYSytyc0RXS3A3amJQelF6UzgwYi91a1I5NVg1L05jTjQ3Tm9QRnB5QjAyMmpWK012MHlFKzZteFRGei9pZ0poMGpHNXVoVmtPVjhXT1lYK2tSRFJsekFKREliYk5WNlJyYndQMVllTjZGNlhQM0pPQVZNeUFBVEdqN0dMRU9GYjFmVlRLNENSVi8vZHU1dFZYcFZlQWRSYXhtczZySTQwRSttcm9HT3FBVWNwZnFNVEV1OUZhN05xZzU4ZjQ1Q3ZadXJNMk01RFBIN2JnMGRuS0NHb2UwL2UwV1lIOGZqWkJnUE9HSDV4MHRQeTJrRGNCY2tFZGhPd1hkeDduUjVIVmF4OWxkQkJDQ1pVYjZzQnJuNjc0dzF6WnBsV3AwdVFJYkZ0ZWFDbmZVWGdrdWNVbHk3Y1UwTmxqK3FwNTlWcGx0U1pxRnQvaklrZTkwcm9aLzN0MTk1MjF1MnhqcXZ1LzRzYnpFWWhRNFNReFkxSWRBQXZBNVF2cE1aNTd2R2xaRFlLQW9qcjdhMnA5ekx6aFBQNVZ1UGFDc25lMEhjMzdrL3l0blpTVFBtMWRsTXVtYTZNdkptWloyL081YzF4ZklGeitHSkhWbFVEcFY0MGRjY1dKZmZSYnQzRnY2VHZQdVJ4dFBWTDNmMVEzazlxMjRhMDhRVTlNWUduUnRkYkYyenQwWTJCS0F3L21tSjV5MnhPSGF3aFNHQS9FYkZqbHNxNysyVGFCUXcxNVhQNEVnWXV5eU83aDBkNENLNXJ2aGhrVERaTUtJdGJZSDFyQjliWnZicjQ1aEFpUEMzUzZCS2kwMGZOYmU4MmdFU0l6ekQ3UnhXTVJ1ODBWMGkzVmRKUVo0SE9FYVNValdITTQxc3pnMng3TUNOajY0N2pobG4yQUh1VzlyamIzOFVYZU0yQVMrQ0M3NjdTMGJoRmhZUEpEWWVvNnFJUkhxL3IvaVFQQ1JoakVSSFJNUUNrZThlNkJIL2RIb3NGSFUyY3VPUG5kU3NneU95Z1JrUmY3TWp1KzRpd21jWkZzZWxEYkRqblB5VEc2cEt6UEkxMGpFUzFoOVJ5OEp6dk82UzBQbk5mSjJmZ1BUUEc4MlJFOGYyNk5BVVJnM1JwVkJuemFKVDdWbys4S1kxc1RQdGVWN2VoakkzNks0aHhtNlQrK2hXWmVsSThUbmFNYlQ0ei9pSWl4MXArTzRjeHhrQ1BrTnNpQ2hSdFlBU1ZBak4zQkc0UndhNVRacW9IdEFMT3B4SWE3Q3RBVGE0NjNwUDVPaktOcTVYTVpCS3ZRNzhTbEd4RHZLc3hOTHAxaDVEK0g2WXRLZGxnNi9WWFljNitkdEtDcTZjN0Q4VllCemVnaWMxMk5rTTR2UU43UEVFREpSWGV3RWJtTnhpUjZvUDRFV3ZYODM1ZUlUNFc1b3RySm5NWGFOcWZ5MzhWWU0rWXA1UnEvYWtETHV1OGhFRkV6U2VVZjg3S09GdWlIR1VxRFBQRzNZK3FSVngxZVJtMWpmeDVtOFR5cDI0andDMGRydFpNUHhFYXpMaFVFUlpYN2I1K1YzYUtkR3pxVFNqWVdObzVBU0RHV3JGYmNWWUdReGsrTHFQWkwybWROWE1zUWJlOFhOeVkxRXVSWlhUVUREUTlKcXIzd01PMnNuMGNsSjhkWVRCUWxuU1BFTnBhaDAxUVhTdUxiZVBPUkNoVEFVUzZVVzNjaTF0WWg4MHk4cHMyNWVHeWlzQzdyLzFoeUs3NUdsaU9sb1pUc2FqbWRpU1JFT2JObG5ROXNpRzBiaUgzV2JZNnN4YlplVnZqK3dNYmJscFlEVlNyKzRYY21LaEVhaHpJWjNrTE5CRHRINzEyK1JTWGZpLy9PdmJwSVNYUWcrUEFvb05uM3JicTM2YW1oczg2QTF6TElWWEVkNUQ1M2lOZHpwaVhia3MrQUpuMXl4N3lETTdnV3JEQUM5cHQrcEc3VkVQWlV4djUyRmExZTVCdCtoMTlnK3NWOGtUMWhoS3I2VlgxVmhGWE1nSnp4YWo1N2Rlb2lhWHZyVFZqdWpkQmY4SktGMk9aeEg0d1dOOEtPLzR6MVVORWNKcVdpYmdGTU1USGFYWVY4MWhFbXhJN1VaMHlZeUhvUGJtOCtlcm5rb1BqQVF2dDFKakp3eUgzWTBJRWZkZnpBWU9FOXZUODhZMW9pUTZNaGFpVENOeHJkbkpXN2RnK0lIYmFJOW9qQXpnMFhvWStnb1dzNE9CcVRhcS9tMGdZejV1TkVEckRDeUUwdTUwRVlhSnhicW92b1BZY2VhNTRrTkw5dHd5QzJscUpqTmp6WmEvbFoxUE5UbG4xenBZU2dqQnUzNmltaHU2RjFnQ0t4bkFpQjhjM29rSHNSN1FVdG12b3AvQ0drYjcwcEd1WXpueEVwd3BBZTBITHhnQzNYbU5LcUJnRExjNUl3QW9HSkcrZjBwYi9MMUFHdXFwNnA1SGFTbm9mdS9SdTBmWHBZdW1kM2YwZUJ1ZFNtWVVRM0ZMa1czbHdQazYrZ3lLdXpmRTBjUVRreFd2WUxwY1Mvb1oyc200RklmQ1VlcWJHYktjNmdEdjhvU1NlK01CN1FST3JMSnhSTnVwdUUxWHoxS1BheWxJMW1JYW9hVkNlck9TLzVLaHR5andCVm1sa0Y0TmI1US9QU2xRTXJyTnM1S2tLUUNBWC9rWUE2UlJmOXlKRXJEMHpTVjk0ZFkzTDhYalFHRzJHL1BIbzBEem9XdTZpYmxzZEFKb3VMd0lKRzV4WnB3SlR6cWQ1YzJmbGhNSE5UNmU3dGJNZGNITDVGVHZoOXdwQUZYaFdlU2RHelBXS0w5TnJmZ1RZS2VPSVVQUys1c1l5Q0hTMXBRMEJMeXNOdWxidEsrNjhCUklWOWMrR3NGKzRBOVJLenBORjB1K21aOTZYZnEvYkF6dEJiSkxWcWk0Q0tKZU5XMFpvQWdSKzNnR1hKTmRQcnM2bUJMYVJWaEE4b0FKSStaZEtzMXJEUWN4cXkvY3dSVmFxYktCS1BqNVVUbUVDL0VGNlVpMjREVjZ1R015S2ZWeEd2OTlYdVRqZEc4dllZTklHZHRtd2x0V2IyRW0vbnYybXZ1eTgxNEEyaktvMFlsdVVXTnBHZUQ4RFF5dG05cGx5R1B5aS9WT3B0enNiYUdmL3ZJS2RwL3owS1R6dWpPdGN6d3BBbVlHeW5EbUNYVjNMQzY1Y3lsVndlaHQ0Z0lqbXdSSm1pQnZsQnRYa0d4WlJVNWtNSkM4RCtTU0RveDZxVHhuVnVmNVhVcVh5bjZUWGpldTY3TXJRenZjcUFQVXllVm5kNjF1ellkMWJweWdMRGRzb013RlJaS05zR3hFNVA4WmRQNzNGYU9SV2pEVDlXSGV5cWZ5QjllRmpoMVUyVUFXcVZmb0xEVmVyanZYM2VpdEhUTlZtL0JXQTVxV08yaFlLQnJYNE5TRWVjU2VPTmxHMlE3UXkzeWh3RDZReUV2d0NrTFQ0eXBBdWplaVlkV3RZVjdKL0t0M1pYOVh0R1Voelk5R215ZXBtVldmbGlWVjloKzFRQUZWdHlkcnI3SHVsWnpiU2x1SzdHUlZJRnNsZU5GWVhXU3NWTmhubzFPTklSSENPTTZtcTZrR3FTTyt1WS83S3VONVZWd05wamZLS2hXN2pRSW1aWnNhWmJhR3BqUnVWRnBnbzM5aE5XT2NOZ2N6RHFPYklaSVJBVWdFOEJ5U1JsYko5Tm9wWXF1eWRYWHQvaVh5V2RlK0FQVStFZVN1SGZZWjhLL2FSQktxS2daUWpWc1owVm1QNngvTzVLaHRiUVdaVkpoUlo1Nm1ScWd4bUlFaU1sUE5tSUJGRlZxckFCSUw5YThGS2Z1OXY3SjZjOW95ME43WXI4T1E4bFp3TUlHU2hWK0prQ3N3WWNQUy9IckZXVlNaK2JFZkF4RlpQRFcyam1ZMzZ0Y3hJUk0vWklJT0ppSUx4L2NRejg3eFBWOHQrSnoxUlZaNDNwanU3cDBxckNaUkJWYkhRVGg2eWtCRFIvd0V3WWxkZjl6LzJkUUFBQUFCSlJVNUVya0pnZ2c9PSIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogIDxwYXRoIGlkPSJwYXRoNTA5MSIgZD0ibTM1MC4xNyAyMDAuOTdoLTkuMThjLTIxLjczOSAwLTM5LjQyNCAxNy42ODUtMzkuNDI0IDM5LjQyMnY1MjMuMDdjLTI3LjM0IDE1LjY0NC00NC41MDkgNDQuNzc1LTQ0LjUwOSA3Ni44MjYgMCA0OC44MTEgMzkuNzExIDg4LjUyMiA4OC41MjIgODguNTIyIDQ4LjgxIDAgODguNTIxLTM5LjcxMiA4OC41MjEtODguNTIyIDAtMzIuMDUyLTE3LjE2OS02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4NS0zOS40MjItMzkuNDIzLTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTIzIDEwLjc2NSAyMy45MjMgMjMuOTIydjUzMi42NWMyNi4xNjEgMTEuMTA2IDQ0LjUwOSAzNy4wMzEgNDQuNTA5IDY3LjI0MiAwIDQwLjMzLTMyLjY5MiA3My4wMjItNzMuMDIxIDczLjAyMi00MC4zMyAwLTczLjAyMi0zMi42OTItNzMuMDIyLTczLjAyMiAwLTMwLjIxMSAxOC4zNDktNTYuMTM2IDQ0LjUwOS02Ny4yNDJ2LTUzMi42NWMwLTEzLjE1OCAxMC43NjUtMjMuOTIyIDIzLjkyNC0yMy45MjJoOS4xOCIgZmlsbD0iIzEwMGYwZCIvPgogIDxnIGlkPSJnNTA5MyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzUwOTUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUwOTctNikiPgogICAgPHBhdGggaWQ9InBhdGg1MTExIiBkPSJtMjY4OS4xIDU5NDEuOWgtNzMuNDRjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjIyLTM1Ni4wNy0zNDMuMjItMzU2LjA3LTU5My43IDAtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk1IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk1IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di00MjYxLjJjMjA5LjI5LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NS01ODQuMTggNTg0LjE4IDAgMjQxLjY5IDE0Ni43OSA0NDkuMDkgMzU2LjA3IDUzNy45NHY0MjYxLjJjMCAxMDUuMjYgODYuMTIgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDQiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ1MTAxKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNTExMyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzUxMTUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUxMTctMCkiPgogICAgPHBhdGggaWQ9InBhdGg1MTI5IiBkPSJtMjQ3NC4zIDM4NjUuNnYxODIzLjljMCAzMC40NiAyNC43IDU1LjE2IDU1LjE2IDU1LjE2IDMwLjQ3IDAgNTUuMTctMjQuNyA1NS4xNy01NS4xNnYtMTc2My44Yy00My4zMy03LjE0LTgxLjg5LTI4Ljk2LTExMC4zMy02MC4xNCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDUxMjEpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1MTMxIiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAxNC4wMzIgOTQ4LjE0KSI+CiAgIDxnIGlkPSJnNTEzMyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTEzNS03KSI+CiAgICA8cGF0aCBpZD0icGF0aDUxNDUiIGQ9Im0yNTI5LjUgMTUzMS43Yy0zMC40NiAwLTU1LjE2IDI0LjY5LTU1LjE2IDU1LjE2djIyNzguOGMyOC40NCAzMS4xOCA2NyA1MyAxMTAuMzMgNjAuMTR2LTIzMzguOWMwLTMwLjQ3LTI0LjctNTUuMTYtNTUuMTctNTUuMTYiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ1MTM5KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNjEwNSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzYxMDciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYxMDktOSkiPgogICAgPGcgaWQ9Imc2MTEzIiB0cmFuc2Zvcm09Im1hdHJpeCg5MTguMiwwLDAsMjUzLjIsMjE5Mi45LDYyLjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNjExNSIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFYNEFBQUJwQ0FZQUFBQTlma2hyQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBSUFCSlJFRlVlSnp0blh2d0xWbFYzNys3ei95YmtNR0JZUmdId2tzR1poeFVOQ0dPb0lRU2Rhd2FTbFRFcWxGSFVTbUloQUJURXRSWWxCS2lwYUZDYVVKRlV4Z2ZWV0pSeG9JcXFBaUpLRVJOQ0NOUERUQno3M0R2OWM2ZCs1N0xuWEdHdVo3dS9MRjc3MTU3N2JYMm83dlA0M2R2cjZwZm5UNjkxMXA3ZDUveis2eTFWKy91WTdxdTY5REwxWS83QjlpMDNIUG9NSXd4TUtZQmdIN2JvT2xmVGRQNGZVNzh2bjdiMjVIM0RkRy8zT1hHWDd3VlprV09sMjdTN1lhZGs2Q05OVFd5UCs1VHM1ZVV0SThrc3FXU2Fpdnd2UXNaL29NeTBpWjhLRzJpYjJFbnQ0OVUyUHV1N2NpMnJodm9kWXJPdXNQZGIvd1FzWWtQcGxOT1Vpc2RTL0crdGtDblM3OW5ZKzNBOWZVKzZibmh2cmtmZXBLcHo1YjQrUHF2ZXg0MktlY3ZYUFRicG0zYjZHdzkvaC85dzQwTzRON0Q5eTNnbjBsS2d3Q1FnWHVqNkFtNnNtOWxnQlVCSWV1TFM2bmVwaVVCZENvYTNJRkU4Q2lBdktpV0FIM2tJeFVVQ21FZjJpL2dyd0gvcG9FUEFPY2UvSEx3UGdBLy9SQ3V1ZnB4R3gzSW9mdU8yQUVzNEo5TlVrRUFtR2syVU9BN3RKWDNwK2hmK2xFV0I0Z05TZ3JtZ1Y1dVZxQW9GRUVlcUFNOTA0OEJwdWl0Ty96Vm16NU05TFF4TCtBdkJmL3picmtsR3ZPY2N1YjhoZUM5NDZUNSsvVTZPbHZ1WkR6aHE2N2U2S0FBNFBDWGppN2czNEJFUVFDWVpUWWc2aXQyVWo4cG4xbWphYXF6UzNHSko2TmNWZVlCc3BBWGZaWm05VXlYd3o0YzN3Sit6WGNPL0pzR1BnQ2NQbnNld01CSUtoNzg0b2NGNElsYmdQOTlSNDR0NE4rZzNQaUx0d0pBOFd3QXlKZDdwZ1FEcWIvWVY3cTl5dG1tcElMODQ4bzhtcTlNTmkvWWxtYjFBUEJYYjhobjlhbTJCZnhwOE4veXRadUgvcW16NTdWL1BjdlRTNWN1QlNPTWduOS9NTmRlOC9nTkRDK1VvOGVPTCtEZnNJaEJBSmdjQ0t5T3RFOGpmMktRUXY5SjNSMlVmRXBMUEVCSm1VZnJvNnkrTDlrblFjOXN1bldIVDk3MVArUXhMT0JYKzZ3Ri96YUFEd0FuejV3TCtBbkUvMjdtMHFWTDhYZEMrWkMyQWY5angwOHM0TitpekJZSUJCdXJwL2V0QmdYRlYwcTIrZkZYbFhnQUZleStXUUM4M1YvbVN3d1F1WXU5Q2RpSGZoYndhMzNXZ1ArV20yK094amEzbkR4elR0elBnd0FBbU1mNmpELzFBUVBEQ1hyU0U2K1pPcjRpT1g3aXBOOWV3TDk1Y2YrQXozM2J0d0NvRHdSQWZYYWZ5OVNUZ2FHaW45bWxFdndhMkcxYlhUOWpJQStVZ3o3MnZZQmY2N01FL05zQVBnQThjT29NQUxtZXo4VVlBL09WeHg1VFA5bjRwQXh5M1JZQ2dJUC9BdjdOaS9RUENKUUhBaUIxSVhka3VXZEVDYWM2V0ZSS0N1S3lmazZocnAraUZUMzllbzFQL2ZTZkVIOFZ0YW1ndndYOFdwODU4RzhMK2ljYzlJVTJMUkJFNEUvQm5wKzBKMS83aFBwUmpwRDdUNTVld0w5aDBjRFB4UVVDb0N3WUFDTUNRb0ZQVlgyRDlmNmF1djVnbFBOWkFYakZYN2Z1QXNqTC9TemdGOTl2QVB4ZmUvTk4wVGcySWZlZlBCM3RDMHJrdkkwRUFmUG9vNDlHWjh2dEVFOGtPMUhYWDNkdHhWQ255UU9ueml6ZzM1Q1VnbCtTWkRBQWt2RE9yKzRaOGJsdTRxc3c0dlNVekE2cVYvU3NPM3o2elI4Ui9KU1ZhbXRsQWIvZXB3VCtiVUdmbHNLZDhPdytHUVFjK0d0Z3ovVzZyc01OMTE5WE91Wko0aTVnTE9DZlY2YUFYNUpzTVBDTmFUOWpQOVk1U3o2MTVSMXZsek5MdEx0eXpXZDZ5R2RkTGVEZktmaHZ2dW01VWQrYmtyKzkvd0cvTFYyNEJmSkJ3RHp5NktOZDdtVDQvUXoyWFA4cE4xeGZOdklaeEsxVFhjQS9qOHdOL3BRVUJ3V3ZOTDZ2cVYrTDBhZWx3STdEZllxN0JmeTdBZjgyZ1gvMDJIRy9IWlJ0M04yNEZVSEEvTjBqai9oREs4bnN1UjV0YjdzT1QzdnFEV1ZITVpPNFc1SVg4RStUYllJL0p6UXdBSVhCSVNWanpDZWVqbzdkRVAvWnQveVpyRmNJNUFYOCt3ZittNTd6bktpL1RZbTd5ZFVKWCs3TzkrZUNnSG40NFlmakduOG1zK2V3NTdwZDErRVpUM3RxeWZITUltZk9YMWpBUDFIMkNmdzFrTHJwN1M4cTFpMEpJQnpZbW1nZ3I1VUYvQWNQL05zRS9xSDdqb1NRNzdkelFVQ3lvZUxCUHdmc2gzM1c1cGxQZjFyQm9jMG4vQWwwaTVUTFFRVi9rYitwOWhzOE53djREdzc0bjN2anN5UC9tNVI3RDk4SEFQNUp4blo3bmlCZ0hucm9vUmo4UWlsSGdqM2REbXBmWFJmb2ZNMHpuNUU5eURsbENRRDFzb0EvWWIrQXY2cnRjZ1QvdHFIL3hYc1BBVUQ4RkFNaENOQjJLUWlJcGFDTEZ5OWE4Q2ZxOW54ZkNleDVJTG54MlYrVFBkZzVaWUYvblN6Z1Q5Z3Y0SzlxdTV6QS81eG5ieGY0bi8vQ0YwVlF1MjBwQ0dobG5VWUlESDRXOE9VTEY2S012N1NVdzlzNDdPbEo3THJ0MThlQUpRQ1V5Z0wraFAwQy9xcTJ5d0g4MndZK0FQek41NzhRL21ZR3pDeEJRSndGWEhqd1FYOG1wbWIzL25vQW5BMENXN2Z1ZFp0TG9Kd3NBU0F0Qy9nVDlndjRxOW9PTXZpM1haa0FnTTkrN3EvN1I5S0hJQzhKQW1OTFFlYjgrZlArVE15ZDNRL3RRNkR3ZWx0OFRDbVZKUURJc29BL1liK0F2NnJ0SUlKL0Y4RC96R2MvNDU4eFloQkMyZ1dCSVpOSHFLZGN1QjFnbjc0Z2JNNmRPeWVBZjF4MnI4SGU2N0Zmb2RuVzcwMUtzZ1NBVUJid0ord1g4RmUxSFNUd1AvdFp6eFRIdFduNTVLYytqY2JEdmQ5cEdnOTJ1My9lV1VDdzc5eTVjMTBPK0tYWnZTdmxTTm05MDIzYnNDK24rL3h2K0FieEJHMWFsZ0JnWlFGL3duNEJmMVhiUVFEL3JvRC9pYnZ2RnNIY05DYTh5NXdFZ1pKWlFPb21MbkVXY1BiczJhNFcrRkx0dmlTN2wvcHA2VzkvQXZpbjMvU053dW5hdkZ6cEFXQUJmOEorQVg5VjI3NkRmMWZRLy9qLy9ZVGZ0cUNYQTREZGRqdkxad0UxWlNCejVzenBqb00rQmZ6U2NvN2JUWUZQL1hQZ2QxMXJnMEhiNFp2LzJRdVVVN2Q1dVZJRHdBTCtoUDBDL3FxMmZRVC90dThsb3ZJWGYvbS9ZVHpvWFUzZnlnRDZ1RDRmekFLRWF3RlRBb0E1ZmZwVUJINWdHdkNsY283VDkyMjh2ZC9ma24wdnZQVlcvV3h1V0s2MEFMQ0FQMkcvZ0wrcWJaL0EvNnhuUEYzc2R4dnlzVC8vY3dBV3RvMERkd0Q2Sm5pTVZHb1drQ29EbFY0SENQeWVPbld5R3dQODB2cDlLcnQzUGdFTGZEK09ickQ5MWhlOU1EcWgyNVFySlFBczRFL1lMK0N2YXRzSDhPOFMrQUR3WngvOVdBaHhFd1lBQU1Fc1lKTUJRSm9CbUpNbkgraUE4VFg4MllEdis2VFhHQVovTC82MmJ4TlA4TGJrY2c4QUMvZ1Q5Z3Y0cTlwMkNmNWRBLzhqZi9xbkFJYU0zbTM3MXowSkFPYUJFL2QzcVZVNmN3S2YvbkJCRHZpMC9OUzFMZHErL2FYZi91M1MrZDZhWEs0QllBRi93bjRCZjFYYkxzQy83UWRDY3ZuUWh6L2NBejBzcmRBQVFQZk5IZ0FxcndHWUV5ZU8yNHlmQWQvdUkyV2R6RVZicllaZm0rRnJ3S2U2WGRmaHU3N3pPMU9mdzFia2Nnb0NDL2dUOWd2NHE5cTJDZjVkQXg4QS92c2YvekVBQ3ZydEJBRHRJckMyQ2lpQS8vMzNIKytTZGZ6RXNreHBsVTdxb3UwWTRIUG91ekVDd0cyM2ZaZjJXV3hWTG9jQXNJQS9ZYitBdjZwdDArRGY1bTk5cE9RREgvZ2dBMnU0UWljWEFPWXFBZVdXZ1VyM0Faamp4NC8xTmY1OFdTZTFEci96KzFzWitMMlNWc1BQWmZodWpHNi84OXQxSFY1MisrM3BUMmhMY3BBRHdBTCtoUDBDL3FxMlRZRi9YNEQvdnZlL1B3QnZZNXJKQVVDN0NLeXRBbkx3RC9zckwvK1k0OGVQZGJWWmZxcXNvOVh4cVIyb1BnRSs5VjhDZkI0Z1h2NDkzNVAvMUxZa0J5MElMT0JQMkMvZ3IycWJHL3hQLzhkUFNRMXhhL0xmL3VpUFJLZ0hvR1lCZ091N0FDQ1ZiSGo1QjBBUUFPWW8vM2k5WTBlUCtMTThlNWFmS09zTVVBL0xPbEdBNklOU3krMm9mdHNOZ1FNZFh2RjkzNWYrQkxjb0J5VUFMT0JQMkMvZ3IycWJBL3o3QW5zQStJUDN2dGZDRlE3Y01kQnBBSWhMSzZFT2dDZ0FsTlQvWjgzK2p4MDkwbWxyOGt0cStWT3ovRlFkM3dHZnRuSGcyejQ3ZElnRHh3Lyt3QThVZmJEYmtuME9BZ3Y0RS9ZTCtLdmFwb0QvYVUrOW9XSjBtNVhmZjg5N0FBSGFQQURJbVgxWS9xRnRVdlkvdE1mWnYzcy9KdnZYbG42YW8wZS8xSldVZG5KTE5ITzEvS2xaZmhRZytpeWZBdCsxb2V1d1hnKzJQL3hEZHhSKzFOdVJmUXdBQy9nVDlndjRxOXBxd2I5UHNBZUEzL25kMy9NQVhhMzZUSnhCMjhFZlFCUUFTc28vSmRsLzZ1SnZPT3VJcy85czZlZm9rY1AyNG03L3VhU2dYMUxhQ1FDTUVQb2xGMjlMc254YTFnbDhFT0JMOWo5NjU0OVVmUUcySWZzU0JCYndKK3dYOEZlMWxZRC9xVS81NmxIajJiVDgxOS8rSFRHRFg2MmFBUDYrYlVUMm43cjRXMUw3ejYzOEtWbjNiNDRlT2R5NUxCOUFFdnE1ZGZrMXBaMG9RQ2haUGgxTFRaYlAyMm5mUC9HcUg2djlQbXhGZGhrRUZ2QW43QmZ3VjdWcDROOVgyQVBBZjNuM2I0VVp1UENYeS82MXJMNjI5Qk8yVFlPL3M3V3ZBL3pOa1M4ZDd1YUdmbXFaWm0xcGgvWXAxdko3NEFmOWtUOXhodEdQNXpXdi9za1JYNUh0eUxhRHdBTCtoUDBDL3FvMjJ0ZFRicmgrVkwvYmtIZjk1OTlRWUdzaStOTjJudjA3K0FNUXMvK2EwczlRUHBKTFB4VCt0ais5N3ArRS8zMkhEL1dsbnZtZ0g5YmlkZWlubG1uV2xuYWs4azdVYi8rRnRQdGI3L3QxLytLMVk3ODdXNUZ0QklFRi9BbjdCZnhWYlRkY2Y5Mm92cllsdi9ZZi94UEwwQnVoemo3QTMrb0kyWDlGNllmcVNLV2ZtcnIvSFBBMzl4MCsxTzA3OUR2RVVKZEtPOEV4Q05CdjZUamJRYWR0VzdSZGl6ZSsvdlhqdjAxYmtrMEZnUVg4Q2ZzRi9ObTJmWWM5QUx6am5lOUVZeG8wWkNYTkFHa1ovbDVISy8xSXdXRlA0Ui9VL0E4ZnVxZlArTWRkeUExS05wbWEvaWFoVDhHdlFkOEIzeDVIQ0gzdm8rMXcxNXZlT1BhN3RYV1pLeEFzNEUvWUwrQVg1YXVmL0tSUlByY3R2L3J2M3dFUStEbjRBdzdDT3Z3RG5TM0RmeE0xZjkvLzRVUDNkQlNjcVhYNlkxYnZjREFEK1pwK1RYbG5idWl2TzZEdC8wbmU4dE4zVGZpNmJWK21CSUVGL0FuN0Jmd0FEZzdvbmZ5N1gvNFZ3QmcwVFlPVlFRUi9BRDc3M3piOGEycit0YXQ5VXIvMjVYMVQ4SSs5T1d2c2trME4zcmtMdVZKcFoyN29vKysvN2Z2N056LzdscW5mdzYxTFRTQll3Sit3djRMQmYvMTExNDZ5MjZXODdlMi9CQUJvVnYyakNtYUF2MnNINUZVNzBnVmZRTC9aaTQ2QmdsL3lQMldkdjFydlAzVHZGN3NwSlo3YXVqNkZ2cmRSU2p3YytnREViRDhGZlQ5R29hYlBvUS8wNEdmUTd6cGJjT3JhRm0vOStaK2I4SlhjcmFRQ3dRTCtoUDBWQlA2RENIb0FlT3N2dk0zQ0VXVDkvSW84cGlBQi81cWFQODM2Z1JEUzJtcWYwc2M4ekZIdkx5NzVTT0FmZTFmdVhDV2Vtcm8raFg1Z3c3SjlGZnIyQUlkc1B3RjkraUM1dG0zeGIzL2hyUk8rcXJzWEdnZ1c4Q2ZzTDJQd0gxVFFPL25abjM4cnk5aGorRHZ3QThqQ240SWZrSUNiS2ZrQVdmalBVZklwZmJhUEJINEFNUGZlODRXdStIRU1NMmI3WXNhL29SSVBCN1phNG1IUTkvMEF3YkcxN1dEZnRpMSsrZTF2bS9UbDNSYzVjLzdDcm9jQVlBRy9xSmRyTHdEL1FZZThremYvek05NVVKcytrNDh5WThqdzUrQUhCbGlXbG54OFA0VWxId2QrSVAxNEJ3Mzh3eGhuelBvcCtPZXE3WE00QTV2UDl1VUFORitKaDBQZjlkVzJyWDNNQlRyODZpKzlmZElYZWg5bEY4RmdBYitnbDJ0blkzenl0VThZT2FMOWxidis5YzhBNk92bWpSSGhMNEVmS0t2M2MvQUQrVFgrYzJiOUh2S1ZXVDhORHFWWi8xVzVreTJ0NUNrUkR2MGE0ZEN2c2hNdTZPWkV5L1p6ZlZIb3Qrc1diN2pyemQ3Zk85L3hLOFhqM21lNTV1ckhSZnYyWldhd2lKWHJubmpOcm9ld01YbmRHKzVDMDhPNlhiZStiaTlKMTNWb0FUUnRDelNOWjRjRFkwcmFyZ1ZhV3dxeVBoQmNjRTMxU1dXOWJnZjRWOWkyYU5HZ0tScnJZTnZDbUFaZDF4WGJBWGJsWmhiODJtRDVMOXlYMkVXMTlVS1JidFFxRlo3dDE0eVhaL3RGL2ZYNlAvV3YzdVJuQisvNnRmOVFOZVo5RnlrWUFFdEEyTFJjem9Ebjh0cC8rUWEvMWw2VHRtMjlEdDB1a1hVSHJOb09uVS9RYTRBTEd4d3E3THg5MjFuREViWnQxL21nVnQxdjF3SWs2NytxcmN6R1EyZjFJSjdUZHN3RlNWcm1xUlZhMjYrUjEvelU2N0h1YlgvelhiOWUzZTlCRVMwZ0FFdFFLSlVuWFVGdzUvS1RyMzBkVmszakw4Uks0ckoreTc5SytLR0Q2ZXFEQk5DWDVFWUFGK2lEaGJISWFjYmFvejVRQUVPZzRiYlZHYi9zZkh4TmRtcjlkSXI5ZWtMWDdjaGpmdFdyWDR1dTdmRHUzM3pYK000UG9LU0NncFBMUFRoY2U4M2pkejJFdlpNZis0blh3UFQxK3BRNFdJK0JINEJzbVNocDIzV2pnQTJndWd3enQ3MG1zNEQvU3BVcHM2VTdYL1ZxQVBhRC9lMTMvOFpjUXpyUVVoSWN1SncrZTM0REk4bkxBdkh4Y3NlZFA0N1ZCSWpYeXFiZ2VaQmxBZjhleUIxMy9yaS9Edkg3di90YnV4N09nWkluZk5YVnV4N0NJZ1h5Z3ovMG93QlFYV0taUTNZSi9YME5PSlBCYjR3Qm1tWjB1Y2NZTTZsY004VitaY2FYZSt3WHVCMlY5WnZHb0VFamxvdGVlY2VkYU5kcmRGMkg5NzduOThZTmJwRkY5a0MrLzVWMzlPdm9Wd0F3cVZRenhSYkE2RElQTU53Yk1GYW1qSHRxNE5CV0psM1ZOR1ljdkl3RjN4am8wb09aWXQrZzdvN1R4alJvMFQrQzJocFg5ZGswQTZ5cnhtME1WazJEZFYrbkxMMCs4UDJ2dkFQcnRnVzZGdXUydy92KzhBL0srMXhra1MzTDdTOS9oVjNMM2hqQTZLQjBaWjZtYVlxV1RGSng2L0diZ3VzQ1hLSjEvSlhDYjZxcTdadmVIekNtMzdIWEdmaWR2a0JseHU4T3VCYldwakYyalN3c2VHdnRWNnZHLzhwV1RmK21NV2phZnVsVkJYRDlCOXQxUU5PZ0lZOXJ5TmsxVFFPMGJkQm5icnc4S0RTcmxjLzZxZHorOGxkNGZ4OTQzeDhXSGNzaWkyeFN2dnRsM3h2Y1FPV2c3K0crV21WQk9kZ09kNzJXUWwyNmdhdFloQWUybFp1YTBZRmdVZ0JoVCtxc3N1MWZremR3bWNhZ2F6dWZKWGZvaW0rQm5KTFIyNmc4dk8rc2svSStIYWpSNU84MWFBeFdyWVY3MjdZd25jMG8yblhhenM4NGZMbW53STVuL2YwTkcxcGdjUEMzTnNDcWFiRnVoMkR5M1MvN1huK1BRZHUyK05BSDM1OCsxa1VXbVVGZWV0dnRhSnJ3YnRZQStvcFF1Tk5zUHdWNEIzSVBkWkx0cDBSOVpFTkd0RWMybElqMHlJWWFPK2twblhuYitKRU5wWElWWUNOQTIzYldycUtFNDhvOXNFNTY0T1poTDU2VXh0NHRsOHFzSlRzM0FxMVBudldqeFZEdVVXd0NpTk9zSC9yTXdjQjR1NUtzbjhPZlovMVVYNE8vR3hlYUJpKzk3ZmJocHlYYkZ2L3pReDlVeitNaWk5VElTMTU2V3cvcVJvVytGeUhiNTlEbkltWDdGUHFTcEI3WG9Bb3Q4MVJrKzZtZlpFemFPUnZoQ1ozRnRwVmxIdWx4RFpKY1ZYSnhsR2I5TG5LV0JZWkJOeWozSkRMeHhoaGJlbWRaUHkvM1JEYkJqbnpXNzhmV0lNcjZPMkZxWXhUNFN4ZDUxWklQaHJLT3Z5bURYT2hObFh4SzRPK09vWUg5WjNXUHNGaTNMVDc2a1ErcjUzeVJSYWk4K0NYZjRXRUhvQWo2V29rbktPUDBRck45RGZwVWVMYlBvYzhsKzRBMlFWSVBhTk9FdzdrMDI0K2hYbmJoT3ZXY25uUi84V09abzFJUHpmclI1Sit6bzJYOVFDS2pKaGRad3dhNVAyTk1FdjVTUHdPbzdWUWl5dnBwUU1pVmZMb2hGTGdnSThNL3RNdkIzKzByZ1QrUXp2eFhHTzR1ZExlVDA3WVh2K1E3Yk1EcGc4Ny8rdWlmaUovTklsZVdmTXVML3JtSGgybDZjQWZsbU9GbkN0WHlUZytoa2t5L0ZQcGFpWWRDM3duTjlqWG9CMUx3Y0RZcVVyYWZxOU5yMlg3U0p2Rnd0cFM0TWs5cHRnLzBHVDlnRTBacGRZOVU2ODlsL1ZFR2pqRHI1M2JCQUgzSkJ6N3JUK29QWmtHZnpzYU9SWUUvSy9uWUthQU9mN2RzZFU3NHIxYXIvdUZTY3RuSEhXL1hkV0xOMzl1ejdML3QrK2phRm0xdnZ3S3dCb0MyeFF1LzlTWCsrVW50Mm42dS8rY3ZQaVorbm90Y1B2S0NiMzRSbWxYRG5nRWZBaC9RczN6Zlp1UUx1YzZuVnRNUDdlWHlEdFZKUWIvb09meTlsRHlSMDBucGo3QU0rdm5ITVhQaDJUNkhmcVN2WlBzTytwcG92N3ViL0xIMXVYNlFoZm9IMG85bzlyb0Z6K2FuZnFYSE5OT3grREZtSHRNTUlQdWpMUFlZOU9mekQrZHdzRzBGL1g1UXZ2VFR0djM0eUpMUm9LMkh2N09sU3owOS9QMzVHeTc4MHRvL0xmOTBSSjhHQUsvYnR2akV4LzlTLzFZdHN0ZnlUUys0RlVBUHlQNGFsQVo4dDQ4REg4Q29MRDlvWTFtK2E1T2dIMlQ5bWZKT2FhWS81eTl2QVhGR3JrRS85QnYvVGZuWlJhcGI4aWhtZXg0S3dBOWs0RThlMDF3Qy8wZzM4M3grcHpNWC9PM1lDNTdSYnhVbi9UZ0xnT2lSelJUTVVRQWdnSGUyTGdCMFVoc0pBR3Qzb1prRmdPRWM1Z09BMDNPZkJ3QWZCRnJhM3RyMlQ5NzljU3l5UC9MMXovOG5GaUxOQUdvSGV3QWUrQjVjQlJtKzA0dUFieHZFNVpwU2xtL1Y5ZElPb0svZXNUNWo2Tk1NUGZlakt3Q0tNdjA1b1E5Z0k4L2Z0enBsUDdUdWRLVnNId0RNZlljUGRlNEM2Rmo0MDFMTW5EL0Y2SFRtZ0wvMWxmNkJGdXBIZ2orQTVJKzBBQkN6ZjI3THMzL1hMOC8rL2ZGUENBRCttRWdBOE9lQ0JRaHRGbUMvQjNJUW9HTUJnTTk4Nm00c3NobTU1ZXVlSHdCVGdqMkFaSGJ2ZEVNd2h5VWRaNU1EZnF5WHFlVmJBekhMOTc2RUxKLzN3N044YnV1Zzc0RHZ6NEVBL1pxZldhUitwa0ovK0Z6eTBCL2E4OUNudWlyMFRRTno1RXVIdTdhMU1BVGtrby9kUHgvOFhUL0E5TElQa1A4QmRucGNZMzZXRWRCTFArNGNsZjVLRjUwNWxKWi8zSGtxQ1FCQVdBS2lZK0N6SDIwVzRIendJQUJBTEFmNThRbUJnQWFMdi83Y3A3R0lMamZkL0R3QURPSUVwRUZXNStBdlpQYk9Ub0s5dDJuMGk3Yk9QbFhTOFg0bUFKLzY0R3YwUjJYNWZUK3BKWnU1MVRzYTlEbjRwNVozM09mSSt4d0xmZis5S0lBK0FKaWpSdzVieG0wUi9yUVBRSVkvMWRIZzczUm85aThGanJGMWYrK2ZaZi8ybk1nLzBXalBTM241eCs4ZkdRRDhNVkxZSm1ZQnJyK1NJQUFNTXdHMzdYMGtaZ051bkY2UHptcUFTSmUzZi83L2ZRNlhtOXo0bkpzRE9BSUk0QTBnYWk4QnZkTWJZSkxQN0syTmlZT0JrdDNidmxhaEwxTlEwckdLQWJ4ZDM2VmxIZGRYN1U4cWd1alZsSGFDL29TL2twOVdwSDFMMEsrcDZWdWRlYUR2ZkRXTnNlQUhMTTlTOEFmbXEvbFQveEw4ZVR1SHZ4M0wrTktQYjYvSi91M0Jpclgvb0QrQUJaYnhBY0NmU3dueXlrVmc1OGZOQW1nN09wZk41NE1BM0hHd2NwRzFsd01CRU04SW5KNzdqQU5kSlNCUW0yZy9TUzZrRytuY3ZudnYrWHpVTmxhZSthd2JQWVNvOEgzMEg0ekRQZGlYQUR5MW9hVWJJTTdvblc0cXEzZmJ2Rzd2ZFl5NzJEcXMwSEgrdGV6ZXR6ZjZTcDFBSjVIaDIzM2x3Qi9HVmxiTEQvcGtXVDQ5SDFLVzcvcVNzdnlnTGJGa1U0Tys1SC9zaGR5aFBaL3AyLzBZd0EvTUFQOWVJUVFzeStJVGRmK09saWJHbEg3Nmc2ak4vdTB4aEFFZ2hISFp5aDk2enNZRUFLb24yYm5aUm1vV1FOdGRIMklRc0laUk9XZzR4L2xBNFBhdE9lQ0ZZR0MvR3VtQUVOZ29zT2R0MUE4WEtURFVpbmlEVUNZSVNBRUF5QU1leUVNZWdBcDZ0NDluOWM1SFZNYXhCbG5ZdXo2MTdONlBTUUczVnNPbmZZd0J2clBud0hmbnVxYVc3L2NMZndCR2wzYmNXRGoweFJtR0FIMnFrMXE5WTc4Ny9mZE1nSDdqKzdZNlZ4bXo2cC9EMDhJWWE5UzI5bzdYNGFDTkR3Q20vN0xRRzd3YTB3Q21Ed0JzbmIrN3djdVkvZzdoSnJ6Skt4RDMvVy9oSCtqbURqcUFPMW5yYjR4Qlo3b0F1TzVHTDJybmJOMTZmOS9XdjdxYnZjd0tNQ1FBdURYLy92ajZ2bFp0Wi92cEE4QnFaVHpBdmUvK2k5dTFyZi9TVU1EYUw3dDk3ODhaT3BpMVFVUEE3YzlkLzM1bGpBMENxNVdGZk5QQnRMMTkyd1g2NjM1N3RWcjVJTENDS3djMVdLMkdhd0pZTmNOc29OZHZRQUxCYWhVRkFxeFc5dDRCaE1FQUlET0RKaDBRM0hHNzQvUDdnbTBHZitHTzdGTFE4MEJDUVowU01ldG50L3NIMDNnaDJ3ZEN1SE1mdFpDbjcxT2d0NzdpbXIwZFJ6NnpkOGVtd1o3YVN0bTlPMjc2VkUwSy9BQytNd1BmK2lndjY5Q3hjZUQ3TmlITHAzWlNscy9iS2ZERHRyaTBNNHcxaGo3OTltclFENzdpcHJGMzdob1lkS1pKd3Q4NlJRUi8wd2NGN1NhdjNySi9iYjJqcG9laFZJSnhOM3F0ek1wbi8xUWM3STNQL2hHY25LN3JQQnpkbmI0MENCZzNrNkg3amVuOXhRSEE2YmdickVvQ0FFQldBUGtwdTRVWkI3a0xBUDQ5Qmg4dUNOQmdRWU5IU1JCdzUzVk50dWwrczE0RFdQVUJJZzRFVG84R0FpQzhwdUhHVHZmbkFnS3VpbWNKMWg4cDg3QTd0ZDE1OGUwSzdLZjhIQ2dWN1paOWNTWkFieGdpejMrWHdFNTlyeGpJcVY0cDVBTmRFMmIwcm84VTZDTWRvWXpqeHlyQTNvMUpXNGRQZGFWQVljai9TS0JUVU5KeDU3Z0UrQ0QrUzhvNmZEOEZmdFJHZ08vYlJtVDVWTCsybmsrL0R4SDB6UkNBemQ4ZU85cjVmOHcrODNjeXBmVGo5S1hTRC9WVmV1SFgrZVVsSEtwWGV2RTM2TDhMYS90OFhHN01xUktRNzRkZUEraFBZS29NWkk4cHJ1blQ5OXB5VUdycnhoT1V1OWcxQVgrTzJIV0JTTWY1NDZ1RXJJUCt2VndlQWprdU56NDNOdHFtbFhHbzdacnE4TklPeTlodFgyV2cxNTZnV3ZwREhkSUR2ZmlNZ1FjTCtwd2FEbXNnZlQyQTFxS3BYUzZiRDNRWjZGMWJLcXZuT2xOaFQvdVJzbnZYSG1UM2ZiOCt1N2RHRVdDM0NYeDNqampzL2ZGVjFQTEQvWFZaUHBBdTdRejczY2tkenFreEJ1YjQ4V01kaFNzUUJnQU9mN3N2REFBTy9yNU51UEJMN1lDeTJyK3pzZTBPMHZyRlg2ZFhFZ0FrSDFvQXNNY3lYQU1ZM3V2M0FBQklyZ1R5K3NnSEFTQzhIZ0JBdkNqc2JQaUZZU0N1Ky90enhjQXQ2bFVHQXljbFFTRWNUd2psVkoxZjBuZXlWdmFQbFZVakJ3VnBOc0NEUUNOQW4rNFBIaFdRQ2dvVThGWTVHQnVGdkhzZjIwcmxJbG1Qd28vWDNwMjlnelNBV1dEdi9HalovVEFHL2E3Ym9BOHB3eDRCL0xBUDJVZHBXY2Q5em5ObCtZRWQ5TkpPQVAybUI3OXJwd0dnTlBzSEVBVUFDbitnYnRtbjFaZWhUc2ZJcyswNUF3QWZnOWRwaDFWQVFSOEZzd0RYN21ZQkFOVDdBV2ovems0S0F1NXo0aGVHblQzZnh3TkIxTjRPL2wzYm1zTmZDQWIwUGZYTmc0TGtoOXRHOEdmQWwwbzdwV1dkM0RVQXFYd2pTZTdpTGhBQ25kdElZS2Y3T2R6dFBoM3cxRThBYjhUWlBOZWxHWDJrTDREZTloMnZ5SEg2MnF4QWduM2dpOWhwU3pLcFgyMVpKclVKK2xlQXZRM2dCK2VnQVBoV3Y3NldQK3gzanNuTUNRakdhVTZjT043ellQam56MlgvZHJ1dS9BTnNMZ0RRY2VRQ0FOWDFFR1NyZ0lMakdqa0xvTWZyeDEwUUJIeGYvamoxYkg2V1FPQStXT2l6Z3VDOUVEaG9lN0N2RHdxU1hlL2NiMHJCd1lrSWUyRUdNTGlkTitNSFpOZ0RjWVlQeEVHQTZnUS9WaUtBbmVvN3VBZjdFb0FQMmdYSWUzMENMNjZqZ1I3SVovWGNSMGxtNzN4SnNBY3dPYnVuL3FoT2JtbW1PNGRqZ1IrTUl4cGZlVm5INld0bEhkZXUxZko5UHczcis4U0o0eDFnLy9lRGNnN1AvdTFPT04yUzhvL2RYMTcvRC9RVEFjRGJrM0ZLQVNCNG43b0hvRDhvYlJiZzNtdDNBZzlqekFjQlAzWWhDTGl4OFdzQzdueW1aZ1AwblBCQUFNU2xJZWZISDFzaUdIRGY3cnpUOTFTSDIyaDZkbHh4Y0hBU2xXeVVPajROR3Bxa0FnVVZDZVNTaUw4MkpkVC9KYUE3U1lHZDdoT0RRZ25nN1E3WmpvQTAyc2N5ZW1lbmdkNzU0UmRvdlc2bWpPUDB4c0RMNUx6R0FBQVBlVWxFUVZTZTJqYnN2WmpkOStjazBzbGN0UFhITWpMRHQzclRnVS9QZVdsWlp4aG40M1hOQXlmdXQrQ0hnMEFtQUNUS1AzYmY3Z0tBZTUrN0E5ajdLcHdGY0I5OExMNDlFd1FBdlJ3RVFMd3dETVN6QVhkZXRVQVFIQS9acDgwS0FoMGhpTkQzV2tBSWROdDRIOWRkYTRHZzVFSXVDUlpjU2dGZks2bUFRQUd1NmFkbkFUcllnUmp1Vks4RzhPTCtWWmdkOHRLTmU1MENldGN2djBEcjlWa1paL0Fmdzk3WlJKQlZNbndnbmQySGZjaytwRlU2WEdjTzRBODZaWFY4dSswR2t5anJFT0M3ejhXY1BQbUFCWDg3M0hFSmhBR0FRaFhJQjRBZ1M4NEVBS0NzQk9UODVlNEFkdVBWWmdHaEwza1c0SStoRzVhRFRnMENibHhTbHIySlFFRGJ0Rm1CZlkxbkJxS2VZay8zdWFBQUZHVDlmRjErQnZqYUJkdFMwSmV1OFMrdDh3TnlNSkF1QktlQ0FBVkpMdHVuLzcybGdBL2FsRXllNmxDLzNHY1I2UHR4bG1iMTNvZnZJNS9aK3pZQjFHNWJxOTI3Y2RaazkyNWNVai9oMlBNMWZPcDdPRjQ1dzQvN213SDQ3cHlmT25YU2d0OW56dk1GQUtkTEE0QzF5d2NBYWd1UU1aQlpnRlIvRDQ4bFB3dncyek1IZ1VoUFdCN0s5Y1VWUWxZSlFCd0kzRmc1ckxzdXZFYmcrcUU2MFlxYnlvQVE2QWFCT2hFWXlMaUJHT1ljNHZLRlhCMzBjOXlwbTVKVVVKQit1U21WNVFONXFITWZITVMwWGN2Z3JjNDB5RHRkc1E4V2FGS2dsL3JpeXk5ZDN5cGNDeko3cVMwRis3Q2ZmSGJ2M3VjZXB1WmZEYUx6TUtha1k3ZmRnTkxBcDdvVStGNzM5T2xUWFNmODQyNDdBRkMvcVVkQWdOaHdxSmJNQXFoL1RiOGtDQUFvdWlaQTIveTJNaHNZOXNtQndMK1NRT0QwM0luWFFFMW5CdTZjdWY1b2V5NGcyRzA1S0ZEOXlFYjRqa2w2WWdhdlpQV2x5elpMWndWT3BFeGVFbTJaSndUN1pOWlBRWjZCUGxBSGQ3cXR6UTYwVEY3c2kvaWlwUnRuTXhmb3ZYNEMrTUE0MkV1NnFkbzkzUzdPN3ZzVFNvT0p0UXR0dGcxOGIzUG16R21mOGRjR0FOdk9yZ0hZblY1WFd3VUVBTG43QUlDNERCU01UWmdGV0J1aDlGSVpCT2hZZUJDSWZHWm1BMjY3SkJDNDQ5SUNnUnR6TklaRU1LQ2Z6ZFNBSU5ueTlqQzRFeDBsUUhCNzJqOFhEZUtienZhZEpMTitBZmg4V1dkdUZrQnZKRXVCbmRwS013UnBkaUJsOFlGdXJtUmpIVWEyRlBMRFBsNGlhZUpBSTJUUTB2Nm9oTk9QTFlJNHE5bHJ2bkt3bDNYMTdEN29SOG51cVEzOXhJdFc2VmdubzBvNjBqRUJnRGw3OW13UGZnRW1DQU1BTVB3anB3S0ExeU1CQU1oZkI3QjIraXpBMnltekFEcitLVUVnOE1PQ2dQZUoyRjl1TnNDM3RVRGd6amNOQkc2c0dwQnJad2IrTStvbEZ4RGNtT2k0cVI3MXorRmNFaUQ0ZVBpNHVKUm04V01EUXZHYWZtVjJJTjBKYkJEcU5nMS9MMmY0S2JBRFpYQ24yNmtzSHFpSFBCMERyOUc3TWNYWnJBNTZ2ay9LNnYyMmNJRlcxTnN3N1BuNUdjNkZuTjNIWTJ4OEc4M3VyUjA3TnlVMWZQYTU4OEJpenAwNzEya0FsVjZCY0JZd0pMMERuTFFBNFBTMU1oQVF6Z0tjWDNVVzBDdEtRVUF2cmFTRGdOc3ZCWUZvdTNBMkFLUUR3ZENuZk02MVdRRWRjMjVtUUY5NVFLQTJKVUVoOERtNFVPL016V1gzcVVDUnNndmJ5Z0xCSE1LQkhiYmxMKzd5ZmFsWmdBUjFBRUVJRVROL0JlNjB2eFRnN2U1eGtIZTZzYjk4UmcvVWdUN25MMWV6NTl2RnNMY25WNFc5MVowL3UvZCsvUkRpOHk2T0ZVTndNY2JBbkQ5L3ZoUC9vUlV3YXdIQXRrMmJCZEMrcEZtQWI2OE1BdFN2dEFSU1A3NzBiQ0RhVGdRQy8wb0NnZGEzTml1SWRBcUNBYlhoQVVIeVd4SVUzUEg1ZHFXRUUzNzJvZVR1ME9YMlhFb3krZEpaZ1NaYU5zOUZteDJVbEg3NGpXRVMwUG0yVmc3aW1Uc1F3NTM2cWdHODNhOUQzcjFLa09kdFd1bEcxUk51cXBKMGVWWlB4NjdwNjljWjZtQlA3U1RZaDhjOUxyc25weWxaenBINjRtM213b01QZGh3UWRGc0xBSUdPVUFhaXR1TEZZTnZnOWZtMUFOcmZsQ0FnSFVzSTYzaDFrSGlzYkRZZzZjZSt3MENnMmFBTHJ4Rm9yeVhCd0IxVHFDY0hCR3BURkJTc2d0OFVnME4vUEg2VElUOTYybVpodHA5QytDYnUxRTJKZGhldmI1ZjJaYkorSUFGMHF6em9WWUxkTmplUjdSVEFpL1lGc05kcTlGNGY4WmhLUU8vM0pVbzRibHU2SjRBZU45Ky9NZGhiUjhYWnZSMUtlVGxIMHJIbnk4QjgrY0tGamdNRmtQL3BhOHRBdHExOEZ1RDA1d29DZm14Q0VIRCtVeVVoMm05OG5PTUNnZlB2amwwOWw0WEJnTDZXQm9SUU53d0traCtBcjlEUlN6OUV5Vyt1ZVZOMFI2NlE3U2RRcjlYOUl4OFRNMzZnSXV0WG51NFpBZHc2RFcxWklGbHhFd0ZtZkRzRmR0c3V3ejFzR3c5NDZYVk1Ocy85ekFWNmQzeXByTjYvR2hEYnVqSU85ZVgwNW9SOTdGOFBYSHc3S0J0ZXZIaXhrNStKUG0wV0FHdy9DRGc5RGxwN1BPblpRR1E3SVJEdy9sSzIyZ1ZqNmRVQmNtcEFFSDBqbmltNGN4S2ZTL256amxmbVZHYjNTcDJlQjQ3QVpNdlp2cFBjeGQ4STNrQUFjQ2M4c0Vnd2wvcVVvQTd3MGtZanRPdVpZQ25nK1Q0SjhPS3JVcC9uci9GNDlOS05iak11cTNmOURlZXJEdlpUTW50eUNrZlg3dm01Y2VjaWFyOTQ4YUwvdHdwKzhTZ3hDNkRiMnd3Q1FObUZZV3RmUGh1UXh4N0RWUXNFa2owL0p1bENxNTZaeHpNRFVhOHlJRWo3OHA5dkhCaGl1L0J6cE1Kcjkvd2NTZUJPUFZ1L05JdWZJOXNIeWpQK2xKNzBEUC9zc2s0ZUVBUkljTDFVVnNqM2lUQW96ZUR0enJSTkpwUG5yMUkySHgvM2RORDcxMFJXUC9qSXI4YWhmWlRBUHV4ak91eWo0M0pqbFQ1WCt2MTQ2S0dIQnZBTC8rVFNMSUR1bDJEcDlzOGFCR3hEWUNNRkFmZGFPeHZ3OWtvZ2lPeFpJT0Q5UytlSEg1dTI2bWJ1Z0FEb1FTRzNqL2FYMG9tUFV3NFNWamNHdTNaSHJyYWVYK3QzbTVLR3Zkd20zZUZyZUdZZlFUKy96Sk8rbC83cDZYWnVYeTZERC9ZSk5YbkpSc3ZrQTUzRWRZb3dPQXdnMHpMdXlNY0kwQVA1RWc0NVRjN2hySmw5N3Rqb3R2cTVTNzRmZnZoaEdmd2VOdktLalZUNXdHN1BGd1FpbTRyWmdIdWRJeEJveHhpQVhnZ0dxUXhjT2thdFRDUWRGN1hYQW9MYU41c3BhRGFsMjduc1BsdnFjZnVWOGswdUNPeGFOTmdESWJ6OVBnWGMycjRvSUdRQVQ3ZEZzTnVHckEySHV6MmVmQlpQWDJzaDcxN1RLNGppV1JNdDNRdytaTkRiMTdoOEUvbENPcXVuNThqYlptcjIwakhKNTZEZzNnN3BNOHpjNkJlQW44cStCSUhJdm1JMmtPcDdkQ0RvRFZTSVM5QWVPVE9nKzFPcmJaSkJRbHJKVXhrWUFEMDRhSDVLMy9PeDVYUnIyamN0dVRLUTFpNEZpRndRU0wzUFFWM1REL2FOZ0x1MEx3WDRsSzhjNU8xckFtWW1IZ00vQmkyakg5cjVXTXV6ZW42c1l5L1Fhc2ZKdDhmQVBqaTJ2M3Zra1NIanp6d0JVYXYzVGlrSDVmeVBtZzNZeHNCT0N3VHVOYjZMTk16b2FYLzJPTXRtQmJUUDNNd2dkMDYwNDA2dHNzbVZjVktCSVduSHg4WGVyOGN1Mnh3WkJNYnFsa29POGlWNnVZeGUyNmZCbk91cTI0aUJBY1JncDlzbGNBL2FLZ0JQeHpFVzh1NDl2MzdDTDhZU2RkdnZGa0ZQank4WXY5RC9uTERuMjZMdUk0OCsyZ0ZTeHBrT0FuUzdKZ2pFMitXekFXQjdnUURRWndWK3JOR0Z6THFaZ2RSbkxpQm8rK2p4QjhlUVdaZGZ0SzJ0NkFFL3B2cHNud2NNSnp4d3hHYTd6ZmFkNUFMQ1Nsbm15UUV1K2NxK3p3Q2QyOVNVZzNqbXp2M21mSXdCdkRpV0RPUmRtd1I1SUYyamQrMzJmZUE0V29ZcmxXKzRYUzZyMThhZ3RlZHE5bG9md2JpVlJRVG1VUWQrMGpnbENGRDlxVUVnMTBjTW52R0JBSWl2RWZCeFNMTUNPdTdTWUtBZEgvY2xIYlAxV1I4VXJMMThMcVhnVUxvdHZzK3Q3RkhXNktkZ1hncjZUUVdFMG93L3B5ditjd3ByL1ZPcmVyaWYwakpRTGRnMSt4U2s1Z0E4SFI4ZkcvVW5RZDdwU05tODA0aytnc0tNM3I0U013YjY0SGlFY2RSazlaRk9UUmtuTlNiMytwWEhIdXM0MkRjUkJQaitYUVFDcXllczFtRVhpNTJ0TkN2Z1l5a0pCcTVmS3JVQlFmU1pDQXFxWHFwY3c4YkoyN1VBTWVaOWRuL20yVHY3ZEtFM2RWRVhrSUVLS0VHZ051dG43NlV5VE1rWWNzRWlkUytBQmpkUlp3VGdpWm1YVXNqYjkwRUh2YitEQjNxK0hZeWpBUFpVMTN6bHNjZUMveUFKN0FQTUpGRHNieUN3WTZmdGdwL0NXWUd6THdrR1FQelFzTkVCZ1JqWEJJVm9PL05relpyZ0VQU2JDQlMydjNTdHY2U2ZYV2Y3VGpaVjU5ZjJSMURNekFJMDNiSVpnQTUydXMzQkhyWUZsQXoyelFINHFBOWlOd1h5emcvL0NLYUFYdEwxeDdRajJBZnZPZmk1N0hvMmtMU3ZDQVN1djlKWlFlUXJFUXlBelFjRW9Dd29STzNNZjBxMzlPbWFZN1A3YUV4S1ZwK3pUOTNndFF1UmJ0Q2lrZ3dFQ1hpbjdFdExQVFUzZU5udDlMSlRucm5UZmRKNUtBVThVQVo1RWZDMm85NXZmQzRreUhNZkpSZGo0MzM3QzNwSlAyaDc3TktsRGlqN1o2MEpBcEsrcEx1cFFPRGFVbENTWmdWV3B5d1lCTFl6QndUck96d3U3WUtyazFSZ2lPelZ6THR3MmVhSXAydHVNdHZmTjVrcjY0OUxPVHpyTDRPUEJQU2tqUUIyMjM4NTNKa2JhNStCTzdXdmdYd3FrK2MrSk1qenNlUkFYeHhzWndSOVpGZWdyOW1iUzVjdWlmOWEreHdJZ0xLU2hYMmZuaFZvZlVyQmdQdXordk1HQkdtY3FhQkFmWlFHQm1tOHRRR0M5NTNTci9rdTdQT2R1aW5KbFlFMmMzTlhHY3lqOXlPZ3puVnJNbmQ1UENIYzdiNm84NzRQK1ZoS3l6VldkenprYzhjeUYraXo0eXpRRjMzeWM0OGUvRnlSN3hnVEJMaWZ1UU1CYnl1ZEZkajNkY0hBOVYwYkRFUS9tWUFBMUFVRnFoLzJRMjJVTWsvaUFtcHBrQkRibFgyU1NNZFk2Nk5XZDZyTVUrdlAvOE5xZmlLLy9HMEc1bDR2QTNYQnRmV1plUVMwNUtlMlJCUDRMc2ppcVk5YXlQT3hqc25tZWIvN0J2cEk1Ky9YOXRtSEtvU2xmVHNNQlB6OW1GbUIvTDZ1VEVUNzFnS0M2RmVaSVFSK1JPRGIxK0RlZ2tSZzROdmFMMVJwQVVJYVkzNmxUWG84WXFjNTNSRTZ1NUpjUUZEYnRkMVJabG53VDYvQTNMNVBBeDJRb1M3NTR2NVVzTnRCa1g3THNuZnFmd3pnQTk4elFKNjNsWmJZdUdUSG5ORVhmVXFmVGFaL0QzNHUreHdJdUg3cUluTXFHR1Q5Wk9yeVJmMFh6aEtzWFVGUXNJcHh1ekJqMFByaGZVVG5LQUg0ZURaWUJ2dGMwSkNrOUNMdXRvTkJhY1lQbE1FNjhsLzArR1lHa053NEtvRE8rMGlXWTN4RGZlWk8rNUQ4emduNGxCMlFoandmdzFqSVI3YUZOcUx2Z3F3KzVjKzA3TDljZlc3S2xnT0I1bWVPWU1EYjZ4OHVWaFlRVXVPUWJtSkt6UlNzcmQ1UDVGTUYvckJkRWlDay9xVCtTMy96VnROSzlaL3FkNStrSkNCb0paN1NVQ0w5M20vSjlRQ3BmKzRyQjNVZ0RYYXJPZy9jdVYvNmZncmdnZkdadlBRK2FOc0QwS2Q4Qm1XcXpvcW9tTHJRTm5jZzBIeE9tUlZ3bTVwZ0FFd1BDRlNuSmlnQTB3S0QxcC9vTnduODhIMjBGSFZpUnA3Nlhtenp4OU0zS2FrZlpnZDBrSXdKSXNVZ0J3S1lBekxRK1RoeWo1Uk9nZDNhajRjNzd5dGw2NlFtaXkveGw3SVY3UXZ0UlA4emc1NzdOaEwxOXprUWNKOHFWQXBuQnVMN2lRRkI3bU5hVU9CajB4OTdrTzdUK3NpUDM3ZFZCQXB0SEU2U0phUXRyczJueDF0VHRwbEx0S3hmQ3hKRlF5d0V1ZlhIQWFqMW00WTY3NmNFaG5SZjZVcWxYUUplc2hkOUZOcUovaXRxOUNtL0tkQnorZi90WEZVUUxDUk1YQUFBQUFCSlJVNUVya0pnZ2c9PSIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogPC9nPgo8L3N2Zz4K\",\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDM3NSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ1NTcxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxZS03IC0xNzA0LjMgLTE3MDQuMyAtMWUtNyA0NTk2LjYgMjgyOC42KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTU3MyIgc3RvcC1jb2xvcj0iI2Y3ZDY0OSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTU3NSIgc3RvcC1jb2xvcj0iI2YxODM1NSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTU1MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoNDU2LjIyIDAgMCAtNDU2LjIyIDQzNjguNCAzNDk0LjMpIj4KICAgPHN0b3AgaWQ9InN0b3A1NTU1IiBzdG9wLWNvbG9yPSIjY2FjOWM4IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1NTU3IiBzdG9wLWNvbG9yPSIjZjZmNmY2IiBvZmZzZXQ9Ii43NTI2OSIvPgogICA8c3RvcCBpZD0ic3RvcDU1NTkiIHN0b3AtY29sb3I9IiNkNGQzZDIiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU1MzMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEzNDUuNSAwIDAgLTEzNDUuNSAzOTIzLjggMzA2NS45KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTUzNSIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTUzNyIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIuMSIvPgogICA8c3RvcCBpZD0ic3RvcDU1MzkiIHN0b3AtY29sb3I9IiNmZWZmZmYiIG9mZnNldD0iLjI0NzMxIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTU0MSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTQ1NSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMWUtNyAtMTcwNC4zIC0xNzA0LjMgLTFlLTcgNDU5Ni42IDI4MjguNikiPgogICA8c3RvcCBpZD0ic3RvcDU0NTciIHN0b3AtY29sb3I9IiNmM2I3MGMiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDU0NTkiIHN0b3AtY29sb3I9IiNlNzNlMjAiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU0MzciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiA0MzY4LjQgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNTQzOSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTQ0MSIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A1NDQzIiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MTIxLTMiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MTIzLTYiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTQxNDIuNCAxOTAuMDNjMCA2Ny4yMzEgMjAzLjMxIDEyMS43NyA0NTQuMTQgMTIxLjc3czQ1NC4xNC01NC41MzkgNDU0LjE0LTEyMS43N2MwLTY3LjMyLTIwMy4zMS0xMjEuODYtNDU0LjE0LTEyMS44NnMtNDU0LjE0IDU0LjU0My00NTQuMTQgMTIxLjg2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTU2Ny03IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTU2OS01IiBkPSJtNDQ3My43IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnYyOTE0LjFjMjguNDMgMzEuMTggNjYuOTkgNTMgMTEwLjMyIDYwLjE0di0yOTc0LjJjMC0zMC40Ny0yNC43LTU1LjE2LTU1LjE2LTU1LjE2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTU0OS02IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTU1MS0yIiBkPSJtNDQxOC41IDQ1MDF2MTE4OC42YzAgMzAuNDYgMjQuNjkgNTUuMTYgNTUuMTYgNTUuMTYgMzAuNDYgMCA1NS4xNi0yNC43IDU1LjE2LTU1LjE2di0xMTI4LjRjLTQzLjMzLTcuMTQtODEuODktMjguOTYtMTEwLjMyLTYwLjE0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTUyOS03IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTUzMS0wIiBkPSJtNDYzMy4zIDU5NDEuOWgtNzMuNDRjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjIyLTM1Ni4wNy0zNDMuMjItMzU2LjA3LTU5My43IDAtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk1IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk1IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di00MjYxLjJjMjA5LjI4LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NS01ODQuMTggNTg0LjE4IDAgMjQxLjY5IDE0Ni43OCA0NDkuMDkgMzU2LjA3IDUzNy45NHY0MjYxLjJjMCAxMDUuMjYgODYuMTIgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDQiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NTE1LTYiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NTE3LTIiIGQ9Im00NTk2LjYgOTU1Ljg5Yy0xODkuMTYgMC0zNDIuNSA5Ny4yMTktMzQyLjUgMjE3LjE1IDAgMTE5LjkyIDE1My4zNCAyMTcuMTQgMzQyLjUgMjE3LjE0IDExOC41OSAwIDIyMy4xLTM4LjIxIDI4NC41Ny05Ni4yOCAzNi41OC0zNC41NSA1Ny45Mi03Ni4xMiA1Ny45Mi0xMjAuODYgMC04My4zOS03NC4xMy0xNTUuOC0xODIuOS0xOTIuMTgtNDcuNjUtMTUuOTM3LTEwMS45Ni0yNC45NjgtMTU5LjU5LTI0Ljk2OCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDU0OTktNiIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDU1MDEtMSIgZD0ibTQ3NTYuMiA5ODAuODZjMTA4Ljc3IDM2LjM4MSAxODIuOSAxMDguNzkgMTgyLjkgMTkyLjE4di0xLjI0Yy0wLjczLTgyLjg3LTc0LjY3LTE1NC43My0xODIuOS0xOTAuOTRtMTgyLjkgMTkyLjE4YzAgNDQuNzQtMjEuMzQgODYuMzEtNTcuOTIgMTIwLjg2IDM2LjI0LTM0LjIzIDU3LjUzLTc1LjM2IDU3LjkyLTExOS42M3YtMS4yMyIvPgogIDwvY2xpcFBhdGg+CiAgPHJhZGlhbEdyYWRpZW50IGlkPSJyYWRpYWxHcmFkaWVudDU1MDMtOCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGN5PSIwIiBjeD0iMCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCg1ODQuMTggMCAwIC01ODQuMTggNDU5Ni42IDg2Mi43NSkiIHI9IjEiPgogICA8c3RvcCBpZD0ic3RvcDU1MDUtNyIgc3RvcC1jb2xvcj0iI2YzYjcwYyIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTUwNy05IiBzdG9wLWNvbG9yPSIjZTczZTIwIiBvZmZzZXQ9IjEiLz4KICA8L3JhZGlhbEdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NDgzLTIiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NDg1LTAiIGQ9Im00OTM5IDExNzEuOHYxLjI0IDEuMjMtMS4yMy0xLjI0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTQ2Ny01IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTQ2OS05IiBkPSJtNTE4MC43IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4IDAgMzIyLjYzIDI2MS41NCA1ODQuMTggNTg0LjE4IDU4NC4xOCAzMjIuNjMgMCA1ODQuMTgtMjYxLjU1IDU4NC4xOC01ODQuMTgiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NDUxLTkiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NDUzLTciIGQ9Im00ODI0LjcgMTQwMC43Yy03MC4wOSAyOS43Ni0xNDcuMTggNDYuMjItMjI4LjExIDQ2LjIyLTgwLjk0IDAtMTU4LjAzLTE2LjQ2LTIyOC4xMS00Ni4yMnYyOTcxLjVjMCA0OS40NCAxOSA5NC42NyA1MC4wNyAxMjguNzJ2LTI5MTQuMWMwLTMwLjQ3IDI0LjY5LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ2IDAgNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2Mjk3NC4yYzEwLjA5IDEuNjYgMjAuNDUgMi41MyAzMSAyLjUzaDczLjQ0YzEwNS4yNiAwIDE5MS4zOS04Ni4xMyAxOTEuMzktMTkxLjM5di0yOTcxLjUiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NDMzLTEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NDM1LTIiIGQ9Im00ODI0LjcgNDM3Mi4yYzAgMTA1LjI2LTg2LjEzIDE5MS4zOS0xOTEuMzkgMTkxLjM5aC03My40NGMtMTAuNTUgMC0yMC45MS0wLjg3LTMxLTIuNTN2MTEyOC40YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNi0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTExODguNmMtMzEuMDctMzQuMDUtNTAuMDctNzkuMjgtNTAuMDctMTI4LjcydjEyODkuN2MwIDEwNS4yNiA4Ni4xMiAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtMTI4OS43Ii8+CiAgPC9jbGlwUGF0aD4KIDwvZGVmcz4KIDxnIGlkPSJsYXllcjEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMTcuMDcgLTIwNC41MykiPgogIDxnIGZpbGw9IiNmZmYiPgogICA8cGF0aCBpZD0icGF0aDUxNDciIGQ9Im0zMDUuNTkgOTUxLjdjLTQ4Ljg5IDAtODguNTIzLTEwLjYzMi04OC41MjMtMjMuNzUgMC03LjA4NjQgMTEuNTcxLTEzLjQ0OSAyOS45MTktMTcuOCAwLjAzNSAwLjAzIDAuMDY3NSAwLjA1OCAwLjEwMjUgMC4wOS0xOC4yMiA0LjMzNS0yOS43IDEwLjY2My0yOS43IDE3LjcxIDAgMTMuMDY5IDM5LjQ4OSAyMy42NjQgODguMjAxIDIzLjY2NCA0OC43MTEgMCA4OC4yMDEtMTAuNTk1IDg4LjIwMS0yMy42NjQgMC03LjA0NzQtMTEuNDgxLTEzLjM3NS0yOS43MDEtMTcuNzEgMC4wMzYyLTAuMDMxIDAuMDY3NS0wLjA2IDAuMTAyNS0wLjA5IDE4LjM0OCA0LjM1MSAyOS45MTkgMTAuNzEzIDI5LjkxOSAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjEgMjMuNzVtMzcuNTAyLTQ1LjE3NGMwLjA0MzctMC4wMjYgMC4wODc1LTAuMDU0IDAuMTMxMjUtMC4wNzktMC4wNDM4IDAuMDI1LTAuMDg3NSAwLjA1Mi0wLjEzMTI1IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE0OSIgZD0ibTMwNS41OSA5NTEuNjFjLTQ4LjcxMyAwLTg4LjIwMS0xMC41OTUtODguMjAxLTIzLjY2NCAwLTcuMDQ3NCAxMS40OC0xMy4zNzUgMjkuNy0xNy43MSAwLjAzMzcgMC4wMjkgMC4wNyAwLjA2MSAwLjEwMjUgMC4wOTEtMTguMDg5IDQuMzE3NC0yOS40ODEgMTAuNjExLTI5LjQ4MSAxNy42MTkgMCAxMy4wMjEgMzkuMzQ1IDIzLjU3OCA4Ny44OCAyMy41NzhzODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OGMwLTcuMDA3NC0xMS4zOTQtMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzM4LTAuMDMgMC4wNy0wLjA2MiAwLjEwMjUtMC4wOTEgMTguMjIgNC4zMzUgMjkuNzAxIDEwLjY2MyAyOS43MDEgMTcuNzEgMCAxMy4wNjktMzkuNDkgMjMuNjY0LTg4LjIwMSAyMy42NjRtMzcuMzcxLTQ1LjAxYzAuMDQ1LTAuMDI2IDAuMDg2My0wLjA1MSAwLjEzMTI1LTAuMDc4LTAuMDQ1IDAuMDI2LTAuMDg2MyAwLjA1MS0wLjEzMTI1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE1MSIgZD0ibTMwNS41OSA5NTEuNTJjLTQ4LjUzNSAwLTg3Ljg4LTEwLjU1Ny04Ny44OC0yMy41NzggMC03LjAwNzQgMTEuMzkyLTEzLjMwMSAyOS40ODEtMTcuNjE5IDAuMDM2MiAwLjAzIDAuMDY4OCAwLjA1OSAwLjEwMzc1IDAuMDktMTcuOTYgNC4zMDI2LTI5LjI2NSAxMC41NjItMjkuMjY1IDE3LjUyOSAwIDEyLjk3NCAzOS4yMDEgMjMuNDkxIDg3LjU2IDIzLjQ5MSA0OC4zNTggMCA4Ny41NTktMTAuNTE4IDg3LjU1OS0yMy40OTEgMC02Ljk2NzItMTEuMzA1LTEzLjIyNi0yOS4yNjQtMTcuNTI5IDAuMDM1LTAuMDMxIDAuMDY3NS0wLjA2IDAuMTAyNS0wLjA5IDE4LjA4OSA0LjMxNzQgMjkuNDgyIDEwLjYxMSAyOS40ODIgMTcuNjE5IDAgMTMuMDIxLTM5LjM0NSAyMy41NzgtODcuODggMjMuNTc4bTM3LjI0LTQ0Ljg0NWMwLjA0MjUtMC4wMjUgMC4wODc1LTAuMDUyIDAuMTMxMjUtMC4wNzktMC4wNDM4IDAuMDI2LTAuMDg4NyAwLjA1NC0wLjEzMTI1IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE1MyIgZD0ibTMwNS41OSA5NTEuNDRjLTQ4LjM1OSAwLTg3LjU2LTEwLjUxOC04Ny41Ni0yMy40OTEgMC02Ljk2NzIgMTEuMzA1LTEzLjIyNiAyOS4yNjUtMTcuNTI5IDAuMDMzNyAwLjAyOSAwLjA3IDAuMDYxIDAuMTAzNzUgMC4wOS0xNy44MyA0LjI4NjYtMjkuMDQ4IDEwLjUxMS0yOS4wNDggMTcuNDM5IDAgMTIuOTI2IDM5LjA1OCAyMy40MDYgODcuMjM5IDIzLjQwNiA0OC4xOCAwIDg3LjIzOS0xMC40OCA4Ny4yMzktMjMuNDA2IDAtNi45Mjc4LTExLjIxOC0xMy4xNTItMjkuMDQ4LTE3LjQzOSAwLjAzMjUtMC4wMjkgMC4wNy0wLjA2MSAwLjEwMzc1LTAuMDkgMTcuOTU5IDQuMzAyNiAyOS4yNjQgMTAuNTYyIDI5LjI2NCAxNy41MjkgMCAxMi45NzQtMzkuMjAxIDIzLjQ5MS04Ny41NTkgMjMuNDkxbTM3LjEwOC00NC42OGMwLjA0MjUtMC4wMjYgMC4wODg4LTAuMDUyIDAuMTMyNS0wLjA3OS0wLjA0MzggMC4wMjYtMC4wOSAwLjA1My0wLjEzMjUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTU1IiBkPSJtMzA1LjU5IDk1MS4zNWMtNDguMTgxIDAtODcuMjM5LTEwLjQ4LTg3LjIzOS0yMy40MDYgMC02LjkyNzcgMTEuMjE4LTEzLjE1MiAyOS4wNDgtMTcuNDM5IDAuMDM1IDAuMDMxIDAuMDY3NSAwLjA2IDAuMTAzNzUgMC4wOTEtMTcuNzAxIDQuMjY5LTI4LjgzMSAxMC40NTktMjguODMxIDE3LjM0OCAwIDEyLjg3OSAzOC45MTUgMjMuMzIgODYuOTE5IDIzLjMyczg2LjkxOC0xMC40NDEgODYuOTE4LTIzLjMyYzAtNi44ODg2LTExLjEzLTEzLjA3OS0yOC44My0xNy4zNDggMC4wMzUtMC4wMzEgMC4wNjc1LTAuMDYgMC4xMDM3NS0wLjA5MSAxNy44MyA0LjI4NjYgMjkuMDQ4IDEwLjUxMSAyOS4wNDggMTcuNDM5IDAgMTIuOTI2LTM5LjA1OSAyMy40MDYtODcuMjM5IDIzLjQwNm0zNi45NzQtNDQuNTE2YzAuMDQ2My0wLjAyOCAwLjA4NzUtMC4wNTMgMC4xMzM3NS0wLjA3OS0wLjA0NjMgMC4wMjYtMC4wODc1IDAuMDUxLTAuMTMzNzUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTU3IiBkPSJtMzA1LjU5IDk1MS4yN2MtNDguMDA0IDAtODYuOTE5LTEwLjQ0MS04Ni45MTktMjMuMzIgMC02Ljg4ODYgMTEuMTMtMTMuMDc5IDI4LjgzMS0xNy4zNDggMC4wMzUgMC4wMzEgMC4wNjg4IDAuMDYgMC4xMDM3NSAwLjA5LTE3LjU2OSA0LjI1MjUtMjguNjE0IDEwLjQxLTI4LjYxNCAxNy4yNTcgMCAxMi44MzEgMzguNzcxIDIzLjIzNCA4Ni41OTggMjMuMjM0IDQ3LjgyNiAwIDg2LjU5OC0xMC40MDMgODYuNTk4LTIzLjIzNCAwLTYuODQ3Ni0xMS4wNDYtMTMuMDA1LTI4LjYxNS0xNy4yNTYgMC4wMzYyLTAuMDMxIDAuMDY4Ny0wLjA2IDAuMTA1LTAuMDkxIDE3LjcgNC4yNjkgMjguODMgMTAuNDU5IDI4LjgzIDE3LjM0OCAwIDEyLjg3OS0zOC45MTQgMjMuMzItODYuOTE4IDIzLjMybTM2Ljg0MS00NC4zNTNjMC4wNDI1LTAuMDI1IDAuMDktMC4wNTIgMC4xMzI1LTAuMDc3LTAuMDQyNSAwLjAyNS0wLjA5IDAuMDUyLTAuMTMyNSAwLjA3NyIvPgogICA8cGF0aCBpZD0icGF0aDUxNTkiIGQ9Im0zMDUuNTkgOTUxLjE4Yy00Ny44MjYgMC04Ni41OTgtMTAuNDAzLTg2LjU5OC0yMy4yMzQgMC02Ljg0NzYgMTEuMDQ1LTEzLjAwNSAyOC42MTQtMTcuMjU3IDAuMDMzNyAwLjAzIDAuMDcxMiAwLjA2MiAwLjEwNSAwLjA5MS0xNy40NDEgNC4yMzQ5LTI4LjM5OCAxMC4zNTctMjguMzk4IDE3LjE2NiAwIDEyLjc4NCAzOC42MjYgMjMuMTQ4IDg2LjI3NiAyMy4xNDggNDcuNjQ5IDAgODYuMjc2LTEwLjM2NCA4Ni4yNzYtMjMuMTQ4IDAtNi44MDg2LTEwLjk1Ni0xMi45MzEtMjguMzk4LTE3LjE2NiAwLjAzMjUtMC4wMjkgMC4wNzEyLTAuMDYyIDAuMTAzNzUtMC4wOSAxNy41NjkgNC4yNTE1IDI4LjYxNSAxMC40MDkgMjguNjE1IDE3LjI1NiAwIDEyLjgzMS0zOC43NzEgMjMuMjM0LTg2LjU5OCAyMy4yMzRtMzYuNzA4LTQ0LjE4OGMwLjA0NS0wLjAyNiAwLjA4NzUtMC4wNTEgMC4xMzM3NS0wLjA3OS0wLjA0NjMgMC4wMjgtMC4wODg3IDAuMDUzLTAuMTMzNzUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTYxIiBkPSJtMzA1LjU5IDk1MS4wOWMtNDcuNjUgMC04Ni4yNzYtMTAuMzY0LTg2LjI3Ni0yMy4xNDggMC02LjgwODYgMTAuOTU2LTEyLjkzMSAyOC4zOTgtMTcuMTY2IDAuMDM1IDAuMDMxIDAuMDY4OCAwLjA2IDAuMTA1IDAuMDkxLTE3LjMxMSA0LjIxODctMjguMTgyIDEwLjMwNy0yOC4xODIgMTcuMDc1IDAgMTIuNzM2IDM4LjQ4NCAyMy4wNjEgODUuOTU2IDIzLjA2MSA0Ny40NzEgMCA4NS45NTUtMTAuMzI1IDg1Ljk1NS0yMy4wNjEgMC02Ljc2ODUtMTAuODcxLTEyLjg1Ni0yOC4xODEtMTcuMDc1IDAuMDM1LTAuMDMxIDAuMDY4OC0wLjA2IDAuMTA1LTAuMDkxIDE3LjQ0MSA0LjIzNDggMjguMzk4IDEwLjM1NyAyOC4zOTggMTcuMTY2IDAgMTIuNzg0LTM4LjYyOCAyMy4xNDctODYuMjc2IDIzLjE0N20zNi41NzItNDQuMDI0YzAuMDQzOC0wLjAyNSAwLjA5MTMtMC4wNTIgMC4xMzUtMC4wNzctMC4wNDM4IDAuMDI1LTAuMDkxMyAwLjA1Mi0wLjEzNSAwLjA3NyIvPgogICA8cGF0aCBpZD0icGF0aDUxNjMiIGQ9Im0zMDUuNTkgOTUxLjAxYy00Ny40NzMgMC04NS45NTYtMTAuMzI1LTg1Ljk1Ni0yMy4wNjEgMC02Ljc2ODUgMTAuODcxLTEyLjg1NiAyOC4xODItMTcuMDc1IDAuMDM1IDAuMDMgMC4wNjg4IDAuMDYgMC4xMDUgMC4wOS0xNy4xODQgNC4yMDIxLTI3Ljk2NiAxMC4yNTYtMjcuOTY2IDE2Ljk4NSAwIDEyLjY4OCAzOC4zNCAyMi45NzYgODUuNjM1IDIyLjk3NnM4NS42MzUtMTAuMjg4IDg1LjYzNS0yMi45NzZjMC02LjcyOS0xMC43ODQtMTIuNzgzLTI3Ljk2Ni0xNi45ODUgMC4wMzUtMC4wMyAwLjA2ODctMC4wNiAwLjEwNS0wLjA5IDE3LjMxIDQuMjE4OCAyOC4xODEgMTAuMzA3IDI4LjE4MSAxNy4wNzUgMCAxMi43MzYtMzguNDg0IDIzLjA2MS04NS45NTUgMjMuMDYxbTM2LjQzOC00My44NThjMC4wNDUtMC4wMjYgMC4wODg4LTAuMDUzIDAuMTM1LTAuMDc5LTAuMDQ2MyAwLjAyNi0wLjA5IDAuMDUzLTAuMTM1IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE2NSIgZD0ibTMwNS41OSA5NTAuOTJjLTQ3LjI5NSAwLTg1LjYzNS0xMC4yODgtODUuNjM1LTIyLjk3NiAwLTYuNzI5IDEwLjc4Mi0xMi43ODMgMjcuOTY2LTE2Ljk4NSAwLjAzMzcgMC4wMjkgMC4wNzEyIDAuMDYyIDAuMTA1IDAuMDkxLTE3LjA1NCA0LjE4NDUtMjcuNzUxIDEwLjIwNC0yNy43NTEgMTYuODk0IDAgMTIuNjQxIDM4LjE5OCAyMi44OSA4NS4zMTUgMjIuODkgNDcuMTE4IDAgODUuMzE0LTEwLjI0OSA4NS4zMTQtMjIuODkgMC02LjY4OS0xMC42OTgtMTIuNzA5LTI3Ljc1MS0xNi44OTQgMC4wMzM3LTAuMDI5IDAuMDcyNS0wLjA2MiAwLjEwNjI1LTAuMDkxIDE3LjE4MiA0LjIwMjEgMjcuOTY2IDEwLjI1NiAyNy45NjYgMTYuOTg1IDAgMTIuNjg4LTM4LjM0IDIyLjk3Ni04NS42MzUgMjIuOTc2bTM2LjMwMS00My42OTZjMC4wNDYzLTAuMDI2IDAuMDktMC4wNTEgMC4xMzYyNS0wLjA3Ny0wLjA0NjMgMC4wMjYtMC4wOSAwLjA1MS0wLjEzNjI1IDAuMDc3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE2NyIgZD0ibTMwNS41OSA5NTAuODRjLTQ3LjExOCAwLTg1LjMxNS0xMC4yNDktODUuMzE1LTIyLjg5IDAtNi42ODkgMTAuNjk4LTEyLjcwOSAyNy43NTEtMTYuODk0IDAuMDM2MiAwLjAzIDAuMDcgMC4wNiAwLjEwNjI1IDAuMDktMTYuOTI1IDQuMTY5LTI3LjUzNiAxMC4xNTQtMjcuNTM2IDE2LjgwNCAwIDEyLjU5NCAzOC4wNTIgMjIuODA0IDg0Ljk5NCAyMi44MDQgNDYuOTQgMCA4NC45OTItMTAuMjEgODQuOTkyLTIyLjgwNCAwLTYuNjQ5OS0xMC42MTEtMTIuNjM1LTI3LjUzNi0xNi44MDQgMC4wMzYyLTAuMDMgMC4wNzEzLTAuMDYgMC4xMDYyNS0wLjA5IDE3LjA1NCA0LjE4NDUgMjcuNzUxIDEwLjIwNCAyNy43NTEgMTYuODk0IDAgMTIuNjQxLTM4LjE5NiAyMi44OS04NS4zMTQgMjIuODltMzYuMTY2LTQzLjUzMWMwLjA0MzgtMC4wMjYgMC4wOTEzLTAuMDU0IDAuMTM1LTAuMDc5LTAuMDQzOCAwLjAyNS0wLjA5MTMgMC4wNTMtMC4xMzUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTY5IiBkPSJtMzA1LjU5IDk1MC43NWMtNDYuOTQxIDAtODQuOTk0LTEwLjIxLTg0Ljk5NC0yMi44MDQgMC02LjY0OTkgMTAuNjExLTEyLjYzNSAyNy41MzYtMTYuODA0IDAuMDM2MyAwLjAzMSAwLjA3IDAuMDYgMC4xMDYyNSAwLjA5MS0xNi43OTYgNC4xNTEzLTI3LjMyMSAxMC4xMDItMjcuMzIxIDE2LjcxMiAwIDEyLjU0NiAzNy45MDkgMjIuNzE3IDg0LjY3MyAyMi43MTcgNDYuNzYyIDAgODQuNjcyLTEwLjE3MSA4NC42NzItMjIuNzE3IDAtNi42MDk5LTEwLjUyNS0xMi41NjEtMjcuMzIyLTE2LjcxMiAwLjAzNjItMC4wMzEgMC4wNzEyLTAuMDYgMC4xMDYyNS0wLjA5MSAxNi45MjUgNC4xNjkgMjcuNTM2IDEwLjE1NCAyNy41MzYgMTYuODA0IDAgMTIuNTk0LTM4LjA1MiAyMi44MDQtODQuOTkyIDIyLjgwNG0zNi4wMjktNDMuMzY4YzAuMDQ2My0wLjAyNiAwLjA5MTMtMC4wNTIgMC4xMzc1LTAuMDc3LTAuMDQ2MyAwLjAyNS0wLjA5MTMgMC4wNTEtMC4xMzc1IDAuMDc3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE3MSIgZD0ibTMwNS41OSA5NTAuNjZjLTQ2Ljc2NCAwLTg0LjY3My0xMC4xNzEtODQuNjczLTIyLjcxNyAwLTYuNjA5OSAxMC41MjUtMTIuNTYxIDI3LjMyMS0xNi43MTIgMC4wMzYyIDAuMDMgMC4wNzEyIDAuMDYgMC4xMDYyNSAwLjA5LTE2LjY2OCA0LjEzNTMtMjcuMTA4IDEwLjA1My0yNy4xMDggMTYuNjIyIDAgMTIuNDk5IDM3Ljc2NiAyMi42MzEgODQuMzUzIDIyLjYzMSA0Ni41ODYgMCA4NC4zNTEtMTAuMTMzIDg0LjM1MS0yMi42MzEgMC02LjU2OTktMTAuNDM5LTEyLjQ4Ny0yNy4xMDgtMTYuNjIyIDAuMDM2Mi0wLjAzIDAuMDcxMi0wLjA2IDAuMTA2MjUtMC4wOSAxNi43OTggNC4xNTE0IDI3LjMyMiAxMC4xMDIgMjcuMzIyIDE2LjcxMiAwIDEyLjU0Ni0zNy45MSAyMi43MTctODQuNjcyIDIyLjcxN20zNS44OTEtNDMuMjA0YzAuMDQ2My0wLjAyNiAwLjA5MTMtMC4wNTEgMC4xMzc1LTAuMDc4LTAuMDQ2MyAwLjAyNi0wLjA5MTMgMC4wNTEtMC4xMzc1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE3MyIgZD0ibTMwNS41OSA5NTAuNThjLTQ2LjU4NiAwLTg0LjM1My0xMC4xMzMtODQuMzUzLTIyLjYzMSAwLTYuNTY5OSAxMC40NC0xMi40ODcgMjcuMTA4LTE2LjYyMiAwLjAzNjIgMC4wMzEgMC4wNzEzIDAuMDYyIDAuMTA3NSAwLjA5MS0xNi41NDEgNC4xMTc2LTI2Ljg5NCAxMC0yNi44OTQgMTYuNTMxIDAgMTIuNDUxIDM3LjYyMSAyMi41NDUgODQuMDMxIDIyLjU0NSA0Ni40MDkgMCA4NC4wMzEtMTAuMDk0IDg0LjAzMS0yMi41NDUgMC02LjUzMTMtMTAuMzUyLTEyLjQxNC0yNi44OTQtMTYuNTMxIDAuMDM1LTAuMDMgMC4wNzEyLTAuMDYgMC4xMDYyNS0wLjA5MSAxNi42NjkgNC4xMzUzIDI3LjEwOCAxMC4wNTMgMjcuMTA4IDE2LjYyMiAwIDEyLjQ5OS0zNy43NjUgMjIuNjMxLTg0LjM1MSAyMi42MzFtMzUuNzU0LTQzLjA0YzAuMDQ2My0wLjAyNSAwLjA5MjUtMC4wNTEgMC4xMzc1LTAuMDc4LTAuMDQ1IDAuMDI2LTAuMDkxMiAwLjA1My0wLjEzNzUgMC4wNzgiLz4KICAgPHBhdGggaWQ9InBhdGg1MTc1IiBkPSJtMzA1LjU5IDk1MC40OWMtNDYuNDEgMC04NC4wMzEtMTAuMDk0LTg0LjAzMS0yMi41NDUgMC02LjUzMTIgMTAuMzUyLTEyLjQxNCAyNi44OTQtMTYuNTMxIDAuMDM1IDAuMDMgMC4wNzEyIDAuMDYgMC4xMDc1IDAuMDkxLTE2LjQxNSA0LjEwMDEtMjYuNjgxIDkuOTQ4Ny0yNi42ODEgMTYuNDQgMCAxMi40MDQgMzcuNDc5IDIyLjQ1OSA4My43MTEgMjIuNDU5IDQ2LjIzMSAwIDgzLjcxLTEwLjA1NSA4My43MS0yMi40NTkgMC02LjQ5MTMtMTAuMjY2LTEyLjM0LTI2LjY4LTE2LjQ0IDAuMDM2Mi0wLjAzMSAwLjA3MTItMC4wNjIgMC4xMDc1LTAuMDkxIDE2LjU0MSA0LjExNzYgMjYuODk0IDEwIDI2Ljg5NCAxNi41MzEgMCAxMi40NTEtMzcuNjIyIDIyLjU0NS04NC4wMzEgMjIuNTQ1bTM1LjYxNi00Mi44NzZjMC4wNDUtMC4wMjUgMC4wOTEzLTAuMDUxIDAuMTM3NS0wLjA3OC0wLjA0NjMgMC4wMjYtMC4wOTI1IDAuMDUyLTAuMTM3NSAwLjA3OCIvPgogICA8cGF0aCBpZD0icGF0aDUxNzciIGQ9Im0zMDUuNTkgOTUwLjQxYy00Ni4yMzMgMC04My43MTEtMTAuMDU1LTgzLjcxMS0yMi40NTkgMC02LjQ5MTIgMTAuMjY2LTEyLjM0IDI2LjY4MS0xNi40NCAwLjAzNSAwLjAzIDAuMDcxMyAwLjA2IDAuMTA3NSAwLjA5LTE2LjI4OSA0LjA4NC0yNi40NjggOS44OTc1LTI2LjQ2OCAxNi4zNSAwIDEyLjM1NiAzNy4zMzUgMjIuMzczIDgzLjM5IDIyLjM3M3M4My4zOS0xMC4wMTYgODMuMzktMjIuMzczYzAtNi40NTI2LTEwLjE4LTEyLjI2Ni0yNi40NjgtMTYuMzUgMC4wMzYyLTAuMDMgMC4wNzEzLTAuMDYgMC4xMDc1LTAuMDkgMTYuNDE0IDQuMTAwMSAyNi42OCA5Ljk0ODggMjYuNjggMTYuNDQgMCAxMi40MDQtMzcuNDc5IDIyLjQ1OS04My43MSAyMi40NTltMzUuNDc2LTQyLjcxMmMwLjA0NjMtMC4wMjYgMC4wOTM3LTAuMDUzIDAuMTQtMC4wNzktMC4wNDYzIDAuMDI2LTAuMDkzNyAwLjA1Mi0wLjE0IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE3OSIgZD0ibTMwNS41OSA5NTAuMzJjLTQ2LjA1NSAwLTgzLjM5LTEwLjAxNi04My4zOS0yMi4zNzMgMC02LjQ1MjYgMTAuMTc5LTEyLjI2NiAyNi40NjgtMTYuMzUgMC4wMzYyIDAuMDMgMC4wNzEzIDAuMDYgMC4xMDc1IDAuMDkxLTE2LjE1OSA0LjA2NjQtMjYuMjU0IDkuODQ2MS0yNi4yNTQgMTYuMjU5IDAgMTIuMzA5IDM3LjE5IDIyLjI4OCA4My4wNjkgMjIuMjg4IDQ1Ljg3OCAwIDgzLjA2OS05Ljk3OSA4My4wNjktMjIuMjg4IDAtNi40MTI2LTEwLjA5NS0xMi4xOTItMjYuMjU1LTE2LjI1OSAwLjAzNjItMC4wMyAwLjA3MjUtMC4wNjEgMC4xMDg3NS0wLjA5MSAxNi4yODggNC4wODQgMjYuNDY4IDkuODk3NSAyNi40NjggMTYuMzUgMCAxMi4zNTYtMzcuMzM1IDIyLjM3My04My4zOSAyMi4zNzNtMzUuMzM4LTQyLjU0N2MwLjA0NjMtMC4wMjYgMC4wOTI1LTAuMDUzIDAuMTM4NzUtMC4wNzgtMC4wNDYzIDAuMDI1LTAuMDkyNSAwLjA1MS0wLjEzODc1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE4MSIgZD0ibTMwNS41OSA5NTAuMjNjLTQ1Ljg3OSAwLTgzLjA2OS05Ljk3OS04My4wNjktMjIuMjg4IDAtNi40MTI2IDEwLjA5NS0xMi4xOTIgMjYuMjU0LTE2LjI1OSAwLjAzNjIgMC4wMyAwLjA3MjUgMC4wNiAwLjEwODc1IDAuMDktMTYuMDMxIDQuMDQ4OC0yNi4wNDIgOS43OTY1LTI2LjA0MiAxNi4xNjkgMCAxMi4yNjEgMzcuMDQ4IDIyLjIwMSA4Mi43NDkgMjIuMjAxIDQ1LjcgMCA4Mi43NDgtOS45Mzk5IDgyLjc0OC0yMi4yMDEgMC02LjM3MjUtMTAuMDExLTEyLjEyLTI2LjA0MS0xNi4xNjkgMC4wMzUtMC4wMyAwLjA3MTMtMC4wNiAwLjEwNzUtMC4wOSAxNi4xNiA0LjA2NjMgMjYuMjU1IDkuODQ2MSAyNi4yNTUgMTYuMjU5IDAgMTIuMzA5LTM3LjE5MSAyMi4yODgtODMuMDY5IDIyLjI4OG0zNS4xOTgtNDIuMzg2YzAuMDQ2My0wLjAyNSAwLjA5MzctMC4wNTEgMC4xNC0wLjA3Ni0wLjA0NjMgMC4wMjUtMC4wOTM3IDAuMDUxLTAuMTQgMC4wNzYiLz4KICAgPHBhdGggaWQ9InBhdGg1MTgzIiBkPSJtMzA1LjU5IDk1MC4xNWMtNDUuNzAxIDAtODIuNzQ5LTkuOTQtODIuNzQ5LTIyLjIwMSAwLTYuMzcyNSAxMC4wMTEtMTIuMTIgMjYuMDQyLTE2LjE2OSAwLjAzNjIgMC4wMzEgMC4wNzI1IDAuMDYyIDAuMTA4NzUgMC4wOTEtMTUuOTA2IDQuMDMxMy0yNS44MyA5Ljc0NDEtMjUuODMgMTYuMDc4IDAgMTIuMjE0IDM2LjkwNCAyMi4xMTUgODIuNDI4IDIyLjExNSA0NS41MjIgMCA4Mi40MjgtOS45MDA5IDgyLjQyOC0yMi4xMTUgMC02LjMzMzUtOS45MjM4LTEyLjA0Ni0yNS44My0xNi4wNzggMC4wMzYyLTAuMDMgMC4wNzI1LTAuMDYgMC4xMDg3NS0wLjA5MSAxNi4wMyA0LjA0ODkgMjYuMDQxIDkuNzk2NSAyNi4wNDEgMTYuMTY5IDAgMTIuMjYxLTM3LjA0OCAyMi4yMDEtODIuNzQ4IDIyLjIwMW0zNS4wNTYtNDIuMjIzYzAuMDQ4Ny0wLjAyNiAwLjA5MjUtMC4wNSAwLjE0MTI1LTAuMDc3LTAuMDQ4NyAwLjAyNy0wLjA5MjUgMC4wNTEtMC4xNDEyNSAwLjA3NyIvPgogICA8cGF0aCBpZD0icGF0aDUxODUiIGQ9Im0zMDUuNTkgOTUwLjA2Yy00NS41MjQgMC04Mi40MjgtOS45MDA5LTgyLjQyOC0yMi4xMTUgMC02LjMzMzUgOS45MjM4LTEyLjA0NiAyNS44My0xNi4wNzggMC4wMzYyIDAuMDMgMC4wNzI1IDAuMDYyIDAuMTA4NzUgMC4wOTEtMTUuNzc5IDQuMDEzNy0yNS42MTkgOS42OTE0LTI1LjYxOSAxNS45ODYgMCAxMi4xNjYgMzYuNzYxIDIyLjAyOSA4Mi4xMDggMjIuMDI5IDQ1LjM0NiAwIDgyLjEwNi05Ljg2MjggODIuMTA2LTIyLjAyOSAwLTYuMjk0OS05LjgzODgtMTEuOTczLTI1LjYxOS0xNS45ODYgMC4wMzYyLTAuMDMgMC4wNzM4LTAuMDYxIDAuMTEtMC4wOTEgMTUuOTA2IDQuMDMxMyAyNS44MyA5Ljc0NDIgMjUuODMgMTYuMDc4IDAgMTIuMjE0LTM2LjkwNSAyMi4xMTUtODIuNDI4IDIyLjExNW0zNC45MTUtNDIuMDU5YzAuMDQ2My0wLjAyNSAwLjA5NS0wLjA1MSAwLjE0MTI1LTAuMDc4LTAuMDQ2MyAwLjAyNi0wLjA5NSAwLjA1My0wLjE0MTI1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE4NyIgZD0ibTMwNS41OSA5NDkuOThjLTQ1LjM0NiAwLTgyLjEwOC05Ljg2MjgtODIuMTA4LTIyLjAyOSAwLTYuMjk0OSA5Ljg0LTExLjk3MyAyNS42MTktMTUuOTg2IDAuMDM2MyAwLjAzIDAuMDczOCAwLjA2IDAuMTEgMC4wOS0xNS42NTIgMy45OTc3LTI1LjQwOCA5LjY0MTctMjUuNDA4IDE1Ljg5NiAwIDEyLjExOSAzNi42MTYgMjEuOTQyIDgxLjc4NiAyMS45NDIgNDUuMTY5IDAgODEuNzg1LTkuODIzNyA4MS43ODUtMjEuOTQyIDAtNi4yNTQ4LTkuNzUzOC0xMS44OTktMjUuNDA2LTE1Ljg5NiAwLjAzNjItMC4wMyAwLjA3MjUtMC4wNiAwLjEwODc1LTAuMDkgMTUuNzggNC4wMTM4IDI1LjYxOSA5LjY5MTUgMjUuNjE5IDE1Ljk4NiAwIDEyLjE2Ni0zNi43NiAyMi4wMjktODIuMTA2IDIyLjAyOW0zNC43NzItNDEuODk1YzAuMDQ4Ny0wLjAyNiAwLjA5MzctMC4wNTEgMC4xNDI1LTAuMDc4LTAuMDQ4NyAwLjAyNi0wLjA5MzcgMC4wNTEtMC4xNDI1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE4OSIgZD0ibTMwNS41OSA5NDkuODljLTQ1LjE3IDAtODEuNzg2LTkuODIzNy04MS43ODYtMjEuOTQyIDAtNi4yNTQ5IDkuNzU1LTExLjg5OSAyNS40MDgtMTUuODk2IDAuMDM2MiAwLjAzIDAuMDcyNSAwLjA2MiAwLjExIDAuMDkxLTE1LjUyOCAzLjk3OTktMjUuMTk2IDkuNTg4OS0yNS4xOTYgMTUuODA1IDAgMTIuMDcxIDM2LjQ3MiAyMS44NTYgODEuNDY1IDIxLjg1NiA0NC45OTEgMCA4MS40NjUtOS43ODUyIDgxLjQ2NS0yMS44NTYgMC02LjIxNjItOS42Ny0xMS44MjUtMjUuMTk2LTE1LjgwNSAwLjAzNjItMC4wMyAwLjA3MzgtMC4wNjEgMC4xMS0wLjA5MSAxNS42NTIgMy45OTc2IDI1LjQwNiA5LjY0MTYgMjUuNDA2IDE1Ljg5NiAwIDEyLjExOS0zNi42MTYgMjEuOTQyLTgxLjc4NSAyMS45NDJtMzQuNjMtNDEuNzMxYzAuMDQ3NS0wLjAyNSAwLjA5NjItMC4wNTMgMC4xNDI1LTAuMDc4LTAuMDQ2MyAwLjAyNS0wLjA5NSAwLjA1Mi0wLjE0MjUgMC4wNzgiLz4KICAgPHBhdGggaWQ9InBhdGg1MTkxIiBkPSJtMzA1LjU5IDk0OS44Yy00NC45OTMgMC04MS40NjUtOS43ODUyLTgxLjQ2NS0yMS44NTYgMC02LjIxNjIgOS42Njg4LTExLjgyNSAyNS4xOTYtMTUuODA1IDAuMDM2MiAwLjAzIDAuMDczOCAwLjA2MiAwLjExIDAuMDkxLTE1LjM5OCAzLjk2MTQtMjQuOTg2IDkuNTM3Ny0yNC45ODYgMTUuNzE0IDAgMTIuMDI0IDM2LjMzIDIxLjc3IDgxLjE0NSAyMS43NyA0NC44MTQgMCA4MS4xNDQtOS43NDYxIDgxLjE0NC0yMS43NyAwLTYuMTc2Mi05LjU4NzUtMTEuNzUyLTI0Ljk4NS0xNS43MTQgMC4wMzYyLTAuMDMgMC4wNzM3LTAuMDYxIDAuMTEtMC4wOTEgMTUuNTI2IDMuOTc5OSAyNS4xOTYgOS41ODg5IDI1LjE5NiAxNS44MDUgMCAxMi4wNzEtMzYuNDc0IDIxLjg1Ni04MS40NjUgMjEuODU2bTM0LjQ4OC00MS41NjljMC4wNDg3LTAuMDI1IDAuMDk1LTAuMDUgMC4xNDI1LTAuMDc2LTAuMDQ3NSAwLjAyNi0wLjA5MzcgMC4wNTEtMC4xNDI1IDAuMDc2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE5MyIgZD0ibTMwNS41OSA5NDkuNzJjLTQ0LjgxNSAwLTgxLjE0NS05Ljc0NjEtODEuMTQ1LTIxLjc3IDAtNi4xNzYyIDkuNTg4OC0xMS43NTIgMjQuOTg2LTE1LjcxNCAwLjAzNjIgMC4wMjkgMC4wNzM3IDAuMDYgMC4xMSAwLjA5LTE1LjI3MiAzLjk0NDgtMjQuNzc1IDkuNDg1OC0yNC43NzUgMTUuNjI0IDAgMTEuOTc2IDM2LjE4NiAyMS42ODUgODAuODI0IDIxLjY4NXM4MC44MjItOS43MDkgODAuODIyLTIxLjY4NWMwLTYuMTM3Ny05LjUwMTItMTEuNjc5LTI0Ljc3NS0xNS42MjQgMC4wMzYyLTAuMDMgMC4wNzUtMC4wNjIgMC4xMTEyNS0wLjA5IDE1LjM5OCAzLjk2MTQgMjQuOTg1IDkuNTM3NyAyNC45ODUgMTUuNzE0IDAgMTIuMDI0LTM2LjMzIDIxLjc3LTgxLjE0NCAyMS43N20zNC4zNDQtNDEuNDA1YzAuMDQ2My0wLjAyNSAwLjA5NjMtMC4wNTIgMC4xNDM3NS0wLjA3Ny0wLjA0NzUgMC4wMjUtMC4wOTc1IDAuMDUyLTAuMTQzNzUgMC4wNzciLz4KICA8L2c+CiAgPGc+CiAgIDxwYXRoIGlkPSJwYXRoNTE5NSIgZD0ibTMwNS41OSA5NDkuNjNjLTQ0LjYzOCAwLTgwLjgyNC05LjcwOS04MC44MjQtMjEuNjg1IDAtNi4xMzc3IDkuNTAyNS0xMS42NzkgMjQuNzc1LTE1LjYyNCAwLjAzODcgMC4wMzEgMC4wNzI1IDAuMDYgMC4xMTEyNSAwLjA5MS0xNS4xNDYgMy45Mjc4LTI0LjU2NiA5LjQzNTEtMjQuNTY2IDE1LjUzMyAwIDExLjkyOSAzNi4wNDIgMjEuNTk5IDgwLjUwNCAyMS41OTkgNDQuNDYgMCA4MC41MDItOS42Njk5IDgwLjUwMi0yMS41OTkgMC02LjA5NzctOS40Mi0xMS42MDUtMjQuNTY2LTE1LjUzMyAwLjAzODctMC4wMzEgMC4wNzI1LTAuMDYgMC4xMTEyNS0wLjA5MSAxNS4yNzQgMy45NDQ4IDI0Ljc3NSA5LjQ4NTggMjQuNzc1IDE1LjYyNCAwIDExLjk3Ni0zNi4xODUgMjEuNjg1LTgwLjgyMiAyMS42ODVtMzQuMTk5LTQxLjI0NGMwLjA0ODctMC4wMjUgMC4wOTYyLTAuMDUgMC4xNDUtMC4wNzctMC4wNDg3IDAuMDI2LTAuMDk2MiAwLjA1MS0wLjE0NSAwLjA3NyIgZmlsbD0iI2ZlZmZmZiIvPgogICA8cGF0aCBpZD0icGF0aDUxOTciIGQ9Im0zMDUuNTkgOTQ5LjU1Yy00NC40NjEgMC04MC41MDQtOS42Ny04MC41MDQtMjEuNTk5IDAtNi4wOTc2IDkuNDItMTEuNjA1IDI0LjU2Ni0xNS41MzMgMC4wMzYyIDAuMDMgMC4wNzUgMC4wNjIgMC4xMTEyNSAwLjA5MS0xNS4wMjIgMy45MTAxLTI0LjM1NiA5LjM4MjctMjQuMzU2IDE1LjQ0MSAwIDExLjg4MSAzNS44OTkgMjEuNTEzIDgwLjE4MyAyMS41MTMgNDQuMjgyIDAgODAuMTgxLTkuNjMxMyA4MC4xODEtMjEuNTEzIDAtNi4wNTg2LTkuMzMzOC0xMS41MzEtMjQuMzU2LTE1LjQ0MSAwLjAzNzUtMC4wMyAwLjA3NS0wLjA2MSAwLjExMTI1LTAuMDkxIDE1LjE0NiAzLjkyNzggMjQuNTY2IDkuNDM1MiAyNC41NjYgMTUuNTMzIDAgMTEuOTI5LTM2LjA0MiAyMS41OTktODAuNTAyIDIxLjU5OW0zNC4wNTQtNDEuMDhjMC4wNDg3LTAuMDI2IDAuMDk2Mi0wLjA1MSAwLjE0NS0wLjA3Ny0wLjA0ODcgMC4wMjYtMC4wOTYyIDAuMDUxLTAuMTQ1IDAuMDc3IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTE5OSIgZD0ibTMwNS41OSA5NDkuNDZjLTQ0LjI4NCAwLTgwLjE4My05LjYzMTMtODAuMTgzLTIxLjUxMyAwLTYuMDU4NiA5LjMzMzgtMTEuNTMxIDI0LjM1Ni0xNS40NDEgMC4wMzYyIDAuMDI5IDAuMDc1IDAuMDYyIDAuMTExMjUgMC4wOS0xNC44OTUgMy44OTIyLTI0LjE0NiA5LjMzMjctMjQuMTQ2IDE1LjM1MSAwIDExLjgzNCAzNS43NTUgMjEuNDI2IDc5Ljg2MSAyMS40MjZzNzkuODYxLTkuNTkyMyA3OS44NjEtMjEuNDI2YzAtNi4wMTg1LTkuMjUxMi0xMS40NTktMjQuMTQ4LTE1LjM1MSAwLjAzNjItMC4wMjkgMC4wNzUtMC4wNjIgMC4xMTEyNS0wLjA5IDE1LjAyMiAzLjkxMDIgMjQuMzU2IDkuMzgyOCAyNC4zNTYgMTUuNDQxIDAgMTEuODgxLTM1Ljg5OSAyMS41MTMtODAuMTgxIDIxLjUxM20zMy45MDktNDAuOTE3YzAuMDQ4Ny0wLjAyNSAwLjA5NjItMC4wNSAwLjE0NS0wLjA3Ny0wLjA0ODcgMC4wMjYtMC4wOTYyIDAuMDUxLTAuMTQ1IDAuMDc3IiBmaWxsPSIjZmVmZWZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTIwMSIgZD0ibTMwNS41OSA5NDkuMzdjLTQ0LjEwNiAwLTc5Ljg2MS05LjU5MjMtNzkuODYxLTIxLjQyNiAwLTYuMDE4NSA5LjI1MTItMTEuNDU5IDI0LjE0Ni0xNS4zNTEgMC4wMzg3IDAuMDMxIDAuMDczOCAwLjA2IDAuMTEyNSAwLjA5MS0xNC43NyAzLjg3NTUtMjMuOTM5IDkuMjgwMi0yMy45MzkgMTUuMjYgMCAxMS43ODYgMzUuNjExIDIxLjM0IDc5LjU0MSAyMS4zNCA0My45MjkgMCA3OS41NC05LjU1MzcgNzkuNTQtMjEuMzQgMC01Ljk4LTkuMTY3NS0xMS4zODUtMjMuOTM5LTE1LjI2IDAuMDM4Ny0wLjAzMSAwLjA3MzctMC4wNiAwLjExMjUtMC4wOTEgMTQuODk2IDMuODkyMSAyNC4xNDggOS4zMzI2IDI0LjE0OCAxNS4zNTEgMCAxMS44MzQtMzUuNzU1IDIxLjQyNi03OS44NjEgMjEuNDI2bTMzLjc2Mi00MC43NTRjMC4wNDYzLTAuMDI1IDAuMDk4OC0wLjA1MiAwLjE0NjI1LTAuMDc3LTAuMDQ3NSAwLjAyNS0wLjEgMC4wNTItMC4xNDYyNSAwLjA3NyIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDUyMDMiIGQ9Im0zMDUuNTkgOTQ5LjI5Yy00My45MyAwLTc5LjU0MS05LjU1MzctNzkuNTQxLTIxLjM0IDAtNS45OCA5LjE2ODgtMTEuMzg1IDIzLjkzOS0xNS4yNiAwLjAzNjIgMC4wMyAwLjA3NjMgMC4wNjIgMC4xMTI1IDAuMDkxLTE0LjY0NSAzLjg1NjUtMjMuNzMgOS4yMjc2LTIzLjczIDE1LjE2OSAwIDExLjczOSAzNS40NjggMjEuMjU1IDc5LjIyIDIxLjI1NSA0My43NTEgMCA3OS4yMTktOS41MTYyIDc5LjIxOS0yMS4yNTUgMC01Ljk0MTQtOS4wODM4LTExLjMxMi0yMy43My0xNS4xNjkgMC4wMzc1LTAuMDMgMC4wNzYzLTAuMDYxIDAuMTEyNS0wLjA5MSAxNC43NzEgMy44NzU1IDIzLjkzOSA5LjI4MDIgMjMuOTM5IDE1LjI2IDAgMTEuNzg2LTM1LjYxMSAyMS4zNC03OS41NCAyMS4zNG0zMy42MTUtNDAuNTkxYzAuMDQ4Ny0wLjAyNSAwLjA5NzUtMC4wNTEgMC4xNDc1LTAuMDc2LTAuMDUgMC4wMjUtMC4wOTg3IDAuMDUxLTAuMTQ3NSAwLjA3NiIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDUyMDUiIGQ9Im0zMDUuNTkgOTQ5LjJjLTQzLjc1MyAwLTc5LjIyLTkuNTE2MS03OS4yMi0yMS4yNTUgMC01Ljk0MTQgOS4wODUtMTEuMzEyIDIzLjczLTE1LjE2OSAwLjAzODcgMC4wMzEgMC4wNzM4IDAuMDU5IDAuMTEyNSAwLjA5LTE0LjUyMSAzLjgzOTktMjMuNTIyIDkuMTc3My0yMy41MjIgMTUuMDc5IDAgMTEuNjkxIDM1LjMyNSAyMS4xNjkgNzguOSAyMS4xNjlzNzguODk5LTkuNDc3NiA3OC44OTktMjEuMTY5YzAtNS45MDEzLTkuMDAxMi0xMS4yMzktMjMuNTIyLTE1LjA3OSAwLjAzODctMC4wMzEgMC4wNzM3LTAuMDU5IDAuMTEyNS0wLjA5IDE0LjY0NiAzLjg1NjUgMjMuNzMgOS4yMjc2IDIzLjczIDE1LjE2OSAwIDExLjczOS0zNS40NjggMjEuMjU1LTc5LjIxOSAyMS4yNTVtMzMuNDY4LTQwLjQzYzAuMDUxMy0wLjAyNiAwLjA5NjMtMC4wNSAwLjE0NzUtMC4wNzctMC4wNTEyIDAuMDI2LTAuMDk2MiAwLjA1LTAuMTQ3NSAwLjA3NyIgZmlsbD0iI2ZkZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDUyMDciIGQ9Im0zMDUuNTkgOTQ5LjEyYy00My41NzUgMC03OC45LTkuNDc3Ni03OC45LTIxLjE2OSAwLTUuOTAxNCA5LjAwMTItMTEuMjM5IDIzLjUyMi0xNS4wNzlsMC4xMTM3NSAwLjA5MWMtMTQuMzk1IDMuODIwNy0yMy4zMTUgOS4xMjUtMjMuMzE1IDE0Ljk4NyAwIDExLjY0NCAzNS4xODEgMjEuMDgzIDc4LjU3OSAyMS4wODNzNzguNTc4LTkuNDM5IDc4LjU3OC0yMS4wODNjMC01Ljg2MjItOC45Mi0xMS4xNjYtMjMuMzE1LTE0Ljk4NyAwLjAzNzUtMC4wMjkgMC4wNzc1LTAuMDYyIDAuMTEzNzUtMC4wOTEgMTQuNTIxIDMuODM5OSAyMy41MjIgOS4xNzcyIDIzLjUyMiAxNS4wNzkgMCAxMS42OTEtMzUuMzI0IDIxLjE2OS03OC44OTkgMjEuMTY5bTMzLjMxOS00MC4yNjhjMC4wNDg3LTAuMDI1IDAuMDk4Ny0wLjA1MSAwLjE0ODc1LTAuMDc2LTAuMDUgMC4wMjUtMC4xIDAuMDUxLTAuMTQ4NzUgMC4wNzYiIGZpbGw9IiNmZGZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1MjA5IiBkPSJtMzA1LjU5IDk0OS4wM2MtNDMuMzk4IDAtNzguNTc5LTkuNDM4OS03OC41NzktMjEuMDgyIDAtNS44NjIyIDguOTItMTEuMTY2IDIzLjMxNS0xNC45ODcgMC4wMzg3IDAuMDMxIDAuMDc1IDAuMDYgMC4xMTM3NSAwLjA5MS0xNC4yNzEgMy44MDQyLTIzLjEwOCA5LjA3MjctMjMuMTA4IDE0Ljg5NiAwIDExLjU5NiAzNS4wMzYgMjAuOTk2IDc4LjI1OCAyMC45OTYgNDMuMjIgMCA3OC4yNTgtOS4zOTk5IDc4LjI1OC0yMC45OTYgMC01LjgyMzgtOC44MzYyLTExLjA5Mi0yMy4xMDktMTQuODk2IDAuMDQtMC4wMzEgMC4wNzUtMC4wNiAwLjExMzc1LTAuMDkxIDE0LjM5NSAzLjgyMDcgMjMuMzE1IDkuMTI1IDIzLjMxNSAxNC45ODcgMCAxMS42NDQtMzUuMTggMjEuMDgyLTc4LjU3OCAyMS4wODJtMzMuMTctNDAuMTA1YzAuMDQ4Ny0wLjAyNSAwLjEtMC4wNTEgMC4xNDg3NS0wLjA3Ni0wLjA0ODcgMC4wMjUtMC4xIDAuMDUxLTAuMTQ4NzUgMC4wNzYiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1MjExIiBkPSJtMzA1LjU5IDk0OC45NGMtNDMuMjIxIDAtNzguMjU4LTkuMzk5OS03OC4yNTgtMjAuOTk2IDAtNS44MjM3IDguODM2Mi0xMS4wOTIgMjMuMTA4LTE0Ljg5NiAwLjAzNjIgMC4wMjkgMC4wNzc1IDAuMDYyIDAuMTEzNzUgMC4wOTEtMTQuMTQ2IDMuNzg1MS0yMi45MDEgOS4wMjE0LTIyLjkwMSAxNC44MDUgMCAxMS41NDkgMzQuODk0IDIwLjkxIDc3LjkzOCAyMC45MSA0My4wNDIgMCA3Ny45MzYtOS4zNjE0IDc3LjkzNi0yMC45MSAwLTUuNzgzNy04Ljc1NS0xMS4wMi0yMi45MDEtMTQuODA1bDAuMTEzNzUtMC4wOTFjMTQuMjcyIDMuODA0MiAyMy4xMDkgOS4wNzI3IDIzLjEwOSAxNC44OTYgMCAxMS41OTYtMzUuMDM4IDIwLjk5Ni03OC4yNTggMjAuOTk2bTMzLjAyLTM5Ljk0MmMwLjA0ODctMC4wMjUgMC4xMDEyNS0wLjA1MSAwLjE1LTAuMDc2LTAuMDQ4NyAwLjAyNS0wLjEwMTI1IDAuMDUxLTAuMTUgMC4wNzYiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjEzIiBkPSJtMzA1LjU5IDk0OC44NmMtNDMuMDQ0IDAtNzcuOTM4LTkuMzYxNC03Ny45MzgtMjAuOTEgMC01Ljc4MzcgOC43NTUtMTEuMDIgMjIuOTAxLTE0LjgwNSAwLjA0IDAuMDMxIDAuMDc2MiAwLjA2IDAuMTE1IDAuMDktMTQuMDI1IDMuNzY4Ni0yMi42OTUgOC45Njk3LTIyLjY5NSAxNC43MTUgMCAxMS41MDEgMzQuNzUgMjAuODI0IDc3LjYxNiAyMC44MjRzNzcuNjE1LTkuMzIyMyA3Ny42MTUtMjAuODI0YzAtNS43NDUyLTguNjctMTAuOTQ2LTIyLjY5NC0xNC43MTUgMC4wMzg3LTAuMDMgMC4wNzUtMC4wNTkgMC4xMTM3NS0wLjA5IDE0LjE0NiAzLjc4NTEgMjIuOTAxIDkuMDIxNCAyMi45MDEgMTQuODA1IDAgMTEuNTQ5LTM0Ljg5NCAyMC45MS03Ny45MzYgMjAuOTFtMzIuODctMzkuNzhjMC4wNTEyLTAuMDI2IDAuMDk4Ny0wLjA1IDAuMTUtMC4wNzYtMC4wNTEzIDAuMDI2LTAuMDk4OCAwLjA1LTAuMTUgMC4wNzYiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjE1IiBkPSJtMzA1LjU5IDk0OC43N2MtNDIuODY2IDAtNzcuNjE2LTkuMzIyMi03Ny42MTYtMjAuODI0IDAtNS43NDUxIDguNjctMTAuOTQ2IDIyLjY5NS0xNC43MTUgMC4wMzYyIDAuMDMgMC4wNzc1IDAuMDYyIDAuMTE1IDAuMDkxLTEzLjkwMSAzLjc1LTIyLjQ4OSA4LjkxNzUtMjIuNDg5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDYgMjAuNzM3IDc3LjI5NSAyMC43MzdzNzcuMjk1LTkuMjgzNiA3Ny4yOTUtMjAuNzM3YzAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40ODktMTQuNjI0IDAuMDM2Mi0wLjAyOSAwLjA3NzUtMC4wNjIgMC4xMTUtMC4wOTEgMTQuMDI0IDMuNzY4NyAyMi42OTQgOC45Njk4IDIyLjY5NCAxNC43MTUgMCAxMS41MDItMzQuNzQ5IDIwLjgyNC03Ny42MTUgMjAuODI0bTMyLjcxOS0zOS42MThjMC4wNS0wLjAyNSAwLjEwMTI1LTAuMDUxIDAuMTUxMjUtMC4wNzYtMC4wNSAwLjAyNS0wLjEwMTI1IDAuMDUxLTAuMTUxMjUgMC4wNzYiIGZpbGw9IiNmY2ZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjE3IiBkPSJtMzA1LjU5IDk0OC42OGMtNDIuNjg5IDAtNzcuMjk1LTkuMjgzNy03Ny4yOTUtMjAuNzM3IDAtNS43MDYgOC41ODc1LTEwLjg3NCAyMi40ODktMTQuNjI0IDAuMDM4OCAwLjAzMSAwLjA3NjMgMC4wNiAwLjExNSAwLjA5MS0xMy43NzggMy43MzE1LTIyLjI4NCA4Ljg2NTMtMjIuMjg0IDE0LjUzMyAwIDExLjQwNiAzNC40NjIgMjAuNjUyIDc2Ljk3NSAyMC42NTIgNDIuNTExIDAgNzYuOTc0LTkuMjQ2MSA3Ni45NzQtMjAuNjUyIDAtNS42Njc1LTguNTA2Mi0xMC44MDEtMjIuMjg0LTE0LjUzMyAwLjA0LTAuMDMxIDAuMDc2Mi0wLjA1OSAwLjExNjI1LTAuMDkxIDEzLjkgMy43NSAyMi40ODkgOC45MTc1IDIyLjQ4OSAxNC42MjQgMCAxMS40NTQtMzQuNjA2IDIwLjczNy03Ny4yOTUgMjAuNzM3bTMyLjU2Ni0zOS40NTVjMC4wNTI1LTAuMDI2IDAuMTAxMjUtMC4wNSAwLjE1MjUtMC4wNzYtMC4wNTEyIDAuMDI2LTAuMSAwLjA1LTAuMTUyNSAwLjA3NiIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDUyMTkiIGQ9Im0zMDUuNTkgOTQ4LjZjLTQyLjUxMyAwLTc2Ljk3NS05LjI0NjEtNzYuOTc1LTIwLjY1MiAwLTUuNjY3NSA4LjUwNjItMTAuODAxIDIyLjI4NC0xNC41MzMgMC4wMzg3IDAuMDMxIDAuMDc2MyAwLjA2IDAuMTE2MjUgMC4wOTEtMTMuNjUxIDMuNzEzOC0yMi4wNzkgOC44MTM4LTIyLjA3OSAxNC40NDEgMCAxMS4zNTkgMzQuMzE5IDIwLjU2NiA3Ni42NTQgMjAuNTY2IDQyLjMzNCAwIDc2LjY1NC05LjIwNzUgNzYuNjU0LTIwLjU2NiAwLTUuNjI3NS04LjQyNzUtMTAuNzI4LTIyLjA3OS0xNC40NDEgMC4wMzg3LTAuMDMxIDAuMDc2My0wLjA2IDAuMTE1LTAuMDkxIDEzLjc3OCAzLjczMTUgMjIuMjg0IDguODY1MiAyMi4yODQgMTQuNTMzIDAgMTEuNDA2LTM0LjQ2MiAyMC42NTItNzYuOTc0IDIwLjY1Mm0zMi40MTQtMzkuMjk0YzAuMDUtMC4wMjUgMC4xMDM3NS0wLjA1MSAwLjE1MjUtMC4wNzctMC4wNDg3IDAuMDI1LTAuMTAyNSAwLjA1MS0wLjE1MjUgMC4wNzciIGZpbGw9IiNmY2ZjZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjIxIiBkPSJtMzA1LjU5IDk0OC41MWMtNDIuMzM1IDAtNzYuNjU0LTkuMjA3NS03Ni42NTQtMjAuNTY2IDAtNS42Mjc1IDguNDI3NS0xMC43MjggMjIuMDc5LTE0LjQ0MSAwLjAzNjIgMC4wMjkgMC4wNzg3IDAuMDYyIDAuMTE2MjUgMC4wOTEtMTMuNTMgMy42OTU0LTIxLjg3NSA4Ljc2MTMtMjEuODc1IDE0LjM1IDAgMTEuMzExIDM0LjE3NiAyMC40OCA3Ni4zMzQgMjAuNDhzNzYuMzMyLTkuMTY4OSA3Ni4zMzItMjAuNDhjMC01LjU4ODktOC4zNDUtMTAuNjU1LTIxLjg3NC0xNC4zNSAwLjAzNzUtMC4wMjkgMC4wNzg4LTAuMDYyIDAuMTE2MjUtMC4wOTEgMTMuNjUxIDMuNzEzOSAyMi4wNzkgOC44MTM5IDIyLjA3OSAxNC40NDEgMCAxMS4zNTktMzQuMzIgMjAuNTY2LTc2LjY1NCAyMC41NjZtMzIuMjYxLTM5LjEzM2MwLjA1MTItMC4wMjUgMC4xMDEyNS0wLjA1IDAuMTUyNS0wLjA3NS0wLjA1MTIgMC4wMjUtMC4xMDEyNSAwLjA1LTAuMTUyNSAwLjA3NSIgZmlsbD0iI2ZjZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDUyMjMiIGQ9Im0zMDUuNTkgOTQ4LjQzYy00Mi4xNTggMC03Ni4zMzQtOS4xNjg5LTc2LjMzNC0yMC40OCAwLTUuNTg4OSA4LjM0NS0xMC42NTUgMjEuODc1LTE0LjM1IDAuMDM4NyAwLjAzIDAuMDc2MyAwLjA2IDAuMTE2MjUgMC4wOTEtMTMuNDA1IDMuNjc2Mi0yMS42NyA4LjcwOS0yMS42NyAxNC4yNTkgMCAxMS4yNjQgMzQuMDMxIDIwLjM5NCA3Ni4wMTMgMjAuMzk0IDQxLjk4IDAgNzYuMDExLTkuMTI5OSA3Ni4wMTEtMjAuMzk0IDAtNS41NDk3LTguMjYzOC0xMC41ODItMjEuNjY5LTE0LjI1OSAwLjAzODctMC4wMzEgMC4wNzc1LTAuMDYxIDAuMTE2MjUtMC4wOTEgMTMuNTI5IDMuNjk1NCAyMS44NzQgOC43NjEyIDIxLjg3NCAxNC4zNSAwIDExLjMxMS0zNC4xNzUgMjAuNDgtNzYuMzMyIDIwLjQ4bTMyLjEwOC0zOC45N2MwLjA1MTMtMC4wMjYgMC4xMDI1LTAuMDUgMC4xNTM3NS0wLjA3Ni0wLjA1MTIgMC4wMjYtMC4xMDI1IDAuMDUtMC4xNTM3NSAwLjA3NiIgZmlsbD0iI2ZiZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDUyMjUiIGQ9Im0zMDUuNTkgOTQ4LjM0Yy00MS45ODEgMC03Ni4wMTMtOS4xMjk5LTc2LjAxMy0yMC4zOTQgMC01LjU0OTcgOC4yNjUtMTAuNTgyIDIxLjY3LTE0LjI1OSAwLjAzODcgMC4wMyAwLjA3NzUgMC4wNiAwLjExNjI1IDAuMDktMTMuMjg0IDMuNjYwMi0yMS40NjUgOC42NTc3LTIxLjQ2NSAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4IDQxLjgwMiAwIDc1LjY5MS05LjA5MTQgNzUuNjkxLTIwLjMwOCAwLTUuNTExMy04LjE4MjUtMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wNC0wLjAzIDAuMDc3NS0wLjA2IDAuMTE3NS0wLjA5IDEzLjQwNSAzLjY3NjIgMjEuNjY5IDguNzA5IDIxLjY2OSAxNC4yNTkgMCAxMS4yNjQtMzQuMDMxIDIwLjM5NC03Ni4wMTEgMjAuMzk0bTMxLjk1Mi0zOC44MDljMC4wNTEzLTAuMDI1IDAuMTAyNS0wLjA1IDAuMTU1LTAuMDc1LTAuMDUyNSAwLjAyNS0wLjEwMzc1IDAuMDUtMC4xNTUgMC4wNzUiIGZpbGw9IiNmYmZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg1MjI3IiBkPSJtMzA1LjU5IDk0OC4yNWMtNDEuODA0IDAtNzUuNjkxLTkuMDkxMy03NS42OTEtMjAuMzA4IDAtNS41MTEyIDguMTgxMi0xMC41MDkgMjEuNDY1LTE0LjE2OSAwLjA0IDAuMDMxIDAuMDc4NyAwLjA2MiAwLjExNzUgMC4wOTEtMTMuMTYxIDMuNjQxMS0yMS4yNjIgOC42MDUtMjEuMjYyIDE0LjA3OCAwIDExLjE2OSAzMy43NDUgMjAuMjIxIDc1LjM3MSAyMC4yMjFzNzUuMzctOS4wNTIyIDc1LjM3LTIwLjIyMWMwLTUuNDcyNi04LjEwMTItMTAuNDM2LTIxLjI2Mi0xNC4wNzggMC4wNC0wLjAzIDAuMDc4OC0wLjA2IDAuMTE3NS0wLjA5MSAxMy4yODQgMy42NjAzIDIxLjQ2NiA4LjY1NzggMjEuNDY2IDE0LjE2OSAwIDExLjIxNi0zMy44ODkgMjAuMzA4LTc1LjY5MSAyMC4zMDhtMzEuNzk4LTM4LjY0NmMwLjA1MTMtMC4wMjUgMC4xMDM3NS0wLjA1MSAwLjE1NS0wLjA3Ni0wLjA1MTIgMC4wMjUtMC4xMDM3NSAwLjA1MS0wLjE1NSAwLjA3NiIgZmlsbD0iI2ZiZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDUyMjkiIGQ9Im0zMDUuNTkgOTQ4LjE3Yy00MS42MjYgMC03NS4zNzEtOS4wNTIyLTc1LjM3MS0yMC4yMjEgMC01LjQ3MjYgOC4xMDEyLTEwLjQzNiAyMS4yNjItMTQuMDc4IDAuMDQgMC4wMzEgMC4wNzg3IDAuMDYyIDAuMTE3NSAwLjA5MS0xMy4wMzggMy42MjI2LTIxLjA1OSA4LjU1MjctMjEuMDU5IDEzLjk4NiAwIDExLjEyIDMzLjYwMSAyMC4xMzYgNzUuMDUgMjAuMTM2czc1LjA0OS05LjAxNjEgNzUuMDQ5LTIwLjEzNmMwLTUuNDMzNy04LjAyLTEwLjM2NC0yMS4wNTktMTMuOTg2IDAuMDQtMC4wMyAwLjA3ODgtMC4wNiAwLjExNzUtMC4wOTEgMTMuMTYxIDMuNjQxMSAyMS4yNjIgOC42MDUgMjEuMjYyIDE0LjA3OCAwIDExLjE2OS0zMy43NDQgMjAuMjIxLTc1LjM3IDIwLjIyMW0zMS42NDEtMzguNDg1YzAuMDUxMy0wLjAyNSAwLjEwMzc1LTAuMDUgMC4xNTYyNS0wLjA3NS0wLjA1MjUgMC4wMjUtMC4xMDUgMC4wNS0wLjE1NjI1IDAuMDc1IiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTIzMSIgZD0ibTMwNS41OSA5NDguMDhjLTQxLjQ0OSAwLTc1LjA1LTkuMDE2MS03NS4wNS0yMC4xMzYgMC01LjQzMzYgOC4wMjEyLTEwLjM2NCAyMS4wNTktMTMuOTg2IDAuMDQgMC4wMyAwLjA3ODggMC4wNjIgMC4xMTg3NSAwLjA5MS0xMi45MTYgMy42MDQtMjAuODU4IDguNS0yMC44NTggMTMuODk1IDAgMTEuMDcyIDMzLjQ1OCAyMC4wNSA3NC43MyAyMC4wNSA0MS4yNzEgMCA3NC43MjktOC45Nzc1IDc0LjcyOS0yMC4wNSAwLTUuMzk1LTcuOTQtMTAuMjkxLTIwLjg1OC0xMy44OTUgMC4wNC0wLjAzIDAuMDc4Ny0wLjA2MSAwLjExODc1LTAuMDkxIDEzLjAzOSAzLjYyMjcgMjEuMDU5IDguNTUyOCAyMS4wNTkgMTMuOTg2IDAgMTEuMTItMzMuNiAyMC4xMzYtNzUuMDQ5IDIwLjEzNm0zMS40ODQtMzguMzI1bDAuMTU3NS0wLjA3NS0wLjE1NzUgMC4wNzUiIGZpbGw9IiNmYWZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg1MjMzIiBkPSJtMzA1LjU5IDk0OGMtNDEuMjczIDAtNzQuNzMtOC45Nzc1LTc0LjczLTIwLjA1IDAtNS4zOTUgNy45NDEyLTEwLjI5MSAyMC44NTgtMTMuODk1IDAuMDQgMC4wMyAwLjA3ODggMC4wNjIgMC4xMTg3NSAwLjA5MS0xMi43OTIgMy41ODUtMjAuNjU1IDguNDQ4OC0yMC42NTUgMTMuODA0IDAgMTEuMDI1IDMzLjMxNCAxOS45NjQgNzQuNDA5IDE5Ljk2NCA0MS4wOTQgMCA3NC40MDgtOC45MzkgNzQuNDA4LTE5Ljk2NCAwLTUuMzU1LTcuODYxMi0xMC4yMTktMjAuNjU0LTEzLjgwNCAwLjAzODctMC4wMyAwLjA3ODctMC4wNjIgMC4xMTc1LTAuMDkxIDEyLjkxOCAzLjYwNCAyMC44NTggOC41IDIwLjg1OCAxMy44OTUgMCAxMS4wNzItMzMuNDU4IDIwLjA1LTc0LjcyOSAyMC4wNW0zMS4zMjYtMzguMTYyYzAuMDUyNS0wLjAyNiAwLjEwNS0wLjA1MSAwLjE1NzUtMC4wNzctMC4wNTI1IDAuMDI1LTAuMTA1IDAuMDUxLTAuMTU3NSAwLjA3NyIgZmlsbD0iI2ZhZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDUyMzUiIGQ9Im0zMDUuNTkgOTQ3LjkxYy00MS4wOTUgMC03NC40MDktOC45MzktNzQuNDA5LTE5Ljk2NCAwLTUuMzU1IDcuODYyNS0xMC4yMTkgMjAuNjU1LTEzLjgwNCAwLjA0IDAuMDMgMC4wNzg4IDAuMDYxIDAuMTE4NzUgMC4wOTEtMTIuNjcyIDMuNTY1OC0yMC40NTIgOC4zOTYtMjAuNDUyIDEzLjcxMiAwIDEwLjk3OCAzMy4xNyAxOS44NzggNzQuMDg4IDE5Ljg3OHM3NC4wODgtOC45IDc0LjA4OC0xOS44NzhjMC01LjMxNjMtNy43ODEyLTEwLjE0Ni0yMC40NTQtMTMuNzEyIDAuMDQtMC4wMyAwLjA4LTAuMDYyIDAuMTItMC4wOTEgMTIuNzkyIDMuNTg1IDIwLjY1NCA4LjQ0ODcgMjAuNjU0IDEzLjgwNCAwIDExLjAyNS0zMy4zMTQgMTkuOTY0LTc0LjQwOCAxOS45NjRtMzEuMTY4LTM4LjAwMmMwLjA1MjUtMC4wMjUgMC4xMDYyNS0wLjA1MSAwLjE1ODc1LTAuMDc1LTAuMDUyNSAwLjAyMy0wLjEwNjI1IDAuMDUtMC4xNTg3NSAwLjA3NSIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDUyMzciIGQ9Im0zMDUuNTkgOTQ3LjgyYy00MC45MTggMC03NC4wODgtOC45LTc0LjA4OC0xOS44NzggMC01LjMxNjQgNy43OC0xMC4xNDYgMjAuNDUyLTEzLjcxMiAwLjA0IDAuMDMgMC4wOCAwLjA2MSAwLjEyIDAuMDkxLTEyLjU1IDMuNTQ3NC0yMC4yNTIgOC4zNDM4LTIwLjI1MiAxMy42MjEgMCAxMC45MyAzMy4wMjggMTkuNzkxIDczLjc2OCAxOS43OTFzNzMuNzY2LTguODYwOSA3My43NjYtMTkuNzkxYzAtNS4yNzc0LTcuNzAxMi0xMC4wNzQtMjAuMjUxLTEzLjYyMSAwLjAzODctMC4wMyAwLjA3ODgtMC4wNjIgMC4xMTg3NS0wLjA5MSAxMi42NzIgMy41NjU5IDIwLjQ1NCA4LjM5NiAyMC40NTQgMTMuNzEyIDAgMTAuOTc4LTMzLjE3IDE5Ljg3OC03NC4wODggMTkuODc4bTMxLjAwOS0zNy44NGMwLjA1MzctMC4wMjYgMC4xMDUtMC4wNSAwLjE1ODc1LTAuMDc1LTAuMDUzNyAwLjAyNS0wLjEwNSAwLjA0OS0wLjE1ODc1IDAuMDc1IiBmaWxsPSIjZmFmYWZhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTIzOSIgZD0ibTMwNS41OSA5NDcuNzRjLTQwLjc0IDAtNzMuNzY4LTguODYwOS03My43NjgtMTkuNzkxIDAtNS4yNzc0IDcuNzAyNS0xMC4wNzQgMjAuMjUyLTEzLjYyMSAwLjA0IDAuMDMgMC4wOCAwLjA2MSAwLjEyIDAuMDkxLTEyLjQzMSAzLjUyODItMjAuMDUxIDguMjkxLTIwLjA1MSAxMy41MyAwIDEwLjg4MiAzMi44ODIgMTkuNzA1IDczLjQ0NiAxOS43MDUgNDAuNTYyIDAgNzMuNDQ1LTguODIyNyA3My40NDUtMTkuNzA1IDAtNS4yMzg3LTcuNjE4OC0xMC4wMDItMjAuMDUtMTMuNTMgMC4wMzg4LTAuMDMgMC4wOC0wLjA2MiAwLjEyLTAuMDkxIDEyLjU1IDMuNTQ3NCAyMC4yNTEgOC4zNDM3IDIwLjI1MSAxMy42MjEgMCAxMC45My0zMy4wMjYgMTkuNzkxLTczLjc2NiAxOS43OTFtMzAuODQ5LTM3LjY3OWMwLjA1MTItMC4wMjUgMC4xMDc1LTAuMDUxIDAuMTYtMC4wNzUtMC4wNTI1IDAuMDIzLTAuMTA4NzUgMC4wNS0wLjE2IDAuMDc1IiBmaWxsPSIjZjlmYWZhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI0MSIgZD0ibTMwNS41OSA5NDcuNjVjLTQwLjU2NCAwLTczLjQ0Ni04LjgyMjctNzMuNDQ2LTE5LjcwNSAwLTUuMjM4NyA3LjYyLTEwLjAwMiAyMC4wNTEtMTMuNTMgMC4wNCAwLjAzIDAuMDggMC4wNjEgMC4xMiAwLjA5MS0xMi4zMSAzLjUxMDItMTkuODUxIDguMjM4Ny0xOS44NTEgMTMuNDM5IDAgMTAuODM1IDMyLjc0IDE5LjYxOSA3My4xMjYgMTkuNjE5IDQwLjM4NSAwIDczLjEyNS04Ljc4MzYgNzMuMTI1LTE5LjYxOSAwLTUuMjAwMy03LjU0LTkuOTI4OC0xOS44NTEtMTMuNDM5IDAuMDQtMC4wMyAwLjA4MTMtMC4wNjEgMC4xMjEyNS0wLjA5MSAxMi40MzEgMy41MjgyIDIwLjA1IDguMjkxIDIwLjA1IDEzLjUzIDAgMTAuODgyLTMyLjg4MiAxOS43MDUtNzMuNDQ1IDE5LjcwNW0zMC42ODgtMzcuNTE4YzAuMDU1LTAuMDI2IDAuMTA2MjUtMC4wNSAwLjE2MTI1LTAuMDc1LTAuMDU1IDAuMDI2LTAuMTA2MjUgMC4wNDktMC4xNjEyNSAwLjA3NSIgZmlsbD0iI2Y5ZjlmOSIvPgogICA8cGF0aCBpZD0icGF0aDUyNDMiIGQ9Im0zMDUuNTkgOTQ3LjU3Yy00MC4zODYgMC03My4xMjYtOC43ODM3LTczLjEyNi0xOS42MTkgMC01LjIwMDIgNy41NDEyLTkuOTI4NyAxOS44NTEtMTMuNDM5IDAuMDQgMC4wMyAwLjA4MTMgMC4wNjIgMC4xMjEyNSAwLjA5MS0xMi4xODggMy40OTExLTE5LjY1MSA4LjE4NjUtMTkuNjUxIDEzLjM0OCAwIDEwLjc4OCAzMi41OTYgMTkuNTM0IDcyLjgwNSAxOS41MzRzNzIuODA0LTguNzQ2IDcyLjgwNC0xOS41MzRjMC01LjE2MTEtNy40NjM4LTkuODU2NS0xOS42NS0xMy4zNDggMC4wNC0wLjAzIDAuMDgtMC4wNjEgMC4xMi0wLjA5MSAxMi4zMTEgMy41MTAzIDE5Ljg1MSA4LjIzODggMTkuODUxIDEzLjQzOSAwIDEwLjgzNS0zMi43NCAxOS42MTktNzMuMTI1IDE5LjYxOW0zMC41MjYtMzcuMzU3YzAuMDUzOC0wLjAyNSAwLjEwNjI1LTAuMDQ5IDAuMTYxMjUtMC4wNzQtMC4wNTUgMC4wMjUtMC4xMDc1IDAuMDQ5LTAuMTYxMjUgMC4wNzQiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1MjQ1IiBkPSJtMzA1LjU5IDk0Ny40OGMtNDAuMjA5IDAtNzIuODA1LTguNzQ2MS03Mi44MDUtMTkuNTM0IDAtNS4xNjExIDcuNDYzOC05Ljg1NjUgMTkuNjUxLTEzLjM0OCAwLjA0MTIgMC4wMzEgMC4wNzg4IDAuMDU5IDAuMTIxMjUgMC4wOTEtMTIuMDY4IDMuNDcyNi0xOS40NTEgOC4xMzM5LTE5LjQ1MSAxMy4yNTYgMCAxMC43NCAzMi40NTEgMTkuNDQ4IDcyLjQ4NCAxOS40NDggNDAuMDMxIDAgNzIuNDg0LTguNzA3NSA3Mi40ODQtMTkuNDQ4IDAtNS4xMjI1LTcuMzgzOC05Ljc4MzgtMTkuNDUyLTEzLjI1NiAwLjA0MjUtMC4wMzIgMC4wOC0wLjA2IDAuMTIyNS0wLjA5MSAxMi4xODYgMy40OTExIDE5LjY1IDguMTg2NSAxOS42NSAxMy4zNDggMCAxMC43ODgtMzIuNTk1IDE5LjUzNC03Mi44MDQgMTkuNTM0bTMwLjM2Mi0zNy4xOTdjMC4wNTUtMC4wMjUgMC4xMDg3NS0wLjA1IDAuMTYzNzUtMC4wNzUtMC4wNTUgMC4wMjUtMC4xMDg3NSAwLjA1LTAuMTYzNzUgMC4wNzUiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1MjQ3IiBkPSJtMzA1LjU5IDk0Ny4zOWMtNDAuMDMzIDAtNzIuNDg0LTguNzA3NS03Mi40ODQtMTkuNDQ4IDAtNS4xMjI1IDcuMzgzOC05Ljc4MzcgMTkuNDUxLTEzLjI1NiAwLjA0IDAuMDMgMC4wODEzIDAuMDYyIDAuMTIxMjUgMC4wOTEtMTEuOTQ5IDMuNDUzNy0xOS4yNTIgOC4wOC0xOS4yNTIgMTMuMTY1IDAgMTAuNjkyIDMyLjMwOSAxOS4zNjEgNzIuMTY0IDE5LjM2MSAzOS44NTQgMCA3Mi4xNjItOC42NjkgNzIuMTYyLTE5LjM2MSAwLTUuMDg1LTcuMzAzOC05LjcxMTMtMTkuMjUyLTEzLjE2NSAwLjA0LTAuMDMgMC4wODI1LTAuMDYxIDAuMTIxMjUtMC4wOTEgMTIuMDY5IDMuNDcyNyAxOS40NTIgOC4xMzM5IDE5LjQ1MiAxMy4yNTYgMCAxMC43NC0zMi40NTIgMTkuNDQ4LTcyLjQ4NCAxOS40NDhtMzAuMi0zNy4wMzdjMC4wNTI1LTAuMDIzIDAuMTEtMC4wNTEgMC4xNjI1LTAuMDc1LTAuMDUyNSAwLjAyNC0wLjExIDAuMDUxLTAuMTYyNSAwLjA3NSIgZmlsbD0iI2Y4ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDUyNDkiIGQ9Im0zMDUuNTkgOTQ3LjMxYy0zOS44NTUgMC03Mi4xNjQtOC42Njg5LTcyLjE2NC0xOS4zNjEgMC01LjA4NSA3LjMwMzgtOS43MTE0IDE5LjI1Mi0xMy4xNjUgMC4wNCAwLjAyOSAwLjA4MjUgMC4wNjIgMC4xMjI1IDAuMDkxLTExLjgyOCAzLjQzNTEtMTkuMDU0IDguMDI3NC0xOS4wNTQgMTMuMDc0IDAgMTAuNjQ1IDMyLjE2NSAxOS4yNzUgNzEuODQzIDE5LjI3NXM3MS44NDEtOC42Mjk5IDcxLjg0MS0xOS4yNzVjMC01LjA0NjQtNy4yMjUtOS42Mzg3LTE5LjA1NC0xMy4wNzQgMC4wNC0wLjAzIDAuMDgyNS0wLjA2MiAwLjEyMjUtMC4wOTEgMTEuOTQ5IDMuNDUzNiAxOS4yNTIgOC4wOCAxOS4yNTIgMTMuMTY1IDAgMTAuNjkyLTMyLjMwOSAxOS4zNjEtNzIuMTYyIDE5LjM2MW0zMC4wMzUtMzYuODc2YzAuMDU1LTAuMDI0IDAuMTEtMC4wNDkgMC4xNjUtMC4wNzQtMC4wNTUgMC4wMjUtMC4xMSAwLjA1LTAuMTY1IDAuMDc0IiBmaWxsPSIjZjhmOGY4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI1MSIgZD0ibTMwNS41OSA5NDcuMjJjLTM5LjY3OCAwLTcxLjg0My04LjYyOTktNzEuODQzLTE5LjI3NSAwLTUuMDQ2NCA3LjIyNjItOS42Mzg2IDE5LjA1NC0xMy4wNzQgMC4wNDI1IDAuMDMxIDAuMDgxMiAwLjA1OSAwLjEyMjUgMC4wOTEtMTEuNzA4IDMuNDE2LTE4Ljg1NSA3Ljk3NS0xOC44NTUgMTIuOTgyIDAgMTAuNTk4IDMyLjAyMSAxOS4xODkgNzEuNTIxIDE5LjE4OXM3MS41MjEtOC41OTEzIDcxLjUyMS0xOS4xODljMC01LjAwNzMtNy4xNDg4LTkuNTY2My0xOC44NTYtMTIuOTgyIDAuMDQyNS0wLjAzMyAwLjA4MTMtMC4wNiAwLjEyMjUtMC4wOTEgMTEuODI5IDMuNDM1MSAxOS4wNTQgOC4wMjczIDE5LjA1NCAxMy4wNzQgMCAxMC42NDUtMzIuMTY0IDE5LjI3NS03MS44NDEgMTkuMjc1bTI5Ljg3LTM2LjcxNWMwLjA1NzUtMC4wMjYgMC4xMDg3NS0wLjA0OSAwLjE2NS0wLjA3NS0wLjA1NjMgMC4wMjYtMC4xMDc1IDAuMDQ5LTAuMTY1IDAuMDc1IiBmaWxsPSIjZjdmOGY4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI1MyIgZD0ibTMwNS41OSA5NDcuMTRjLTM5LjUgMC03MS41MjEtOC41OTE0LTcxLjUyMS0xOS4xODkgMC01LjAwNzQgNy4xNDc1LTkuNTY2NCAxOC44NTUtMTIuOTgyIDAuMDQgMC4wMjkgMC4wODM3IDAuMDYxIDAuMTIzNzUgMC4wOTEtMTEuNTg5IDMuMzk3NS0xOC42NTkgNy45MjI0LTE4LjY1OSAxMi44OTEgMCAxMC41NSAzMS44NzggMTkuMTAyIDcxLjIwMSAxOS4xMDIgMzkuMzIyIDAgNzEuMi04LjU1MjggNzEuMi0xOS4xMDIgMC00Ljk2ODgtNy4wNy05LjQ5MzctMTguNjU4LTEyLjg5MSAwLjA0LTAuMDMgMC4wODI1LTAuMDYyIDAuMTIyNS0wLjA5MSAxMS43MDggMy40MTYgMTguODU2IDcuOTc1IDE4Ljg1NiAxMi45ODIgMCAxMC41OTgtMzIuMDIxIDE5LjE4OS03MS41MjEgMTkuMTg5bTI5LjcwNC0zNi41NTVjMC4wNTUtMC4wMjQgMC4xMTEyNS0wLjA0OSAwLjE2NjI1LTAuMDc0LTAuMDU1IDAuMDI1LTAuMTExMjUgMC4wNS0wLjE2NjI1IDAuMDc0IiBmaWxsPSIjZjdmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI1NSIgZD0ibTMwNS41OSA5NDcuMDVjLTM5LjMyNCAwLTcxLjIwMS04LjU1MjgtNzEuMjAxLTE5LjEwMyAwLTQuOTY4NyA3LjA3LTkuNDkzNiAxOC42NTktMTIuODkxIDAuMDQyNSAwLjAzMSAwLjA4MTMgMC4wNiAwLjEyMzc1IDAuMDkxLTExLjQ3IDMuMzc4NC0xOC40NjEgNy44Njk2LTE4LjQ2MSAxMi44IDAgMTAuNTAyIDMxLjczNCAxOS4wMTYgNzAuODggMTkuMDE2czcwLjg4LTguNTEzNyA3MC44OC0xOS4wMTZjMC00LjkzMDEtNi45OTI1LTkuNDIxMy0xOC40NjEtMTIuOCAwLjA0MjUtMC4wMzEgMC4wODEzLTAuMDYgMC4xMjM3NS0wLjA5MSAxMS41ODggMy4zOTc1IDE4LjY1OCA3LjkyMjQgMTguNjU4IDEyLjg5MSAwIDEwLjU1LTMxLjg3OCAxOS4xMDMtNzEuMiAxOS4xMDNtMjkuNTM4LTM2LjM5NGMwLjA1NS0wLjAyNSAwLjExMTI1LTAuMDUgMC4xNjYyNS0wLjA3NS0wLjA1NSAwLjAyNS0wLjExMTI1IDAuMDUtMC4xNjYyNSAwLjA3NSIgZmlsbD0iI2Y2ZjdmNyIvPgogICA8cGF0aCBpZD0icGF0aDUyNTciIGQ9Im0zMDUuNTkgOTQ2Ljk2Yy0zOS4xNDYgMC03MC44OC04LjUxMzYtNzAuODgtMTkuMDE2IDAtNC45MzAxIDYuOTkxMi05LjQyMTQgMTguNDYxLTEyLjggMC4wNCAwLjAyOCAwLjA4MzcgMC4wNjEgMC4xMjM3NSAwLjA5MS0xMS4zNDkgMy4zNTg4LTE4LjI2NSA3LjgxNzgtMTguMjY1IDEyLjcwOSAwIDEwLjQ1NSAzMS41OTEgMTguOTMxIDcwLjU2IDE4LjkzMXM3MC41NTktOC40NzYgNzAuNTU5LTE4LjkzMWMwLTQuODkxMi02LjkxNS05LjM1MDItMTguMjY1LTEyLjcwOSAwLjA0MTItMC4wMyAwLjA4NS0wLjA2MiAwLjEyNS0wLjA5MSAxMS40NjkgMy4zNzgzIDE4LjQ2MSA3Ljg2OTYgMTguNDYxIDEyLjggMCAxMC41MDItMzEuNzM0IDE5LjAxNi03MC44OCAxOS4wMTZtMjkuMzctMzYuMjMzYzAuMDU2My0wLjAyNSAwLjExLTAuMDQ5IDAuMTY3NS0wLjA3NC0wLjA1NzUgMC4wMjUtMC4xMTEyNSAwLjA0OS0wLjE2NzUgMC4wNzQiIGZpbGw9IiNmNmY2ZjciLz4KICAgPHBhdGggaWQ9InBhdGg1MjU5IiBkPSJtMzA1LjU5IDk0Ni44OGMtMzguOTY5IDAtNzAuNTYtOC40NzYxLTcwLjU2LTE4LjkzMSAwLTQuODkxMSA2LjkxNjItOS4zNTAxIDE4LjI2NS0xMi43MDkgMC4wNDI1IDAuMDMxIDAuMDgyNSAwLjA2IDAuMTI1IDAuMDkxLTExLjIzMiAzLjM0MDMtMTguMDY5IDcuNzY1MS0xOC4wNjkgMTIuNjE4IDAgMTAuNDA4IDMxLjQ0NiAxOC44NDUgNzAuMjM5IDE4Ljg0NSAzOC43OTEgMCA3MC4yMzgtOC40Mzc0IDcwLjIzOC0xOC44NDUgMC00Ljg1MjUtNi44MzYyLTkuMjc3My0xOC4wNjgtMTIuNjE4IDAuMDQyNS0wLjAzMSAwLjA4MTMtMC4wNiAwLjEyMzc1LTAuMDkxIDExLjM1IDMuMzU4OSAxOC4yNjUgNy44MTc5IDE4LjI2NSAxMi43MDkgMCAxMC40NTUtMzEuNTkgMTguOTMxLTcwLjU1OSAxOC45MzFtMjkuMjAxLTM2LjA3NWMwLjA1NS0wLjAyNCAwLjExMzc1LTAuMDUgMC4xNjg3NS0wLjA3NC0wLjA1NSAwLjAyMy0wLjExMzc1IDAuMDUtMC4xNjg3NSAwLjA3NCIgZmlsbD0iI2Y2ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDUyNjEiIGQ9Im0zMDUuNTkgOTQ2Ljc5Yy0zOC43OTMgMC03MC4yMzktOC40Mzc0LTcwLjIzOS0xOC44NDUgMC00Ljg1MjUgNi44MzYyLTkuMjc3NCAxOC4wNjktMTIuNjE4IDAuMDQgMC4wMjkgMC4wODM4IDAuMDYyIDAuMTI1IDAuMDkxLTExLjExNCAzLjMyMTMtMTcuODcyIDcuNzExNC0xNy44NzIgMTIuNTI2IDAgMTAuMzYgMzEuMzAyIDE4Ljc1OSA2OS45MTggMTguNzU5IDM4LjYxNCAwIDY5LjkxOC04LjM5OSA2OS45MTgtMTguNzU5IDAtNC44MTUtNi43Ni05LjIwNTEtMTcuODcyLTEyLjUyNiAwLjA0LTAuMDMgMC4wODUtMC4wNjIgMC4xMjUtMC4wOTEgMTEuMjMxIDMuMzQwMiAxOC4wNjggNy43NjUxIDE4LjA2OCAxMi42MTggMCAxMC40MDgtMzEuNDQ2IDE4Ljg0NS03MC4yMzggMTguODQ1bTI5LjAzMS0zNS45MTVjMC4wNTc1LTAuMDI1IDAuMTEyNS0wLjA0OSAwLjE3LTAuMDc0LTAuMDU3NSAwLjAyNS0wLjExMjUgMC4wNDgtMC4xNyAwLjA3NCIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDUyNjMiIGQ9Im0zMDUuNTkgOTQ2LjcxYy0zOC42MTUgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc1ODgtOS4yMDUxIDE3Ljg3Mi0xMi41MjYgMC4wNDI1IDAuMDMxIDAuMDgyNSAwLjA2IDAuMTI1IDAuMDkxLTEwLjk5NSAzLjMwMjctMTcuNjc4IDcuNjU4Ny0xNy42NzggMTIuNDM1IDAgMTAuMzEyIDMxLjE2IDE4LjY3MiA2OS41OTggMTguNjcyczY5LjU5Ni04LjM1OTkgNjkuNTk2LTE4LjY3MmMwLTQuNzc2My02LjY4MjUtOS4xMzIzLTE3LjY3OC0xMi40MzUgMC4wNDI1LTAuMDMxIDAuMDgzOC0wLjA2IDAuMTI2MjUtMC4wOTEgMTEuMTEyIDMuMzIxMyAxNy44NzIgNy43MTE0IDE3Ljg3MiAxMi41MjYgMCAxMC4zNi0zMS4zMDQgMTguNzU5LTY5LjkxOCAxOC43NTltMjguODYxLTM1Ljc1NWMwLjA1NjItMC4wMjUgMC4xMTI1LTAuMDQ5IDAuMTctMC4wNzQtMC4wNTc1IDAuMDI1LTAuMTEzNzUgMC4wNDgtMC4xNyAwLjA3NCIgZmlsbD0iI2Y1ZjVmNSIvPgogICA8cGF0aCBpZD0icGF0aDUyNjUiIGQ9Im0zMDUuNTkgOTQ2LjYyYy0zOC40MzggMC02OS41OTgtOC4zNTk5LTY5LjU5OC0xOC42NzIgMC00Ljc3NjQgNi42ODI1LTkuMTMyNCAxNy42NzgtMTIuNDM1IDAuMDQyNSAwLjAzMSAwLjA4MzggMC4wNiAwLjEyNjI1IDAuMDkxLTEwLjg3OCAzLjI4MjgtMTcuNDgyIDcuNjA2NS0xNy40ODIgMTIuMzQ0IDAgMTAuMjY1IDMxLjAxNiAxOC41ODYgNjkuMjc2IDE4LjU4NnM2OS4yNzYtOC4zMjEzIDY5LjI3Ni0xOC41ODZjMC00LjczNzMtNi42MDYyLTkuMDYxLTE3LjQ4NC0xMi4zNDQgMC4wNDM3LTAuMDMxIDAuMDgzOC0wLjA2IDAuMTI2MjUtMC4wOTEgMTAuOTk1IDMuMzAyNiAxNy42NzggNy42NTg2IDE3LjY3OCAxMi40MzUgMCAxMC4zMTItMzEuMTU5IDE4LjY3Mi02OS41OTYgMTguNjcybTI4LjY4OS0zNS41OTVjMC4wNTUtMC4wMjQgMC4xMTYyNS0wLjA1IDAuMTcyNS0wLjA3NC0wLjA1NjMgMC4wMjQtMC4xMTc1IDAuMDUtMC4xNzI1IDAuMDc0IiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI2NyIgZD0ibTMwNS41OSA5NDYuNTNjLTM4LjI2IDAtNjkuMjc2LTguMzIxMy02OS4yNzYtMTguNTg2IDAtNC43MzcyIDYuNjA1LTkuMDYxIDE3LjQ4Mi0xMi4zNDQgMC4wNDI1IDAuMDMxIDAuMDgzNyAwLjA2MSAwLjEyNjI1IDAuMDkxLTEwLjc1OCAzLjI2MzctMTcuMjg4IDcuNTUzNy0xNy4yODggMTIuMjUyIDAgMTAuMjE3IDMwLjg3MSAxOC41IDY4Ljk1NSAxOC41IDM4LjA4MiAwIDY4Ljk1NS04LjI4MjggNjguOTU1LTE4LjUgMC00LjY5ODgtNi41MzEyLTguOTg4OC0xNy4yODktMTIuMjUyIDAuMDQyNS0wLjAzIDAuMDgzOC0wLjA2IDAuMTI2MjUtMC4wOTEgMTAuODc4IDMuMjgyNyAxNy40ODQgNy42MDY1IDE3LjQ4NCAxMi4zNDQgMCAxMC4yNjUtMzEuMDE2IDE4LjU4Ni02OS4yNzYgMTguNTg2bTI4LjUxNi0zNS40MzVjMC4wNTc1LTAuMDI1IDAuMTE1LTAuMDQ5IDAuMTcyNS0wLjA3NC0wLjA1NzUgMC4wMjUtMC4xMTUgMC4wNDktMC4xNzI1IDAuMDc0IiBmaWxsPSIjZjRmNGY0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI2OSIgZD0ibTMwNS41OSA5NDYuNDVjLTM4LjA4NCAwLTY4Ljk1NS04LjI4MjctNjguOTU1LTE4LjUgMC00LjY5ODcgNi41My04Ljk4ODcgMTcuMjg4LTEyLjI1MiAwLjA0MTIgMC4wMyAwLjA4NjMgMC4wNjIgMC4xMjc1IDAuMDkxLTEwLjY0MiAzLjI0MzYtMTcuMDk1IDcuNS0xNy4wOTUgMTIuMTYxIDAgMTAuMTcgMzAuNzI5IDE4LjQxNSA2OC42MzUgMTguNDE1IDM3LjkwNSAwIDY4LjYzNC04LjI0NTIgNjguNjM0LTE4LjQxNSAwLTQuNjYxMS02LjQ1MjUtOC45MTc1LTE3LjA5NC0xMi4xNjEgMC4wNC0wLjAyOSAwLjA4NjMtMC4wNjIgMC4xMjYyNS0wLjA5MSAxMC43NTggMy4yNjM4IDE3LjI4OSA3LjU1MzggMTcuMjg5IDEyLjI1MiAwIDEwLjIxNy0zMC44NzIgMTguNS02OC45NTUgMTguNW0yOC4zNDQtMzUuMjc2YzAuMDU4OC0wLjAyNSAwLjExMjUtMC4wNDcgMC4xNzI1LTAuMDcyLTAuMDYgMC4wMjUtMC4xMTM3NSAwLjA0Ny0wLjE3MjUgMC4wNzIiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjcxIiBkPSJtMzA1LjU5IDk0Ni4zNmMtMzcuOTA2IDAtNjguNjM1LTguMjQ1Mi02OC42MzUtMTguNDE1IDAtNC42NjExIDYuNDUyNS04LjkxNzUgMTcuMDk1LTEyLjE2MSAwLjA0MjUgMC4wMzEgMC4wODUgMC4wNjEgMC4xMjc1IDAuMDkyLTEwLjUyNCAzLjIyMzYtMTYuOTAxIDcuNDQ2NC0xNi45MDEgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NSAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1My0xNi45MDItMTIuMDY5IDAuMDQzNy0wLjAzMSAwLjA4NS0wLjA2MSAwLjEyODc1LTAuMDkyIDEwLjY0MSAzLjI0MzYgMTcuMDk0IDcuNSAxNy4wOTQgMTIuMTYxIDAgMTAuMTctMzAuNzI5IDE4LjQxNS02OC42MzQgMTguNDE1bTI4LjE2OS0zNS4xMThjMC4wNTc1LTAuMDI0IDAuMTE2MjUtMC4wNDkgMC4xNzUtMC4wNzQtMC4wNTg3IDAuMDI1LTAuMTE3NSAwLjA1LTAuMTc1IDAuMDc0IiBmaWxsPSIjZjNmM2YzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI3MyIgZD0ibTMwNS41OSA5NDYuMjhjLTM3LjcyOSAwLTY4LjMxNC04LjIwNjEtNjguMzE0LTE4LjMyOSAwLTQuNjIyNSA2LjM3NzUtOC44NDUyIDE2LjkwMS0xMi4wNjkgMC4wNDI1IDAuMDMgMC4wODUgMC4wNiAwLjEyNzUgMC4wOTEtMTAuNDA2IDMuMjA1LTE2LjcwOSA3LjM5MzUtMTYuNzA5IDExLjk3OCAwIDEwLjA3NSAzMC40NDEgMTguMjQzIDY3Ljk5NCAxOC4yNDMgMzcuNTUxIDAgNjcuOTkyLTguMTY3NCA2Ny45OTItMTguMjQzIDAtNC41ODQtNi4zMDEyLTguNzcyNS0xNi43MDktMTEuOTc4IDAuMDQyNS0wLjAzMSAwLjA4NS0wLjA2MSAwLjEyNzUtMC4wOTEgMTAuNTI1IDMuMjIzNyAxNi45MDIgNy40NDY0IDE2LjkwMiAxMi4wNjkgMCAxMC4xMjItMzAuNTg1IDE4LjMyOS02OC4zMTQgMTguMzI5bTI3Ljk5Mi0zNC45NThjMC4wNTg4LTAuMDI1IDAuMTE4NzUtMC4wNSAwLjE3NjI1LTAuMDc0LTAuMDU3NSAwLjAyNC0wLjExNzUgMC4wNDktMC4xNzYyNSAwLjA3NCIgZmlsbD0iI2YzZjNmMiIvPgogICA8cGF0aCBpZD0icGF0aDUyNzUiIGQ9Im0zMDUuNTkgOTQ2LjE5Yy0zNy41NTMgMC02Ny45OTQtOC4xNjc0LTY3Ljk5NC0xOC4yNDMgMC00LjU4NCA2LjMwMjUtOC43NzI1IDE2LjcwOS0xMS45NzggMC4wNDM3IDAuMDMgMC4wODYzIDAuMDYyIDAuMTI4NzUgMC4wOTEtMTAuMjg5IDMuMTg1MS0xNi41MTYgNy4zNDEzLTE2LjUxNiAxMS44ODYgMCAxMC4wMjcgMzAuMjk4IDE4LjE1NiA2Ny42NzMgMTguMTU2IDM3LjM3NCAwIDY3LjY3Mi04LjEyODkgNjcuNjcyLTE4LjE1NiAwLTQuNTQ0OS02LjIyODgtOC43MDExLTE2LjUxOC0xMS44ODYgMC4wNDM3LTAuMDMgMC4wODYyLTAuMDYyIDAuMTI4NzUtMC4wOTEgMTAuNDA4IDMuMjA1IDE2LjcwOSA3LjM5MzUgMTYuNzA5IDExLjk3OCAwIDEwLjA3NS0zMC40NDEgMTguMjQzLTY3Ljk5MiAxOC4yNDNtMjcuODE2LTM0Ljc5OWMwLjA2LTAuMDI1IDAuMTE2MjUtMC4wNDcgMC4xNzYyNS0wLjA3My0wLjA2IDAuMDI1LTAuMTE2MjUgMC4wNDgtMC4xNzYyNSAwLjA3MyIgZmlsbD0iI2YyZjJmMiIvPgogICA8cGF0aCBpZD0icGF0aDUyNzciIGQ9Im0zMDUuNTkgOTQ2LjFjLTM3LjM3NSAwLTY3LjY3My04LjEyODktNjcuNjczLTE4LjE1NiAwLTQuNTQ0OSA2LjIyNzUtOC43MDExIDE2LjUxNi0xMS44ODYgMC4wNDM3IDAuMDMgMC4wODYzIDAuMDYxIDAuMTI4NzUgMC4wOTEtMTAuMTcyIDMuMTY1LTE2LjMyNCA3LjI4NzUtMTYuMzI0IDExLjc5NSAwIDkuOTggMzAuMTU0IDE4LjA3IDY3LjM1MSAxOC4wNyAzNy4xOTggMCA2Ny4zNTEtOC4wODk5IDY3LjM1MS0xOC4wNyAwLTQuNTA3My02LjE1MTItOC42Mjk4LTE2LjMyNS0xMS43OTUgMC4wNDI1LTAuMDMgMC4wODYyLTAuMDYyIDAuMTI4NzUtMC4wOTEgMTAuMjg5IDMuMTg1MSAxNi41MTggNy4zNDEzIDE2LjUxOCAxMS44ODYgMCAxMC4wMjctMzAuMjk5IDE4LjE1Ni02Ny42NzIgMTguMTU2bTI3LjYzOS0zNC42NGMwLjA1ODgtMC4wMjQgMC4xMi0wLjA0OSAwLjE3NzUtMC4wNzItMC4wNTc1IDAuMDIzLTAuMTE4NzUgMC4wNDktMC4xNzc1IDAuMDcyIiBmaWxsPSIjZjJmMWYxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI3OSIgZD0ibTMwNS41OSA5NDYuMDJjLTM3LjE5OCAwLTY3LjM1MS04LjA4OTgtNjcuMzUxLTE4LjA3IDAtNC41MDc0IDYuMTUxMi04LjYyOTkgMTYuMzI0LTExLjc5NSAwLjA0MzggMC4wMzEgMC4wODc1IDAuMDYxIDAuMTMgMC4wOTItMTAuMDU2IDMuMTQ1LTE2LjEzNCA3LjIzMzktMTYuMTM0IDExLjcwMyAwIDkuOTMyNiAzMC4wMTEgMTcuOTg0IDY3LjAzMSAxNy45ODRzNjcuMDMtOC4wNTEzIDY3LjAzLTE3Ljk4NGMwLTQuNDY4OC02LjA3NzUtOC41NTc3LTE2LjEzNC0xMS43MDMgMC4wNDM4LTAuMDMxIDAuMDg3NS0wLjA2MSAwLjEzLTAuMDkyIDEwLjE3NCAzLjE2NSAxNi4zMjUgNy4yODc1IDE2LjMyNSAxMS43OTUgMCA5Ljk4LTMwLjE1NCAxOC4wNy02Ny4zNTEgMTguMDdtMjcuNDYtMzQuNDhjMC4wNi0wLjAyNSAwLjExODc1LTAuMDQ5IDAuMTc4NzUtMC4wNzQtMC4wNiAwLjAyNS0wLjExODc1IDAuMDQ5LTAuMTc4NzUgMC4wNzQiIGZpbGw9IiNmMWYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg1MjgxIiBkPSJtMzA1LjU5IDk0NS45M2MtMzcuMDIgMC02Ny4wMzEtOC4wNTEzLTY3LjAzMS0xNy45ODQgMC00LjQ2ODcgNi4wNzc1LTguNTU3NiAxNi4xMzQtMTEuNzAzIDAuMDQzOCAwLjAzIDAuMDg3NSAwLjA2MiAwLjEzIDAuMDkxLTkuOTQgMy4xMjUtMTUuOTQyIDcuMTgwMi0xNS45NDIgMTEuNjExIDAgOS44ODQ3IDI5Ljg2NiAxNy44OTcgNjYuNzEgMTcuODk3IDM2Ljg0MiAwIDY2LjcxLTguMDEyNyA2Ni43MS0xNy44OTcgMC00LjQzMTItNi4wMDM4LTguNDg2NC0xNS45NDQtMTEuNjExIDAuMDQzOC0wLjAzIDAuMDg3NS0wLjA2MSAwLjEzLTAuMDkxIDEwLjA1NiAzLjE0NSAxNi4xMzQgNy4yMzM5IDE2LjEzNCAxMS43MDMgMCA5LjkzMjYtMzAuMDEgMTcuOTg0LTY3LjAzIDE3Ljk4NG0yNy4yODEtMzQuMzIxYzAuMDYtMC4wMjUgMC4xMTg3NS0wLjA0OSAwLjE3ODc1LTAuMDczLTAuMDYgMC4wMjQtMC4xMTg3NSAwLjA0OC0wLjE3ODc1IDAuMDczIiBmaWxsPSIjZjBmMGYwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI4MyIgZD0ibTMwNS41OSA5NDUuODRjLTM2Ljg0NCAwLTY2LjcxLTguMDEyNy02Ni43MS0xNy44OTggMC00LjQzMTEgNi4wMDI1LTguNDg2NCAxNS45NDItMTEuNjExIDAuMDQzNyAwLjAzIDAuMDg3NSAwLjA2MiAwLjEzMTI1IDAuMDkxLTkuODI1IDMuMTA2NC0xNS43NTQgNy4xMjc0LTE1Ljc1NCAxMS41MiAwIDkuODM3NCAyOS43MjQgMTcuODEyIDY2LjM5IDE3LjgxMiAzNi42NjUgMCA2Ni4zODktNy45NzUxIDY2LjM4OS0xNy44MTIgMC00LjM5MjYtNS45Mjc1LTguNDEzNi0xNS43NTItMTEuNTIgMC4wNDI1LTAuMDMgMC4wODc1LTAuMDYxIDAuMTMtMC4wOTEgOS45NCAzLjEyNSAxNS45NDQgNy4xODAzIDE1Ljk0NCAxMS42MTEgMCA5Ljg4NDgtMjkuODY4IDE3Ljg5OC02Ni43MSAxNy44OThtMjcuMS0zNC4xNjNjMC4wNi0wLjAyNCAwLjEyLTAuMDQ5IDAuMTgxMjUtMC4wNzItMC4wNjEyIDAuMDIzLTAuMTIxMjUgMC4wNDktMC4xODEyNSAwLjA3MiIgZmlsbD0iI2YwZjBlZiIvPgogICA8cGF0aCBpZD0icGF0aDUyODUiIGQ9Im0zMDUuNTkgOTQ1Ljc2Yy0zNi42NjYgMC02Ni4zOS03Ljk3NTEtNjYuMzktMTcuODEyIDAtNC4zOTI2IDUuOTI4OC04LjQxMzYgMTUuNzU0LTExLjUyIDAuMDQ1IDAuMDMxIDAuMDg1IDAuMDYgMC4xMzEyNSAwLjA5MS05LjcxMTIgMy4wODY1LTE1LjU2NCA3LjA3MzgtMTUuNTY0IDExLjQyOSAwIDkuNzkgMjkuNTggMTcuNzI2IDY2LjA2OSAxNy43MjZzNjYuMDY4LTcuOTM2IDY2LjA2OC0xNy43MjZjMC00LjM1NS01Ljg1MjUtOC4zNDIzLTE1LjU2Mi0xMS40MjkgMC4wNDUtMC4wMzEgMC4wODUtMC4wNiAwLjEzMTI1LTAuMDkxIDkuODI1IDMuMTA2NCAxNS43NTIgNy4xMjc0IDE1Ljc1MiAxMS41MiAwIDkuODM3NC0yOS43MjQgMTcuODEyLTY2LjM4OSAxNy44MTJtMjYuOTE4LTM0LjAwNWwwLjE4MjUtMC4wNzNjLTAuMDYgMC4wMjQtMC4xMjEyNSAwLjA0OS0wLjE4MjUgMC4wNzMiIGZpbGw9IiNlZmVmZWYiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjg3IiBkPSJtMzA1LjU5IDk0NS42N2MtMzYuNDg5IDAtNjYuMDY5LTcuOTM2MS02Ni4wNjktMTcuNzI2IDAtNC4zNTUgNS44NTI1LTguMzQyMiAxNS41NjQtMTEuNDI5IDAuMDQyNSAwLjAzIDAuMDg3NSAwLjA2MiAwLjEzMTI1IDAuMDkyLTkuNTkzOCAzLjA2NDgtMTUuMzc0IDcuMDItMTUuMzc0IDExLjMzNiAwIDkuNzQyNyAyOS40MzYgMTcuNjQgNjUuNzQ4IDE3LjY0IDM2LjMxMSAwIDY1Ljc0OC03Ljg5NzUgNjUuNzQ4LTE3LjY0IDAtNC4zMTYzLTUuNzgtOC4yNzE1LTE1LjM3NS0xMS4zMzYgMC4wNDM3LTAuMDMgMC4wODg3LTAuMDYyIDAuMTMyNS0wLjA5MiA5LjcxIDMuMDg2NSAxNS41NjIgNy4wNzM3IDE1LjU2MiAxMS40MjkgMCA5Ljc5LTI5LjU3OSAxNy43MjYtNjYuMDY4IDE3LjcyNm0yNi43MzUtMzMuODQ2YzAuMDYtMC4wMjMgMC4xMjI1LTAuMDQ5IDAuMTgyNS0wLjA3Mi0wLjA2IDAuMDIzLTAuMTIyNSAwLjA0OS0wLjE4MjUgMC4wNzIiIGZpbGw9IiNlZWUiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjg5IiBkPSJtMzA1LjU5IDk0NS41OWMtMzYuMzExIDAtNjUuNzQ4LTcuODk3Ni02NS43NDgtMTcuNjQgMC00LjMxNjQgNS43OC04LjI3MTUgMTUuMzc0LTExLjMzNiAwLjA0MzggMC4wMyAwLjA4ODggMC4wNjIgMC4xMzI1IDAuMDkxLTkuNDgxMiAzLjA0NjUtMTUuMTg2IDYuOTY2NS0xNS4xODYgMTEuMjQ1IDAgOS42OTQ5IDI5LjI5MiAxNy41NTQgNjUuNDI4IDE3LjU1NCAzNi4xMzQgMCA2NS40MjYtNy44NTg4IDY1LjQyNi0xNy41NTQgMC00LjI3ODctNS43MDM4LTguMTk4Ny0xNS4xODUtMTEuMjQ1IDAuMDQyNS0wLjAzIDAuMDg4OC0wLjA2MSAwLjEzMTI1LTAuMDkxIDkuNTk1IDMuMDY0OSAxNS4zNzUgNy4wMiAxNS4zNzUgMTEuMzM2IDAgOS43NDI2LTI5LjQzNiAxNy42NC02NS43NDggMTcuNjRtMjYuNTUxLTMzLjY4OGMwLjA2MjUtMC4wMjUgMC4xMjEyNS0wLjA0OCAwLjE4Mzc1LTAuMDczLTAuMDYyNSAwLjAyNS0wLjEyMTI1IDAuMDQ4LTAuMTgzNzUgMC4wNzMiIGZpbGw9IiNlZWVlZWQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjkxIiBkPSJtMzA1LjU5IDk0NS41Yy0zNi4xMzUgMC02NS40MjgtNy44NTg4LTY1LjQyOC0xNy41NTQgMC00LjI3ODcgNS43MDUtOC4xOTg3IDE1LjE4Ni0xMS4yNDUgMC4wNDUgMC4wMzEgMC4wODYzIDAuMDYgMC4xMzI1IDAuMDkxLTkuMzY3NSAzLjAyNjItMTQuOTk4IDYuOTEzNS0xNC45OTggMTEuMTU0IDAgOS42NDc1IDI5LjE0OSAxNy40NjcgNjUuMTA2IDE3LjQ2NyAzNS45NTYgMCA2NS4xMDYtNy44MTk4IDY1LjEwNi0xNy40NjcgMC00LjI0MDItNS42MzEyLTguMTI3NS0xNC45OTktMTEuMTU0IDAuMDQ2My0wLjAzMSAwLjA4NzUtMC4wNiAwLjEzMzc1LTAuMDkxIDkuNDgxMiAzLjA0NjQgMTUuMTg1IDYuOTY2NCAxNS4xODUgMTEuMjQ1IDAgOS42OTQ5LTI5LjI5MiAxNy41NTQtNjUuNDI2IDE3LjU1NG0yNi4zNjUtMzMuNTNjMC4wNjEzLTAuMDI0IDAuMTI1LTAuMDQ5IDAuMTg2MjUtMC4wNzEtMC4wNjEzIDAuMDIzLTAuMTI1IDAuMDQ3LTAuMTg2MjUgMC4wNzEiIGZpbGw9IiNlZGVkZWMiLz4KICAgPHBhdGggaWQ9InBhdGg1MjkzIiBkPSJtMzA1LjU5IDk0NS40MWMtMzUuOTU4IDAtNjUuMTA2LTcuODE5OC02NS4xMDYtMTcuNDY3IDAtNC4yNDAyIDUuNjMtOC4xMjc1IDE0Ljk5OC0xMS4xNTQgMC4wNDM3IDAuMDMgMC4wOSAwLjA2MiAwLjEzMzc1IDAuMDkyLTkuMjUxMiAzLjAwNTMtMTQuODExIDYuODU4OC0xNC44MTEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNiAxNy4zODEgNjQuNzg2IDE3LjM4MXM2NC43ODUtNy43ODEyIDY0Ljc4NS0xNy4zODFjMC00LjIwMjctNS41NTg4LTguMDU2Mi0xNC44MS0xMS4wNjIgMC4wNDI1LTAuMDMgMC4wOS0wLjA2MiAwLjEzMjUtMC4wOTIgOS4zNjc1IDMuMDI2MiAxNC45OTkgNi45MTM1IDE0Ljk5OSAxMS4xNTQgMCA5LjY0NzUtMjkuMTUgMTcuNDY3LTY1LjEwNiAxNy40NjdtMjYuMTc5LTMzLjM3MWMwLjA2MjUtMC4wMjQgMC4xMjM3NS0wLjA0NyAwLjE4NjI1LTAuMDcyLTAuMDYyNSAwLjAyNS0wLjEyMzc1IDAuMDQ4LTAuMTg2MjUgMC4wNzIiIGZpbGw9IiNlZGVkZWMiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjk1IiBkPSJtMzA1LjU5IDk0NS4zM2MtMzUuNzggMC02NC43ODYtNy43ODEzLTY0Ljc4Ni0xNy4zODEgMC00LjIwMjYgNS41Ni04LjA1NjEgMTQuODExLTExLjA2MiAwLjA0NSAwLjAzMiAwLjA4NzUgMC4wNiAwLjEzMzc1IDAuMDkxLTkuMTM4OCAyLjk4NTQtMTQuNjI0IDYuODA2Ni0xNC42MjQgMTAuOTcgMCA5LjU1MjcgMjguODYyIDE3LjI5NSA2NC40NjUgMTcuMjk1IDM1LjYwMiAwIDY0LjQ2NS03Ljc0MjIgNjQuNDY1LTE3LjI5NSAwLTQuMTYzNy01LjQ4NS03Ljk4NDktMTQuNjI0LTEwLjk3IDAuMDQ1LTAuMDMxIDAuMDg3NS0wLjA1OSAwLjEzMzc1LTAuMDkxIDkuMjUxMiAzLjAwNTQgMTQuODEgNi44NTg5IDE0LjgxIDExLjA2MiAwIDkuNjAwMS0yOS4wMDUgMTcuMzgxLTY0Ljc4NSAxNy4zODFtMjUuOTkxLTMzLjIxNGMwLjA2MjUtMC4wMjQgMC4xMjM3NS0wLjA0NyAwLjE4NzUtMC4wNzEtMC4wNjI1IDAuMDI0LTAuMTI1IDAuMDQ3LTAuMTg3NSAwLjA3MSIgZmlsbD0iI2VjZWNlYiIvPgogICA8cGF0aCBpZD0icGF0aDUyOTciIGQ9Im0zMDUuNTkgOTQ1LjI0Yy0zNS42MDMgMC02NC40NjUtNy43NDIxLTY0LjQ2NS0xNy4yOTUgMC00LjE2MzYgNS40ODUtNy45ODQ5IDE0LjYyNC0xMC45NyAwLjA0MzcgMC4wMyAwLjA5MTMgMC4wNjIgMC4xMzM3NSAwLjA5My05LjAyMzggMi45NjM4LTE0LjQzNiA2Ljc1MS0xNC40MzYgMTAuODc4IDAgOS41MDQ4IDI4LjcxOCAxNy4yMSA2NC4xNDQgMTcuMjEgMzUuNDI1IDAgNjQuMTQ0LTcuNzA1MSA2NC4xNDQtMTcuMjEgMC00LjEyNjUtNS40MTI1LTcuOTEzNy0xNC40MzgtMTAuODc4IDAuMDQzOC0wLjAzIDAuMDkxMy0wLjA2MiAwLjEzNS0wLjA5MyA5LjEzODggMi45ODUzIDE0LjYyNCA2LjgwNjYgMTQuNjI0IDEwLjk3IDAgOS41NTI4LTI4Ljg2MiAxNy4yOTUtNjQuNDY1IDE3LjI5NW0yNS44MDItMzMuMDU1YzAuMDYyNS0wLjAyNCAwLjEyNS0wLjA0OSAwLjE4ODc1LTAuMDczLTAuMDYzNyAwLjAyNC0wLjEyNjI1IDAuMDQ5LTAuMTg4NzUgMC4wNzMiIGZpbGw9IiNlY2ViZWEiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjk5IiBkPSJtMzA1LjU5IDk0NS4xNmMtMzUuNDI2IDAtNjQuMTQ0LTcuNzA1MS02NC4xNDQtMTcuMjEgMC00LjEyNjUgNS40MTI1LTcuOTEzNiAxNC40MzYtMTAuODc4IDAuMDQ2MyAwLjAzMSAwLjA5IDAuMDYgMC4xMzYyNSAwLjA5MS04LjkxMjUgMi45NDM5LTE0LjI1MiA2LjY5NzMtMTQuMjUyIDEwLjc4NiAwIDkuNDU3NSAyOC41NzUgMTcuMTI0IDYzLjgyNCAxNy4xMjRzNjMuODIyLTcuNjY2MSA2My44MjItMTcuMTI0YzAtNC4wODg4LTUuMzQtNy44NDIyLTE0LjI1MS0xMC43ODYgMC4wNDYyLTAuMDMxIDAuMDg4OC0wLjA2IDAuMTM1LTAuMDkxIDkuMDI1IDIuOTYzOSAxNC40MzggNi43NTEgMTQuNDM4IDEwLjg3OCAwIDkuNTA0OS0yOC43MTkgMTcuMjEtNjQuMTQ0IDE3LjIxbTI1LjYxMS0zMi44OTljMC4wNjM3LTAuMDIzIDAuMTI3NS0wLjA0NyAwLjE5MTI1LTAuMDcxLTAuMDYzNyAwLjAyMy0wLjEyNzUgMC4wNDctMC4xOTEyNSAwLjA3MSIgZmlsbD0iI2ViZWJlYSIvPgogICA8cGF0aCBpZD0icGF0aDUzMDEiIGQ9Im0zMDUuNTkgOTQ1LjA3Yy0zNS4yNDkgMC02My44MjQtNy42NjYxLTYzLjgyNC0xNy4xMjQgMC00LjA4ODkgNS4zNC03Ljg0MjIgMTQuMjUyLTEwLjc4NiAwLjA0MjUgMC4wMyAwLjA5MTMgMC4wNjIgMC4xMzUgMC4wOTEtOC43OTg4IDIuOTI0OS0xNC4wNjYgNi42NDUyLTE0LjA2NiAxMC42OTUgMCA5LjQxMDEgMjguNDMxIDE3LjAzOCA2My41MDMgMTcuMDM4IDM1LjA3MSAwIDYzLjUwMi03LjYyNzUgNjMuNTAyLTE3LjAzOCAwLTQuMDQ5Ny01LjI2NzUtNy43Ny0xNC4wNjgtMTAuNjk1IDAuMDQzNy0wLjAyOSAwLjA5MjUtMC4wNjIgMC4xMzYyNS0wLjA5MSA4LjkxMTIgMi45NDM5IDE0LjI1MSA2LjY5NzMgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzQgMTcuMTI0LTYzLjgyMiAxNy4xMjRtMjUuNDItMzIuNzQxYzAuMDYyNS0wLjAyNCAwLjEyODc1LTAuMDQ3IDAuMTkxMjUtMC4wNzEtMC4wNjI1IDAuMDI0LTAuMTI4NzUgMC4wNDgtMC4xOTEyNSAwLjA3MSIgZmlsbD0iI2VhZWFlOSIvPgogICA8cGF0aCBpZD0icGF0aDUzMDMiIGQ9Im0zMDUuNTkgOTQ0Ljk4Yy0zNS4wNzEgMC02My41MDMtNy42Mjc1LTYzLjUwMy0xNy4wMzggMC00LjA0OTcgNS4yNjc1LTcuNzcgMTQuMDY2LTEwLjY5NSAwLjA0NjMgMC4wMzEgMC4wOSAwLjA2MSAwLjEzNjI1IDAuMDkyLTguNjg1IDIuOTAzOC0xMy44ODEgNi41ODk4LTEzLjg4MSAxMC42MDIgMCA5LjM2MjMgMjguMjg2IDE2Ljk1MSA2My4xODEgMTYuOTUxIDM0Ljg5NCAwIDYzLjE4MS03LjU4ODkgNjMuMTgxLTE2Ljk1MSAwLTQuMDEyNy01LjE5NjItNy42OTg3LTEzLjg4Mi0xMC42MDIgMC4wNDYzLTAuMDMxIDAuMDkxMy0wLjA2MSAwLjEzNjI1LTAuMDkyIDguOCAyLjkyNDkgMTQuMDY4IDYuNjQ1MiAxNC4wNjggMTAuNjk1IDAgOS40MTAxLTI4LjQzMSAxNy4wMzgtNjMuNTAyIDE3LjAzOG0yNS4yMjgtMzIuNTg0YzAuMDY1LTAuMDI0IDAuMTI3NS0wLjA0NiAwLjE5MjUtMC4wNzEtMC4wNjUgMC4wMjUtMC4xMjc1IDAuMDQ4LTAuMTkyNSAwLjA3MSIgZmlsbD0iI2VhZWFlOCIvPgogICA8cGF0aCBpZD0icGF0aDUzMDUiIGQ9Im0zMDUuNTkgOTQ0LjljLTM0Ljg5NSAwLTYzLjE4MS03LjU4ODktNjMuMTgxLTE2Ljk1MSAwLTQuMDEyNyA1LjE5NjItNy42OTg3IDEzLjg4MS0xMC42MDIgMC4wNDYyIDAuMDMxIDAuMDkxMyAwLjA2MSAwLjEzNjI1IDAuMDkxLTguNTcyNSAyLjg4MzgtMTMuNjk4IDYuNTM3Ni0xMy42OTggMTAuNTExIDAgOS4zMTUgMjguMTQ0IDE2Ljg2NSA2Mi44NjEgMTYuODY1IDM0LjcxNiAwIDYyLjg2LTcuNTQ5NyA2Mi44Ni0xNi44NjUgMC0zLjk3MzctNS4xMjUtNy42Mjc1LTEzLjY5OC0xMC41MTEgMC4wNDYyLTAuMDMgMC4wOTEzLTAuMDYgMC4xMzYyNS0wLjA5MSA4LjY4NjIgMi45MDM4IDEzLjg4MiA2LjU4OTggMTMuODgyIDEwLjYwMiAwIDkuMzYyMy0yOC4yODggMTYuOTUxLTYzLjE4MSAxNi45NTFtMjUuMDMyLTMyLjQyNmMwLjA2MzctMC4wMjIgMC4xMzEyNS0wLjA0NyAwLjE5NS0wLjA3MS0wLjA2MzcgMC4wMjQtMC4xMzEyNSAwLjA0OS0wLjE5NSAwLjA3MSIgZmlsbD0iI2U5ZTllOCIvPgogICA8cGF0aCBpZD0icGF0aDUzMDciIGQ9Im0zMDUuNTkgOTQ0LjgxYy0zNC43MTggMC02Mi44NjEtNy41NDk4LTYyLjg2MS0xNi44NjUgMC0zLjk3MzYgNS4xMjUtNy42Mjc1IDEzLjY5OC0xMC41MTEgMC4wNDYyIDAuMDMxIDAuMDkxMyAwLjA2MSAwLjEzNzUgMC4wOTItOC40NiAyLjg2MjgtMTMuNTE0IDYuNDgzLTEzLjUxNCAxMC40MTkgMCA5LjI2NzYgMjggMTYuNzc5IDYyLjU0IDE2Ljc3OXM2Mi41NC03LjUxMTIgNjIuNTQtMTYuNzc5YzAtMy45MzYtNS4wNTM4LTcuNTU2Mi0xMy41MTUtMTAuNDE5IDAuMDQ2Mi0wLjAzMSAwLjA5MTItMC4wNjEgMC4xMzc1LTAuMDkyIDguNTcyNSAyLjg4MzcgMTMuNjk4IDYuNTM3NiAxMy42OTggMTAuNTExIDAgOS4zMTUtMjguMTQ0IDE2Ljg2NS02Mi44NiAxNi44NjVtMjQuODM4LTMyLjI2OGMwLjA2NS0wLjAyNCAwLjEzLTAuMDQ3IDAuMTk1LTAuMDcxLTAuMDY1IDAuMDI0LTAuMTMgMC4wNDctMC4xOTUgMC4wNzEiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg1MzA5IiBkPSJtMzA1LjU5IDk0NC43M2MtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTQtMTAuNDE5IDAuMDQ2MiAwLjAzMSAwLjA5MjUgMC4wNjIgMC4xMzg3NSAwLjA5My04LjM1IDIuODQxMy0xMy4zMzIgNi40MjczLTEzLjMzMiAxMC4zMjYgMCA5LjIyMDMgMjcuODU4IDE2LjY5NCA2Mi4yMiAxNi42OTRzNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0YzAtMy44OTg4LTQuOTgyNS03LjQ4NDgtMTMuMzMxLTEwLjMyNiAwLjA0NjMtMC4wMzEgMC4wOTEzLTAuMDYyIDAuMTM3NS0wLjA5MyA4LjQ2MTIgMi44NjI5IDEzLjUxNSA2LjQ4MyAxMy41MTUgMTAuNDE5IDAgOS4yNjc2LTI4IDE2Ljc3OS02Mi41NCAxNi43NzltMjQuNjQtMzIuMTExYzAuMDY2My0wLjAyNCAwLjEzMTI1LTAuMDQ3IDAuMTk3NS0wLjA3MS0wLjA2NjIgMC4wMjQtMC4xMzEyNSAwLjA0Ny0wLjE5NzUgMC4wNzEiIGZpbGw9IiNlOGU4ZTYiLz4KICAgPHBhdGggaWQ9InBhdGg1MzExIiBkPSJtMzA1LjU5IDk0NC42NGMtMzQuMzYzIDAtNjIuMjItNy40NzM2LTYyLjIyLTE2LjY5NCAwLTMuODk4OSA0Ljk4MjUtNy40ODQ5IDEzLjMzMi0xMC4zMjYgMC4wNDYyIDAuMDMgMC4wOTEzIDAuMDYgMC4xMzg3NSAwLjA5MS04LjIzNzUgMi44MTk5LTEzLjE1IDYuMzczNS0xMy4xNSAxMC4yMzUgMCA5LjE3MjMgMjcuNzEyIDE2LjYwNyA2MS44OTkgMTYuNjA3IDM0LjE4NSAwIDYxLjg5OS03LjQzNTEgNjEuODk5LTE2LjYwNyAwLTMuODYxNC00LjkxMjUtNy40MTUtMTMuMTUtMTAuMjM1IDAuMDQ2Mi0wLjAzIDAuMDkyNS0wLjA2MiAwLjEzODc1LTAuMDkxIDguMzQ4OCAyLjg0MTIgMTMuMzMxIDYuNDI3MiAxMy4zMzEgMTAuMzI2IDAgOS4yMjAzLTI3Ljg1NiAxNi42OTQtNjIuMjE5IDE2LjY5NG0yNC40NDEtMzEuOTU3YzAuMDY2Mi0wLjAyMiAwLjEzMjUtMC4wNDYgMC4xOTg3NS0wLjA3LTAuMDY2MiAwLjAyNC0wLjEzMjUgMC4wNDctMC4xOTg3NSAwLjA3IiBmaWxsPSIjZTdlN2U2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTMxMyIgZD0ibTMwNS41OSA5NDQuNTVjLTM0LjE4NiAwLTYxLjg5OS03LjQzNS02MS44OTktMTYuNjA3IDAtMy44NjE0IDQuOTEyNS03LjQxNSAxMy4xNS0xMC4yMzUgMC4wNDYzIDAuMDMxIDAuMDkyNSAwLjA2MSAwLjEzODc1IDAuMDkyLTguMTI2MiAyLjc5ODktMTIuOTY4IDYuMzE5OS0xMi45NjggMTAuMTQzIDAgOS4xMjM1IDI3LjU2OSAxNi41MjIgNjEuNTc4IDE2LjUyMiAzNC4wMDggMCA2MS41NzgtNy4zOTggNjEuNTc4LTE2LjUyMiAwLTMuODIyOC00Ljg0MjUtNy4zNDM4LTEyLjk2OS0xMC4xNDMgMC4wNDc1LTAuMDMxIDAuMDkzNy0wLjA2MSAwLjE0LTAuMDkyIDguMjM3NSAyLjgxOTkgMTMuMTUgNi4zNzM1IDEzLjE1IDEwLjIzNSAwIDkuMTcyNC0yNy43MTQgMTYuNjA3LTYxLjg5OSAxNi42MDdtMjQuMjQxLTMxLjc5OWMwLjA2ODctMC4wMjQgMC4xMzI1LTAuMDQ2IDAuMi0wLjA3MS0wLjA2NzUgMC4wMjYtMC4xMzEyNSAwLjA0OC0wLjIgMC4wNzEiIGZpbGw9IiNlN2U2ZTUiLz4KICAgPHBhdGggaWQ9InBhdGg1MzE1IiBkPSJtMzA1LjU5IDk0NC40N2MtMzQuMDA5IDAtNjEuNTc4LTcuMzk4LTYxLjU3OC0xNi41MjIgMC0zLjgyMjcgNC44NDEyLTcuMzQzNyAxMi45NjgtMTAuMTQzIDAuMDQ2MiAwLjAzIDAuMDkzNyAwLjA2MiAwLjE0IDAuMDkxLTguMDE2MiAyLjc3ODctMTIuNzg4IDYuMjY2MS0xMi43ODggMTAuMDUxIDAgOS4wNzc3IDI3LjQyNiAxNi40MzUgNjEuMjU4IDE2LjQzNSAzMy44MzEgMCA2MS4yNTYtNy4zNTc0IDYxLjI1Ni0xNi40MzUgMC0zLjc4NTEtNC43NzEyLTcuMjcyNS0xMi43ODYtMTAuMDUxIDAuMDQ2Mi0wLjAzIDAuMDkyNS0wLjA2MSAwLjEzODc1LTAuMDkxIDguMTI2MiAyLjc5ODkgMTIuOTY5IDYuMzE5OSAxMi45NjkgMTAuMTQzIDAgOS4xMjM1LTI3LjU3IDE2LjUyMi02MS41NzggMTYuNTIybTI0LjA0LTMxLjY0M2MwLjA2NjItMC4wMjIgMC4xMzYyNS0wLjA0NiAwLjIwMTI1LTAuMDctMC4wNjUgMC4wMjQtMC4xMzUgMC4wNDgtMC4yMDEyNSAwLjA3IiBmaWxsPSIjZTZlNmU0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTMxNyIgZD0ibTMwNS41OSA5NDQuMzhjLTMzLjgzMSAwLTYxLjI1OC03LjM1NzUtNjEuMjU4LTE2LjQzNSAwLTMuNzg1MSA0Ljc3MTItNy4yNzI1IDEyLjc4OC0xMC4wNTEgMC4wNDYzIDAuMDMxIDAuMDkzNyAwLjA2MiAwLjE0IDAuMDkyLTcuOTA2MiAyLjc1NzgtMTIuNjA2IDYuMjExNS0xMi42MDYgOS45NTkgMCA5LjAyODcgMjcuMjgxIDE2LjM0OSA2MC45MzYgMTYuMzQ5IDMzLjY1NCAwIDYwLjkzNi03LjMxOTkgNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NS00LjcwMTItNy4yMDEyLTEyLjYwOC05Ljk1OSAwLjA0NzUtMC4wMyAwLjA5NS0wLjA2MSAwLjE0MTI1LTAuMDkyIDguMDE1IDIuNzc4NyAxMi43ODYgNi4yNjYxIDEyLjc4NiAxMC4wNTEgMCA5LjA3NzYtMjcuNDI1IDE2LjQzNS02MS4yNTYgMTYuNDM1bTIzLjgzOC0zMS40ODVjMC4wNjg3LTAuMDI0IDAuMTM1LTAuMDQ4IDAuMjAyNS0wLjA3MS0wLjA2NzUgMC4wMjQtMC4xMzM3NSAwLjA0Ny0wLjIwMjUgMC4wNzEiIGZpbGw9IiNlNWU1ZTMiLz4KICAgPHBhdGggaWQ9InBhdGg1MzE5IiBkPSJtMzA1LjU5IDk0NC4zYy0zMy42NTUgMC02MC45MzYtNy4zMTk5LTYwLjkzNi0xNi4zNDkgMC0zLjc0NzUgNC43LTcuMjAxMSAxMi42MDYtOS45NTkgMC4wNDYyIDAuMDMgMC4wOTUgMC4wNjIgMC4xNDEyNSAwLjA5My03Ljc5NSAyLjczNjQtMTIuNDI4IDYuMTU2My0xMi40MjggOS44NjYzIDAgOC45ODE1IDI3LjEzOSAxNi4yNjMgNjAuNjE2IDE2LjI2MyAzMy40NzYgMCA2MC42MTUtNy4yODEyIDYwLjYxNS0xNi4yNjMgMC0zLjcxLTQuNjMxMi03LjEyOTktMTIuNDI4LTkuODY2MyAwLjA0NzUtMC4wMzEgMC4wOTUtMC4wNjIgMC4xNDEyNS0wLjA5MyA3LjkwNjIgMi43NTc5IDEyLjYwOCA2LjIxMTUgMTIuNjA4IDkuOTU5IDAgOS4wMjg4LTI3LjI4MiAxNi4zNDktNjAuOTM2IDE2LjM0OW0yMy42MzItMzEuMzI5YzAuMDY4Ny0wLjAyNCAwLjEzNzUtMC4wNDcgMC4yMDUtMC4wNy0wLjA2NzUgMC4wMjItMC4xMzYyNSAwLjA0Ni0wLjIwNSAwLjA3IiBmaWxsPSIjZTRlNGUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMyMSIgZD0ibTMwNS41OSA5NDQuMjFjLTMzLjQ3OCAwLTYwLjYxNi03LjI4MTItNjAuNjE2LTE2LjI2MyAwLTMuNzEgNC42MzI1LTcuMTI5OSAxMi40MjgtOS44NjYyIDAuMDQ2MyAwLjAzIDAuMDk1IDAuMDYxIDAuMTQxMjUgMC4wOTEtNy42ODUgMi43MTYyLTEyLjI0OCA2LjEwMjUtMTIuMjQ4IDkuNzc0OCAwIDguOTMzNyAyNi45OTUgMTYuMTc2IDYwLjI5NSAxNi4xNzZzNjAuMjk0LTcuMjQyNiA2MC4yOTQtMTYuMTc2YzAtMy42NzIzLTQuNTYxMi03LjA1ODYtMTIuMjQ4LTkuNzc0OCAwLjA0NjMtMC4wMyAwLjA5NS0wLjA2MiAwLjE0MTI1LTAuMDkxIDcuNzk2MiAyLjczNjMgMTIuNDI4IDYuMTU2MiAxMi40MjggOS44NjYyIDAgOC45ODE1LTI3LjEzOSAxNi4yNjMtNjAuNjE1IDE2LjI2M20yMy40MjgtMzEuMTczYzAuMDY3NS0wLjAyNCAwLjEzNzUtMC4wNDYgMC4yMDUtMC4wNy0wLjA2NzUgMC4wMjQtMC4xMzc1IDAuMDQ2LTAuMjA1IDAuMDciIGZpbGw9IiNlNGUzZTIiLz4KICAgPHBhdGggaWQ9InBhdGg1MzIzIiBkPSJtMzA1LjU5IDk0NC4xMmMtMzMuMyAwLTYwLjI5NS03LjI0MjctNjAuMjk1LTE2LjE3NiAwLTMuNjcyNCA0LjU2MjUtNy4wNTg2IDEyLjI0OC05Ljc3NDkgMC4wNSAwLjAzMiAwLjA5MzcgMC4wNjEgMC4xNDI1IDAuMDkyLTcuNTc3NSAyLjY5MzktMTIuMDY5IDYuMDQ3OS0xMi4wNjkgOS42ODI3IDAgOC44ODYyIDI2Ljg1MSAxNi4wOTEgNTkuOTc0IDE2LjA5MSAzMy4xMjIgMCA1OS45NzQtNy4yMDUxIDU5Ljk3NC0xNi4wOTEgMC0zLjYzNDgtNC40OTI1LTYuOTg4OC0xMi4wNy05LjY4MjcgMC4wNDg3LTAuMDMxIDAuMDkzNy0wLjA2IDAuMTQyNS0wLjA5MiA3LjY4NjIgMi43MTYzIDEyLjI0OCA2LjEwMjUgMTIuMjQ4IDkuNzc0OSAwIDguOTMzNi0yNi45OTQgMTYuMTc2LTYwLjI5NCAxNi4xNzZtMjMuMjItMzEuMDE2YzAuMDY3NS0wLjAyNCAwLjEzODc1LTAuMDQ4IDAuMjA3NS0wLjA3LTAuMDY4OCAwLjAyMi0wLjE0IDAuMDQ2LTAuMjA3NSAwLjA3IiBmaWxsPSIjZTNlM2UxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMyNSIgZD0ibTMwNS41OSA5NDQuMDRjLTMzLjEyMyAwLTU5Ljk3NC03LjIwNS01OS45NzQtMTYuMDkxIDAtMy42MzQ3IDQuNDkxMi02Ljk4ODcgMTIuMDY5LTkuNjgyNiAwLjA0NzUgMC4wMyAwLjA5NjIgMC4wNjIgMC4xNDM3NSAwLjA5My03LjQ2NzUgMi42NzI0LTExLjg5MiA1Ljk5MjItMTEuODkyIDkuNTg5OSAwIDguODM4OCAyNi43MDggMTYuMDA1IDU5LjY1NCAxNi4wMDUgMzIuOTQ1IDAgNTkuNjUyLTcuMTY2IDU5LjY1Mi0xNi4wMDUgMC0zLjU5NzctNC40MjM4LTYuOTE3NS0xMS44OTEtOS41ODk5IDAuMDQ2My0wLjAzIDAuMDk2Mi0wLjA2MiAwLjE0MjUtMC4wOTMgNy41Nzc1IDIuNjkzOSAxMi4wNyA2LjA0NzkgMTIuMDcgOS42ODI2IDAgOC44ODYzLTI2Ljg1MSAxNi4wOTEtNTkuOTc0IDE2LjA5MW0yMy4wMS0zMC44NjFjMC4wNzEzLTAuMDI0IDAuMTM4NzUtMC4wNDYgMC4yMS0wLjA3LTAuMDcxMyAwLjAyNC0wLjEzODc1IDAuMDQ2LTAuMjEgMC4wNyIgZmlsbD0iI2UyZTJlMCIvPgogICA8cGF0aCBpZD0icGF0aDUzMjciIGQ9Im0zMDUuNTkgOTQzLjk1Yy0zMi45NDYgMC01OS42NTQtNy4xNjYtNTkuNjU0LTE2LjAwNSAwLTMuNTk3NiA0LjQyNS02LjkxNzUgMTEuODkyLTkuNTg5OSAwLjA0ODcgMC4wMzEgMC4wOTM3IDAuMDYgMC4xNDM3NSAwLjA5MS03LjM2IDIuNjUyOC0xMS43MTUgNS45MzktMTEuNzE1IDkuNDk5IDAgOC43OTEgMjYuNTY0IDE1LjkxOSA1OS4zMzMgMTUuOTE5IDMyLjc2OCAwIDU5LjMzMi03LjEyOCA1OS4zMzItMTUuOTE5IDAtMy41Ni00LjM1NjItNi44NDYyLTExLjcxNS05LjQ5OSAwLjA0ODgtMC4wMzEgMC4wOTUtMC4wNiAwLjE0Mzc1LTAuMDkxIDcuNDY3NSAyLjY3MjQgMTEuODkxIDUuOTkyMyAxMS44OTEgOS41ODk5IDAgOC44Mzg5LTI2LjcwOCAxNi4wMDUtNTkuNjUyIDE2LjAwNW0yMi43OTktMzAuNzA2YzAuMDcxMi0wLjAyMiAwLjE0MTI1LTAuMDQ2IDAuMjExMjUtMC4wNjktMC4wNyAwLjAyMy0wLjE0IDAuMDQ2LTAuMjExMjUgMC4wNjkiIGZpbGw9IiNlMmUxZGYiLz4KICAgPHBhdGggaWQ9InBhdGg1MzI5IiBkPSJtMzA1LjU5IDk0My44N2MtMzIuNzY5IDAtNTkuMzMzLTcuMTI4LTU5LjMzMy0xNS45MTkgMC0zLjU2IDQuMzU1LTYuODQ2MiAxMS43MTUtOS40OTkgMC4wNDYyIDAuMDMgMC4wOTYyIDAuMDYzIDAuMTQzNzUgMC4wOTMtNy4yNTEyIDIuNjI5OC0xMS41MzkgNS44ODM3LTExLjUzOSA5LjQwNjIgMCA4Ljc0MzYgMjYuNDIxIDE1LjgzMiA1OS4wMTMgMTUuODMyIDMyLjU5MSAwIDU5LjAxMS03LjA4ODkgNTkuMDExLTE1LjgzMiAwLTMuNTIyNS00LjI4NzUtNi43NzY0LTExLjUzOS05LjQwNjIgMC4wNDc1LTAuMDMgMC4wOTc1LTAuMDYyIDAuMTQ1LTAuMDkzIDcuMzU4OCAyLjY1MjcgMTEuNzE1IDUuOTM5IDExLjcxNSA5LjQ5OSAwIDguNzkxLTI2LjU2NSAxNS45MTktNTkuMzMyIDE1LjkxOW0yMi41ODYtMzAuNTVjMC4wNjg3LTAuMDIyIDAuMTQzNzUtMC4wNDcgMC4yMTI1LTAuMDctMC4wNjg4IDAuMDIyLTAuMTQzNzUgMC4wNDctMC4yMTI1IDAuMDciIGZpbGw9IiNlMWUwZGYiLz4KICAgPHBhdGggaWQ9InBhdGg1MzMxIiBkPSJtMzA1LjU5IDk0My43OGMtMzIuNTkxIDAtNTkuMDEzLTcuMDg4OS01OS4wMTMtMTUuODMyIDAtMy41MjI1IDQuMjg3NS02Ljc3NjQgMTEuNTM5LTkuNDA2MiAwLjA0ODcgMC4wMzEgMC4wOTYyIDAuMDYxIDAuMTQ1IDAuMDkyLTcuMTQyNSAyLjYwODgtMTEuMzYyIDUuODI5MS0xMS4zNjIgOS4zMTQgMCA4LjY5NjIgMjYuMjc2IDE1Ljc0NiA1OC42OTEgMTUuNzQ2IDMyLjQxNCAwIDU4LjY5LTcuMDQ5OSA1OC42OS0xNS43NDYgMC0zLjQ4NDktNC4yMi02LjcwNTItMTEuMzYxLTkuMzE0IDAuMDQ4OC0wLjAzMSAwLjA5NS0wLjA2MSAwLjE0Mzc1LTAuMDkyIDcuMjUxMiAyLjYyOTggMTEuNTM5IDUuODgzNyAxMS41MzkgOS40MDYyIDAgOC43NDM2LTI2LjQyIDE1LjgzMi01OS4wMTEgMTUuODMybTIyLjM3Mi0zMC4zOTVjMC4wNzI1LTAuMDIyIDAuMTQtMC4wNDUgMC4yMTM3NS0wLjA2OS0wLjA3MzggMC4wMjQtMC4xNDEyNSAwLjA0Ni0wLjIxMzc1IDAuMDY5IiBmaWxsPSIjZTBlMGRlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMzMyIgZD0ibTMwNS41OSA5NDMuNjljLTMyLjQxNSAwLTU4LjY5MS03LjA0OTgtNTguNjkxLTE1Ljc0NiAwLTMuNDg0OSA0LjIyLTYuNzA1MSAxMS4zNjItOS4zMTQgMC4wNDYyIDAuMDMgMC4wOTg3IDAuMDYyIDAuMTQ1IDAuMDkzLTcuMDM2MiAyLjU4Ni0xMS4xODYgNS43NzM1LTExLjE4NiA5LjIyMTMgMCA4LjY0ODggMjYuMTMyIDE1LjY2IDU4LjM3IDE1LjY2IDMyLjIzNiAwIDU4LjM3LTcuMDExMyA1OC4zNy0xNS42NiAwLTMuNDQ3OC00LjE1MTItNi42MzUzLTExLjE4OC05LjIyMTMgMC4wNDc1LTAuMDMgMC4wOTg3LTAuMDYyIDAuMTQ2MjUtMC4wOTMgNy4xNDEyIDIuNjA4OSAxMS4zNjEgNS44MjkxIDExLjM2MSA5LjMxNCAwIDguNjk2My0yNi4yNzYgMTUuNzQ2LTU4LjY5IDE1Ljc0Nm0yMi4xNTUtMzAuMjM5YzAuMDcxMy0wLjAyMyAwLjE0NjI1LTAuMDQ2IDAuMjE3NS0wLjA3LTAuMDcxMiAwLjAyNC0wLjE0NjI1IDAuMDQ3LTAuMjE3NSAwLjA3IiBmaWxsPSIjZTBkZmRkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMzNSIgZD0ibTMwNS41OSA5NDMuNjFjLTMyLjIzOCAwLTU4LjM3LTcuMDExMy01OC4zNy0xNS42NiAwLTMuNDQ3NyA0LjE1LTYuNjM1MiAxMS4xODYtOS4yMjEyIDAuMDUgMC4wMzEgMC4wOTc1IDAuMDYxIDAuMTQ3NSAwLjA5Mi02LjkyODggMi41NjQ4LTExLjAxNCA1LjcxODctMTEuMDE0IDkuMTI4OCAwIDguNjAxMiAyNS45OSAxNS41NzUgNTguMDUgMTUuNTc1czU4LjA0OS02Ljk3NCA1OC4wNDktMTUuNTc1YzAtMy40MTAxLTQuMDg1LTYuNTY0LTExLjAxMi05LjEyODggMC4wNDg3LTAuMDMxIDAuMDk2Mi0wLjA2MSAwLjE0NjI1LTAuMDkyIDcuMDM2MiAyLjU4NiAxMS4xODggNS43NzM1IDExLjE4OCA5LjIyMTIgMCA4LjY0ODktMjYuMTM0IDE1LjY2LTU4LjM3IDE1LjY2bTIxLjkzOC0zMC4wODRjMC4wNzM4LTAuMDIyIDAuMTQzNzUtMC4wNDUgMC4yMTc1LTAuMDY5LTAuMDczNyAwLjAyNC0wLjE0Mzc1IDAuMDQ2LTAuMjE3NSAwLjA2OSIgZmlsbD0iI2RmZGVkYyIvPgogICA8cGF0aCBpZD0icGF0aDUzMzciIGQ9Im0zMDUuNTkgOTQzLjUyYy0zMi4wNiAwLTU4LjA1LTYuOTc0MS01OC4wNS0xNS41NzUgMC0zLjQxMDEgNC4wODUtNi41NjQgMTEuMDE0LTkuMTI4OSAwLjA0ODcgMC4wMzEgMC4wOTc1IDAuMDYyIDAuMTQ2MjUgMC4wOTMtNi44MjEyIDIuNTQyNS0xMC44MzkgNS42NjM3LTEwLjgzOSA5LjAzNjIgMCA4LjU1MzcgMjUuODQ2IDE1LjQ4OSA1Ny43MjkgMTUuNDg5IDMxLjg4MiAwIDU3LjcyOS02LjkzNSA1Ny43MjktMTUuNDg5IDAtMy4zNzI1LTQuMDE3NS02LjQ5MzctMTAuODQtOS4wMzYyIDAuMDUtMC4wMzEgMC4wOTc1LTAuMDYyIDAuMTQ3NS0wLjA5MyA2LjkyNzUgMi41NjQ5IDExLjAxMiA1LjcxODggMTEuMDEyIDkuMTI4OSAwIDguNjAxMS0yNS45ODkgMTUuNTc1LTU4LjA0OSAxNS41NzVtMjEuNzE4LTI5LjkzYzAuMDczOC0wLjAyMyAwLjE0NjI1LTAuMDQ1IDAuMjItMC4wNjktMC4wNzM3IDAuMDI0LTAuMTQ2MjUgMC4wNDYtMC4yMiAwLjA2OSIgZmlsbD0iI2RlZGRkYiIvPgogICA8cGF0aCBpZD0icGF0aDUzMzkiIGQ9Im0zMDUuNTkgOTQzLjQ0Yy0zMS44ODMgMC01Ny43MjktNi45MzUtNTcuNzI5LTE1LjQ4OSAwLTMuMzcyNSA0LjAxNzUtNi40OTM2IDEwLjgzOS05LjAzNjEgMC4wNSAwLjAzMSAwLjA5ODcgMC4wNjEgMC4xNDc1IDAuMDkyLTYuNzE1IDIuNTIxNS0xMC42NjYgNS42MDg5LTEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQgMjUuNzAyIDE1LjQwMiA1Ny40MDkgMTUuNDAyIDMxLjcwNSAwIDU3LjQwOC02Ljg5NTkgNTcuNDA4LTE1LjQwMiAwLTMuMzM1LTMuOTUxMi02LjQyMjQtMTAuNjY2LTguOTQzOSAwLjA1LTAuMDMxIDAuMDk4OC0wLjA2MSAwLjE0NzUtMC4wOTIgNi44MjI1IDIuNTQyNSAxMC44NCA1LjY2MzYgMTAuODQgOS4wMzYxIDAgOC41NTM4LTI1Ljg0NiAxNS40ODktNTcuNzI5IDE1LjQ4OW0yMS40OTUtMjkuNzc1YzAuMDczOC0wLjAyMyAwLjE0ODc1LTAuMDQ2IDAuMjIyNS0wLjA2OS0wLjA3MzcgMC4wMjItMC4xNDg3NSAwLjA0Ni0wLjIyMjUgMC4wNjkiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg1MzQxIiBkPSJtMzA1LjU5IDk0My4zNWMtMzEuNzA2IDAtNTcuNDA5LTYuODk2LTU3LjQwOS0xNS40MDIgMC0zLjMzNSAzLjk1MTItNi40MjI0IDEwLjY2Ni04Ljk0MzkgMC4wNSAwLjAzIDAuMSAwLjA2MiAwLjE0ODc1IDAuMDkzLTYuNjEgMi40OTg3LTEwLjQ5NCA1LjU1MzgtMTAuNDk0IDguODUxMiAwIDguNDU5IDI1LjU1OSAxNS4zMTYgNTcuMDg4IDE1LjMxNiAzMS41MjggMCA1Ny4wODYtNi44NTc0IDU3LjA4Ni0xNS4zMTYgMC0zLjI5NzQtMy44ODM4LTYuMzUyNS0xMC40OTQtOC44NTEyIDAuMDUtMC4wMzEgMC4wOTg3LTAuMDYyIDAuMTQ4NzUtMC4wOTMgNi43MTUgMi41MjE1IDEwLjY2NiA1LjYwODkgMTAuNjY2IDguOTQzOSAwIDguNTA2NC0yNS43MDIgMTUuNDAyLTU3LjQwOCAxNS40MDJtMjEuMjctMjkuNjJjMC4wNzM3LTAuMDIzIDAuMTUxMjUtMC4wNDYgMC4yMjUtMC4wNjktMC4wNzM4IDAuMDIyLTAuMTUxMjUgMC4wNDYtMC4yMjUgMC4wNjkiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg1MzQzIiBkPSJtMzA1LjU5IDk0My4yNmMtMzEuNTI5IDAtNTcuMDg4LTYuODU3NC01Ny4wODgtMTUuMzE2IDAtMy4yOTc0IDMuODgzOC02LjM1MjUgMTAuNDk0LTguODUxMSAwLjA1IDAuMDMgMC4xMDI1IDAuMDYyIDAuMTUxMjUgMC4wOTQtNi41MDUgMi40NzYxLTEwLjMyNCA1LjQ5NDYtMTAuMzI0IDguNzUzNSAwIDguNDE1IDI1LjQxNCAxNS4yMzMgNTYuNzY4IDE1LjIzM3M1Ni43NjgtNi44MTc5IDU2Ljc2OC0xNS4yMzNjMC0zLjI1ODktMy44MjEyLTYuMjc4OS0xMC4zMjYtOC43NTQ5IDAuMDUtMC4wMyAwLjEwMTI1LTAuMDYyIDAuMTUtMC4wOTIgNi42MSAyLjQ5ODYgMTAuNDk0IDUuNTUzOCAxMC40OTQgOC44NTExIDAgOC40NTktMjUuNTU5IDE1LjMxNi01Ny4wODYgMTUuMzE2bTIxLjAzNC0yOS40NjJjMC4wNzc1LTAuMDI0IDAuMTU4NzUtMC4wNDggMC4yMzYyNS0wLjA3MS0wLjA3NzUgMC4wMjMtMC4xNTg3NSAwLjA0Ny0wLjIzNjI1IDAuMDcxIiBmaWxsPSIjZGNkYmQ5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTM0NSIgZD0ibTMxMi4zMiA5MjcuOTVjMCAwLjk5NzUtMy4wMTEyIDEuODA1MS02LjcyNjIgMS44MDUxcy02LjcyNzUtMC44MDc2LTYuNzI3NS0xLjgwNTFjMC0wLjk5NjEgMy4wMTI1LTEuODA1MSA2LjcyNzUtMS44MDUxczYuNzI2MiAwLjgwOSA2LjcyNjIgMS44MDUxIiBmaWxsPSIjMTAwZjBkIi8+CiAgPC9nPgogIDxnIGlkPSJnNTQyOSIgdHJhbnNmb3JtPSJtYXRyaXgoMC4xMjUgMCAwIC0wLjEyNSAtMjY4Ljk4IDk1MS43KSI+CiAgIDxnIGlkPSJnNTQzMSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTQzMy0xKSI+CiAgICA8cGF0aCBpZD0icGF0aDU0NDUiIGQ9Im00ODI0LjcgNDM3Mi4yYzAgMTA1LjI2LTg2LjEzIDE5MS4zOS0xOTEuMzkgMTkxLjM5aC03My40NGMtMTAuNTUgMC0yMC45MS0wLjg3LTMxLTIuNTN2MTEyOC40YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNi0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTExODguNmMtMzEuMDctMzQuMDUtNTAuMDctNzkuMjgtNTAuMDctMTI4LjcydjEyODkuN2MwIDEwNS4yNiA4Ni4xMiAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtMTI4OS43IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTQzNykiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU0NDciIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU0NDkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU0NTEtOSkiPgogICAgPHBhdGggaWQ9InBhdGg1NDYxIiBkPSJtNDgyNC43IDE0MDAuN2MtNzAuMDkgMjkuNzYtMTQ3LjE4IDQ2LjIyLTIyOC4xMSA0Ni4yMi04MC45NCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjJ2Mjk3MS41YzAgNDkuNDQgMTkgOTQuNjcgNTAuMDcgMTI4Ljcydi0yOTE0LjFjMC0zMC40NyAyNC42OS01NS4xNiA1NS4xNi01NS4xNiAzMC40NiAwIDU1LjE2IDI0LjY5IDU1LjE2IDU1LjE2djI5NzQuMmMxMC4wOSAxLjY2IDIwLjQ1IDIuNTMgMzEgMi41M2g3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTMgMTkxLjM5LTE5MS4zOXYtMjk3MS41IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTQ1NSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU0NjMiIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU0NjUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU0NjctNSkiPgogICAgPHBhdGggaWQ9InBhdGg1NDc3IiBkPSJtNTE4MC43IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4IDAgMzIyLjYzIDI2MS41NCA1ODQuMTggNTg0LjE4IDU4NC4xOCAzMjIuNjMgMCA1ODQuMTgtMjYxLjU1IDU4NC4xOC01ODQuMTgiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1NTAzLTgpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1NDc5IiB0cmFuc2Zvcm09Im1hdHJpeCgwLjEyNSAwIDAgLTAuMTI1IC0yNjguOTggOTUxLjcpIj4KICAgPGcgaWQ9Imc1NDgxIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1NDgzLTIpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTQ5MyIgZD0ibTQ5MzkgMTE3MS44djIuNDctMS4yMy0xLjI0IiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NTUwMy04KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNTQ5NSIgdHJhbnNmb3JtPSJtYXRyaXgoMC4xMjUgMCAwIC0wLjEyNSAtMjY4Ljk4IDk1MS43KSI+CiAgIDxnIGlkPSJnNTQ5NyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTQ5OS02KSI+CiAgICA8cGF0aCBpZD0icGF0aDU1MDkiIGQ9Im00NzU2LjIgOTgwLjg2YzEwOC43NyAzNi4zODEgMTgyLjkgMTA4Ljc5IDE4Mi45IDE5Mi4xOHYtMS4yNGMtMC43My04Mi44Ny03NC42Ny0xNTQuNzMtMTgyLjktMTkwLjk0bTE4Mi45IDE5Mi4xOGMwIDQ0Ljc0LTIxLjM0IDg2LjMxLTU3LjkyIDEyMC44NiAzNi4yNC0zNC4yMyA1Ny41My03NS4zNiA1Ny45Mi0xMTkuNjN2LTEuMjMiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1NTAzLTgpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1NTExIiB0cmFuc2Zvcm09Im1hdHJpeCgwLjEyNSAwIDAgLTAuMTI1IC0yNjguOTggOTUxLjcpIj4KICAgPGcgaWQ9Imc1NTEzIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1NTE1LTYpIj4KICAgIDxnIGlkPSJnNTUxOSIgdHJhbnNmb3JtPSJtYXRyaXgoNjk2LjIsMCwwLDQ1My4yLDQyNDcuOSw5NDUuOSkiPgogICAgIDxpbWFnZSBpZD0iaW1hZ2U1NTIxIiB4bGluazpocmVmPSJkYXRhOmltYWdlL3BuZztiYXNlNjQsaVZCT1J3MEtHZ29BQUFBTlNVaEVVZ0FBQUpFQUFBQmVDQVlBQUFBalp2WkNBQUFBQkhOQ1NWUUlDQWdJZkFoa2lBQUFHWHBKUkVGVWVKemRYZG1TNURDcWhlejgvNys5RWZlaHk4eURCVG9jSVZuT3JLenFHU0lxeW91RU5nd0h0S1FlLy85L0p2L05wUC9kMVE4eS9lMGF2RXhQK2Z2MU9lNlBiK0J4MWJmL0l6SjBTZC9SenVNYmVCVDAvQmhua2M1NkVJUWJYNTEzM3YrS3hybExMMmtvVzk1K056M2wrS0FtOHZiZmJVUWxNTi9kRWZvaDgyRS9JT3l2bXI0UFZlMHA5a0Voc3UvNWtENUMzMkZxSy9xZ1luK0hQcW5Jbi9ZT0psb0l5QjNoMFhkYStIanhxL3dYQi90NHJSOXNvN08zVU1HTHcvQzBOOHlacWt6TmdwcUlQUFpxOVlvRmlHTC9SV0Y0ays3MngvWm5kS3krZW52WkVqL2w2KzlyT1IrTHh0SUFKemtyaEc3V3ROVUhkdG5nZnhXSXYrSEszMm9TZFZDNlhmRjU0YU44aXIzNEtjOVU3ME5HNXdEdmQvclFCZTBWT1hDczg0L0tVTkFMM1I1TjJsRVpWMGxtNWI4ZzVFLzVlNkdKS3A3RkoyRStlSlYxWE5XckFyaFZCNEFHV3pienlqcHZtdGkzYVdVNkxpalZjTmZHcklSeXdVSjNoV25CNHlWTlpCWEhMNmtGUWhjVlVMbitJbjNRZ2NkV3R3TGdUakwvU3hncXh1UkY4THdsbEMrQTVrcEcxWXBCV3duUmNTdzBFZkVLWlRBelQ2NEZac2FiaFl5VFZmbFlzK3g2WTVBdmNmM0oyWVYzbGQ2T3dLM016eDJUVlh5a3BSSXNudDNTUktNRzBzbVlGQ1VWV0ttekdiWE5tS2I5MzYzdUZLMXY1djhrM1FMSU8ya21qWjFwK2lMOUtUQ3NmZlpNMmxOYm5NaFFDekE0WmFBTGFVTmFzVHhLcHlKWm8xeHBwQVhaYnBEd1g1c3VlUUd3VHZIS0RrSGVEc2pYR3FnL1krR2hSTUJiVFZFVFFjSUIzMXdNaENxWnVLeFpzam14N3Zvemp3VlpnWTNHZWxRWmwyeC9seTdxWmlxaVcxaklhbll6d2IwU0VtYTIwRWdtSWsrenIvTUtoUVlHZW1tR25EL2pwdEs3NmtLUWhhZW90QlBVcVp4TzR6NjZJekNmMGxEZkhRZENkaGNlMkxRcnArWnJVblpWRHl3YitSMHVSSkJJMWJJcGNET0VRbVlTbWlOa28xMU1ZMExXK1djWHR2TWZPakdBZXRFb2VMeHQ0aEw5TzBLMFpiWm1IOUVrNzNRcVpPcU5SY2I4Y2lZOFVNWlQwRHRUN1R3ZUlvb00yVnp4SzN4ZkJmd2NWaVd0QlRjSHRZOEVJd2tZeFhwbVNzVldKdklYekp6T1lqNWNUVEJoTVc2VlJocUN1cHVZQjNsVWhIeEFVRTJVaE9wazNwZUN1Qm55L00zS3hRZ2RJZ29EYXp4eU82NytneVY1a3E1NFZHcXZDOEl2L0RWdDlUNmxPbnpIMHFBN2JqdUhDQ1pDbHZVRENQQkNDL1U0UUxqNFpNS2NFQS9oWEprTG5CWHBrQWUrRjA1VFZBaTlQdW9zWlhOYWtUZTBhU3FyeXZoaEtnVUh3ZklGTmx0R1lLNDB6eXdzd3ZsWTBIR2Nqem9kOG5sYU0yZUtBdy9hU0hBNkE4dEdrM0kwVHdJYndwcXA2cXlacTcrRm9ndmFpVGVWK1c2bVozcW52S3QyelV6Z0xOK1Z3QkFNR1hHb2xtbXpHY1BxYVYrVUZ1T0hlQWMxREVvb2FSNlgzbVQrQWtoVHdUT05valkreC9ZVVg2UytFVy82bG56ZlRKZHgzNnFlTENRVDdUUElZc3MzZkxlc2ZTYlBBMytaQTJzWEFqMExDNjMwSmFJSVRwTlc2clZTQWN6UnhGdG5EdWRzanExMFA2QUFKSy8vWmpEdVV5dGhkK24yT3AxU1dHWnA5N3d3RGFFSmQvcDh2dEpjU1dnQWFuamU5dWpVUkNiZHhUYnRwa3A3QitoRUs5a1EzVGJRU3RRWXRWTW9WL2hwRmpDc05GVkZGVXhZcGY4dXdIMDN1cndyV010RlZZdDAyZ1orQ29nMVpkT0o5ckhpdVZMWnozQ2xEa21lbUttZXQ1b2xONWhGMnBVSE5ycFlxVTBVS2hCeHJWRUV4NjZXY0t5Q2xwanN1MHpnckh4bnV5dFVWL1V0STliTmNaaVlxQ25mcEdVbW50c3NtbTBLZWlKam1YRHh6VFVORElaakhRWFBMR0VjdFM1amxRZUcxMlR1OEQxT3pRMGRzMXBrdHRCT1V4UDJRMHRCWnNVdnRXSVZXYjZJV0d2RmMyS1Nob3pDR2dqZGV6czdFYlVSRHB2MS9LY1FxWWgrdGErZFBTc1JzYStPazB3a3RJM0N5T21YOU9CZUZVUEN5cEpXTVpNaGhqUUkydUpkVFM4Q29kMUZheTh1T3BzR0hKRndNRk5tR1FUdEhGRGl5WFZiYXF3UjY4Uno0MmVacDJ2SnA0ZUtrd0FaNVhwbzZYbWx4dmx6ampQNGN4eWNDdi9nWEIya0tSZjhyL0FUcDBIYXdUKzc1bTFIaHI0Qko2bElDTVdWVnFyN2RXSGlIRTNnMENUY3ErY0FvT2tEcjh3bmlMdDNkblNCR1VvZGtMNmNBMnNpd3d3K202c1VjNEtFbkUrQUo3SXJjRlBuVVR3S3BGaGsraGQyaGdTZ0xlcDMxYzVoT3VMQ2k1aHBwREpJZWI3VVEvdHI0LzhGcGpLVnA4a2hLaWUyc2NvRGMxTUVBeHhZQ2IwMDRZQWxBQjBSV2E0TUFFQWY3N25oa1JhdUQ3amxmcm5TS0wrOGVWRnBnSWJxcnVwL0ZWRGtOQmZtUy9FVkM5QkI0K2pYMnZIVVUrMXZwRGh4MGFUaWhTMGZ0S05MODZOdzQxTWxMa2E4WFBqYkc5TUZadzZzZzZidG1UeC9sMTRFOUtmYm5BT0E1MDJsc1ZqRDBQM0ZuSm1sOTRCc1dmTmczdkxkK2E4dkJSRTVZMFVIYUNRZmZEUmRRdThFbm92UVVoTDZQNlBaYktzV1g5Q2ozQ1lBdk9oK1ExamVEVVp1QlJOMzZqWERNbHpZd0d1TUVaVmxPYzcwWVl5cGhnTHpZRm1oalpybWF0TmNucTFOZTFpOFRJVkdnK0RCUThZZ0lsUStMZlh3Uk53aERKU3JCZW1zeldacE40Vm1pQTl0NU5tbEpiNS9OVjYwWElDLzFqUWpGaHBOMkZBbVgxZm1FSVJSRFlWSWVzUTZxVFhYS0M0d0QzakptaUtseHdvMzArWTBpMDRuL2xnSEdZbWZNeStxMnBDdVpQbStiWnR0V2FpMDNPVnUxRmwxcXJiTzVybEUrdUQ3TUZWZUZsN254VjZaUDREcm1PbHZlUGxjQ3VLZzk5RkhWVTJHeElQM2hJTlBKazZINlJDOHdUeFVXU2w0TXBYbWpwSU1nUHkrb0JqRnJuU3hoV2Y2NXM0aW9wVmRYQ3pmbU9mTkdrUzEwSXhXOUgrVVFmbGRCMFNlODhGVDdHL1dPSUw1SUxjcFRKNFdnQmJ6Vi92MFdiZ3F1cnNZZjVsdWdyTmtGSTRwWFZpTkZhMEVib3ZScFA5NkFlMy9JZzVVbWxKWENKekhLRkdSeGtRYUdNclBuMlpITzkwRHpCZG1UcTQ1Z1I2Y0trRmhpdlE5cmFMcDQ4QmpkSWhjYnlmQysxVjBHUVNhNlVvcFhZM3hsbEtyZUZ6aG96dFI4RUdqeUNCWXFwSm0zMU1abFZBaVQ4M2Fab2hMV1gvMkZQbDdZaGNFUko2QXRNc0FodzdKTVorMEZrbVRPZXFyQVZvYTB4eFh3c3JGNWRWb0Y4OTRITzRHR0hWVFNONkhVZGZhaGlqaHJzcktINjN5MnE3VHl3TDM0UE5qZkowUWlNRWY1RFZ6NzB6MWhFWThkell4VHhGa3hGQ0EwUFVnWU43UUhwUThnVGYzUkUrYmxPSXdIemR6Z2VINmhjTWJ0azNXcnJBaFhXbWF3SUtnd1dkTFlGbEdBQUFQQy9hZDNUUUFxYUdKWXVoZEZGaERNVTlST202dndFYkJVZHdrZ2JhSnFaTGdCNEpuV1lEZ09vVWxBRHQwellRZUhWZmErUmNORThrald6VCtFZy9kRUl3bEpIdG5IMzIxZXpYZCtFZFllRmhWK2dRMUp0cnN5RHhVdEp2Q0ZDdnFlVjF3dTR1UGd4VmVtUUhMWmwvVEpzZjJ6OU82ZHZKQ0VzK3VyWklTU1dWN3BhbWhkelFLQ3VqdXNUVWZvR3BNdDdkRkR5WjVGdmVaNEJ0TWdvTEIrTE85dDdKTU1KOHdUemFVWnlKUFM4ZG5XTm1HcERwb3diNnFaSzNqdHJWWW4xUVNtb1ZaSEtucS9KbGdZZDBXZy9hcExVUzM5czlYVGJneWVRZ3JlSnJFdTYvaTYrYXBXcWw0YUJtN2l1RWo0WWwxYW5LYXdYTTkwUU5BbEJJWDNNcnNiL3lnaHFScHN2ZDFSZ1dzN2lqU1JKRjlKa3czQm53YjAzeElJOTBLQTF6MWpkUFZ5UjZiQUYxeFMwNllweE9JTy9ZNVk0T2Fxekxnb3ROcDBwYjlLZkwzbEU3Y21BaTE0QU9QVkxvdGpLK1pURmxJZTdXc1pBRHBIV1FQd2tKYWJEQ0RCZWxzZXhMUlN4c0pOMmpQczlzcmZEaHhCWG5Ud0JyanlBTnZHald6WkVwcFVOTlkwWVltZkZhWXRYTmxvMzJCNmVuQVdVRlFrOGZVek0rd2lLM3hVQlVwdnlidkFWcnVZWWlST0w3RXdzT2RPS0V0SmJQSmE0dXN2SnpTc2pqOHdQd1JNeVdUbDk0UFdtdDBVclNsc3lSSVdDQ1lOL28vWUMyMTVwMGxkOXkzKzdUUlJkQ3NQdWdXMnNhOW5XUnIzZHk1b0hEWFRtTkl1Y0tWOEJpYnVpZ1RyaGY0cTZSUGdlMEpYOXRJSTlJeHp5Qk0vb0ZYVXhncXd3UnRCOGc1TFU2aWp0NmtnZ0IxamVJbUxKNGZ2TnVqYlJzeTFrZ3c0TjRZSmF3VWhHYlI2dWR3UWdCbGJzSVhXblBpdHBZMlkwTVM3Z2pMU2wxOHE5QlZiWFRBVE1MZzV4QWxzRkt4SkUxMTJQalloY2N2RVdJVjhhUkJBOEhnbnN0andiVEVxa1hweUQvNFY5ckRZeFpVT1c2UG9hbWFmYUZmcDNCMlhuWEMzWDFrYjYwVCtvQjJHczFTblc2dU1NbkZ0dlNTZUk5YXJOS0FHZ0NadEJXdXBZWTgxbDJ6dnJMeFhHR0VwUW1Zclg0L3htc212dnZSMDVXSG9FODFDYlVPTGt0ek9DRXQ4aS9wdXdEMlJsbGMxT3pvR3lzQ2xmVlNrMFhsTldzTVZSRGlJb3h3dnRmekl6eWFaRkdBY2NCSjdib2ZMZk93ZW8xekNKVG1EWTdUQVpac0J2MGZDaUhhZHIvMmt6d1NPMnJzWkphL3NJZzFWYkdsVCtFaHAwWGNaL0laam01NHBDa2F2dG9lZEdqU0Q0Rm5BRkFQWm9vMEVXcWV6THZwZzNNQzFvT05CbmlJVFJocnBrWFArOXhZQ0FmSVN1VjlsYTdYbkhkb09YYVRmK3FRODd0MHBlbU9yR1BDY2RpZTBSOHhDdVpOWEhCdWpDdUdYdllYNDZDY0pFN1Zhek1ZcDNjVzNwS0ltemRja0thTmd3bDVVQ2hVQnZLZ1VHcFVjdElwWmdOT211RnBaRjJhVVpHOTVTSGZaY0pXZENYVFIvM3hkRVhmUHBhS2RZa3EwSlczT2pGb3VNSEx3bm8vR29ZRkdUQUtCZWpSdmJmdTRvdUkvWkZ4OXQwazRrZ1BsWDRpQ0MvbTF5cTIwSlF3OE11UmFXOFVkTXF3b0swbGNuTW5CVjI1OXdNWXFaaDhtTGpNelRyYlJOaEVwRjVUNUplbzdEbndtRENOSjRRS2ZXbU9GSEE1QWFQTjF4T2RTMEY4WDMwRW9HWXVQRFlnTFVxekdyTW9ORUpsN0R5S1V2TXlocGpadnpOL3hvVEpmdXJZdlZjMlN0NDR2RUhGY2w5U2VieXZMVjFuKzlsQks0NlR3UnBFMTB4UVRwL3ZWRGlDMkh6QXRGZ25CTzQrMms0MGFWNUF0ZVVaUFRNOG53Z0FYa3lacEt6MWIzRDFHRlVOdEhOaXV2K3RYYkE3OGc1MmZMWkxaUERTcXFXdXcvV2tjRDRWRGNjRDZtQ29yVHlOV3huVDd1S3J1S3djZzBuRFRRTjQrSUppNFpHZzM4Y2w3NVVLVFdmRnMwNG51NVd1bDR0d0FSUnhvWUZlV01mZmkxaGhyTzB0US8xcjNIYnhaMU1jay9YUlNzK1NSKzZQMFk2NUpzTHFSSnk0QS9xbkhWOW5qSUJYSWVLZ3R1dncwQTdwYTZaUmE0SHdKVzJhOXVERGkwSkxxT1owS1VtMXgzODErTVYyN0hlRVpVYmxoaFJxaDRqc0NaUkJINndXbWtuUmRCeS9BWGRtZDc5YTVhaEgxem85cGxUVUh3WExSSjdxSTg1Ymd0S3Mvdm5lbzhtdWtSUk5HZzN3Mlk1aXBMRWxJS2pENzhCV1FsWUp6NFVXK0syajlvYm1YQWt2djYvU015NVN3SkFyQVQxOE5JcVBHUU9LVGZoQ1BvNmNsTTJkNXoweFVlRzI2MkZ0dHl1Y3Z1Z1RjTjZBQXdTSnBqV2l1a3J2c0RPS3dHUnFKQUgwbEhmMTY0ejhBVERmbjZDcVBVNnJBUjhXMHE5NUo2VTFlRkVaOHd6ZkpianBJdWQ0QnZ4eFpZQnpaWUdSTk1uS1V4eVZ3d0JyY1paUUhIZDNhREpQQmhjcHRPNHRKSUhCVmlRUDRzcHJZczFUN1VsM29wQkJ5UWZwT3dUclN0TnNtckpMZnZ3Y3RNWDR3Wmd3UGpKNGxkaVJoamt4c2sxTUtuekFoN2JOaXltTzB3citJMm5nZklxdFl5Zkw4YVJ1dzhaV0ljYUtjaVFMenlBa1k5Mkh2WEU3ZUdqUUNCV0FXZkI1bFZhSExGd0pWQkttaVlRUG1BY2NueFcvZG0vaTVzb0g1T1JodVBQREtML0tHUkdTckkyU0psTE1BYTUzdUhqOFZTZE1vNk1BVmU0MUw4V2RtVHJNVzJHajZqbVhoWHc1NzR6dWFLVTd3cmM3alhGbDFxczBnR3RTOGxrd0VqN2tZWE1qbEl0aGhqaUExT0VPSUt5bnlOOWVvRWVpUVV1a2hWQXVOR0MybERFTkNoRCtGOG1lMjQ0bW1peEE0K20yVjA3OG1McmwzNkNWdHRkT2U1RTAyRk1RUGx0WGpSL2oxWE1XcEZaZWRkUnd1bVplb2ZtMG5kbklsUTl3WlhuN2N4SWtUNlI1a1JvT1B1WmpjZ0dhZldXRnA2aG9pNzFoMGp0QWIwekNmc0xWdjB2akx0WHpYd2ZMVU1tSUdFK2tzUVRXbVc4dnB5clg4cnNqNXp0YWZDZk5QalJ6MkhiQXdndm45UWNxZ2dJQzF3b1lwUThrVlJ3MURuOGxLNkk0VlVyT21BZ1cxZDJsN3dvQnZGSjJDblVrWnUzMUN2eE9LMEw4SjRMRW1pZHdqcWZGOUs1MVN2UXU4cFJ3NWFFd3QzdnF6MmMyNFh5L1BBdklCYWlJU0pmUG5LSU9DeUE4RzRRN1c0eCtTeU5WQWpHSEp4M3d6dEpWL0k3NXUrRVFPaE5SbVp4K3dPRVVHdDl6QXBhbk9mNklQQTdyV2crRkRBYmUxTEpiWDBpcTRqSVRuQ2ZqWjVHaFYyNzRLUWdtYnZIVUl5dnkvalRONnJUcHFha3NjQXMvNDNRcTVWUUs5dTBwU0pVNzMzbWVzeVFOVW9OWmZmWjRTMHZkRnV1ZmdTWVIrME02bGR6MUNEaHl3N1NkK3VIOEVSdnhjaE9lTStNdkxnSDNoZXFZeFlkK1dxaFcydTNXWEZybW1WYTh6a0lHbGFDWjVPZzJxZDh1bkRZMWY0NUhINkd0ZW9LbnlGYytVaG9xR3dVbWQ1dUFOZ3RTSkN2QU5XSXZKQWgyWHBxdlhWeTFNbkUvYWNMdXJCcFlhUmVnUE4xQm5VQnJ3VEMvcW8yZUlNK0h4UWRNNHllYTB6bFBheWZxdXhBa1FhMjBrR2pmbjBUSDBDQ3dUc3RqZTlaUkE5Rzc2UzlaN3dRa2R5UGVNM3BITzYxNDd6Z1FWL3dxVENNeWVOTmxIdFlvVFFBSDB4aG1xMWY0NU4vMGpzcnBtZkVCRVJKbk5uWnRFa0xYTWhuZ0lmN2gzNzR1aU5RamFqT2VlVWRjaEkxZ2JDVkZPa3pQNzNjMTFJeCtVanRWZFYxcHJJZlVab3F2bVUvU0xPZEZDRWF5TnVEV1Z4MWhkSU5LNmhCNVduUDh3NlJoQXA0T3FXd3hyVWtKemZTUU9nanAxNHhmSnZqb1Z1ZXV6bno4TFhDOXhFZEZwWGJNSC9mZkxESk4xN2k4STJteXhEamZ4ZlNHOHdLdDVYdlpubkljZmEyUW8ybG5ja2pmY3k4eWdsb0tDeWhjeXdHQ2hJM0J1TkZZNzlydDV6UXpnU2dHb1B3SnJRWHR4bzFlaWtrTjlTdVlyQUt3VGhYZU0zcEhlZEw2b0VySXFqSlJnRVNrSGdocjVzenhDR09adHVkZUZVeGhNam1nblR4cnpLVlI1WHVaSFFOVkI1N3p6RFBqSk9ya3l3TTJGbCsyVnBzZ2Q0V3RlRmE2MFp1c0IzOWlwVjFFeW9iM1V6dG1lUUN1bE9aeEhJK0hJZllpcmRTV2tqeEZqcndZbnBldFJnWVpnNUt1VVZ3d3FtVVgxZlloazdZWjBrRzY1UDhySWt3MUxQNjY0SkZtUm5hT3hMdEJnMUpkQ1BqME1Lelo4MEVKd01lR3kwRjRpeEFLQUJKREFjWkRiY3hCUjhBN0FOZnFFN0FKZ0FsZm5KMHhjNjhkUkx1Z3RRcW16WTVFQTFaeUFaM0ZlVVlPUTJlSDhGeEZaMWRzdjVuNjRlSGp1MmgrS1RTTFdsN01qNVZZaCt0QldxZzZKUzA1UWdrVDZaRHVxWTZPU2svTDRwSGhZalFZbEk2RFR0V1cxbXA3Y3Y2Qm1hS0JnNG5DaVQ0a3BjUk94V0E4YnVLaGltSkg4QnZ6SStHQVZaaHRXdkQ4UGcwUm15WnhOOTd6V1pyb0RYTW03ckpiMWc5NE0reDVBOXlDRVd2ejFVZFFxRFNBamVPSFo5a001THhwd0VNVkhoS2JtQjVGRENrSlRacnFJQnM5aXlNUjNSV2VKYTRpbURHam5WVUJEc0VxWWJvcS83eTIvb2dGaDhwWEVUbXNRNGFoZXFSaGhyaVRpd0dVWXlJU1B5K2tFb0lVWnpibUw5NzZpYkN6aHFFM0ZqY3llRmRoS1EvclAwaU1HaW5GaEZpNkp2YWQ4eUdMRFZ6MWlhUDJacnVZSy9JNmxzSlU1YTJlUFVUR0F4Lzh4akh1UW9BNEkyNlpiY1NtVmcybWFBRkRQUWVSUm5ETkdBWEFOWnhPUE5iU0crY1R0WkpWYk9TdHRpbFZWQzBqNFRKM3ZuSlhCUjhHUnNGKzZ5enJ6ZWV6ZGxtZHhyL3Q2VkY4RG1OWUczbG1BTlVKZDltSVI1L1ZWc3M0NzlqL280WUlRZUxQR1U0VmFhZEZKTUF1K2F2SlA2Nm5XWmlRcm1KR3lIUkcvRHNWdjBGM01WVVYycGhHclR0V0hiZWh5K214SVZBV3hIamEvMW5qZGFRMytjcG91WWpCcjFGSEljMUdKZStxblYwZCs4N2NMdExobjJuUjBxRkprTkpVQ0xSNzJDS1NnQmgxVnFHQnRvS0R2eWc3ZCtoeWtYM2wxUzR5RDNOclIzOHhvSlVHbHM4aWJOUkEwcUZReEpQYTgyZmNFUkFQUlJTQ0pTMG9DZXFsaWk1akN3SlE1N2JrZ2FmR0I4QmVnK3Jsc1VhdllKNGJDOWtTM1FISlRoT2hMdDF6VHIraFRVdVhuUU9Sek44eGpqVXRSUm90ZitmV3ZUeVRIckVXa1hNdEVaN0phSEpPWFlSa3RTczNWMUNwWWVsa3UxZkVSeURSSWtLTHptamswMCtHR2dtUVRnZGkxNE9MdEZIZVpwNFYzZEY0UzBFcVhxNStFOVl2cXpZZ1RyTGNqMGtiaFRueld3dmU0M21Pa3JEUnMzT3ludkRCM3JWelptelVnV29jQUFyQ29CaFZMR2J2MDdyczJKWkVuY1czc3pnUlZYVkt5TytUWnU0TjNzTW1VSkhSNVRzdWhFZWsrRENzZjl4OExwRmxuc1BIZUtBMzNyQlJ3MmxQUm1zSHgxaHd6NWtWd3VVMnIxazVOUU90QkxaUlpCUVE1NExDeEQ5YXpDYXNORjhiOXVzRGJ2MVdlVHZDVkdpZTByUnhsUDVTZTJZVDVqenpwa1RMWTN4QTFSdnVzbWJla3FIQk9OR1o0SHdkKysrOXhGWnhlMGliaUcwMjg1RVpjbXZWUzNINFpKSk5VdEpXWTV2akJYZFNKUWo4YTVBVnpYNWo1S2RvSnpCVkhpZFROQTBlbEk0SzNzQkFvd2FKKzBKSWcrWFJCQ2ZTRUtZNnVwbnJtQWpubm9hZkhKYzJOMlZOQWx2ZzBEMHdyekQ4ajRPN2d3ZTArT0dxcDFmeVNwbVU3eCtiQU9nM0JXaTNBcXlGRzlYbkc0QXRxSVJIQklTbnJSY2pMTlI1ZWZvR1NRNWsxYlJBT1YxenJxRTNFM2s2c3pSR1BrOEdEN1dCS1h0WURLZ0tyTmtGcklNcklGUHN5Q3RlNFNNb1hxbWpTaURkaEhsS3IzcGJuNllyRTFRMWlSZldEOXJKZVZ1WmFKajNTemlvcFdIKzFXL0Z3dlBBUitiQSttaFBkNVprSEUyUUVNZElFL2pBTUtHT3FKVkFyRVhBUzBqdXBNSXZBQUl0RlJmSFVQNDF1bEJNNWF6L0lBaEZSdFJtbFpLRzN3cXhTb0FPRXFDS3NHL2IvTnVUNTBOT015VjVrTWxVU2N2anA2YUZ5ZmZGYmRZYVpDcCtvR2kwZ2IrYXl3WDJSVzh0WU5EUy9mK0hhSHFRZ2toWi8wRW1DdStKOCtkZ1l1V201L1RER25kM3BEeHRjK3RSeEZTYU9jdW1qQVRKWi9PeGdHcUdrYjBSREJwR0V1dnQ0WFFpdFRjMmxEUHZmSjI2Ynpmb2poZjNSbEZYSjNKY0hiVW5JdE5wa0V2aFNlVk1Lc2hDaGdKRWd2amtiV1dDSHpKcXBGVGdLVncrbFpHY242a3c5UWFWd29ScFcvb3hWaUoxNThva3RySkxyNTdHSHhyNDFmaUJydXRjdldNelhRa1A5dEZNMEVTU3V6OHNnUzBFeUc4UkQwa0FhNnBVYkJYeVNpQmVTdFVRd1pQZmZVcXR4NFVHN3ExOVJhZlRRTjRkbHBjV3pyOHRCTy9YNFhZWlhGVjRRTjk0WEE4SnlOeVpDMHBpSWdHOEczZzU4K0E2cEtZRS9nTk8zb202SEd4VDdRQUFBQUJKUlU1RXJrSmdnZz09IiB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwtMSwwLDEpIiBoZWlnaHQ9IjEiIHdpZHRoPSIxIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIi8+CiAgICA8L2c+CiAgIDwvZz4KICA8L2c+CiAgPHBhdGggaWQ9InBhdGg1NTIzIiBkPSJtMzEwLjE4IDIwNC41M2gtOS4xOGMtMjEuNzM5IDAtMzkuNDI0IDE3LjY4NS0zOS40MjQgMzkuNDIydjUyMy4wN2MtMjcuMzQgMTUuNjQ0LTQ0LjUwOSA0NC43NzUtNDQuNTA5IDc2LjgyNiAwIDQ4LjgxMSAzOS43MTEgODguNTIyIDg4LjUyMyA4OC41MjIgNDguODExIDAgODguNTIxLTM5LjcxMiA4OC41MjEtODguNTIyIDAtMzIuMDUyLTE3LjE2OC02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4NS0zOS40MjItMzkuNDIyLTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTI0IDEwLjc2NSAyMy45MjQgMjMuOTIydjUzMi42NWMyNi4xNiAxMS4xMDYgNDQuNTA5IDM3LjAzMSA0NC41MDkgNjcuMjQyIDAgNDAuMzMtMzIuNjk0IDczLjAyMi03My4wMjIgNzMuMDIyLTQwLjMzIDAtNzMuMDIzLTMyLjY5Mi03My4wMjMtNzMuMDIyIDAtMzAuMjExIDE4LjM0OS01Ni4xMzYgNDQuNTA5LTY3LjI0MnYtNTMyLjY1YzAtMTMuMTU4IDEwLjc2NS0yMy45MjIgMjMuOTI0LTIzLjkyMmg5LjE4IiBmaWxsPSIjMTAwZjBkIi8+CiAgPGcgaWQ9Imc1NTI1IiB0cmFuc2Zvcm09Im1hdHJpeCgwLjEyNSAwIDAgLTAuMTI1IC0yNjguOTggOTUxLjcpIj4KICAgPGcgaWQ9Imc1NTI3IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1NTI5LTcpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTU0MyIgZD0ibTQ2MzMuMyA1OTQxLjloLTczLjQ0Yy0xNTQuMzcgMC0yNzkuOTYtMTI1LjU5LTI3OS45Ni0yNzkuOTZ2LTQyMDUuNWMtMjE4LjExLTExNi4yMi0zNTYuMDctMzQzLjIyLTM1Ni4wNy01OTMuNyAwLTM3MC45NiAzMDEuNzktNjcyLjc1IDY3Mi43NS02NzIuNzUgMzcwLjk1IDAgNjcyLjc0IDMwMS43OSA2NzIuNzQgNjcyLjc1IDAgMjUwLjQ4LTEzNy45NSA0NzcuNDgtMzU2LjA3IDU5My43djQyMDUuNWMwIDE1NC4zNy0xMjUuNTkgMjc5Ljk2LTI3OS45NSAyNzkuOTZtMC04OC41OGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYzIwOS4yOC04OC44NSAzNTYuMDctMjk2LjI1IDM1Ni4wNy01MzcuOTQgMC0zMjIuNjMtMjYxLjU1LTU4NC4xOC01ODQuMTgtNTg0LjE4LTMyMi42NCAwLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOCAwIDI0MS42OSAxNDYuNzggNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyIDE5MS4zOCAxOTEuMzkgMTkxLjM4aDczLjQ0IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTUzMykiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU1NDUiIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU1NDciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU1NDktNikiPgogICAgPHBhdGggaWQ9InBhdGg1NTYxIiBkPSJtNDQxOC41IDQ1MDF2MTE4OC42YzAgMzAuNDYgMjQuNjkgNTUuMTYgNTUuMTYgNTUuMTYgMzAuNDYgMCA1NS4xNi0yNC43IDU1LjE2LTU1LjE2di0xMTI4LjRjLTQzLjMzLTcuMTQtODEuODktMjguOTYtMTEwLjMyLTYwLjE0IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTU1MykiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU1NjMiIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU1NjUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU1NjctNykiPgogICAgPHBhdGggaWQ9InBhdGg1NTc3IiBkPSJtNDQ3My43IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnYyOTE0LjFjMjguNDMgMzEuMTggNjYuOTkgNTMgMTEwLjMyIDYwLjE0di0yOTc0LjJjMC0zMC40Ny0yNC43LTU1LjE2LTU1LjE2LTU1LjE2IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTU3MSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzYxMTciIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzYxMTkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYxMjEtMykiPgogICAgPGcgaWQ9Imc2MTI1IiB0cmFuc2Zvcm09Im1hdHJpeCg5MTUuMiwwLDAsMjUzLjIsNDEzOS45LDYyLjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNjEyNyIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFYMEFBQUJwQ0FZQUFBRFdTZk5vQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBSUFCSlJFRlVlSnp0blh2d1pWbFYzNy83M1BsWGNZYUJZUmdITWp3RVpCeFVZa0lrRXNnWUpKQ0NDQVpJYXBKTVVMUWdFZ0ppRWFPeEtLTXhGZ2tsWlJKS3NUREdKQll4OFZWQ0JKSm9JR3BDb0ZRRU04K2VtVzU2K2pIZDA5UE1qTk5POHpzN2YreTc5MWw3N2JYMjR6enU3L2ZyUHF2cVY3OTc5bDVydis2OW43WE9PdnVjYTZ5MUZsdTU4Z2xmZ1NYbHpydVB3QmdEQURDbWd6RUd4aGgwMi8rbUc4cThoTEx0YTJjYkgzZEUvMUtYa3krOXdiM1lUam1hdXJIa05UT2t4eDJyeXRreDNXTGJ1VExlVjZYTkpTR1phVnVyVEZ5eXFTbnI4L1ZSZnpuZDZQVmdZNW5PVjN6azg4Tmh6eHYwTnVuQWU2Rk0wcFBMK2dvZG16OW1ZN1hnK25xZnR0ZmI1dTJBMnBHcW5yVHhEVi8vQWl3cDU4NC9IRjZidm85SGY5VlhmZVdpbmQ5MTVCN1g4UXI5eVhMeXBUZEVvRlFkQUJBRE5RUDJCTXJTMG5KSG9DMS9hN25VdjZwWXA3YTRWQTVYQlh1dWpWcm90MENlNi9QMlJvQStNbCtoM3dUOXBXRVBBQTgrOUtYb09FQ2Z2Z0ZYWC9tRXhRZHk5ejMzcmRDZlViSU9BSmpuTEVDekYreFV2Vng1cVk2cTFUcUhCU1VMOGtoeFpIME40QVc5TE9TNWZnSjlIZlJmK2RFdmtEcDUwQ3YwNjZIL2dwdHVTc1k4cDV3NWR6NDY5cHcwWDk3YmkwYm9GK0pKVDd4eTBRRUJ3SkY3ajY3UVgwQzRBd0FhemdLazR6a2RnYVpiVXpkRmR3bXA5VHM1dlZ4ZEJlQ0JSc2p6WTJhYkEzMnN0MEpmYTdzRS9hVmhEd0FQbkQwSFlHQWtGZlBsdlQycnZsRUFucnd3L08rNTc5Z0svUVdGWHdNQUNtY0JURmM4Rm1CZTdRZ1UreXE3cWJwelNzdUpSaXZZTXpaRndFdTJ0ZEg4VnZjSi8vV1B0M1g2d0Zmb2o0UCtUViszUFBCUG56Mm5uMlIzSGN6Rml4ZkRDSlAzZnp1UmE2NithcUhoRFhMMDJQRVYrZ3VMNUFDQUdad0FVTzhJY20xazJoclZ6bExTQW53TjZvVzIxTlJSS1lxWHlnclJ2SWQ4T29ZVitscWZyZERmQmV3QjROU1pCeU4rQXNMWDkzRUNmVUIvTTNjQi9tUEhUNnpRMzZITTVnUzBzZ3k4WjkzRjArSWtwa29KNGx3S0RxSWE3bHBiWWxrRzhsc2JEZlN4M1FwOXJjOFc2TjkwNDQzSjJPYVdVMmNlVk9zU0ovRDR4WXMyOStZQ3crSTg1Y2xYVHg5ZGhSdy9jU3E4WHFHL3ZQZ3YzK21YUGNNVnREb0J3YVpZWGdCMTg0WGFYWDBFR29lVnZkaWJjeUFqQWUvNlRPMXFJSisyczBKZjY3TUcrcnVBUFFDY1BIMEdnSnkvNTJLTWdmblR4eDhYMzcxMFFRYTVkb2Z3WDZHL3ZFaGZQcURCQ1FDeUl4QnNpK1ZlUmtUdlMrL29xZDZ0NDJWa2FrZXRxd1E4QUh6VngyNGJpaFFRbDJTRnZ0NW5DZnE3QXY0SkQzeWhUbk1DRWZSem9PY0w5dFJybmpSdWxJMXkvNmtIVnVndkxCcjB1UVFuQUlpZnNpWm5vTFRSVk05bHlUVFBHRzZXbGxXclY1eUwrRGJaR1BDaTNRcDkrWGdCNkgvZGpjOVB4ckdFM0gvcWdhUXNTb3Z6T3VJQXpJVUxGK0tjUG4xZFdLVHJycjJtY2FqajVlVHBNeXYwRjVKYTZFc3l5aEdFeWtLL1k5N1dKVDRLWTVhbnhxWUY3dHMyci96NDdZSitYWHEyVlZibzYzMUswTjhWOEduNjJ3dVA2ck1PNE1LRkMyRnU0c0lKYjJKMEttTXRyci91MnZvUlR4Qi9zV0tGL3J3eUJmcWFhS2toS3NXM2JVcTZacTZQeE5naEZGSkIyU1hmMWwyMUJYenhoR0dGL3I1Qy84Ym5mMjNTOTFMeXhmdFBodGY4QW0wb0x6Z0E4eWVQUFphc1ZnM291ZTdUcnIrdWN0alR4ZTlEWGFFL2p5d0JmVTFLWndaY0pyMjFVM1A4clRsOGF0cVEycmxLaU40VlZhV3ZGZnI3QmYxZEFmL29zZVBoZFpTcUNjOHlxM2NBQWZwalFFL0wvQnQ0dzlPdnI1ekdkUEczR2EvUW55YTdoSDVKSXFjQVRJN1l4M3cwSmk4SHMzL2lmN3RUVnF1RThRcjlnd2Y5NXovdmVVbC9TNG0vZ2RVTDM5TE95MHNPd0R6NjZLTnhUbjhFNkhuNU0vN00wMnJtTW91Y09YZCtoZjVFT1VqUWJ3SFVBemMvcTc3aG1vOUk1VEpvRUcrVkZmcUhEL3E3aEwxL1Bwa1gvM3FxQXpDUFB2cW9uUVAwL3JWL001NzFqQnVxSmphWDhDZkpyVkl2aHhYNlZlMU50Vjl3YlZib0h4N29mKzF6bjVPMHY2VFFweEY3R2VNQUpCdnp5Q09QV0FuMHdMQkl0YUQzWlZUbmE1NzF6SW9wemljci9OdGxoWDdHZm9WK1U5MmxDUDFkQS8rT3UrNEdnUFRwQklJRG9QV1NBeENqLzRjZmZ0aEtvQWVHTjBVOUU3RHhtUUQvRDdpRmZlNXp2cVptcnJQS0N2OTZXYUdmc1YraDMxUjNLVUgvZWMvWkxleHZ1LzBPQUhxVVBzWUJpTkgvbDg2ZmR4ZHlNMUg5R05EVEJiUjI5L2t3WUFWL3JhelF6OWl2MEcrcXV4U2d2MnZZQThBZjMzWjd0T25Bd0loUnVuOHRPWURhOUk4NS85QkRGc2hIOWJuMFRkRHgrWDk0RzBTMnU5N2lSR1dGZjE1VzZHZnNWK2czMVIxbTZPOUhSdUtQUHYrRjdXUGxZNGpYT0lEMGYyWDBmKzdjdVNqU255dXFIK3FaazRERkxoODFTbVdGdnl3cjlEUDJLL1NiNmc0ajlQY0Q5Z0R3dVQvNkhHQTZHTEFvdm9zaDdybnQ5VnJTUCtLRjNBY2ZmSkJCZjF4VVh3UDZvWjNkL1Q2a0pDdjhZMW1objdGZm9kOVVkOWlndngvQS8vMC8rRU4wQWV6YlFnOXBEQkg2Mk9pL0NQK3paODl1b1Y4SCs5cW9QdWlGc3dWMzJQYzJPWU40NFRkK283ZzRTOHNLZnljcjlEUDJLL1NiNmc0TDlKL3o3SVo3UEdhU3ozejJzeUtVdTg2d256TnRpLzZiVXo5bnpqd1FWb0xDdmphRjQzUDFFZXlGcUY1ektyMjNCL0RudituUGlvdTF0Rnp1OEYraG43RmZvZDlVZDlDaHZ4K3dCNEJQLzkvUGhOY084aW1VcGVoZmc3KzNhVTM5R05NNTZOZkFYcm93cTZWd3BLamV0MjJ0RGFCM2RxNU5heTFzYi9ITmYrRkYrc290TEpjci9GZm9aK3hYNkRmVkhWVG83L3ArSVMrLyszdi9HNlpqME4zV0RaQTNTVVFlUmY4VFVqOFMvTTNwMDZlaW5ENlF3cjQyWDU5TDRWRFllMnNLZXkvOXR1eGJYdnhpWlJtWGw4c04vaXYwTS9ZcjlKdnFEaHIwOXd2MkFQQ3AzL2tkR0dQUWVXaEhrTytpSjRQNDZMOGw5VFBjZ0VVY1NnMzhUNTA2T2FSM0prVDJBZWdFOWxJS0o2cmZsbnZRZXlWcSs1ZGU4aTNTZXU1RUxoZjRyOURQMksvUWI2bzdDTkIvOWpPZndkVjNLdi96azUrS0FXNFF3UjlBaVA3M0EvN20xS21UZGduWWF5a2MzeWFRaHoySXpjdGUrbEo1ZFhja2x6cjhWK2huN0Zmb045WHRKL1QzRy9hLzlkdS9EV0NJNVAzcjhMOEFmd0JSNm1jTS9HdHkvdWJFaWVQa1F1NzRuSDB1c2xkaEh4d012YVlRdDJmN1BudzQvc3EzZnF1MDFqdVRTeFgrSy9RejlpdjBtK3IyQS9xN2ZyZ2psNDkvNGhOYm1MT0ltc0NjbHUwMy9NMkpFOGZ0bE4wNGM4S2U1djg5N0tPeTdkOWZmY1VyOHUvQ0R1UlNjZ0FyOURQMksvU2I2bllKL2YyR1BRRDg1c2MrQm9CQzNneDN2bmJkSlBnM3AzMHF0M3FhKys4L2JxWG92bVhyNVpTY3ZSYlpTN0QzWS9UbHIzclZLek52eDI3a1VvRC9DdjJNL1FyOXBycWxvZi9NRzU2dWptbVg4cEdQZkpSQk5kNkpzeXY0bDdaNml2bis0OGVQMmJuejloVDJYbjlPMkhzbllxM0ZhMTc5NnVJYnRBczV6UEJmb1oreFg2SGZWTGNVOUE4SzdIL3QxMzg5Z201bnVpcjRhMm1mWE01ZjIrMlQyK2Rmay9JeFh6eDIxTTZWeXBHMlh1WXUwUEtjUFFWOXBMOTFTaFQyWFArMTMvN3R4VGRzVjNMWUhNQUsvWXo5Q3YybXVybWh2OHRmNGN2SkwvL0tyd0NJWVY2Q1AzY0FIdjV4ZWV3TVBOazUvR3Z5L2RvZS95VHFQM2IwUGd1MFIvZityWkdpKzF3cUo0N2VoK2krQnZaMEhJUFRzSVBUZ01YcnYrTTdLdDdDM2NoaGdmOEsvWXo5Q3YybXVqbWdmMUJBRHdBZi9xVmZjbUNGaDNZSzh3SHFCT2hkcXVPUEtmeW5wbnlxb241K2M5ZXhvL2RaQ2ZpbG02dm1pTzV6cVJ3UGUxN1A5WHByWVpIVy9jMDN2S0gyZmQySkhHUUhzRUkvWTc5Q3Y2bHVDdlJ2ZVByMURhTmJYbjd4d3g5T29jM2dMMGYwY2RSUDYycFRQalZSLzloY3Z6bDYzNUd3OGxvNloycnVma3AwTDZaekdPeHBIYXpGM3Q1ZyszZis5aTMxNy9LTzVLQTVnQlg2R2ZzVitrMTFyZEEvYUtBSGdGLzQ5LzhCeGhoc050c29tNmQwUUMvU3B1bWVVc3BuYXRSZnl2VVhkL2g0NkplQVgwem4yUGlzSU5LdHlOM1hSUGM4bFJPMXNZVzladnYzYnYyN2pXLzk4bkpRNEw5Q1AyTy9RcitwcmdiNlQzL2FWNDhhejlMeWIzLyszNG1wbTgybWk4QWY2b1dvbjl1MlJQMVNmbjVzdWljSGZuUGZ2UTc2SmVCbnQySTJwSE9TeUJ4MUYycWxWSTdYNGRFOXI2ZDl2L2s3MzlUNldkaUo3S2NEV0tHZnNWK2gzMVNuOWZXMDY2OGJOWVpkeU05KzZPZElkSzVIN2h6K3RWRi9LZGZma3U2aFp4RnhuL1Y1Zm5QZnZVZHNEZkFwb0FFZCtMbXRtQzNwbkFqOFd1NStxeU1CbjhKZUdzOWJ2dWU3UjM5SWxwWmRPNEFWK2huN0ZmcE5kYlN2Z3d4NkFQanBuL21nQUZxVGdKL1dTK0FIOUZ4L2Jib25janBDdXFjMXo2ODl3YlByRE13OVIrNjJCeEg0OFJtRkRId1ArNmhQQnZ6NGpNS1BwdytPNUcxLy82MWpQek03a1YwNGdCWDZHZnNWK2sxMTExOTM3YWkrZGlVLzlhLy9UWUF6c0FWaEFsb1N2UXNBcCtDUGRETGc5M3E3QnI4VThac2pkOSs1emVsUFMra3NBZnhTL2w0OEt4Q0FIMTlQR0lEdjV0dWp0ejNlK2ZhM2ovNGc3VXFXY2dBcjlEUDJLL1NMZFFjZDlBRHd2dmUvSDUzcDBKRjk4Z09nWmZBSG5RejRBUWJ1RWJ0N3hvSy9KZFVUNWZpUDNIMm41Y0FINGloL2FnNy9JQUNmUG1xQ0F6KzAwMXU4Ni92ZU9mcUR0V3VaeXdtczBNL1lyOUFYNWF1ZitwUlJiZTVhL3NXL2ZCOUF3T2NCNmVHZkF6OUFkdGpNQVA2V2lELzNDQWNPL3RhTHUrYnV1KzRJa1g1dUgzN053OUkwNEh0OVlMNlV6aExBMzdOQTc3OGcxdUlIM3YzOUV6NXV1NVVwRG1DRmZzWitoVDZBd3dONUx6LytFKzhGakVIWGRkZ1lST0FIRUVYOWM0Qy9KZFZUbStPdnZZTlhTdk00SFJuODV1Njc3ckFsNEFQNXRJNmNScEZ5NmpMd0kvM01SZHVXSFA1VTRGdFk5TnYrL3NrUC9zRFV6K0RPcGNVSnJORFAyRi9HMEwvdTJtdEcyZTJuL05NZiszRUh3YzMyOFFNSytGc2kvdHBVVDVMS0tZQi96SGJPMm4zOFVyUWZiTzY2ODNZN05xM1Rrc2R2MlljdkFSOG83OUtoNDNEamR0QXZBUi9BQUgwR2ZHdXRXNU8reDN0KytJZW1mU0wzVVhKT1lJVit4djR5Z3Y1aGhEd0F2T2RIZnRTQkVmRnVsUnJ3ZDJUM1RnbjhuV0ZRejRDZlExK3lLYVY1NG5ITm1PYWgwTmVBRDZBNnlwK1MxbW5KNDFQZyszNmtLRjhEZmhnYmpmSXp3SGRyTWJUell6L3lubW1mMUgwVzZnUlc2R2ZzTDJIb0gxYkllL25CSDM0UGk5Umo4QWZvQTBYd1UrZzc5VHo0cFhSTkNmeHpwSGxvNm9icTFrVDd3QmI4ZDl4K1cvakU3Q0xLMTlJelM2UjFvdXNEcGJRT0FYN1VEeEROcmU4SCs3N3Y4UlAvN0VlbmZYSVBpSnc1ZDM2L2h3QmdoYjZvVjZxdmdQNWhCN3lYZC8vakh3cVFObHVRQXl3aWhnQitCbjFRblFYeSt4VDZBSnJTUEVtYWFPNW8zME9mQWg5QUZmUWw0THU2dWx3K0JiN3JLdzk5RGZoUm54VlJmbXRhaDdaQisrcjczdDNKdkxWNzMzdi8rZFRQOUlHUy9YQUVLL1FGdlZJOUcrTlRyM25TeUJFZFhIbm45LytqQVBDdU13bjRKZWhqKzc4MnplUGJBRkNWNXBrcjJ1OG93Q3VqL1NtNS9TdWtCUllmazF3cEhQaTFvajB0czZsUGR2RzJKRnFVWDlNWEJ6NEF2T05kNzBiZjkzai8rOTViUGZhRExGZGYrWVNrN0tDY0Vhemk1Tm9uWDczZlExaE0zdmFPZDZFajBid21mZCtqNnpyMEFMcStCN29Pc0RhQU9DZldXdlRvZ1I1REc1VjJYR3I2azJ4NzlGR0VYbWZiUitDdmxiNjNNdlRUbmpBSzVJQ2MycWszanFQODFuNTVoRjdYWlJybDE5cjFXLzN2L1lmZkY5SkFIL2lwbjJ3YTkwRVh5UkVBcXpOWVdpNWx1SE41Nno5NFJ4YjA3aHBrSDZWMmFxWGZPb1ZOYjJHN2NhQ3VkUXpCRmhhOUJib2VRRU9mdEcvdjBFekJBVXEyUUErUWFMOE8ra2xESXdCT0JyRWZ0dnhoYnEzOWVvaTN5bHUrOSszWTI5cCs4QVAvcXRuK3NJam1ESURWSWRUS1V5NGpzSFA1N3JlK0RadHV1Tm1KU29qazkvcjQ0bXlsQkRzTHNmMnNyYldqWUFzTWpxSVY4clBaOXpiYUR1cGxGUFQ1b1BiRGR0S09rMzUveHYyZDMvTlcyTjdpUXgvOHdPZzJEcVBrSElLWHk4RXhYSFAxVmZzOWhBTWxiM3J6VzJDNmZQcW10N1lwcXFZeUhicmpVaWkrNzdIOVRyWHZiVDVkTkFuNmw2dTBwSXNrZWRPYjN4TE9ISDcrUXo4ejA2Z090OVE0Qmk0UG5EMjN3RWpLc3NKN3ZOeHk2M2RoUSs0eVhXWDNNZ242eHBoRmR6Y2NWT202YmpMNHZkeHk2M2VGOU5Fdi9zTFB6ZExtNVNKUGV1S1YrejJFVlNya2piZmNHbTJ2M0pXc2prV1cwZENmQ3Z3cDlzWVlkQmgzVTVFeEJ0WVpqKzU3N0xqZDdvRGgvZ0V1Yjd6bFZ2UjdlN0RXWXEvdjhjdi82VCtPNm1lVlZmWmIvc1liYjlsdWw5eE1BbjdON3AyY1RBRy8zN0k1dHQ5cGZZKzNMZTBFR2dWOVk3cnFiWkd4M1RDUlZuQk9zZTI2RG01WFZ2dkYyTTRZZCtWOEc5M1gyZ2Q5QUp1dUN4ZHphOFFZZzAzWDRiV3YvMXVBN2JIWFcvemFmL2x3MDdoWFdXWFg4cHJYdlFHYnpnQUtkUHpuZW93VG9QdnRPK0hpWkU3NFB2MVc0VGRvTmRtU0c3UkcyVTV3SHZUaGExVHFvRzhRQnQ0TTY4NjRQYkRvd2lNZHF1eU1jY0RjZE5FZHVYVzJIZEQxNk56MjIzcFFiN3Bocjc2MVFOZFYzVURqN1p4ejZhdjc5RitDUGI4N29lL1JiVGJvOS9ZUzNWZS85dlVocGZRYnYvcWZxK2F6eWlwTHlxdGU4N3JvNWlnUC9BRDJ6U1pyYnpwcUd6K3RzaVRTelZtMUlqMTFzMWI0elZtMUl2Mldib3Z3SjI2MkNIOFVRd0o5WXd6UXVlMCtuZW1hb21NK29HcTd6amhBVzhDUGJsU2ZYWWV1WW0rOVQvRnNlZ2YydnU5aHJOdFBteE9heTNjUlE0Kyt6OXNGNTBYaGp0NDVRY0doK0M5THY3ZTN0UUUyWFkrOWZ1ai9WYTk1WFhRUHdXLyt4cTlteDczS0tuUEpLLzdhWDQ4QUZBRmZrRmlIMkdTaWRYcDNiSGhkc1ZWVGV3eERTYlRITU5RSWpjYnA4L1ZiN09odjZOYmJ0dCtqQUd6dnlPMDZnNzYzYmprYjBqYXUwOTYzVkFWYjcwUzRsQUR2by8ya1BlVHorc0dab0Q3RkkwWDdYU1o2ZCtzblIvdlNCVi8vb2FmZzU5RSsxZFhBVDlmODVhOTh0UnR1MzZQdmUvejNqMzlVbmQ4cXE3VEl6UzkvNVJiU0Rvd2MrSkd3S0o4RG40c1U1ZlBISWFRMitpTVlOTWs5Z2lFbnVaOVJ6SW4yQ0lhaVhlYVh0RXI5U1k5ZzBLU1kzZ25SUG14MWlvZm4zMzJLSjJjblJmdkE5cmtqaWwyNG9Cc04yRU5hZG1EVThkUkUreVpxTTQzMkthUno0S2ZSZnJETmdOOUgrelhneC9ZTHRiM2hEemUvL0pYaHNSUjdmWTlQL3RZbjFIVmZaUlVxTDd2NTI2STBRZzc0WGtkSzYwaHBGeHJsYThDbndxTjhEbnd1cFlldFNaSjcySm9tSE1xMVViNWtWL1A0aGR4emQ4cjlwYy9VdjhJZjBHaC9tNFJYZDVrTWpRclJQblM0TzBpM1Jmczh0MSt5Y2VtcEFmdzB0eTlGMGhMNGFiUWZidkRJZ0orbmVjSUhPQU4rYTIwVitBRmtVejNvT215d2ZYaVhmM1lJaHR2TnIrZzZ2T3ptYjNOOWJ2djlYNS84SCtKYXIzSjV5Vjk4eVY4TzhEVGRGdHBSQ21iNG9aRnNTcWNpd3E4RnZwYldvY0QzVXYxWTVhM1VQR2lOU3VsQmE1Sm9VWDdSWmtTVUgzUWFvbnhqVFA2WHM0QzJwMjA2L2VVZXNRd3M5RU1xVG5IVWMvV2xCN0FCQ0xZOVdZK3crOGZQcFI5MGJEL1UwNTArdm94dTVmUTdldno2aFhGcyt3cFBBOTJtZTJqVTc4djkrOUR2dWJPNC8vTzduOHArd0ZZNS9QS2liMzVKeUhFUGovT05ZUS9JMFQzQTgvTnhkQTlBQkg0RTlBendReHNWd09kcEhTMlBuMHZydFA1SSt0Z2ZVUEh6MXFKOERuemVmdkdSeW02aUNmU2xLRCswSWYwd09nWC9sQjlUOGZvNThGdEp0L0JzZmQ3dUxzSHY1cUEvWDM5WVE2dUNmMWhISzRMZjEwZDFXL0RUT2c5LzNpNkZmMWlEU3ZnSDNiN0haejc5ZTFqbGNNbzN2ZWpGQUxZQWh3TmgvQWpmQWZhK2pNTWVRSE4wNzl2aUYyM2pkSkNjdzQraS9jeGpsSU5PS2NLZkVmaEFDbnNOK0FDcWZ5clJMYVhjL2x5UFUzYWZBd1g2QUlyZzk5QUg2c0RQb1E3a242OGYycDN4UjFYYzJQUGdEK1BMZ0QvMEJ4bjh2cDQvZHBsQ1dZcjY5d2l3cGFnL3FpTlJQNEJKOFBmMkZQNEFnZ09nWnd4dTNYcjgvbWMvalZVT2puekRDLzljL0lYZlF0dUR6OE4rQUV3NXN2ZDZQQXJYdG1TT2llNUR2d1Q0SE5xbGk3YTVITDV2ZjQ1bjV3TjF3QTk2TXo4L2Y2Z3YvMW9XMVpXaWZBQU8rZ0I5VHZ5UUp5K0JQNEg1anNGUHg2MkJmMmhMLzNFVk9pOEovRzR0OUI5WThYMjBwbnNBSlBDUHdGMlo4Z0hxNEI5c1Nkb0gyM25zRWNkQW8vOHdkOEVCUk9NSDhMay8rQ3hXV1VadSt2b1hSckRzekxEdDBRTWJRRGFxOTdveGxDdlNPRTZ4Q3ZaK2ZBbnNuVUZ6ZE0vN2FmMmhsTEFHQXZCcmZ4cVJ0akZIaEQrOEwzbmd4M1VUZnhmWERITTE5eHk1Mi9LZlBoejdRK25BUE9Dblk1bVM0NC9hVWNEdmRjYjhzbFpZbjRhb1ArNnZuUEx4NjFTSy9IMVpMdWNQTWxZcCt2ZjFrZ01BSUthQXd2Z0VKMEFkeFJjKy80ZFlSWmZuMy9nQ0FBemdEUEt1ZmdDQ0ZORjdPd24wd1liMHdTTktuck1Ia0tSeFlqMGQ5dDZXdzk3M0srWHVhZHVTelpoMGpyUE43OUtwdVdpYkEzNTRIdzR3OEYwYkhjeDk5eDZ4ZmU5QUNDd2Y4ZE0rQUJuOFZFY0NmOVNHbFg5TzBldG92NkVieGlta2U2TDJHNkordHk0eC9Da0FlY29ubEJmZzcvVW8vQUVrT1g4QThRVmZ0OEFoK2cvdDJEanRWWElBL25Wb2c1MEZET01ibkVEUW8zTkM3QWlpNDIzOWJmL3Y4N2pVNUxuUHV6RUNJNEFJM0FDU2VnM3l3QkROZTcwQXBZbWdCOUlVRG9CaXp0NlBzeFRaKzc1clV6bStyNmJvM25XaVJ2ZGhUVWY4Qmk1US9qbEUzNFlFZkFwN1NXOU1TbWVZVHgzd3U4N0FITDN2aUdQYlJQQlRPMEFHUDlYUndDL3BVUEM3c2NqcEh0RmhFUEJIZFN6cWQvT0o0UitkeFFpNWZnQ1Q0ZS9IVkVyN1VEM3RnaSsxazZKL0FLSURBT0l6Z0hETVVrVE9YbllDWVMwZ3c1MXZDT0N3bDJ4b21hWkx4WmZkZGVkdFNkMVllZGF6bnd0Z0FLNFhma3kvWEJ6c1VSbURPNEFrVlJQcEs1RzgxOTJ3dm1JUXQ0TSt0S0ZFOWJRUDB3bjFGSjRNdGpXd2ovUUk3SDJiSFBaZVg4cmRwMjNWUmZlMEx5bTZqK3FWYlpsVGdEKzBzUUR3RFJ6MEFSZkVTdUFIeWhkM3ZmNlU3Wnl1WGs3M1JNQVcwajFELzIxUmZ6eW1PT3AzODY1UCtkQTF5OEUvaktXUTg2ZDZZWTJzbnZyeCtsVU93QmttS2FCaGplT3pnTEJPRE5UOFRJRFdVVWZnUGp0NVowQnRJajN5K2FEbFhyUm5JMGxPb1ZXMEczcjRyeWhSUFFuK1FCeTU4ellrd0hzYkNuZ0FhaVR2eXpqay9aamlLRDNPMHdjZEFmUytUeTJxRDJOU29LM2w3S2xlTHJMM1piV3c5K3ZXa3JzUDY5VVkzUS9yck50SzJ6TEZNNHRvVFBXN2RQeThjam44b1h6YjV0R2o5MW9FY01mZ2QyVngxQytCSHhpZjU2ZDlBQ240YVgxTDFDL2FCU0NsS1oraGZEejhnVFR5ZDNPU1V6Z1UvbjR1V3VySEgvczVBaGpsQU53WTB3dkE3blhjVnhnbldaL1FscEtla2M0SWFEMTNCbjdldEMxcTUrZEZSWHB3WHkza3VST2hrTTZKNUFENDNaUlJKQ2RBSFpEQnpzY2lBZDdYYStraERubGZ4Nk41MzI3UXFZam8vZHdrMEZNOUNsQWExZnQ1KzZqZTIwaDJwVFNPSHllRmZkQ3JUT1ZFL1F1Z2JvM3VvN29SMFgxY2wwYjNkSDdEbk9MbzNxMUxDbndLKzYweHJqQXdzS1lEYkE5ai9OMjVybkgzYzJGK29aMURNRnREWXdCckJ2anp4elVZWXdMWXBEdDNZVXdFTk5lZmUyU0RnWUhwVFlBL0ZXTU1yTEV3QWY2SUZzWmFpNDB4RWZ6cFdLeTE0ZEVOb1h6NzM5L0ZhemFBSWZEdnVpN2NOZHZiSHRhNGRqWUFOcWFMNEwvWmJKS2N2LzhnMjc0UGZRTE94bjN3eVRITWNHZnZua0ZIb001dC9UeXgyVGpBZHhZYmJFSUt5TDkzMWxyc2tkZTBmSVBCQ1d3MnhBbHN1dVJNb1BNMm00M29DSUFCMWwzQkdlQ0sxQ0c0OWtpMHorL0Faby9JMEVCZjgyVFVHc245TG1xUzNpRmZQL3FRTHducXRHMzZUQm9KMnJTOEJIaC9UQ041MzRjRytjRW1uN29KWTYwQXZWOERMYXFueDlyMlM5b0dpUE9waWV4ZGUvV3dwK1VVOXJ6Y3czNW92eTI2VC9wdmpPN2pmdVBvUHUySEFkOE16dmNLLzRLQ2Y3TXgyNmkvUzhBUERPa2VzRWMyZEtZRHpCRDEreStDQjc5alZNOGdPMHlVZ3BrK2twbTJTUUZoTm1hQVAyd01CV095OERjKzBxYmx4cmZYQTJhRDN0Z0kvcHZOQnAzdG9zZ2ZHNE9OdFJIOERZeGJRNTc2SWZDbjQ2RlFseHpBQmh2M1RKK1NBM0FkT2NCdjRyVE5SamdMR042YmdoTUFvck1Ccit2N3BzZTF6b0RhMEhyWFIrb1lKQnN2dFkvczVrN0VTODJUR0lFMHNnZlNNd1h1S0hKUUIvTDVmd3BFYXNldkQvQW9QdEt0aEx6cko3MVl1ejFJWU8zSEo0SGU2ZW5iTHIxdE5vV3o3VGQzZ1phT0o1ZkdpZG92d1o3TWw4SitHTE1NK2xtamUwQk41d1RkOEZtSnh4SG16NEJQZ3hMenhXTkhiZlRsZ3cxZmNOdVE3Z2wxRFJkNUI1djZyWjIwclVTL010OHZ0ZUhHWE03NVV6MXR0dytBN0k2Zm9BODU5OC9YUzh2L2h6clNKdDhCQkNCSjg0UzFZc0FXOWZ5eGNsM0EyY1ZuYTVKOWRIR1dSdlpLU2tlN2dDdnBVdGxUeXNlSzlIUklRRDRMNEE2Z0U0RFB5eld3VTcwSTdrNDVHaHNGdkQ5T2JjdVFEM29NMU9HMUJINGxvcWYySFBRQW1xUDZZUXhkTWliNktPU2FIVGtBQmU0NDJOTTJhbUF2alczeDZONFpoM240TVpyang0OVpZQUFyRUlNZlNPRWZBNmt0MSsvdFFmVVR3TTRMLzJqTVZzLzUrN0xjelYxaDNKbThmeGp6ZHNlUHI0ZVZ3YTA1QUc4bk9RRC9QdkVkUU42ZWwzRW5rTlQzUS90MHpXc2RnWmNXaDhCdE9jajVZN09sZEU1dEtxZVU4OWN1Mm5LUllKL3M3TWxFL3hMVWFYbjBYUG9LdU5OMkpNRDdlcHF1OFhVMGt2ZGxHdVJEV1NGSEgvcHJCTDIzMDI2cW91MUtLUnkvemlKb0ZWam4wampEMk11dzkyUGg3ZEIxck4yWjQvcGRJTG9uVHNuY2YvOXhHMkJPd0EvSVViOTd6UzlHVXBzNitFZjlzQXU5VGw4R09oMm5CdjlRUnVEUDlRSUEyVzZmYUY3Q1dZSHJQeC85Ui9PRjdnRDhHa3NPZ1BiankzTm5BYlN0MEtldlo4NlQ3d2dDMmgwQlBaWjA2SzkvY2J0dDQrRWxkUXhhdTFwOVVqZHpwQS9Jb0FmU3lCNFFjdjFFUndLNkswL2hUMzkxcWdYdXJ1bU1Qb0VSMTZtQnZLL24wVHh2UTByZHVIa3BvQWV5dVhwM3JFZjF0Qjl0NjZVL0xsMmc5V3U0SzlnUCtqSHN2VDZIUFgzL2FxUDdVR1U2bUJNbmpwUHZmd3IvZ0tER2xJOXZqOEkvdDcwejBzL0FQOWl6Y2RiYzFldm5remdTcTZkKy9MRzIxMzhZNDN3T2dQYmgxek4zRmtEWGhEc0JJRTBIK1hiQzNEakVGYmhyem9EcTVCeENVaTQ0Qmk5Sm1rYkoyM09ISVVuT1NWQ1JJQzZKK0N0UlFyNWZncm1YSE5ScG1lZ1FhdUR1Q21RN0F0R2tyQUx5dkV5SzVyMHVmK0tsdDVVaWV0ZFdIZWlwWFhWVXYxMlRwTDV3YzFXWVN5R05NK2hWd0g2N1dCejJnODcwVkU3Y1poZDB6Y2tUOTRmWTBnWEE4VmE1Q1A2WmxJOHJteC8rYmh6bGJaNitMSGRucjlmTnBYNkFPUHFYMnREU1AwTmQ2Z0NBSVFVVTljbXZBV3dYbHdJN21TOTBKOEIxQStRTFp3TytMYWxjY3dhaUxqMjdVMkF2MlhOOVhoZDBoTjhQenVuUEpacERrSDZYY2JhV0FBQU8vVWxFUVZRSE5nRjhOdnJYb1E0Z0Fzd1V1SXZsRk1nWUxyNVNIUjdKKy9ZMHlQdDJ6UkRDRm5QMGZteFRRUy9WNTZMNnVDKzlqYkU1ZTZlM0RPeTNTN3R0TDVQS0liQjNYUnVZVTZkTzJyQjFUb0EvQldyUW1RbitRRjNPUDdUWjkzTEVUUjFHSnZVVEhTdlJmK2kvMFFIRWJjY093STlMaXE2bHN3Q2czUW00UHZLcG5Kd2o0RzFIZW9vOUxmUHJCZVNkZ2w4TEtpWFlheGRuYXlGZnU0ZS9OcThQeUU1QXV1aWJjd0FTMExrTkJ6dXQxK0F1MWlrUlBOWFJvbmczMXZHUTk3bzhtdmZsUEVmdis1VlNONkZPZ0xSL3JlWHF3L3lWcUo0ZWF5a2NiaU5mVDVBdjBBN3pIUWQ3WDFjTCsrM2JFZVlkM3NQVHAwOVpHbEVQR2VYNTRlL3N5dkNudGdBWmd3aFZQZlhqajdVYnZjTHhSQWN3OUtPZGlhUVhnZW40Z2ZRc0lQeFhuSUFmS3dlMWRqWkE2NUtkTlkzT0lOS2xEamZuRk1pNGdSVGtIT0R5UlZzZDhuUGNnWnVUbkVPUWZxa29GOTBEekVFSVFPZHR0SUFkaUtOM1NiY1c4RjVYUER0Z3pvZEQzdi9QUmZOT0o3MFlTL3ZsanplV1h0ZUEzclUvTGFvZjVpUTVKWDAzam0rYmpzSFBmZTdJbnVwUzJBZmQwNmRQaFcvU2ZzR2Z0a3NCM1pyNm9YMUowVDl0WHdOM3F3T1ErdlFPUUc2ZmczaThFL0I2ZnVFMVNFdG5CUEVZNnB5QmV4MDdCS20veEVad0RKS2VWQTkrdkpYYXJabTFad05lcEFoZUUzRTdwMkNmamZaNVhRYnFRSnFTY1hveTJPbHI3YXlnR3ZCa2J2emlhMkxYQ0hsdUg2QmFnUDB1UUU5ZlMxRzludyszS1cyOXBHdWV1MEJMbG4weTdJUE5tVE1QMkJTc1pmaTdlcGJ6ZDRWQlY5dnRBNkE1OWNQdGE2Si9ic1BIUE1ZQkpHM2FkQXVvOUhxc0V3ajJKQjJVakNIakNPaDdNOVVaU0xhOFBuYnNxV1BndHR5ZTlzOGxCL0NsSTMyZ0VPMExzT2RiTjB2UnZ4U3BPN3NZM054V0F6c2Rnd1QzeUxZUjhQNC9CWHpVM2dqSVMrVkpOTDhkbTZpanBHN29hdzU2TjZaMFhyRnVmVlRQMTBWTDRiZzFpL3NKYTVXNVFCdmFHZ043Ly9yczJiTldnckkvQmdZWVNEbC9WOC9zMkZaUFlIcjBIOFpqQldqTjZBQ2lkcGdEQ0cwaWJhOTBGaENQVjNjQ2ZyMTdQamZsYkNCWkgvSmZUQTJSTjZYV0dmZ3grZjZIWm1Ud1M1OGhydE96VkkxRmZFekh4YVUyZWgvckNLcjM3Q3RuQmRJZHZnYXhidGZ4NHp6UU5SM3FXRFN3Ui9vWnVBUElBdDRWQzgvN2p5THFqcFdsTzM5eWtPZGxValFmWHJPTHNWUkhha3VMNkNXNzNKMnp2TjNXcUQ2ZGk1Q3ZkNDFVN2NaeDdaZGhIK3pPbmowYnZrRldnVWNNeWo2Q2YyU1hnYi9YbnkzNjN5cW1qaXJ2QUtJNjVnQjhYZWtpc05mWHpnTENmOFVKSkcweEo1RG9DV2NEZE14akhBRlFQak1BNU5STnppblFjWEs3cUMrbFhnTjZEdURjZ1N3cEhOWnhYZmxDcmxTbUFWOENPb0RJZlhCZ0FuSUthQXpjcVg0dDRIbGJpZDBFeVBzNWxhSjUvMXI2MFJLcDM3bEI3M1RybzNyM09qU2lSdldoWFFYMjNNbnp2a0w1Z3c4K1NLQWZROFdYbGVEdjZ1cWpmNi9mRXYySCtvVWNRRHEvL0ZsQThyclJDV2g5UjA1d29pT2dOdHdaU08xbUhZSlREUFB6b3FWdEVwalQxeFhwbkttcG5OcXpBVTIwS0o2TGRsWlFrKzdoTjMxSk1PZXZ0UlFRaHpxUWdwMjIxUUozVno0TzhMeU8vNitGdkIrTDFtWXViWlBYcjB6ZHVNRWs2MWFUdm9ublBTNnFkLy85TU9Tb1BwMm5Fdm1mTzNmT0Frb2twOEEvMGhGU1A5Uld2UERyS2lMOUpSeEFPaS81SWpDZlg4dFpRTHhldWhQUWJFcG5BOXd1NXdqOG5HSzkrSDJRMnFoeUNFNGh2TnlqeFlKam9QTU9ldnlwbVpWUmZnN2ZTOXlCbTVQY2t6ZUJHTnlockNiYTEyRHVsQWU5U3FqVDF4enNjVjA3M0VYN0N0QnJPZm1najNSTUpjZFJHODBQK3NPOGNvQXNSZlNEWGhuMHZ2MldYTDIzMGFKNmJZN1NYSkt4bkgvb0lRdklwK1JTMU96TGw0eit0ZjY0QS9CNmtnTUlZN054RzJCMm9VNDVDeEJ0RlNlZ3JRMGRYL0ZzWUxzUWUxcWVYZmxmNnd4aTNiSkRBUGhPbklySVhuRU92QzAvVnk1U2ZqL1lLM24rcEkySmtUN1FFTzByVCtsTTRPMGFqVzJaRTlsd2t3TFFuWW9PZFZjdlJiN3p3VjM2VHdIdnk2VW8zcmV0WFV6bFphMlFIOXJQUi9QaHZ4bktXbE0zdEMydnR4VG8rVnk5cmFUajF5Q1VmZW44K1NUU2w3NzR1YlNKcURPREF3RDBNd0JmNTJ5SGNkRkllNWhQL1ZsQTZHT2tFK0Q5WloxQm95TUFNTm9aNUhUY1dLUUx0YnBUNFBicERwekdxRjdKeTNPbkVabnNPTXIzVXJyUW00QWJpT0R0aFR1VkpQb24wS25MKyt0UWQvVTZGRXB3bDhxMEZJMzR2eERGKy8vcGVQUjBqV1lyNWVicG12QnlLVzJUam5GZTBOUDVTT2tiVjErZnE1Zld4SzlGMHNiRER6L3NvQy9kUmFsQ1g0L0dSZjBkT2dCbnp4eFQ0MW1Bbjl0WUorRG41T3UwaTZxMWprQ3pwWXRUY2daYVdjN0JTMDZCMnZoMTRuT1c5SGg3Z0E1dDdmbjR0ZEg3SEZFK1VCL3A1M1NsWi9BWHQyNXlaeURBbk92bG9rRmVKb0tnQUcxQWp0N0YvNFVJbnYvbndDMUJYb3ZrMC9yMmFINW9JOTUxQStnWFk0YzZvandSOVBrMUsyemRsZDVYMnU0amp6eVNSUHJ5THhvVm9MQ2tBM0FWa1kyMEM4ai96NldCZkIvRHZNWTVBVDhYS2EwaXJRK2ZXODRSaVA5SE9nTkFkd2lsTXRwZlRpZWRwK3dnQm4xMkZxQkUrZHArZmEzZlhVck9JWEJvQnhzeDJvOGRRTzFqbWZseExkVHA2eGF3aTNaQzlKNXJmd3pnK2YvOGZ2L3hrUGYySFBLOEQ2K1g3THB4RFJaVE45Um1hZERUdHFPeVJ4OTlOSVUrZ1JvdkEzYnJBQktiaHJNQS8zOE9KNkROTVlLOGNITlRMdktXNWppWE04alpiQThBREE0aG5WL2I2MUpVWDB6ditISTErdDgvd05lSUJub2cvbEtITWtFL0IvWEVHUlNBVGwrTFVIY1ZSUnNPZGplZk10enAvN0dBOS8zS080WFNzeVdhcnZGbGMwSGUxM1BJMHpVS3RvVWN2VE9kRG5wQWNmQ1p0Z0VDZlNvSHhRRWs5aVBQQXZqWU5DY1F4cWc1QVdLZ25RM3dPdTB1MTJ3YVJuQjJyYzZBdHRNTGExWUV1WENtd0hWejdVakhVdG1ZdTI5cjZwZVdVdXBIVC9lMHdiNTBYQUs2cGgrVk5ZQmRLcXVCZTg2dUJIajNQd015azQ1Qm5rTWQ1TDF1YlRUUDU5cDZNYlkwVC82NkZ2VDhkU2o3azhjZWM1Rit4Wk1NNTNZQXBmWkhuUVc0eXNpdXhRbTRkbUtBMC83Y1BGTkhvRWJ5MHU2ZUVZNUFtM2N5RnNXKzFTbG85dUs0MlBIZTJLMlpJeDNBV04xYUtRRytScThtc3BmS05ueG5VRU9FRDhSUmFBN3E5SFd1VFBxaDl6RndkM2JqQU8rUCtmV1NtcHo4VUQ4djVLbGRMcHFYK3Q4SjZLbnVZeGN1MkZ4T05pcWY2QURTMS9WbkFjRHVuQUNRWGhpbSt2eHN3TTEzM0JsQjlGOXhCcUp1ZzBQUTdLcGVLenQzM0JTblJmbmNXWGpoVHFQWXpqNUp5Umtrd0I0TWkyMFZqeFdZQXh4SU9zaTExelZnbDlxZUFuZHhySldBOTIzeEVlWjIyUGg2ZHh3MW5HeTFsVkkyM0s0bG1xZHRhTHFsSEwxbUY0MWIyVEJnTGx5NEVIMkZwamdBcmovQXA5MEJsUHJnZTdtbk9BRmczTmtBMWE4OUk5RG1KN1lsckt0MmRwQnJsNDlMU2h0cGJlVmVpOGVsSFR6S0h2d2N5R3NodjVRenFJMzBTN3JpRjFQWXk1K0RPRytuTnZVVFI0M0N4YjNDbGxJTjdIUzhjOENkam8rUHJRUjRyeU5GOFY0bmVRdEdSUEp1eU10Rjg0bE9JVWNmVDBmZkdlWmZtUXNYTGlUZmxBRm9FanhTSjlEaUFJRDljUUpPVDRpNjJZVmhid3VrRjRlbHNaVE9DSHkvVkZxZGdkaG14aUdvZXJrVWpURE9HdWRRYzl4U0pvMkR5MEc2cUp1N2dBdklNQVVVQjlBYTdiTmpLVUt2SFVQdWpDQzMxMThEbTZnekF1N0VMRWlYdmQ2UWorSmRlN3VGdktRYjVqSXhiY1AxRXh0QjEvenA0NCtIYjVBSWRQcDZnYk1BWUJrbjRNWk82NFYyS3M4R3FIMnJJM0J0eG4xWE93TmlYT01RTkwzU0V6SmJIRVBVcjNMMjRLVjBnMVpOUC9zZDVYdVpJOXB2S1UrQVdJaitOZDI2eUYrSE9uM05RYWEyV1FsM3FXMDZxeHpjcVUwdDRIMGI2WmxUZk9IVjIyVmhtam1qa0hURG5HWkkyM0Q5eEtha1M2SFBoVU45MXc0Z2E5L2dCSHgvdFdjRFNWc1Zqb0NQU1JwWHJUT2c0L0FpUnRyTUlmRFhRMWxGQ2llenQ3NHB2Vk1MYVNXYUw5bHJOMjd0bDBnM1gzSFJ2N2hsUjFJNkk4aEYvOXJOVzFRdkY1MW0reXFBM2ZWWkY3a0RiWUNYVWpTdTNUckF1LzkwS3Zrb1ByUTFJWkpQZENaQ251cEtGcmxuUkpuSEwxNjBOVi9VRmdjZzZVdTZOVTRBYUQ4VDhIVTVJRWxuQTA2bnpoRkV0ak03QTlkMlBLK2NRd0R5WndtSnZScHhWMjdOSFBtVXpKWVVUNm51SUV0clhsOHJUOU0zUE5xdkE0ODdsaUdRaTlSNWZRdllXVFBPUHJOamlJOVRqTjVkaDl1Mjh5a2EybFlKOE5xWWFuTHkvSGd1eUJmSFdOQVYyL1B6ZXZ6aXhlU2JkZENjQUsrcmNRTHVPSDgyb1BVcE9RTGVudE9mMXhsSTQ4dzVCTnBHeVNrQWltTmdEVGFsZHlyU055MmZoWU44QjI1T1NxbWZaVzdjeWtSeXVkeC9BOURkK0hRQTVpSjJhbE1DdXl0TE90NzJJYzlsU2NEVDlqUjlMNjJRbDQ0bG02UzlDbjJ4VGQ0M0FIT1JRWjkvcmNZNEFON09IRTZBMjR4SkNibmpOa2RBK3kxZEl4ajAyNXdCYmEvR0lkRFgyZytJOERPRnhENXpzVFIzMFpaL1FGcWo5MWhQVDlXMEFINlh6cUEydDUrUDlzdGZWcTJkcEYxK21FQXBEM051azR2VWdUcW84M1pLVWJ2cmF6emNxZjJjZ09mSDJidWlaNDdrZ1duUmZMYWRMKys1NXhpV3dCM0s5dEVKOE9QYXM0SEVMdU1JYUgyTkE1SzJJTGFlR1VUdFZEZ0VxZTNjZUV1T1FSeHJNdC84K3k3bDJzWFBpdEpNMWVmcWdFYjZRTmtacVBWYXNhQmZ1bjZnZ2R3ZDUyRU95RURYeGlKQjNaVW5neUw5MW9HZHRqOEc3bEhiQ3dNK3Fkc3g1R3ZhNGUwRjZGTlJBU3lWN2RnSmNQM2Fzd0ZnZmtkUUdudHRtc2paVkRnRXA1aldrK3FTWStCOUpHdVVnWHQ2RmxnSCtwTERrS1QyZ3UydUhVRnRwQS9VWGVSTjJxOTZCRE9EUjJrY1dXZWdwQnBxSXZXZ0xLZGllUHUxVVR0dGF3NjQ1K3lBUE9ENUdNYW1hcmp1MkhTTk9BWk5UL3RWdDU1OHk5WG5vT3pZQ1dqdHpPRUllSDM3ZzhMcW5FRnBISnBEa1Bwd3Ruby9TWHNxN0lmWE5jNWhxTSt2U2UxdjFPYTBXc2R3MEtUR0dhZ1hVeHY2NGIvUld3c2hxZjlzaEI0cTlFaWQ5OWNTc2RQMmFxNXJ6QUYzWUJuQWN6dEpmeGVRejdVWlhYdm9NOS9ZWFRrQnJjM1MyVUJ1TE55bXhSRUEwNTBCMVdtOXdKbExHYWw5RmZiTWg3b0s1OEQ3QTRUdHBoTWo4ZHdZZC9sRDUwdEs3a2ZVZ1Z5VVBjNkJWRUU4S09SaHpzZWgzK0JWaHJxenJ3ZTdYTGNjM0d2YXk5bUs5cFYyWXZzelE1NjNiZmgrVGUzTG1OdFpzU3Nud051YzZnakU0NG5PUU81anVrT2dZOU1mWlpEdmMyaG5oSU53aHFxdU5nNHYyYlRSRHZmZTAvbTJwR3JtRWkzYTF4eEUxUkNUZEU4dTJ1ZndLNmQzOU1zUmJWQ25aYVhyQk9wNEMzQVhiVVpHNzl4V2JhUFNUbXkvNHNKclRiczV5SFA1LytGZnBDUWdMOUdSQUFBQUFFbEZUa1N1UW1DQyIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogPC9nPgo8L3N2Zz4K\",\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDUwMSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MTMzIiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNjEzNSIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNjIxNSAxOTAuMDNjMCA0OC4yMzkgMTQ1Ljg5IDg3LjM5MSAzMjUuOCA4Ny4zOTFzMzI1LjgtMzkuMTUyIDMyNS44LTg3LjM5MWMwLTQ4LjMzMi0xNDUuODktODcuNDgtMzI1LjgtODcuNDhzLTMyNS44IDM5LjE0OC0zMjUuOCA4Ny40OCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDYwODEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDgzIiBkPSJtNjQxNy45IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnY0MTAyLjdjMCAzMC40NiAyNC42OSA1NS4xNiA1NS4xNiA1NS4xNiAzMC40NiAwIDU1LjE2LTI0LjcgNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3LTI0LjctNTUuMTYtNTUuMTYtNTUuMTYiLz4KICA8L2NsaXBQYXRoPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ2MDg1IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxZS03IC0yMzQ5LjEgLTIzNDkuMSAtMWUtNyA2NTQwLjggMzQ2OS4yKSI+CiAgIDxzdG9wIGlkPSJzdG9wNjA4NyIgc3RvcC1jb2xvcj0iI2YxNDg1ZiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNjA4OSIgc3RvcC1jb2xvcj0iI2ExN2Q1YSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNjA2MSIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDYwNjMiIGQ9Im02NTc3LjUgNTk0MS45aC03My40NGMtMTU0LjM3IDAtMjc5Ljk2LTEyNS41OS0yNzkuOTYtMjc5Ljk2di00MjA1LjVjLTIxOC4xLTExNi4yLTM1Ni4xLTM0My4yLTM1Ni4xLTU5My42NSAwLTM3MC45NiAzMDEuOC02NzIuNzUgNjcyLjgtNjcyLjc1IDM3MC45NiAwIDY3Mi43NSAzMDEuNzkgNjcyLjc1IDY3Mi43NSAwIDI1MC40OC0xMzcuOTYgNDc3LjQ4LTM1Ni4wNyA1OTMuN3Y0MjA1LjVjMCAxNTQuMzctMTI1LjU5IDI3OS45Ni0yNzkuOTYgMjc5Ljk2bTAtODguNThjMTA1LjI2IDAgMTkxLjM5LTg2LjEyIDE5MS4zOS0xOTEuMzh2LTQyNjEuMmMyMDkuMjgtODguODUgMzU2LjA3LTI5Ni4yNSAzNTYuMDctNTM3Ljk0IDAtMzIyLjYzLTI2MS41NS01ODQuMTgtNTg0LjE4LTU4NC4xOHMtNTg0LjE4IDI2MS41NS01ODQuMTggNTg0LjE4YzAgMjQxLjY5IDE0Ni43OSA0NDkuMDkgMzU2LjA3IDUzNy45NHY0MjYxLjJjMCAxMDUuMjYgODYuMTMgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDQiLz4KICA8L2NsaXBQYXRoPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ2MDY1IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxMzQ1LjUgMCAwIC0xMzQ1LjUgNTg2OCAzMDY1LjkpIj4KICAgPHN0b3AgaWQ9InN0b3A2MDY3IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A2MDY5IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9Ii4xIi8+CiAgIDxzdG9wIGlkPSJzdG9wNjA3MSIgc3RvcC1jb2xvcj0iI2ZlZmZmZiIgb2Zmc2V0PSIuMjQ3MzEiLz4KICAgPHN0b3AgaWQ9InN0b3A2MDczIiBzdG9wLWNvbG9yPSIjYTNhM2ExIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MDQ3IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNjA0OSIgZD0ibTY1NDAuOCA5NTUuODljLTE4OS4xNiAwLTM0Mi41IDk3LjIxOS0zNDIuNSAyMTcuMTUgMCAxMTkuOTIgMTUzLjM0IDIxNy4xNCAzNDIuNSAyMTcuMTQgMTI3LjgzIDAgMjM5LjMtNDQuNCAyOTguMTQtMTEwLjIgMjguMjMtMzEuNTYgNDQuMzUtNjguMDYgNDQuMzUtMTA2Ljk0IDAtNjUtNDUuMDQtMTIzLjMzLTExNi40Mi0xNjMuMTItNjAuMzItMzMuNjMxLTEzOS40NC01NC4wMjktMjI2LjA3LTU0LjAyOSIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDYwMzEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDMzIiBkPSJtNjc2Ni44IDEwMDkuOWM3MS4zOCAzOS43OSAxMTYuNDIgOTguMTIgMTE2LjQyIDE2My4xMnYtMS4yNGMtMC41Ni02NC41LTQ1LjQ5LTEyMi4zNC0xMTYuNDItMTYxLjg4bTExNi40MiAxNjMuMTJjMCAzOC44OC0xNi4xMiA3NS4zOC00NC4zNSAxMDYuOTQgMjcuOTQtMzEuMjMgNDQuMDItNjcuMjkgNDQuMzUtMTA1Ljcxdi0xLjIzIi8+CiAgPC9jbGlwUGF0aD4KICA8cmFkaWFsR3JhZGllbnQgaWQ9InJhZGlhbEdyYWRpZW50NjAzNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGN5PSIwIiBjeD0iMCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCg1ODQuMTggMCAwIC01ODQuMTggNjU0MC44IDg2Mi43NSkiIHI9IjEiPgogICA8c3RvcCBpZD0ic3RvcDYwMzciIHN0b3AtY29sb3I9IiNlZDFjMjQiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDYwMzkiIHN0b3AtY29sb3I9IiM2MzJkMTYiIG9mZnNldD0iMSIvPgogIDwvcmFkaWFsR3JhZGllbnQ+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDYwMTUiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDE3IiBkPSJtNjg4My4yIDExNzEuOHYxLjI0IDEuMjNjMC4wMS0wLjQxIDAuMDEtMC44MiAwLjAxLTEuMjNzMC0wLjgzLTAuMDEtMS4yNCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDU5OTkiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDAxIiBkPSJtNzEyNC45IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMThzLTU4NC4xOCAyNjEuNTQtNTg0LjE4IDU4NC4xOGMwIDMyMi42MyAyNjEuNTUgNTg0LjE4IDU4NC4xOCA1ODQuMThzNTg0LjE4LTI2MS41NSA1ODQuMTgtNTg0LjE4Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTk4MyIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDU5ODUiIGQ9Im02MzEyLjYgMTQwMC43djQyNjEuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYy03MC4wOCAyOS43Ni0xNDcuMTggNDYuMjItMjI4LjExIDQ2LjIycy0xNTguMDMtMTYuNDYtMjI4LjExLTQ2LjIybTEwNS4yMyA0MzQ0Yy0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3IDI0LjY5LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ2IDAgNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2NDEwMi43YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNiIvPgogIDwvY2xpcFBhdGg+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU5ODciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDFlLTcgLTIzNDkuMSAtMjM0OS4xIC0xZS03IDY1NDAuOCAzNDY5LjIpIj4KICAgPHN0b3AgaWQ9InN0b3A1OTg5IiBzdG9wLWNvbG9yPSIjZWQxYzI0IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1OTkxIiBzdG9wLWNvbG9yPSIjNjMyZDE2IiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1OTY1IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTk2NyIgZD0ibTY3NjguOSAxMzI2LjhjMC0xMDUuMjYtODYuMTMtMTkxLjM5LTE5MS4zOS0xOTEuMzloLTczLjQ0Yy0xMDUuMjYgMC0xOTEuMzkgODYuMTMtMTkxLjM5IDE5MS4zOXY0MzM1LjJjMCAxMDUuMjYgODYuMTMgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDRjMTA1LjI2IDAgMTkxLjM5LTg2LjEyIDE5MS4zOS0xOTEuMzh2LTQzMzUuMiIvPgogIDwvY2xpcFBhdGg+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU5NjkiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiA2MzEyLjYgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNTk3MSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTk3MyIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A1OTc1IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogPC9kZWZzPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTIzMS4zNyAtMTc2LjY3KSI+CiAgPGcgZmlsbD0iI2ZmZiI+CiAgIDxwYXRoIGlkPSJwYXRoNTU3OSIgZD0ibTMxOS44OSA5MjMuODNjLTQ4Ljg4OSAwLTg4LjUyMi0xMC42MzItODguNTIyLTIzLjc1IDAtNy4wODY0IDExLjU3Mi0xMy40NDkgMjkuOTE5LTE3LjggMC4wMzYyIDAuMDI5OCAwLjA2NzUgMC4wNTg1IDAuMTAyNSAwLjA4OTctMTguMjE5IDQuMzM1LTI5LjcgMTAuNjYzLTI5LjcgMTcuNzEgMCAxMy4wNjkgMzkuNDg5IDIzLjY2NCA4OC4yMDEgMjMuNjY0czg4LjIwMS0xMC41OTUgODguMjAxLTIzLjY2NGMwLTcuMDQ3NC0xMS40OC0xMy4zNzUtMjkuNy0xNy43MSAwLjAzNS0wLjAzMTIgMC4wNjc1LTAuMDU4NSAwLjEwMjUtMC4wODk3IDE4LjM0OCA0LjM1MSAyOS45MTggMTAuNzEzIDI5LjkxOCAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjEgMjMuNzUiLz4KICAgPHBhdGggaWQ9InBhdGg1NTgxIiBkPSJtMzE5Ljg5IDkyMy43NGMtNDguNzEyIDAtODguMjAxLTEwLjU5NS04OC4yMDEtMjMuNjY0IDAtNy4wNDc0IDExLjQ4MS0xMy4zNzUgMjkuNy0xNy43MSAwLjAzMzcgMC4wMjg5IDAuMDcgMC4wNjExIDAuMTAzNzUgMC4wOTE0LTE4LjA5IDQuMzE3NC0yOS40ODIgMTAuNjExLTI5LjQ4MiAxNy42MTkgMCAxMy4wMjEgMzkuMzQ1IDIzLjU3OCA4Ny44OCAyMy41NzhzODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OGMwLTcuMDA3NC0xMS4zOTItMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzM4LTAuMDMwMiAwLjA3LTAuMDYyNSAwLjEwMzc1LTAuMDkxNCAxOC4yMiA0LjMzNSAyOS43IDEwLjY2MyAyOS43IDE3LjcxIDAgMTMuMDY5LTM5LjQ4OSAyMy42NjQtODguMjAxIDIzLjY2NCIvPgogICA8cGF0aCBpZD0icGF0aDU1ODMiIGQ9Im0zMTkuODkgOTIzLjY2Yy00OC41MzUgMC04Ny44OC0xMC41NTctODcuODgtMjMuNTc4IDAtNy4wMDc0IDExLjM5Mi0xMy4zMDEgMjkuNDgyLTE3LjYxOSAwLjAzNSAwLjAyOTcgMC4wNjc1IDAuMDU4NiAwLjEwMjUgMC4wODk5LTE3Ljk1OSA0LjMwMjYtMjkuMjY1IDEwLjU2Mi0yOS4yNjUgMTcuNTI5IDAgMTIuOTc0IDM5LjIwMiAyMy40OTEgODcuNTYgMjMuNDkxczg3LjU2LTEwLjUxOCA4Ny41Ni0yMy40OTFjMC02Ljk2NzItMTEuMzA2LTEzLjIyNi0yOS4yNjUtMTcuNTI5IDAuMDM1LTAuMDMxMiAwLjA2NzUtMC4wNjAxIDAuMTAyNS0wLjA4OTkgMTguMDkgNC4zMTc0IDI5LjQ4MiAxMC42MTEgMjkuNDgyIDE3LjYxOSAwIDEzLjAyMS0zOS4zNDUgMjMuNTc4LTg3Ljg4IDIzLjU3OCIvPgogICA8cGF0aCBpZD0icGF0aDU1ODUiIGQ9Im0zMTkuODkgOTIzLjU3Yy00OC4zNTggMC04Ny41Ni0xMC41MTgtODcuNTYtMjMuNDkxIDAtNi45NjcyIDExLjMwNi0xMy4yMjYgMjkuMjY1LTE3LjUyOSAwLjAzMzcgMC4wMjg4IDAuMDcgMC4wNjEgMC4xMDM3NSAwLjA4OTctMTcuODMgNC4yODY2LTI5LjA0OCAxMC41MTEtMjkuMDQ4IDE3LjQzOSAwIDEyLjkyNiAzOS4wNTggMjMuNDA2IDg3LjIzOSAyMy40MDYgNDguMTggMCA4Ny4yMzktMTAuNDggODcuMjM5LTIzLjQwNiAwLTYuOTI3OC0xMS4yMTgtMTMuMTUyLTI5LjA0OC0xNy40MzggMC4wMzM3LTAuMDMwMyAwLjA3LTAuMDYyNSAwLjEwMzc1LTAuMDkxMyAxNy45NTkgNC4zMDI2IDI5LjI2NSAxMC41NjIgMjkuMjY1IDE3LjUyOSAwIDEyLjk3NC0zOS4yMDIgMjMuNDkxLTg3LjU2IDIzLjQ5MSIvPgogICA8cGF0aCBpZD0icGF0aDU1ODciIGQ9Im0zMTkuODkgOTIzLjQ5Yy00OC4xODEgMC04Ny4yMzktMTAuNDgtODcuMjM5LTIzLjQwNiAwLTYuOTI3OCAxMS4yMTgtMTMuMTUyIDI5LjA0OC0xNy40MzkgMC4wMzUgMC4wMzEyIDAuMDY4NyAwLjA2MDEgMC4xMDM3NSAwLjA5MTQtMTcuNzAxIDQuMjY5LTI4LjgzMSAxMC40NTktMjguODMxIDE3LjM0OCAwIDEyLjg3OSAzOC45MTUgMjMuMzIgODYuOTE5IDIzLjMyczg2LjkxOC0xMC40NDEgODYuOTE4LTIzLjMyYzAtNi44ODg2LTExLjEyOS0xMy4wNzktMjguODMtMTcuMzQ4IDAuMDM1LTAuMDMxMiAwLjA2ODctMC4wNjAxIDAuMTAzNzUtMC4wODk5IDE3LjgzIDQuMjg1MSAyOS4wNDggMTAuNTEgMjkuMDQ4IDE3LjQzOCAwIDEyLjkyNi0zOS4wNTkgMjMuNDA2LTg3LjIzOSAyMy40MDYiLz4KICAgPHBhdGggaWQ9InBhdGg1NTg5IiBkPSJtMzE5Ljg5IDkyMy40Yy00OC4wMDQgMC04Ni45MTktMTAuNDQxLTg2LjkxOS0yMy4zMiAwLTYuODg4NiAxMS4xMy0xMy4wNzkgMjguODMxLTE3LjM0OCAwLjAzNjIgMC4wMzEyIDAuMDY4NyAwLjA2IDAuMTA1IDAuMDkwMi0xNy41NyA0LjI1MjUtMjguNjE1IDEwLjQxLTI4LjYxNSAxNy4yNTcgMCAxMi44MzEgMzguNzcxIDIzLjIzNCA4Ni41OTggMjMuMjM0IDQ3LjgyNiAwIDg2LjU5OC0xMC40MDMgODYuNTk4LTIzLjIzNCAwLTYuODQ3Ni0xMS4wNDUtMTMuMDA1LTI4LjYxNC0xNy4yNTYgMC4wMzUtMC4wMzEyIDAuMDY4Ny0wLjA2IDAuMTAzNzUtMC4wOTEyIDE3LjcwMSA0LjI2OSAyOC44MyAxMC40NTkgMjguODMgMTcuMzQ4IDAgMTIuODc5LTM4LjkxNCAyMy4zMi04Ni45MTggMjMuMzIiLz4KICAgPHBhdGggaWQ9InBhdGg1NTkxIiBkPSJtMzE5Ljg5IDkyMy4zMWMtNDcuODI2IDAtODYuNTk4LTEwLjQwMy04Ni41OTgtMjMuMjM0IDAtNi44NDc2IDExLjA0NS0xMy4wMDUgMjguNjE1LTE3LjI1NyAwLjAzMjUgMC4wMjk5IDAuMDcgMC4wNjI1IDAuMTAzNzUgMC4wOTE0LTE3LjQ0MSA0LjIzNDktMjguMzk4IDEwLjM1Ny0yOC4zOTggMTcuMTY2IDAgMTIuNzg0IDM4LjYyNiAyMy4xNDcgODYuMjc2IDIzLjE0NyA0Ny42NDkgMCA4Ni4yNzYtMTAuMzY0IDg2LjI3Ni0yMy4xNDcgMC02LjgwODYtMTAuOTU2LTEyLjkzMS0yOC4zOTgtMTcuMTY2IDAuMDMzNy0wLjAyODkgMC4wNzEyLTAuMDYxNSAwLjEwNS0wLjA5MDQgMTcuNTY5IDQuMjUxNSAyOC42MTQgMTAuNDA5IDI4LjYxNCAxNy4yNTYgMCAxMi44MzEtMzguNzcxIDIzLjIzNC04Ni41OTggMjMuMjM0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTU5MyIgZD0ibTMxOS44OSA5MjMuMjNjLTQ3LjY1IDAtODYuMjc2LTEwLjM2NC04Ni4yNzYtMjMuMTQ4IDAtNi44MDg2IDEwLjk1Ni0xMi45MzEgMjguMzk4LTE3LjE2NiAwLjAzNjIgMC4wMzA4IDAuMDY4NyAwLjA1OTUgMC4xMDUgMC4wODk5LTE3LjMxNCA0LjIxOTYtMjguMTgyIDEwLjMwOC0yOC4xODIgMTcuMDc2IDAgMTIuNzM2IDM4LjQ4NCAyMy4wNjEgODUuOTU2IDIzLjA2MXM4NS45NTYtMTAuMzI1IDg1Ljk1Ni0yMy4wNjFjMC02Ljc2ODUtMTAuODcyLTEyLjg1Ni0yOC4xODItMTcuMDc1IDAuMDM2My0wLjAzMTIgMC4wNy0wLjA2IDAuMTA1LTAuMDkwOCAxNy40NDEgNC4yMzQ5IDI4LjM5OCAxMC4zNTcgMjguMzk4IDE3LjE2NiAwIDEyLjc4NC0zOC42MjggMjMuMTQ3LTg2LjI3NiAyMy4xNDciLz4KICAgPHBhdGggaWQ9InBhdGg1NTk1IiBkPSJtMzE5Ljg5IDkyMy4xNGMtNDcuNDcyIDAtODUuOTU2LTEwLjMyNS04NS45NTYtMjMuMDYxIDAtNi43Njg1IDEwLjg2OS0xMi44NTYgMjguMTgyLTE3LjA3NiAwLjAzNSAwLjAzMTIgMC4wNyAwLjA2MSAwLjEwNSAwLjA5MTMtMTcuMTg0IDQuMjAyMS0yNy45NjYgMTAuMjU2LTI3Ljk2NiAxNi45ODUgMCAxMi42ODggMzguMzQgMjIuOTc2IDg1LjYzNSAyMi45NzZzODUuNjM1LTEwLjI4OCA4NS42MzUtMjIuOTc2YzAtNi43MjktMTAuNzgyLTEyLjc4My0yNy45NjYtMTYuOTg1IDAuMDM1LTAuMDMwMyAwLjA3LTAuMDYgMC4xMDUtMC4wOTA0IDE3LjMxIDQuMjE4OCAyOC4xODIgMTAuMzA3IDI4LjE4MiAxNy4wNzUgMCAxMi43MzYtMzguNDg0IDIzLjA2MS04NS45NTYgMjMuMDYxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTU5NyIgZD0ibTMxOS44OSA5MjMuMDZjLTQ3LjI5NSAwLTg1LjYzNS0xMC4yODgtODUuNjM1LTIyLjk3NiAwLTYuNzI5IDEwLjc4Mi0xMi43ODMgMjcuOTY2LTE2Ljk4NSAwLjAzMzcgMC4wMjg5IDAuMDcyNSAwLjA2MjUgMC4xMDYyNSAwLjA5MTQtMTcuMDU1IDQuMTg0NS0yNy43NTIgMTAuMjA0LTI3Ljc1MiAxNi44OTQgMCAxMi42NDEgMzguMTk4IDIyLjg5IDg1LjMxNSAyMi44OSA0Ny4xMTggMCA4NS4zMTQtMTAuMjQ5IDg1LjMxNC0yMi44OSAwLTYuNjg5LTEwLjY5Ni0xMi43MDktMjcuNzUxLTE2Ljg5NCAwLjAzMzctMC4wMjg5IDAuMDcyNS0wLjA2MjUgMC4xMDYyNS0wLjA5MTQgMTcuMTg0IDQuMjAyMSAyNy45NjYgMTAuMjU2IDI3Ljk2NiAxNi45ODUgMCAxMi42ODgtMzguMzQgMjIuOTc2LTg1LjYzNSAyMi45NzYiLz4KICAgPHBhdGggaWQ9InBhdGg1NTk5IiBkPSJtMzE5Ljg5IDkyMi45N2MtNDcuMTE4IDAtODUuMzE1LTEwLjI0OS04NS4zMTUtMjIuODkgMC02LjY4OSAxMC42OTgtMTIuNzA5IDI3Ljc1Mi0xNi44OTQgMC4wMzUgMC4wMjk4IDAuMDcgMC4wNTk1IDAuMTA1IDAuMDg5Ny0xNi45MjUgNC4xNjktMjcuNTM2IDEwLjE1NC0yNy41MzYgMTYuODA0IDAgMTIuNTk0IDM4LjA1MiAyMi44MDQgODQuOTk0IDIyLjgwNCA0Ni45NCAwIDg0Ljk5NC0xMC4yMSA4NC45OTQtMjIuODA0IDAtNi42NDk5LTEwLjYxMS0xMi42MzUtMjcuNTM2LTE2LjgwNCAwLjAzNS0wLjAzMDMgMC4wNy0wLjA2IDAuMTA1LTAuMDg5NyAxNy4wNTUgNC4xODQ1IDI3Ljc1MSAxMC4yMDQgMjcuNzUxIDE2Ljg5NCAwIDEyLjY0MS0zOC4xOTYgMjIuODktODUuMzE0IDIyLjg5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTYwMSIgZD0ibTMxOS44OSA5MjIuODhjLTQ2Ljk0MSAwLTg0Ljk5NC0xMC4yMS04NC45OTQtMjIuODA0IDAtNi42NDk5IDEwLjYxMS0xMi42MzUgMjcuNTM2LTE2LjgwNCAwLjAzNjIgMC4wMzEyIDAuMDcxMyAwLjA2MDEgMC4xMDYyNSAwLjA5MTQtMTYuNzk5IDQuMTUxNC0yNy4zMjEgMTAuMTAxLTI3LjMyMSAxNi43MTIgMCAxMi41NDYgMzcuOTA5IDIyLjcxNyA4NC42NzIgMjIuNzE3IDQ2Ljc2NCAwIDg0LjY3Mi0xMC4xNzEgODQuNjcyLTIyLjcxNyAwLTYuNjA5OS0xMC41MjUtMTIuNTYxLTI3LjMyMS0xNi43MTIgMC4wMzUtMC4wMzEyIDAuMDctMC4wNjAxIDAuMTA2MjUtMC4wOTE0IDE2LjkyNSA0LjE2OSAyNy41MzYgMTAuMTU0IDI3LjUzNiAxNi44MDQgMCAxMi41OTQtMzguMDU0IDIyLjgwNC04NC45OTQgMjIuODA0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTYwMyIgZD0ibTMxOS44OSA5MjIuOGMtNDYuNzY0IDAtODQuNjcyLTEwLjE3MS04NC42NzItMjIuNzE3IDAtNi42MTE0IDEwLjUyMi0xMi41NjEgMjcuMzIxLTE2LjcxMiAwLjAzNjIgMC4wMjk3IDAuMDcxMyAwLjA2IDAuMTA3NSAwLjA4OTktMTYuNjY5IDQuMTM1Mi0yNy4xMDkgMTAuMDUzLTI3LjEwOSAxNi42MjIgMCAxMi40OTkgMzcuNzY2IDIyLjYzMSA4NC4zNTIgMjIuNjMxczg0LjM1MS0xMC4xMzMgODQuMzUxLTIyLjYzMWMwLTYuNTY5OS0xMC40MzktMTIuNDg3LTI3LjEwNi0xNi42MjIgMC4wMzUtMC4wMjk5IDAuMDctMC4wNjAxIDAuMTA2MjUtMC4wODk5IDE2Ljc5NiA0LjE1MTQgMjcuMzIxIDEwLjEwMiAyNy4zMjEgMTYuNzEyIDAgMTIuNTQ2LTM3LjkwOSAyMi43MTctODQuNjcyIDIyLjcxNyIvPgogICA8cGF0aCBpZD0icGF0aDU2MDUiIGQ9Im0zMTkuODkgOTIyLjcxYy00Ni41ODYgMC04NC4zNTItMTAuMTMzLTg0LjM1Mi0yMi42MzEgMC02LjU2OTkgMTAuNDQtMTIuNDg3IDI3LjEwOS0xNi42MjIgMC4wMzUgMC4wMzEyIDAuMDcgMC4wNjE1IDAuMTA2MjUgMC4wOTEzLTE2LjU0MSA0LjExNzYtMjYuODk0IDEwLTI2Ljg5NCAxNi41MzEgMCAxMi40NTEgMzcuNjIyIDIyLjU0NSA4NC4wMzEgMjIuNTQ1czg0LjAzMS0xMC4wOTQgODQuMDMxLTIyLjU0NWMwLTYuNTMxMi0xMC4zNTItMTIuNDE0LTI2Ljg5NC0xNi41MzEgMC4wMzYyLTAuMDI5OCAwLjA3MTItMC4wNiAwLjEwNzUtMC4wOTEzIDE2LjY2OCA0LjEzNTIgMjcuMTA2IDEwLjA1MyAyNy4xMDYgMTYuNjIyIDAgMTIuNDk5LTM3Ljc2NSAyMi42MzEtODQuMzUxIDIyLjYzMSIvPgogICA8cGF0aCBpZD0icGF0aDU2MDciIGQ9Im0zMTkuODkgOTIyLjYyYy00Ni40MDkgMC04NC4wMzEtMTAuMDk0LTg0LjAzMS0yMi41NDUgMC02LjUzMTIgMTAuMzUyLTEyLjQxNCAyNi44OTQtMTYuNTMxIDAuMDM2MiAwLjAyOTcgMC4wNzEzIDAuMDYgMC4xMDc1IDAuMDg5OS0xNi40MTQgNC4xMDE1LTI2LjY4MSA5Ljk1MDEtMjYuNjgxIDE2LjQ0MSAwIDEyLjQwNCAzNy40NzkgMjIuNDU5IDgzLjcxMSAyMi40NTlzODMuNzEtMTAuMDU1IDgzLjcxLTIyLjQ1OWMwLTYuNDkxMi0xMC4yNjUtMTIuMzQtMjYuNjgtMTYuNDQgMC4wMzYyLTAuMDMxMiAwLjA3MTItMC4wNjE1IDAuMTA3NS0wLjA5MTIgMTYuNTQxIDQuMTE3NiAyNi44OTQgMTAgMjYuODk0IDE2LjUzMSAwIDEyLjQ1MS0zNy42MjIgMjIuNTQ1LTg0LjAzMSAyMi41NDUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjA5IiBkPSJtMzE5Ljg5IDkyMi41NGMtNDYuMjMyIDAtODMuNzExLTEwLjA1NS04My43MTEtMjIuNDU5IDAtNi40OTEyIDEwLjI2OC0xMi4zNCAyNi42ODEtMTYuNDQxIDAuMDM2MiAwLjAzMTIgMC4wNzEyIDAuMDYxNSAwLjEwNzUgMC4wOTEyLTE2LjI4OCA0LjA4NC0yNi40NjggOS44OTc1LTI2LjQ2OCAxNi4zNSAwIDEyLjM1NiAzNy4zMzUgMjIuMzczIDgzLjM5IDIyLjM3M3M4My4zOS0xMC4wMTYgODMuMzktMjIuMzczYzAtNi40NTI2LTEwLjE4LTEyLjI2Ni0yNi40NjgtMTYuMzUgMC4wMzYyLTAuMDI5NyAwLjA3MTMtMC4wNiAwLjEwNzUtMC4wODk5IDE2LjQxNSA0LjEwMDEgMjYuNjggOS45NDg4IDI2LjY4IDE2LjQ0IDAgMTIuNDA0LTM3LjQ3OCAyMi40NTktODMuNzEgMjIuNDU5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTYxMSIgZD0ibTMxOS44OSA5MjIuNDVjLTQ2LjA1NSAwLTgzLjM5LTEwLjAxNi04My4zOS0yMi4zNzMgMC02LjQ1MjYgMTAuMTgtMTIuMjY2IDI2LjQ2OC0xNi4zNSAwLjAzNjMgMC4wMzAzIDAuMDcyNSAwLjA2MDEgMC4xMDg3NSAwLjA5MTQtMTYuMTYgNC4wNjY0LTI2LjI1NSA5Ljg0NjEtMjYuMjU1IDE2LjI1OSAwIDEyLjMwOSAzNy4xOTEgMjIuMjg4IDgzLjA2OSAyMi4yODhzODMuMDY5LTkuOTc5IDgzLjA2OS0yMi4yODhjMC02LjQxMjYtMTAuMDk1LTEyLjE5Mi0yNi4yNTQtMTYuMjU5IDAuMDM1LTAuMDI5OSAwLjA3MTItMC4wNjExIDAuMTA3NS0wLjA5MTQgMTYuMjg4IDQuMDg0IDI2LjQ2OCA5Ljg5NzUgMjYuNDY4IDE2LjM1IDAgMTIuMzU2LTM3LjMzNSAyMi4zNzMtODMuMzkgMjIuMzczIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYxMyIgZD0ibTMxOS44OSA5MjIuMzdjLTQ1Ljg3OCAwLTgzLjA2OS05Ljk3OS04My4wNjktMjIuMjg4IDAtNi40MTI2IDEwLjA5NS0xMi4xOTIgMjYuMjU1LTE2LjI1OSAwLjAzNSAwLjAzMDMgMC4wNzEyIDAuMDYgMC4xMDc1IDAuMDg5OC0xNi4wMyA0LjA0ODktMjYuMDQyIDkuNzk2NS0yNi4wNDIgMTYuMTY5IDAgMTIuMjYxIDM3LjA0OSAyMi4yMDEgODIuNzQ5IDIyLjIwMXM4Mi43NDgtOS45Mzk5IDgyLjc0OC0yMi4yMDFjMC02LjM3MjUtMTAuMDExLTEyLjEyLTI2LjA0MS0xNi4xNjkgMC4wMzYyLTAuMDI5OCAwLjA3MjUtMC4wNTk1IDAuMTA4NzUtMC4wODk4IDE2LjE1OSA0LjA2NjQgMjYuMjU0IDkuODQ2MSAyNi4yNTQgMTYuMjU5IDAgMTIuMzA5LTM3LjE5MSAyMi4yODgtODMuMDY5IDIyLjI4OCIvPgogICA8cGF0aCBpZD0icGF0aDU2MTUiIGQ9Im0zMTkuODkgOTIyLjI4Yy00NS43IDAtODIuNzQ5LTkuOTQtODIuNzQ5LTIyLjIwMSAwLTYuMzcyNSAxMC4wMTItMTIuMTIgMjYuMDQyLTE2LjE2OSAwLjAzNjMgMC4wMzA0IDAuMDcyNSAwLjA2MTYgMC4xMDg3NSAwLjA5MTQtMTUuOTA2IDQuMDMxMi0yNS44MyA5Ljc0NDEtMjUuODMgMTYuMDc4IDAgMTIuMjE0IDM2LjkwNCAyMi4xMTUgODIuNDI4IDIyLjExNXM4Mi40MjgtOS45MDA5IDgyLjQyOC0yMi4xMTVjMC02LjMzMzUtOS45MjM4LTEyLjA0Ni0yNS44My0xNi4wNzggMC4wMzYyLTAuMDI5OCAwLjA3MjUtMC4wNjAxIDAuMTA4NzUtMC4wOTE0IDE2LjAzIDQuMDQ4OSAyNi4wNDEgOS43OTY1IDI2LjA0MSAxNi4xNjkgMCAxMi4yNjEtMzcuMDQ4IDIyLjIwMS04Mi43NDggMjIuMjAxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYxNyIgZD0ibTMxOS44OSA5MjIuMTljLTQ1LjUyNCAwLTgyLjQyOC05LjkwMDgtODIuNDI4LTIyLjExNSAwLTYuMzMzNSA5LjkyMzgtMTIuMDQ2IDI1LjgzLTE2LjA3OCAwLjAzNjIgMC4wMzAzIDAuMDczNyAwLjA2MTUgMC4xMSAwLjA5MTMtMTUuNzggNC4wMTM4LTI1LjYxOSA5LjY5MTUtMjUuNjE5IDE1Ljk4NiAwIDEyLjE2NiAzNi43NiAyMi4wMjkgODIuMTA2IDIyLjAyOXM4Mi4xMDYtOS44NjI4IDgyLjEwNi0yMi4wMjljMC02LjI5NDktOS44Mzg4LTExLjk3My0yNS42MTgtMTUuOTg2IDAuMDM2My0wLjAyOTcgMC4wNzI1LTAuMDYxIDAuMTA4NzUtMC4wOTEzIDE1LjkwNiA0LjAzMTIgMjUuODMgOS43NDQxIDI1LjgzIDE2LjA3OCAwIDEyLjIxNC0zNi45MDQgMjIuMTE1LTgyLjQyOCAyMi4xMTUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjE5IiBkPSJtMzE5Ljg5IDkyMi4xMWMtNDUuMzQ2IDAtODIuMTA2LTkuODYyOC04Mi4xMDYtMjIuMDI5IDAtNi4yOTQ5IDkuODM4OC0xMS45NzMgMjUuNjE5LTE1Ljk4NiAwLjAzNSAwLjAzMDQgMC4wNzI1IDAuMDYwMSAwLjEwODc1IDAuMDg5OS0xNS42NTIgMy45OTc2LTI1LjQwOCA5LjY0MTYtMjUuNDA4IDE1Ljg5NiAwIDEyLjExOSAzNi42MTggMjEuOTQyIDgxLjc4NiAyMS45NDIgNDUuMTY5IDAgODEuNzg2LTkuODIzNyA4MS43ODYtMjEuOTQyIDAtNi4yNTQ5LTkuNzU1LTExLjg5OS0yNS40MDgtMTUuODk2IDAuMDM2Mi0wLjAyOTcgMC4wNzM3LTAuMDU5NSAwLjExLTAuMDg5OSAxNS43NzkgNC4wMTM4IDI1LjYxOCA5LjY5MTUgMjUuNjE4IDE1Ljk4NiAwIDEyLjE2Ni0zNi43NiAyMi4wMjktODIuMTA2IDIyLjAyOSIvPgogICA8cGF0aCBpZD0icGF0aDU2MjEiIGQ9Im0zMTkuODkgOTIyLjAyYy00NS4xNjkgMC04MS43ODYtOS44MjM4LTgxLjc4Ni0yMS45NDIgMC02LjI1NDkgOS43NTUtMTEuODk5IDI1LjQwOC0xNS44OTYgMC4wMzYyIDAuMDMwMyAwLjA3MzcgMC4wNjE1IDAuMTEgMC4wOTE0LTE1LjUyNiAzLjk3OTktMjUuMTk2IDkuNTg4OS0yNS4xOTYgMTUuODA1IDAgMTIuMDcxIDM2LjQ3MiAyMS44NTYgODEuNDY1IDIxLjg1NiA0NC45OTEgMCA4MS40NjUtOS43ODUyIDgxLjQ2NS0yMS44NTYgMC02LjIxNjItOS42Ny0xMS44MjUtMjUuMTk2LTE1LjgwNSAwLjAzNjItMC4wMjk5IDAuMDczOC0wLjA2MTEgMC4xMS0wLjA5MTQgMTUuNjUyIDMuOTk3NiAyNS40MDggOS42NDE2IDI1LjQwOCAxNS44OTYgMCAxMi4xMTktMzYuNjE4IDIxLjk0Mi04MS43ODYgMjEuOTQyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYyMyIgZD0ibTMxOS44OSA5MjEuOTRjLTQ0Ljk5MiAwLTgxLjQ2NS05Ljc4NTEtODEuNDY1LTIxLjg1NiAwLTYuMjE2MiA5LjY3LTExLjgyNSAyNS4xOTYtMTUuODA1IDAuMDM2MyAwLjAzMDIgMC4wNzM4IDAuMDYxNSAwLjExIDAuMDkxMi0xNS4zOTggMy45NjE0LTI0Ljk4NSA5LjUzNzYtMjQuOTg1IDE1LjcxNCAwIDEyLjAyNCAzNi4zMjkgMjEuNzcgODEuMTQ0IDIxLjc3czgxLjE0NC05Ljc0NjEgODEuMTQ0LTIxLjc3YzAtNi4xNzYyLTkuNTg3NS0xMS43NTItMjQuOTg1LTE1LjcxNCAwLjAzNjItMC4wMjk3IDAuMDczNy0wLjA2MSAwLjExLTAuMDkxMiAxNS41MjYgMy45Nzk5IDI1LjE5NiA5LjU4ODkgMjUuMTk2IDE1LjgwNSAwIDEyLjA3MS0zNi40NzQgMjEuODU2LTgxLjQ2NSAyMS44NTYiLz4KICAgPHBhdGggaWQ9InBhdGg1NjI1IiBkPSJtMzE5Ljg5IDkyMS44NWMtNDQuODE1IDAtODEuMTQ0LTkuNzQ2MS04MS4xNDQtMjEuNzcgMC02LjE3NjIgOS41ODc1LTExLjc1MiAyNC45ODUtMTUuNzE0IDAuMDM2MiAwLjAyODkgMC4wNzM4IDAuMDYwMSAwLjExMTI1IDAuMDkwNC0xNS4yNzQgMy45NDQ4LTI0Ljc3NiA5LjQ4NTgtMjQuNzc2IDE1LjYyNCAwIDExLjk3NiAzNi4xODYgMjEuNjg1IDgwLjgyNCAyMS42ODVzODAuODI0LTkuNzA5IDgwLjgyNC0yMS42ODVjMC02LjEzNzgtOS41MDI1LTExLjY3OS0yNC43NzUtMTUuNjI0IDAuMDM2Mi0wLjAzMDMgMC4wNzM4LTAuMDYwMSAwLjExLTAuMDkwNCAxNS4zOTggMy45NjE0IDI0Ljk4NSA5LjUzNzYgMjQuOTg1IDE1LjcxNCAwIDEyLjAyNC0zNi4zMjkgMjEuNzctODEuMTQ0IDIxLjc3Ii8+CiAgPC9nPgogIDxnPgogICA8cGF0aCBpZD0icGF0aDU2MjciIGQ9Im0zMTkuODkgOTIxLjc3Yy00NC42MzggMC04MC44MjQtOS43MDktODAuODI0LTIxLjY4NSAwLTYuMTM3OCA5LjUwMjUtMTEuNjc5IDI0Ljc3Ni0xNS42MjQgMC4wMzc1IDAuMDMwOCAwLjA3MTMgMC4wNTk1IDAuMTEgMC4wOTA3LTE1LjE0NSAzLjkyNzgtMjQuNTY1IDkuNDM1Mi0yNC41NjUgMTUuNTMzIDAgMTEuOTI5IDM2LjA0MiAyMS41OTkgODAuNTAyIDIxLjU5OXM4MC41MDItOS42Njk5IDgwLjUwMi0yMS41OTljMC02LjA5NzYtOS40Mi0xMS42MDUtMjQuNTY1LTE1LjUzMyAwLjAzODctMC4wMzEyIDAuMDcyNS0wLjA1ODUgMC4xMTEyNS0wLjA5MDcgMTUuMjcyIDMuOTQ0OCAyNC43NzUgOS40ODU4IDI0Ljc3NSAxNS42MjQgMCAxMS45NzYtMzYuMTg2IDIxLjY4NS04MC44MjQgMjEuNjg1IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYyOSIgZD0ibTMxOS44OSA5MjEuNjhjLTQ0LjQ2IDAtODAuNTAyLTkuNjY5OS04MC41MDItMjEuNTk5IDAtNi4wOTc2IDkuNDItMTEuNjA1IDI0LjU2NS0xNS41MzMgMC4wMzYyIDAuMDMwMyAwLjA3NSAwLjA2MTUgMC4xMTEyNSAwLjA5MTQtMTUuMDIxIDMuOTA4Ni0yNC4zNTYgOS4zODI3LTI0LjM1NiAxNS40NDEgMCAxMS44ODEgMzUuODk5IDIxLjUxMyA4MC4xODIgMjEuNTEzIDQ0LjI4NCAwIDgwLjE4Mi05LjYzMTQgODAuMTgyLTIxLjUxMyAwLTYuMDU4Ni05LjMzNS0xMS41MzEtMjQuMzU2LTE1LjQ0MSAwLjAzNjItMC4wMjk5IDAuMDc1LTAuMDYxMSAwLjExMTI1LTAuMDkxNCAxNS4xNDUgMy45Mjc4IDI0LjU2NSA5LjQzNTEgMjQuNTY1IDE1LjUzMyAwIDExLjkyOS0zNi4wNDIgMjEuNTk5LTgwLjUwMiAyMS41OTkiIGZpbGw9IiNmZWZmZmYiLz4KICAgPHBhdGggaWQ9InBhdGg1NjMxIiBkPSJtMzE5Ljg5IDkyMS41OWMtNDQuMjg0IDAtODAuMTgyLTkuNjMxNC04MC4xODItMjEuNTEzIDAtNi4wNTg2IDkuMzM1LTExLjUzMyAyNC4zNTYtMTUuNDQxIDAuMDM3NSAwLjAyODcgMC4wNzYyIDAuMDYgMC4xMTI1IDAuMDkwMi0xNC44OTYgMy44OTIxLTI0LjE0OCA5LjMzMjYtMjQuMTQ4IDE1LjM1MSAwIDExLjgzNCAzNS43NTUgMjEuNDI2IDc5Ljg2MSAyMS40MjZzNzkuODYxLTkuNTkyMyA3OS44NjEtMjEuNDI2YzAtNi4wMTg1LTkuMjUxMi0xMS40NTktMjQuMTQ4LTE1LjM1MSAwLjAzNzUtMC4wMjg3IDAuMDc1LTAuMDYxNSAwLjExMjUtMC4wOTAyIDE1LjAyMSAzLjkxMDEgMjQuMzU2IDkuMzgyOCAyNC4zNTYgMTUuNDQxIDAgMTEuODgxLTM1Ljg5OSAyMS41MTMtODAuMTgyIDIxLjUxMyIgZmlsbD0iI2ZlZmVmZiIvPgogICA8cGF0aCBpZD0icGF0aDU2MzMiIGQ9Im0zMTkuODkgOTIxLjUxYy00NC4xMDYgMC03OS44NjEtOS41OTIzLTc5Ljg2MS0yMS40MjYgMC02LjAxODUgOS4yNTEyLTExLjQ1OSAyNC4xNDgtMTUuMzUxIDAuMDM4NyAwLjAzMTIgMC4wNzI1IDAuMDYwMSAwLjExMTI1IDAuMDkwOS0xNC43NyAzLjg3NTUtMjMuOTM5IDkuMjgwMi0yMy45MzkgMTUuMjYgMCAxMS43ODYgMzUuNjEyIDIxLjM0IDc5LjU0MSAyMS4zNHM3OS41NC05LjU1MzcgNzkuNTQtMjEuMzRjMC01Ljk4LTkuMTY3NS0xMS4zODUtMjMuOTM4LTE1LjI2IDAuMDM4Ny0wLjAzMDggMC4wNzI1LTAuMDU5NiAwLjExMTI1LTAuMDkwOSAxNC44OTYgMy44OTIxIDI0LjE0OCA5LjMzMjYgMjQuMTQ4IDE1LjM1MSAwIDExLjgzNC0zNS43NTUgMjEuNDI2LTc5Ljg2MSAyMS40MjYiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjM1IiBkPSJtMzE5Ljg5IDkyMS40MmMtNDMuOTI5IDAtNzkuNTQxLTkuNTUzNy03OS41NDEtMjEuMzQgMC01Ljk4IDkuMTY4OC0xMS4zODUgMjMuOTM5LTE1LjI2IDAuMDM3NSAwLjAzMDMgMC4wNzYyIDAuMDYxNSAwLjExMzc1IDAuMDkxMy0xNC42NDYgMy44NTY0LTIzLjczMSA5LjIyNzYtMjMuNzMxIDE1LjE2OSAwIDExLjczOSAzNS40NjggMjEuMjU1IDc5LjIyIDIxLjI1NSA0My43NTEgMCA3OS4yMi05LjUxNjEgNzkuMjItMjEuMjU1IDAtNS45NDE0LTkuMDg1LTExLjMxMi0yMy43My0xNS4xNjkgMC4wMzYzLTAuMDI5OCAwLjA3NS0wLjA2MSAwLjExMjUtMC4wOTEzIDE0Ljc3IDMuODc1NSAyMy45MzggOS4yODAyIDIzLjkzOCAxNS4yNiAwIDExLjc4Ni0zNS42MTEgMjEuMzQtNzkuNTQgMjEuMzQiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjM3IiBkPSJtMzE5Ljg5IDkyMS4zM2MtNDMuNzUyIDAtNzkuMjItOS41MTYxLTc5LjIyLTIxLjI1NSAwLTUuOTQxNCA5LjA4NS0xMS4zMTIgMjMuNzMxLTE1LjE2OSAwLjAzODcgMC4wMzEyIDAuMDczOCAwLjA1OTEgMC4xMTI1IDAuMDkwNC0xNC41MjEgMy44Mzk5LTIzLjUyMiA5LjE3NzItMjMuNTIyIDE1LjA3OSAwIDExLjY5MSAzNS4zMjQgMjEuMTY5IDc4Ljg5OSAyMS4xNjlzNzguODk5LTkuNDc3NiA3OC44OTktMjEuMTY5YzAtNS45MDE0LTkuMDAxMi0xMS4yMzktMjMuNTIyLTE1LjA3OCAwLjAzODctMC4wMzEyIDAuMDc1LTAuMDYwMSAwLjExMzc1LTAuMDkxNCAxNC42NDUgMy44NTY1IDIzLjczIDkuMjI3NiAyMy43MyAxNS4xNjkgMCAxMS43MzktMzUuNDY5IDIxLjI1NS03OS4yMiAyMS4yNTUiIGZpbGw9IiNmZGZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjM5IiBkPSJtMzE5Ljg5IDkyMS4yNWMtNDMuNTc1IDAtNzguODk5LTkuNDc3Ni03OC44OTktMjEuMTY5IDAtNS45MDE0IDkuMDAxMi0xMS4yMzkgMjMuNTIyLTE1LjA3OSAwLjAzNjIgMC4wMjk3IDAuMDc2MiAwLjA2MjUgMC4xMTI1IDAuMDkxNC0xNC4zOTQgMy44MjA4LTIzLjMxNSA5LjEyNS0yMy4zMTUgMTQuOTg3IDAgMTEuNjQ0IDM1LjE4MSAyMS4wODMgNzguNTc5IDIxLjA4M3M3OC41NzktOS40MzkgNzguNTc5LTIxLjA4M2MwLTUuODYyMi04LjkyMTItMTEuMTY2LTIzLjMxNS0xNC45ODcgMC4wMzYyLTAuMDI4OSAwLjA3NjMtMC4wNjE2IDAuMTEyNS0wLjA5MDQgMTQuNTIxIDMuODM4OSAyMy41MjIgOS4xNzYyIDIzLjUyMiAxNS4wNzggMCAxMS42OTEtMzUuMzI0IDIxLjE2OS03OC44OTkgMjEuMTY5IiBmaWxsPSIjZmRmZWZlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTY0MSIgZD0ibTMxOS44OSA5MjEuMTZjLTQzLjM5OCAwLTc4LjU3OS05LjQzOS03OC41NzktMjEuMDgzIDAtNS44NjIyIDguOTIxMi0xMS4xNjYgMjMuMzE1LTE0Ljk4NyAwLjA0IDAuMDMxMiAwLjA3NSAwLjA2IDAuMTEzNzUgMC4wOTA3LTE0LjI3MSAzLjgwNDItMjMuMTA4IDkuMDcyOC0yMy4xMDggMTQuODk2IDAgMTEuNTk2IDM1LjAzNiAyMC45OTYgNzguMjU4IDIwLjk5NiA0My4yMiAwIDc4LjI1OC05LjM5OTkgNzguMjU4LTIwLjk5NiAwLTUuODIzOC04LjgzNjItMTEuMDkyLTIzLjEwOC0xNC44OTYgMC4wMzg3LTAuMDMwOCAwLjA3NS0wLjA1OTUgMC4xMTM3NS0wLjA5MDcgMTQuMzk0IDMuODIwOCAyMy4zMTUgOS4xMjUgMjMuMzE1IDE0Ljk4NyAwIDExLjY0NC0zNS4xODEgMjEuMDgzLTc4LjU3OSAyMS4wODMiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjQzIiBkPSJtMzE5Ljg5IDkyMS4wOGMtNDMuMjIxIDAtNzguMjU4LTkuMzk5OS03OC4yNTgtMjAuOTk2IDAtNS44MjM4IDguODM2Mi0xMS4wOTIgMjMuMTA4LTE0Ljg5NiAwLjAzNzUgMC4wMjg5IDAuMDc3NSAwLjA2MTUgMC4xMTUgMC4wOTE0LTE0LjE0NiAzLjc4NTEtMjIuOTAxIDkuMDIxNC0yMi45MDEgMTQuODA1IDAgMTEuNTQ5IDM0Ljg5MiAyMC45MSA3Ny45MzYgMjAuOTEgNDMuMDQyIDAgNzcuOTM2LTkuMzYxMyA3Ny45MzYtMjAuOTEgMC01Ljc4MzgtOC43NTUtMTEuMDItMjIuOTAxLTE0LjgwNSAwLjAzNzUtMC4wMjk5IDAuMDc3NS0wLjA2MjUgMC4xMTUtMC4wOTE0IDE0LjI3MSAzLjgwNDIgMjMuMTA4IDkuMDcyOCAyMy4xMDggMTQuODk2IDAgMTEuNTk2LTM1LjAzOCAyMC45OTYtNzguMjU4IDIwLjk5NiIgZmlsbD0iI2ZkZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDU2NDUiIGQ9Im0zMTkuODkgOTIwLjk5Yy00My4wNDQgMC03Ny45MzYtOS4zNjEzLTc3LjkzNi0yMC45MSAwLTUuNzgzOCA4Ljc1NS0xMS4wMiAyMi45MDEtMTQuODA1IDAuMDM4NyAwLjAzMTIgMC4wNzUgMC4wNiAwLjExMzc1IDAuMDkwMi0xNC4wMjQgMy43Njg2LTIyLjY5NSA4Ljk2OTctMjIuNjk1IDE0LjcxNSAwIDExLjUwMSAzNC43NSAyMC44MjQgNzcuNjE2IDIwLjgyNHM3Ny42MTYtOS4zMjIzIDc3LjYxNi0yMC44MjRjMC01Ljc0NTEtOC42Ny0xMC45NDYtMjIuNjk1LTE0LjcxNSAwLjAzODctMC4wMzAzIDAuMDc1LTAuMDU5IDAuMTEzNzUtMC4wOTAyIDE0LjE0NiAzLjc4NTEgMjIuOTAxIDkuMDIxNCAyMi45MDEgMTQuODA1IDAgMTEuNTQ5LTM0Ljg5NCAyMC45MS03Ny45MzYgMjAuOTEiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1NjQ3IiBkPSJtMzE5Ljg5IDkyMC45Yy00Mi44NjYgMC03Ny42MTYtOS4zMjIzLTc3LjYxNi0yMC44MjQgMC01Ljc0NTEgOC42NzEyLTEwLjk0NiAyMi42OTUtMTQuNzE1IDAuMDM3NSAwLjAyOTkgMC4wNzg4IDAuMDYyNSAwLjExNSAwLjA5MTQtMTMuOSAzLjc1LTIyLjQ4OSA4LjkxNzUtMjIuNDg5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDYgMjAuNzM3IDc3LjI5NSAyMC43MzdzNzcuMjk1LTkuMjgzNyA3Ny4yOTUtMjAuNzM3YzAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40ODktMTQuNjI0IDAuMDM3NS0wLjAyODkgMC4wNzc1LTAuMDYxNSAwLjExNS0wLjA5MTQgMTQuMDI1IDMuNzY4NiAyMi42OTUgOC45Njk3IDIyLjY5NSAxNC43MTUgMCAxMS41MDEtMzQuNzUgMjAuODI0LTc3LjYxNiAyMC44MjQiIGZpbGw9IiNmY2ZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1NjQ5IiBkPSJtMzE5Ljg5IDkyMC44MmMtNDIuNjg5IDAtNzcuMjk1LTkuMjgzNy03Ny4yOTUtMjAuNzM3IDAtNS43MDYgOC41ODg4LTEwLjg3NCAyMi40ODktMTQuNjI0IDAuMDM4NyAwLjAzMTIgMC4wNzYzIDAuMDU5NSAwLjExNSAwLjA5MDctMTMuNzc2IDMuNzMxNS0yMi4yODQgOC44NjUyLTIyLjI4NCAxNC41MzMgMCAxMS40MDYgMzQuNDYyIDIwLjY1MiA3Ni45NzUgMjAuNjUyIDQyLjUxMSAwIDc2Ljk3NC05LjI0NjEgNzYuOTc0LTIwLjY1MiAwLTUuNjY3NS04LjUwNjItMTAuODAxLTIyLjI4Mi0xNC41MzMgMC4wMzg3LTAuMDMxMiAwLjA3NjItMC4wNTk1IDAuMTE1LTAuMDkwNyAxMy45IDMuNzUgMjIuNDg5IDguOTE3NSAyMi40ODkgMTQuNjI0IDAgMTEuNDU0LTM0LjYwNiAyMC43MzctNzcuMjk1IDIwLjczNyIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDU2NTEiIGQ9Im0zMTkuODkgOTIwLjczYy00Mi41MTIgMC03Ni45NzUtOS4yNDYxLTc2Ljk3NS0yMC42NTIgMC01LjY2NzUgOC41MDc1LTEwLjgwMSAyMi4yODQtMTQuNTMzIDAuMDQgMC4wMzEyIDAuMDc3NSAwLjA2MDEgMC4xMTYyNSAwLjA5MTQtMTMuNjUxIDMuNzEzOS0yMi4wNzkgOC44MTM5LTIyLjA3OSAxNC40NDEgMCAxMS4zNTkgMzQuMzE5IDIwLjU2NiA3Ni42NTQgMjAuNTY2czc2LjY1NC05LjIwNzUgNzYuNjU0LTIwLjU2NmMwLTUuNjI3NS04LjQyNzUtMTAuNzI4LTIyLjA3OS0xNC40NDEgMC4wNC0wLjAzMTIgMC4wNzYzLTAuMDYwMSAwLjExNjI1LTAuMDkxNCAxMy43NzYgMy43MzE1IDIyLjI4MiA4Ljg2NTIgMjIuMjgyIDE0LjUzMyAwIDExLjQwNi0zNC40NjIgMjAuNjUyLTc2Ljk3NCAyMC42NTIiIGZpbGw9IiNmY2ZjZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1NjUzIiBkPSJtMzE5Ljg5IDkyMC42NWMtNDIuMzM1IDAtNzYuNjU0LTkuMjA3NS03Ni42NTQtMjAuNTY2IDAtNS42Mjc1IDguNDI3NS0xMC43MjggMjIuMDc5LTE0LjQ0MSAwLjAzNzUgMC4wMjg3IDAuMDc4OCAwLjA2MTUgMC4xMTYyNSAwLjA5MTItMTMuNTI5IDMuNjk1NC0yMS44NzQgOC43NjEzLTIxLjg3NCAxNC4zNSAwIDExLjMxMSAzNC4xNzUgMjAuNDggNzYuMzMyIDIwLjQ4IDQyLjE1OCAwIDc2LjMzMi05LjE2ODkgNzYuMzMyLTIwLjQ4IDAtNS41ODg5LTguMzQzOC0xMC42NTUtMjEuODc0LTE0LjM1IDAuMDQtMC4wMzEyIDAuMDc3NS0wLjA2IDAuMTE2MjUtMC4wOTEyIDEzLjY1MSAzLjcxMzkgMjIuMDc5IDguODEzOSAyMi4wNzkgMTQuNDQxIDAgMTEuMzU5LTM0LjMxOSAyMC41NjYtNzYuNjU0IDIwLjU2NiIgZmlsbD0iI2ZjZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDU2NTUiIGQ9Im0zMTkuODkgOTIwLjU2Yy00Mi4xNTggMC03Ni4zMzItOS4xNjg5LTc2LjMzMi0yMC40OCAwLTUuNTg4OSA4LjM0NS0xMC42NTUgMjEuODc0LTE0LjM1IDAuMDM4OCAwLjAzMDMgMC4wNzc1IDAuMDYwMSAwLjExNjI1IDAuMDkwNC0xMy40MDUgMy42NzcyLTIxLjY3IDguNzEtMjEuNjcgMTQuMjYgMCAxMS4yNjQgMzQuMDMyIDIwLjM5NCA3Ni4wMTIgMjAuMzk0czc2LjAxMi05LjEyOTkgNzYuMDEyLTIwLjM5NGMwLTUuNTQ5OC04LjI2NS0xMC41ODItMjEuNjctMTQuMjU5IDAuMDM3NS0wLjAyOTkgMC4wOC0wLjA2MjUgMC4xMTYyNS0wLjA5MTQgMTMuNTMgMy42OTU0IDIxLjg3NCA4Ljc2MTIgMjEuODc0IDE0LjM1IDAgMTEuMzExLTM0LjE3NSAyMC40OC03Ni4zMzIgMjAuNDgiIGZpbGw9IiNmYmZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg1NjU3IiBkPSJtMzE5Ljg5IDkyMC40N2MtNDEuOTggMC03Ni4wMTItOS4xMjk5LTc2LjAxMi0yMC4zOTQgMC01LjU0OTggOC4yNjUtMTAuNTgyIDIxLjY3LTE0LjI2IDAuMDM4NyAwLjAzMDggMC4wNzc1IDAuMDYxIDAuMTE3NSAwLjA5MDgtMTMuMjg0IDMuNjU5Mi0yMS40NjYgOC42NTc4LTIxLjQ2NiAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4IDQxLjgwNCAwIDc1LjY5MS05LjA5MTQgNzUuNjkxLTIwLjMwOCAwLTUuNTExMi04LjE4MTItMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wNC0wLjAyOTggMC4wNzg3LTAuMDYgMC4xMTc1LTAuMDg5OCAxMy40MDUgMy42NzYyIDIxLjY3IDguNzA5IDIxLjY3IDE0LjI1OSAwIDExLjI2NC0zNC4wMzIgMjAuMzk0LTc2LjAxMiAyMC4zOTQiIGZpbGw9IiNmYmZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg1NjU5IiBkPSJtMzE5Ljg5IDkyMC4zOWMtNDEuODA0IDAtNzUuNjkxLTkuMDkxNC03NS42OTEtMjAuMzA4IDAtNS41MTEyIDguMTgyNS0xMC41MSAyMS40NjYtMTQuMTY5IDAuMDM4NyAwLjAzMTIgMC4wNzc1IDAuMDYxNiAwLjExNzUgMC4wOTE0LTEzLjE2MSAzLjY0MTEtMjEuMjYyIDguNjA1LTIxLjI2MiAxNC4wNzggMCAxMS4xNjkgMzMuNzQ0IDIwLjIyMSA3NS4zNyAyMC4yMjFzNzUuMzctOS4wNTIyIDc1LjM3LTIwLjIyMWMwLTUuNDcyNi04LjEwMTItMTAuNDM2LTIxLjI2MS0xNC4wNzggMC4wMzg3LTAuMDI5OCAwLjA3NzUtMC4wNjAxIDAuMTE2MjUtMC4wOTE0IDEzLjI4NSAzLjY2MDIgMjEuNDY2IDguNjU3OCAyMS40NjYgMTQuMTY5IDAgMTEuMjE2LTMzLjg4OCAyMC4zMDgtNzUuNjkxIDIwLjMwOCIgZmlsbD0iI2ZiZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDU2NjEiIGQ9Im0zMTkuODkgOTIwLjNjLTQxLjYyNiAwLTc1LjM3LTkuMDUyMi03NS4zNy0yMC4yMjEgMC01LjQ3MjYgOC4xMDEyLTEwLjQzNiAyMS4yNjItMTQuMDc4IDAuMDM4NyAwLjAzMTIgMC4wNzc1IDAuMDYxNSAwLjExNzUgMC4wOTEzLTEzLjAzOSAzLjYyMjYtMjEuMDYgOC41NTI4LTIxLjA2IDEzLjk4NiAwIDExLjEyIDMzLjYwMSAyMC4xMzYgNzUuMDUgMjAuMTM2czc1LjA1LTkuMDE2MSA3NS4wNS0yMC4xMzZjMC01LjQzMzYtOC4wMjEyLTEwLjM2NC0yMS4wNi0xMy45ODYgMC4wNC0wLjAyOTcgMC4wNzg4LTAuMDYgMC4xMTg3NS0wLjA5MTMgMTMuMTYgMy42NDExIDIxLjI2MSA4LjYwNSAyMS4yNjEgMTQuMDc4IDAgMTEuMTY5LTMzLjc0NCAyMC4yMjEtNzUuMzcgMjAuMjIxIiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTY2MyIgZD0ibTMxOS44OSA5MjAuMjJjLTQxLjQ0OSAwLTc1LjA1LTkuMDE2MS03NS4wNS0yMC4xMzYgMC01LjQzMzYgOC4wMjEyLTEwLjM2NCAyMS4wNi0xMy45ODYgMC4wMzg4IDAuMDMwNCAwLjA3ODggMC4wNjE2IDAuMTE3NSAwLjA5MTQtMTIuOTE2IDMuNjA0LTIwLjg1NiA4LjUtMjAuODU2IDEzLjg5NSAwIDExLjA3MiAzMy40NTYgMjAuMDUgNzQuNzI5IDIwLjA1IDQxLjI3MSAwIDc0LjcyOS04Ljk3NzYgNzQuNzI5LTIwLjA1IDAtNS4zOTUtNy45NC0xMC4yOTEtMjAuODU2LTEzLjg5NSAwLjAzODctMC4wMjk4IDAuMDc4Ny0wLjA2MDEgMC4xMTc1LTAuMDkxNCAxMy4wMzkgMy42MjI2IDIxLjA2IDguNTUyOCAyMS4wNiAxMy45ODYgMCAxMS4xMi0zMy42MDEgMjAuMTM2LTc1LjA1IDIwLjEzNiIgZmlsbD0iI2ZhZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDU2NjUiIGQ9Im0zMTkuODkgOTIwLjEzYy00MS4yNzIgMC03NC43MjktOC45Nzc2LTc0LjcyOS0yMC4wNSAwLTUuMzk1IDcuOTQtMTAuMjkxIDIwLjg1Ni0xMy44OTUgMC4wNCAwLjAyOTcgMC4wOCAwLjA2MTUgMC4xMTg3NSAwLjA5MTItMTIuNzk1IDMuNTg1LTIwLjY1NSA4LjQ0NzItMjAuNjU1IDEzLjgwNCAwIDExLjAyNSAzMy4zMTQgMTkuOTY0IDc0LjQwOSAxOS45NjRzNzQuNDA5LTguOTM5IDc0LjQwOS0xOS45NjRjMC01LjM1NS03Ljg2MjUtMTAuMjE5LTIwLjY1NS0xMy44MDQgMC4wMzg3LTAuMDI5OCAwLjA3ODctMC4wNjE1IDAuMTE4NzUtMC4wOTEyIDEyLjkxNiAzLjYwNCAyMC44NTYgOC41IDIwLjg1NiAxMy44OTUgMCAxMS4wNzItMzMuNDU4IDIwLjA1LTc0LjcyOSAyMC4wNSIgZmlsbD0iI2ZhZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDU2NjciIGQ9Im0zMTkuODkgOTIwLjA0Yy00MS4wOTUgMC03NC40MDktOC45MzktNzQuNDA5LTE5Ljk2NCAwLTUuMzU2NSA3Ljg2LTEwLjIxOSAyMC42NTUtMTMuODA0IDAuMDQgMC4wMjk5IDAuMDggMC4wNjExIDAuMTIgMC4wOTE0LTEyLjY3NCAzLjU2NTgtMjAuNDU0IDguMzk2LTIwLjQ1NCAxMy43MTIgMCAxMC45NzggMzMuMTcgMTkuODc3IDc0LjA4OCAxOS44NzdzNzQuMDg4LTguODk5OSA3NC4wODgtMTkuODc3YzAtNS4zMTY0LTcuNzgtMTAuMTQ2LTIwLjQ1Mi0xMy43MTIgMC4wMzg3LTAuMDMwMiAwLjA3ODgtMC4wNjE1IDAuMTE4NzUtMC4wOTE0IDEyLjc5MiAzLjU4NSAyMC42NTUgOC40NDg4IDIwLjY1NSAxMy44MDQgMCAxMS4wMjUtMzMuMzE0IDE5Ljk2NC03NC40MDkgMTkuOTY0IiBmaWxsPSIjZmFmYWZhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTY2OSIgZD0ibTMxOS44OSA5MTkuOTZjLTQwLjkxOCAwLTc0LjA4OC04Ljg5OTktNzQuMDg4LTE5Ljg3NyAwLTUuMzE2NCA3Ljc4LTEwLjE0NiAyMC40NTQtMTMuNzEyIDAuMDM4NyAwLjAyOTcgMC4wOCAwLjA2MSAwLjExODc1IDAuMDkxMy0xMi41NSAzLjU0NzQtMjAuMjUxIDguMzQzOC0yMC4yNTEgMTMuNjIxIDAgMTAuOTMgMzMuMDI2IDE5Ljc5MSA3My43NjYgMTkuNzkxczczLjc2Ni04Ljg2MDkgNzMuNzY2LTE5Ljc5MWMwLTUuMjc3NC03LjcwMTItMTAuMDc0LTIwLjI1MS0xMy42MjEgMC4wNC0wLjAzMDMgMC4wOC0wLjA2MTUgMC4xMi0wLjA5MTMgMTIuNjcyIDMuNTY1OSAyMC40NTIgOC4zOTYgMjAuNDUyIDEzLjcxMiAwIDEwLjk3OC0zMy4xNyAxOS44NzctNzQuMDg4IDE5Ljg3NyIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDU2NzEiIGQ9Im0zMTkuODkgOTE5Ljg3Yy00MC43NCAwLTczLjc2Ni04Ljg2MDktNzMuNzY2LTE5Ljc5MSAwLTUuMjc3NCA3LjcwMTItMTAuMDc0IDIwLjI1MS0xMy42MjEgMC4wNCAwLjAyOTkgMC4wOCAwLjA1OTYgMC4xMiAwLjA5MTQtMTIuNDMxIDMuNTI4Mi0yMC4wNTEgOC4yOTEtMjAuMDUxIDEzLjUzIDAgMTAuODgyIDMyLjg4MiAxOS43MDUgNzMuNDQ2IDE5LjcwNSA0MC41NjIgMCA3My40NDYtOC44MjI3IDczLjQ0Ni0xOS43MDUgMC01LjIzODgtNy42Mi0xMC4wMDItMjAuMDUxLTEzLjUzIDAuMDQtMC4wMzAzIDAuMDgtMC4wNjE1IDAuMTItMC4wOTE0IDEyLjU1IDMuNTQ3NCAyMC4yNTEgOC4zNDM4IDIwLjI1MSAxMy42MjEgMCAxMC45My0zMy4wMjYgMTkuNzkxLTczLjc2NiAxOS43OTEiIGZpbGw9IiNmOWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg1NjczIiBkPSJtMzE5Ljg5IDkxOS43OWMtNDAuNTY0IDAtNzMuNDQ2LTguODIyNy03My40NDYtMTkuNzA1IDAtNS4yMzg4IDcuNjItMTAuMDAyIDIwLjA1MS0xMy41MyAwLjA0IDAuMDI5OCAwLjA4MTMgMC4wNjEgMC4xMjEyNSAwLjA5MDctMTIuMzExIDMuNTEwMi0xOS44NTEgOC4yMzg4LTE5Ljg1MSAxMy40MzkgMCAxMC44MzUgMzIuNzM5IDE5LjYxOSA3My4xMjUgMTkuNjE5czczLjEyNS04Ljc4MzYgNzMuMTI1LTE5LjYxOWMwLTUuMjAwMi03LjU0LTkuOTI3Mi0xOS44NS0xMy40MzkgMC4wMzg3LTAuMDI5NyAwLjA4LTAuMDYxIDAuMTItMC4wOTA3IDEyLjQzMSAzLjUyODIgMjAuMDUxIDguMjkxIDIwLjA1MSAxMy41MyAwIDEwLjg4Mi0zMi44ODQgMTkuNzA1LTczLjQ0NiAxOS43MDUiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1Njc1IiBkPSJtMzE5Ljg5IDkxOS43Yy00MC4zODYgMC03My4xMjUtOC43ODM2LTczLjEyNS0xOS42MTkgMC01LjIwMDIgNy41NC05LjkyODggMTkuODUxLTEzLjQzOSAwLjAzODcgMC4wMzAzIDAuMDggMC4wNjAxIDAuMTIgMC4wOTE0LTEyLjE4OCAzLjQ5MTEtMTkuNjUxIDguMTg2NS0xOS42NTEgMTMuMzQ4IDAgMTAuNzg4IDMyLjU5NiAxOS41MzQgNzIuODA1IDE5LjUzNHM3Mi44MDQtOC43NDYxIDcyLjgwNC0xOS41MzRjMC01LjE2MTEtNy40NjI1LTkuODU2NS0xOS42NS0xMy4zNDggMC4wNC0wLjAyOTkgMC4wODEyLTAuMDYxMSAwLjEyMTI1LTAuMDkxNCAxMi4zMSAzLjUxMTggMTkuODUgOC4yMzg4IDE5Ljg1IDEzLjQzOSAwIDEwLjgzNS0zMi43MzkgMTkuNjE5LTczLjEyNSAxOS42MTkiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1Njc3IiBkPSJtMzE5Ljg5IDkxOS42MWMtNDAuMjA5IDAtNzIuODA1LTguNzQ2MS03Mi44MDUtMTkuNTM0IDAtNS4xNjExIDcuNDYzOC05Ljg1NjUgMTkuNjUxLTEzLjM0OCAwLjA0MjUgMC4wMzEyIDAuMDggMC4wNTkgMC4xMjEyNSAwLjA5MTItMTIuMDY4IDMuNDcyNi0xOS40NTEgOC4xMzM5LTE5LjQ1MSAxMy4yNTYgMCAxMC43NCAzMi40NTIgMTkuNDQ4IDcyLjQ4NCAxOS40NDggNDAuMDMxIDAgNzIuNDg0LTguNzA3NSA3Mi40ODQtMTkuNDQ4IDAtNS4xMjI1LTcuMzgzOC05Ljc4MzgtMTkuNDUxLTEzLjI1NiAwLjA0MjUtMC4wMzIyIDAuMDc4Ny0wLjA2IDAuMTIxMjUtMC4wOTEyIDEyLjE4OCAzLjQ5MTEgMTkuNjUgOC4xODY1IDE5LjY1IDEzLjM0OCAwIDEwLjc4OC0zMi41OTUgMTkuNTM0LTcyLjgwNCAxOS41MzQiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1Njc5IiBkPSJtMzE5Ljg5IDkxOS41M2MtNDAuMDMxIDAtNzIuNDg0LTguNzA3NS03Mi40ODQtMTkuNDQ4IDAtNS4xMjI1IDcuMzgzOC05Ljc4MzggMTkuNDUxLTEzLjI1NiAwLjA0IDAuMDI4OSAwLjA4MjUgMC4wNjE1IDAuMTIyNSAwLjA5MTQtMTEuOTQ5IDMuNDUzNi0xOS4yNTIgOC4wOC0xOS4yNTIgMTMuMTY1IDAgMTAuNjkyIDMyLjMwOCAxOS4zNjEgNzIuMTYyIDE5LjM2MSAzOS44NTUgMCA3Mi4xNjItOC42NjkgNzIuMTYyLTE5LjM2MSAwLTUuMDg1LTcuMzAzOC05LjcxMTQtMTkuMjUyLTEzLjE2NSAwLjA0LTAuMDI5OSAwLjA4MjUtMC4wNjExIDAuMTIyNS0wLjA5MTQgMTIuMDY4IDMuNDcyNiAxOS40NTEgOC4xMzM5IDE5LjQ1MSAxMy4yNTYgMCAxMC43NC0zMi40NTIgMTkuNDQ4LTcyLjQ4NCAxOS40NDgiIGZpbGw9IiNmOGY4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg1NjgxIiBkPSJtMzE5Ljg5IDkxOS40NGMtMzkuODU1IDAtNzIuMTYyLTguNjY5LTcyLjE2Mi0xOS4zNjEgMC01LjA4NSA3LjMwMzgtOS43MTE0IDE5LjI1Mi0xMy4xNjUgMC4wNCAwLjAyODggMC4wODEyIDAuMDYxNSAwLjEyMTI1IDAuMDkxMy0xMS44MjggMy40MzUxLTE5LjA1NCA4LjAyNzQtMTkuMDU0IDEzLjA3NCAwIDEwLjY0NSAzMi4xNjUgMTkuMjc1IDcxLjg0MiAxOS4yNzUgMzkuNjc4IDAgNzEuODQyLTguNjI5OSA3MS44NDItMTkuMjc1IDAtNS4wNDY0LTcuMjI2Mi05LjYzODYtMTkuMDU0LTEzLjA3NCAwLjA0LTAuMDI5OCAwLjA4MTMtMC4wNjI1IDAuMTIxMjUtMC4wOTEzIDExLjk0OSAzLjQ1MzYgMTkuMjUyIDguMDggMTkuMjUyIDEzLjE2NSAwIDEwLjY5Mi0zMi4zMDggMTkuMzYxLTcyLjE2MiAxOS4zNjEiIGZpbGw9IiNmOGY4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg1NjgzIiBkPSJtMzE5Ljg5IDkxOS4zNWMtMzkuNjc4IDAtNzEuODQyLTguNjI5OS03MS44NDItMTkuMjc1IDAtNS4wNDY0IDcuMjI2Mi05LjYzODYgMTkuMDU0LTEzLjA3NCAwLjA0MjUgMC4wMzEyIDAuMDgxMyAwLjA1ODYgMC4xMjM3NSAwLjA5MTQtMTEuNzA5IDMuNDE2LTE4Ljg1NiA3Ljk3NS0xOC44NTYgMTIuOTgyIDAgMTAuNTk4IDMyLjAyMSAxOS4xODkgNzEuNTIxIDE5LjE4OXM3MS41MjEtOC41OTEzIDcxLjUyMS0xOS4xODljMC01LjAwNzQtNy4xNDc1LTkuNTY2NC0xOC44NTYtMTIuOTgyIDAuMDQyNS0wLjAzMTIgMC4wODEzLTAuMDYwMSAwLjEyMzc1LTAuMDkxNCAxMS44MjggMy40MzUxIDE5LjA1NCA4LjAyNzQgMTkuMDU0IDEzLjA3NCAwIDEwLjY0NS0zMi4xNjUgMTkuMjc1LTcxLjg0MiAxOS4yNzUiIGZpbGw9IiNmN2Y4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg1Njg1IiBkPSJtMzE5Ljg5IDkxOS4yN2MtMzkuNSAwLTcxLjUyMS04LjU5MTMtNzEuNTIxLTE5LjE4OSAwLTUuMDA3NCA3LjE0NzUtOS41NjY0IDE4Ljg1Ni0xMi45ODIgMC4wNCAwLjAyODggMC4wODI1IDAuMDYxIDAuMTIyNSAwLjA5MTMtMTEuNTg4IDMuMzk3NS0xOC42NTkgNy45MjI0LTE4LjY1OSAxMi44OTEgMCAxMC41NSAzMS44NzkgMTkuMTAzIDcxLjIwMSAxOS4xMDNzNzEuMi04LjU1MjggNzEuMi0xOS4xMDNjMC00Ljk2ODgtNy4wNjg4LTkuNDkzNi0xOC42NTgtMTIuODkxIDAuMDQtMC4wMzAzIDAuMDgyNS0wLjA2MjUgMC4xMjI1LTAuMDkxMyAxMS43MDkgMy40MTYgMTguODU2IDcuOTc1IDE4Ljg1NiAxMi45ODIgMCAxMC41OTgtMzIuMDIxIDE5LjE4OS03MS41MjEgMTkuMTg5IiBmaWxsPSIjZjdmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY4NyIgZD0ibTMxOS44OSA5MTkuMThjLTM5LjMyMiAwLTcxLjIwMS04LjU1MjgtNzEuMjAxLTE5LjEwMyAwLTQuOTY4OCA3LjA3MTItOS40OTM2IDE4LjY1OS0xMi44OTEgMC4wNDI1IDAuMDMxMiAwLjA4MTIgMC4wNjAxIDAuMTIzNzUgMC4wOTE0LTExLjQ3IDMuMzc4NC0xOC40NjEgNy44Njk2LTE4LjQ2MSAxMi44IDAgMTAuNTAyIDMxLjczNCAxOS4wMTYgNzAuODggMTkuMDE2czcwLjg4LTguNTEzNiA3MC44OC0xOS4wMTZjMC00LjkzMDEtNi45OTEyLTkuNDIxNC0xOC40NjEtMTIuOCAwLjA0MjUtMC4wMzEyIDAuMDgxMy0wLjA2MDEgMC4xMjM3NS0wLjA5MTQgMTEuNTg5IDMuMzk3NSAxOC42NTggNy45MjI0IDE4LjY1OCAxMi44OTEgMCAxMC41NS0zMS44NzggMTkuMTAzLTcxLjIgMTkuMTAzIiBmaWxsPSIjZjZmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY4OSIgZD0ibTMxOS44OSA5MTkuMWMtMzkuMTQ2IDAtNzAuODgtOC41MTM2LTcwLjg4LTE5LjAxNiAwLTQuOTMwMSA2Ljk5MTItOS40MjE0IDE4LjQ2MS0xMi44IDAuMDQgMC4wMjgyIDAuMDgzOCAwLjA2MSAwLjEyMzc1IDAuMDkwNy0xMS4zNDkgMy4zNTg5LTE4LjI2NCA3LjgxNzktMTguMjY0IDEyLjcwOSAwIDEwLjQ1NSAzMS41OSAxOC45MzEgNzAuNTU5IDE4LjkzMXM3MC41NTktOC40NzYgNzAuNTU5LTE4LjkzMWMwLTQuODkxMS02LjkxNS05LjM1MDEtMTguMjY0LTEyLjcwOSAwLjA0LTAuMDI5NyAwLjA4MzgtMC4wNjI1IDAuMTIzNzUtMC4wOTA3IDExLjQ3IDMuMzc4NCAxOC40NjEgNy44Njk2IDE4LjQ2MSAxMi44IDAgMTAuNTAyLTMxLjczNCAxOS4wMTYtNzAuODggMTkuMDE2IiBmaWxsPSIjZjZmNmY3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY5MSIgZD0ibTMxOS44OSA5MTkuMDFjLTM4Ljk2OSAwLTcwLjU1OS04LjQ3Ni03MC41NTktMTguOTMxIDAtNC44OTExIDYuOTE1LTkuMzUwMSAxOC4yNjQtMTIuNzA5IDAuMDQyNSAwLjAzMTIgMC4wODI1IDAuMDYwMSAwLjEyNSAwLjA5MTQtMTEuMjMxIDMuMzQwMi0xOC4wNjkgNy43NjUxLTE4LjA2OSAxMi42MTggMCAxMC40MDggMzEuNDQ4IDE4Ljg0NSA3MC4yMzkgMTguODQ1czcwLjIzOS04LjQzNzUgNzAuMjM5LTE4Ljg0NWMwLTQuODUyNS02LjgzNjItOS4yNzc0LTE4LjA2OS0xMi42MTggMC4wNDI1LTAuMDMxMiAwLjA4MjUtMC4wNjAxIDAuMTI1LTAuMDkxNCAxMS4zNDkgMy4zNTg5IDE4LjI2NCA3LjgxNzkgMTguMjY0IDEyLjcwOSAwIDEwLjQ1NS0zMS41OSAxOC45MzEtNzAuNTU5IDE4LjkzMSIgZmlsbD0iI2Y2ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDU2OTMiIGQ9Im0zMTkuODkgOTE4LjkzYy0zOC43OTEgMC03MC4yMzktOC40Mzc1LTcwLjIzOS0xOC44NDUgMC00Ljg1MjUgNi44Mzc1LTkuMjc3NCAxOC4wNjktMTIuNjE4IDAuMDQgMC4wMjg3IDAuMDg1IDAuMDYxNSAwLjEyNSAwLjA5MTItMTEuMTEyIDMuMzIxMy0xNy44NzIgNy43MTE0LTE3Ljg3MiAxMi41MjYgMCAxMC4zNiAzMS4zMDIgMTguNzU5IDY5LjkxOCAxOC43NTkgMzguNjE0IDAgNjkuOTE4LTguMzk4OSA2OS45MTgtMTguNzU5IDAtNC44MTUtNi43Ni05LjIwNTEtMTcuODcyLTEyLjUyNiAwLjA0LTAuMDI5NyAwLjA4NS0wLjA2MjUgMC4xMjUtMC4wOTEyIDExLjIzMiAzLjM0MDIgMTguMDY5IDcuNzY1MSAxOC4wNjkgMTIuNjE4IDAgMTAuNDA4LTMxLjQ0OCAxOC44NDUtNzAuMjM5IDE4Ljg0NSIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDU2OTUiIGQ9Im0zMTkuODkgOTE4Ljg0Yy0zOC42MTUgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc2LTkuMjA1MSAxNy44NzItMTIuNTI2IDAuMDQyNSAwLjAzMTIgMC4wODI1IDAuMDYwMSAwLjEyNjI1IDAuMDkxNC0xMC45OTUgMy4zMDI2LTE3LjY3OSA3LjY1ODYtMTcuNjc5IDEyLjQzNSAwIDEwLjMxMiAzMS4xNiAxOC42NzIgNjkuNTk4IDE4LjY3MnM2OS41OTYtOC4zNTk5IDY5LjU5Ni0xOC42NzJjMC00Ljc3NjQtNi42ODI1LTkuMTMyNC0xNy42NzYtMTIuNDM1IDAuMDQyNS0wLjAzMTIgMC4wODI1LTAuMDYwMSAwLjEyNS0wLjA5MTQgMTEuMTEyIDMuMzIxMyAxNy44NzIgNy43MTE0IDE3Ljg3MiAxMi41MjYgMCAxMC4zNi0zMS4zMDQgMTguNzU5LTY5LjkxOCAxOC43NTkiIGZpbGw9IiNmNWY1ZjUiLz4KICAgPHBhdGggaWQ9InBhdGg1Njk3IiBkPSJtMzE5Ljg5IDkxOC43NWMtMzguNDM4IDAtNjkuNTk4LTguMzU5OS02OS41OTgtMTguNjcyIDAtNC43NzY0IDYuNjgzOC05LjEzMjQgMTcuNjc5LTEyLjQzNSAwLjA0MjUgMC4wMzEyIDAuMDgyNSAwLjA2IDAuMTI1IDAuMDkxMy0xMC44NzYgMy4yODI4LTE3LjQ4MiA3LjYwNjUtMTcuNDgyIDEyLjM0NCAwIDEwLjI2NSAzMS4wMTYgMTguNTg2IDY5LjI3NiAxOC41ODZzNjkuMjc2LTguMzIxMyA2OS4yNzYtMTguNTg2YzAtNC43MzcyLTYuNjA2Mi05LjA2MS0xNy40ODItMTIuMzQ0IDAuMDQyNS0wLjAzMTIgMC4wODI1LTAuMDYgMC4xMjYyNS0wLjA5MTMgMTAuOTk0IDMuMzAyNiAxNy42NzYgNy42NTg2IDE3LjY3NiAxMi40MzUgMCAxMC4zMTItMzEuMTU5IDE4LjY3Mi02OS41OTYgMTguNjcyIiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY5OSIgZD0ibTMxOS44OSA5MTguNjdjLTM4LjI2IDAtNjkuMjc2LTguMzIxMy02OS4yNzYtMTguNTg2IDAtNC43MzcyIDYuNjA2Mi05LjA2MSAxNy40ODItMTIuMzQ0IDAuMDQxMiAwLjAyOTcgMC4wODYzIDAuMDYyNSAwLjEyNzUgMC4wOTEyLTEwLjc1OSAzLjI2MzgtMTcuMjg5IDcuNTUzOC0xNy4yODkgMTIuMjUyIDAgMTAuMjE3IDMwLjg3MiAxOC41IDY4Ljk1NSAxOC41IDM4LjA4MiAwIDY4Ljk1NS04LjI4MjggNjguOTU1LTE4LjUgMC00LjY5ODgtNi41My04Ljk4ODgtMTcuMjg5LTEyLjI1MiAwLjA0MzctMC4wMzAzIDAuMDg1LTAuMDYgMC4xMjc1LTAuMDkxMiAxMC44NzYgMy4yODI4IDE3LjQ4MiA3LjYwNjUgMTcuNDgyIDEyLjM0NCAwIDEwLjI2NS0zMS4wMTYgMTguNTg2LTY5LjI3NiAxOC41ODYiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg1NzAxIiBkPSJtMzE5Ljg5IDkxOC41OGMtMzguMDgyIDAtNjguOTU1LTguMjgyOC02OC45NTUtMTguNSAwLTQuNjk4OCA2LjUzLTguOTg4OCAxNy4yODktMTIuMjUyIDAuMDQyNSAwLjAzMTIgMC4wODM4IDAuMDYxMSAwLjEyNjI1IDAuMDkxNC0xMC42NDEgMy4yNDM2LTE3LjA5NSA3LjUtMTcuMDk1IDEyLjE2MSAwIDEwLjE3IDMwLjcyOSAxOC40MTUgNjguNjM1IDE4LjQxNXM2OC42MzQtOC4yNDUyIDY4LjYzNC0xOC40MTVjMC00LjY2MTEtNi40NTI1LTguOTE2NS0xNy4wOTQtMTIuMTYgMC4wNC0wLjAyOTkgMC4wODYzLTAuMDYyNSAwLjEyNjI1LTAuMDkyNCAxMC43NTkgMy4yNjM4IDE3LjI4OSA3LjU1MzggMTcuMjg5IDEyLjI1MiAwIDEwLjIxNy0zMC44NzIgMTguNS02OC45NTUgMTguNSIgZmlsbD0iI2Y0ZjRmNCIvPgogICA8cGF0aCBpZD0icGF0aDU3MDMiIGQ9Im0zMTkuODkgOTE4LjVjLTM3LjkwNiAwLTY4LjYzNS04LjI0NTItNjguNjM1LTE4LjQxNSAwLTQuNjYxMSA2LjQ1MzgtOC45MTc1IDE3LjA5NS0xMi4xNjEgMC4wNDM3IDAuMDMxMiAwLjA4NSAwLjA2MSAwLjEyNzUgMC4wOTIyLTEwLjUyNCAzLjIyMzYtMTYuOTAxIDcuNDQ2NC0xNi45MDEgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NSAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1Mi0xNi45MDEtMTIuMDY5IDAuMDQyNS0wLjAzMTIgMC4wODUtMC4wNjEgMC4xMjc1LTAuMDkxMiAxMC42NDEgMy4yNDM2IDE3LjA5NCA3LjQ5OSAxNy4wOTQgMTIuMTYgMCAxMC4xNy0zMC43MjggMTguNDE1LTY4LjYzNCAxOC40MTUiIGZpbGw9IiNmM2YzZjMiLz4KICAgPHBhdGggaWQ9InBhdGg1NzA1IiBkPSJtMzE5Ljg5IDkxOC40MWMtMzcuNzI5IDAtNjguMzE0LTguMjA2MS02OC4zMTQtMTguMzI5IDAtNC42MjI1IDYuMzc3NS04Ljg0NTIgMTYuOTAxLTEyLjA2OSAwLjA0MzcgMC4wMzAzIDAuMDg1IDAuMDYwMSAwLjEyODc1IDAuMDkxNC0xMC40MDggMy4yMDM2LTE2LjcxIDcuMzkzNS0xNi43MSAxMS45NzggMCAxMC4wNzUgMzAuNDQyIDE4LjI0MyA2Ny45OTQgMTguMjQzIDM3LjU1MSAwIDY3Ljk5Mi04LjE2NzQgNjcuOTkyLTE4LjI0MyAwLTQuNTg0LTYuMzAxMi04Ljc3MjUtMTYuNzA5LTExLjk3OCAwLjA0MzctMC4wMzEyIDAuMDg2My0wLjA2MTEgMC4xMjg3NS0wLjA5MTQgMTAuNTI0IDMuMjIzNiAxNi45MDEgNy40NDY0IDE2LjkwMSAxMi4wNjkgMCAxMC4xMjItMzAuNTg1IDE4LjMyOS02OC4zMTQgMTguMzI5IiBmaWxsPSIjZjNmM2YyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTcwNyIgZD0ibTMxOS44OSA5MTguMzJjLTM3LjU1MSAwLTY3Ljk5NC04LjE2NzQtNjcuOTk0LTE4LjI0MyAwLTQuNTg0IDYuMzAyNS04Ljc3MzkgMTYuNzEtMTEuOTc4IDAuMDQyNSAwLjAyOTggMC4wODUgMC4wNjE1IDAuMTI4NzUgMC4wOTEzLTEwLjI5MSAzLjE4NTEtMTYuNTE4IDcuMzM5OS0xNi41MTggMTEuODg2IDAgMTAuMDI3IDMwLjI5OSAxOC4xNTYgNjcuNjcyIDE4LjE1NiAzNy4zNzQgMCA2Ny42NzItOC4xMjg5IDY3LjY3Mi0xOC4xNTYgMC00LjU0NDktNi4yMjg4LTguNzAxMS0xNi41MTYtMTEuODg2IDAuMDQyNS0wLjAyOTggMC4wODUtMC4wNjE1IDAuMTI3NS0wLjA5MTMgMTAuNDA4IDMuMjA1IDE2LjcwOSA3LjM5MzUgMTYuNzA5IDExLjk3OCAwIDEwLjA3NS0zMC40NDEgMTguMjQzLTY3Ljk5MiAxOC4yNDMiIGZpbGw9IiNmMmYyZjIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzA5IiBkPSJtMzE5Ljg5IDkxOC4yNGMtMzcuMzc0IDAtNjcuNjcyLTguMTI4OS02Ny42NzItMTguMTU2IDAtNC41NDY0IDYuMjI2Mi04LjcwMTEgMTYuNTE4LTExLjg4NiAwLjA0MjUgMC4wMjk4IDAuMDg1IDAuMDYxIDAuMTI4NzUgMC4wOTE0LTEwLjE3NCAzLjE2NS0xNi4zMjUgNy4yODc1LTE2LjMyNSAxMS43OTUgMCA5Ljk4IDMwLjE1NCAxOC4wNyA2Ny4zNTEgMTguMDcgMzcuMTk4IDAgNjcuMzUxLTguMDg5OCA2Ny4zNTEtMTguMDcgMC00LjUwNzQtNi4xNTEyLTguNjI5OS0xNi4zMjUtMTEuNzk1IDAuMDQzNy0wLjAzMDQgMC4wODYyLTAuMDYxNiAwLjEzLTAuMDkxNCAxMC4yODggMy4xODUxIDE2LjUxNiA3LjM0MTQgMTYuNTE2IDExLjg4NiAwIDEwLjAyNy0zMC4yOTkgMTguMTU2LTY3LjY3MiAxOC4xNTYiIGZpbGw9IiNmMmYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzExIiBkPSJtMzE5Ljg5IDkxOC4xNWMtMzcuMTk4IDAtNjcuMzUxLTguMDg5OC02Ny4zNTEtMTguMDcgMC00LjUwNzQgNi4xNTEyLTguNjI5OSAxNi4zMjUtMTEuNzk1IDAuMDQyNSAwLjAzMTIgMC4wODYzIDAuMDYxIDAuMTMgMC4wOTEzLTEwLjA1OCAzLjE0Ni0xNi4xMzUgNy4yMzQ5LTE2LjEzNSAxMS43MDQgMCA5LjkzMjYgMzAuMDExIDE3Ljk4NCA2Ny4wMzEgMTcuOTg0czY3LjAzLTguMDUxMyA2Ny4wMy0xNy45ODRjMC00LjQ2ODgtNi4wNzYyLTguNTU3Ni0xNi4xMzItMTEuNzAzIDAuMDQyNS0wLjAzMTIgMC4wODYzLTAuMDYxIDAuMTI4NzUtMC4wOTIzIDEwLjE3NCAzLjE2NSAxNi4zMjUgNy4yODc1IDE2LjMyNSAxMS43OTUgMCA5Ljk4LTMwLjE1NCAxOC4wNy02Ny4zNTEgMTguMDciIGZpbGw9IiNmMWYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzEzIiBkPSJtMzE5Ljg5IDkxOC4wNmMtMzcuMDIgMC02Ny4wMzEtOC4wNTEzLTY3LjAzMS0xNy45ODQgMC00LjQ2ODggNi4wNzc1LTguNTU3NiAxNi4xMzUtMTEuNzA0IDAuMDQyNSAwLjAzMTIgMC4wODYzIDAuMDYxIDAuMTMgMC4wOTIyLTkuOTQzOCAzLjEyNS0xNS45NDQgNy4xODAyLTE1Ljk0NCAxMS42MTEgMCA5Ljg4NDggMjkuODY4IDE3Ljg5NyA2Ni43MSAxNy44OTdzNjYuNzEtOC4wMTI3IDY2LjcxLTE3Ljg5N2MwLTQuNDMxMS02LjAwMjUtOC40ODY0LTE1Ljk0Mi0xMS42MTEgMC4wNDI1LTAuMDI5NyAwLjA4NjMtMC4wNjEgMC4xMy0wLjA5MTMgMTAuMDU2IDMuMTQ1IDE2LjEzMiA3LjIzMzkgMTYuMTMyIDExLjcwMyAwIDkuOTMyNi0zMC4wMSAxNy45ODQtNjcuMDMgMTcuOTg0IiBmaWxsPSIjZjBmMGYwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTcxNSIgZD0ibTMxOS44OSA5MTcuOThjLTM2Ljg0MiAwLTY2LjcxLTguMDEyNy02Ni43MS0xNy44OTcgMC00LjQzMTEgNi04LjQ4NjQgMTUuOTQ0LTExLjYxMSAwLjA0MjUgMC4wMzA0IDAuMDg2MyAwLjA2MTYgMC4xMyAwLjA5MTQtOS44MjUgMy4xMDY0LTE1Ljc1MiA3LjEyNzQtMTUuNzUyIDExLjUyIDAgOS44Mzc0IDI5LjcyMiAxNy44MTIgNjYuMzg5IDE3LjgxMiAzNi42NjUgMCA2Ni4zODktNy45NzUxIDY2LjM4OS0xNy44MTIgMC00LjM5MjYtNS45Mjc1LTguNDEzNi0xNS43NTItMTEuNTIgMC4wNDM3LTAuMDI5OCAwLjA4NzUtMC4wNjEgMC4xMzEyNS0wLjA5MTQgOS45NCAzLjEyNSAxNS45NDIgNy4xODAyIDE1Ljk0MiAxMS42MTEgMCA5Ljg4NDgtMjkuODY4IDE3Ljg5Ny02Ni43MSAxNy44OTciIGZpbGw9IiNmMGYwZWYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzE3IiBkPSJtMzE5Ljg5IDkxNy44OWMtMzYuNjY2IDAtNjYuMzg5LTcuOTc1MS02Ni4zODktMTcuODEyIDAtNC4zOTI2IDUuOTI3NS04LjQxMzYgMTUuNzUyLTExLjUyIDAuMDQ1IDAuMDMxMiAwLjA4NjIgMC4wNiAwLjEzMTI1IDAuMDkxMi05LjcxIDMuMDg2NS0xNS41NjQgNy4wNzM4LTE1LjU2NCAxMS40MjkgMCA5Ljc5IDI5LjU4IDE3LjcyNiA2Ni4wNjkgMTcuNzI2czY2LjA2OS03LjkzNjEgNjYuMDY5LTE3LjcyNmMwLTQuMzU1LTUuODUzOC04LjM0MjItMTUuNTY0LTExLjQyOSAwLjA0NjItMC4wMzEyIDAuMDg2My0wLjA1ODUgMC4xMzEyNS0wLjA5MTIgOS44MjUgMy4xMDY0IDE1Ljc1MiA3LjEyNzQgMTUuNzUyIDExLjUyIDAgOS44Mzc0LTI5LjcyNCAxNy44MTItNjYuMzg5IDE3LjgxMiIgZmlsbD0iI2VmZWZlZiIvPgogICA8cGF0aCBpZD0icGF0aDU3MTkiIGQ9Im0zMTkuODkgOTE3LjgxYy0zNi40ODkgMC02Ni4wNjktNy45MzYxLTY2LjA2OS0xNy43MjYgMC00LjM1NSA1Ljg1MzgtOC4zNDIyIDE1LjU2NC0xMS40MjkgMC4wNDM3IDAuMDI5OSAwLjA4ODggMC4wNjI1IDAuMTMxMjUgMC4wOTI0LTkuNTkzOCAzLjA2NDktMTUuMzc0IDcuMDItMTUuMzc0IDExLjMzNiAwIDkuNzQyNiAyOS40MzYgMTcuNjQgNjUuNzQ4IDE3LjY0IDM2LjMxMSAwIDY1Ljc0OC03Ljg5NzUgNjUuNzQ4LTE3LjY0IDAtNC4zMTY0LTUuNzgtOC4yNzE1LTE1LjM3NC0xMS4zMzYgMC4wNDI1LTAuMDI5OSAwLjA4NzUtMC4wNjExIDAuMTMxMjUtMC4wOTI0IDkuNzEgMy4wODY1IDE1LjU2NCA3LjA3MzggMTUuNTY0IDExLjQyOSAwIDkuNzktMjkuNTggMTcuNzI2LTY2LjA2OSAxNy43MjYiIGZpbGw9IiNlZWUiLz4KICAgPHBhdGggaWQ9InBhdGg1NzIxIiBkPSJtMzE5Ljg5IDkxNy43MmMtMzYuMzExIDAtNjUuNzQ4LTcuODk3NS02NS43NDgtMTcuNjQgMC00LjMxNjQgNS43OC04LjI3MTUgMTUuMzc0LTExLjMzNiAwLjA0MzcgMC4wMzAyIDAuMDg4OCAwLjA2MTUgMC4xMzI1IDAuMDkxMi05LjQ4MTIgMy4wNDY0LTE1LjE4NiA2Ljk2NjQtMTUuMTg2IDExLjI0NSAwIDkuNjk0OSAyOS4yOTIgMTcuNTU0IDY1LjQyOCAxNy41NTQgMzYuMTM0IDAgNjUuNDI2LTcuODU4OCA2NS40MjYtMTcuNTU0IDAtNC4yNzg4LTUuNzAzOC04LjE5ODgtMTUuMTg1LTExLjI0NSAwLjA0MzgtMC4wMjk3IDAuMDg4OC0wLjA2MSAwLjEzMjUtMC4wOTEyIDkuNTkzOCAzLjA2NDkgMTUuMzc0IDcuMDIgMTUuMzc0IDExLjMzNiAwIDkuNzQyNi0yOS40MzYgMTcuNjQtNjUuNzQ4IDE3LjY0IiBmaWxsPSIjZWVlZWVkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTcyMyIgZD0ibTMxOS44OSA5MTcuNjNjLTM2LjEzNSAwLTY1LjQyOC03Ljg1ODgtNjUuNDI4LTE3LjU1NCAwLTQuMjc4OCA1LjcwNS04LjE5ODggMTUuMTg2LTExLjI0NSAwLjA0NjIgMC4wMzEyIDAuMDg3NSAwLjA2MDEgMC4xMzI1IDAuMDkxNC05LjM2NjIgMy4wMjYyLTE0Ljk5OCA2LjkxMzUtMTQuOTk4IDExLjE1NCAwIDkuNjQ3NSAyOS4xNDkgMTcuNDY3IDY1LjEwNiAxNy40NjcgMzUuOTU4IDAgNjUuMTA2LTcuODE5OCA2NS4xMDYtMTcuNDY3IDAtNC4yNDAyLTUuNjMxMi04LjEyNzUtMTQuOTk4LTExLjE1NCAwLjA0NS0wLjAzMTIgMC4wODYzLTAuMDYwMSAwLjEzMjUtMC4wOTE0IDkuNDgxMiAzLjA0NjQgMTUuMTg1IDYuOTY2NCAxNS4xODUgMTEuMjQ1IDAgOS42OTQ5LTI5LjI5MiAxNy41NTQtNjUuNDI2IDE3LjU1NCIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDU3MjUiIGQ9Im0zMTkuODkgOTE3LjU1Yy0zNS45NTggMC02NS4xMDYtNy44MTk4LTY1LjEwNi0xNy40NjcgMC00LjI0MDIgNS42MzEyLTguMTI3NSAxNC45OTgtMTEuMTU0IDAuMDQzNyAwLjAzMDMgMC4wOSAwLjA2MjUgMC4xMzM3NSAwLjA5MjMtOS4yNTEyIDMuMDA1NC0xNC44MSA2Ljg1ODktMTQuODEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNSAxNy4zODEgNjQuNzg1IDE3LjM4MXM2NC43ODUtNy43ODEyIDY0Ljc4NS0xNy4zODFjMC00LjIwMjYtNS41NTg4LTguMDU2MS0xNC44MS0xMS4wNjIgMC4wNDM3LTAuMDI5OCAwLjA5LTAuMDYyIDAuMTMzNzUtMC4wOTIzIDkuMzY2MiAzLjAyNjIgMTQuOTk4IDYuOTEzNSAxNC45OTggMTEuMTU0IDAgOS42NDc1LTI5LjE0OSAxNy40NjctNjUuMTA2IDE3LjQ2NyIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDU3MjciIGQ9Im0zMTkuODkgOTE3LjQ2Yy0zNS43OCAwLTY0Ljc4NS03Ljc4MTItNjQuNzg1LTE3LjM4MSAwLTQuMjAyNiA1LjU1ODgtOC4wNTYxIDE0LjgxLTExLjA2MiAwLjA0NjIgMC4wMzE3IDAuMDg4OCAwLjA2IDAuMTMzNzUgMC4wOTEyLTkuMTM4OCAyLjk4NTQtMTQuNjI0IDYuODA2Ni0xNC42MjQgMTAuOTcgMCA5LjU1MjggMjguODYyIDE3LjI5NSA2NC40NjUgMTcuMjk1IDM1LjYwMiAwIDY0LjQ2NS03Ljc0MjIgNjQuNDY1LTE3LjI5NSAwLTQuMTYzNi01LjQ4NS03Ljk4NDktMTQuNjI0LTEwLjk3IDAuMDQ1LTAuMDMxMiAwLjA4NzUtMC4wNTk1IDAuMTMzNzUtMC4wOTEyIDkuMjUxMiAzLjAwNTQgMTQuODEgNi44NTg5IDE0LjgxIDExLjA2MiAwIDkuNjAwMS0yOS4wMDUgMTcuMzgxLTY0Ljc4NSAxNy4zODEiIGZpbGw9IiNlY2VjZWIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzI5IiBkPSJtMzE5Ljg5IDkxNy4zN2MtMzUuNjAyIDAtNjQuNDY1LTcuNzQyMi02NC40NjUtMTcuMjk1IDAtNC4xNjM2IDUuNDg1LTcuOTg0OSAxNC42MjQtMTAuOTcgMC4wNDM3IDAuMDMwMyAwLjA5MTMgMC4wNjI1IDAuMTM1IDAuMDkxNC05LjAyNSAyLjk2NTItMTQuNDM4IDYuNzUyNC0xNC40MzggMTAuODc5IDAgOS41MDQ5IDI4LjcxOCAxNy4yMSA2NC4xNDQgMTcuMjFzNjQuMTQ0LTcuNzA1MSA2NC4xNDQtMTcuMjFjMC00LjEyNjUtNS40MTI1LTcuOTEzNi0xNC40MzgtMTAuODc4IDAuMDQzOC0wLjAzMDMgMC4wOTEzLTAuMDYyNSAwLjEzNS0wLjA5MjggOS4xMzg4IDIuOTg1NCAxNC42MjQgNi44MDY2IDE0LjYyNCAxMC45NyAwIDkuNTUyOC0yOC44NjIgMTcuMjk1LTY0LjQ2NSAxNy4yOTUiIGZpbGw9IiNlY2ViZWEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzMxIiBkPSJtMzE5Ljg5IDkxNy4yOWMtMzUuNDI2IDAtNjQuMTQ0LTcuNzA1MS02NC4xNDQtMTcuMjEgMC00LjEyNjUgNS40MTI1LTcuOTEzNiAxNC40MzgtMTAuODc5IDAuMDQ2MyAwLjAzMTIgMC4wODg4IDAuMDYxNSAwLjEzNSAwLjA5MjgtOC45MTEyIDIuOTQzOS0xNC4yNTIgNi42OTcyLTE0LjI1MiAxMC43ODYgMCA5LjQ1NzUgMjguNTc1IDE3LjEyNCA2My44MjQgMTcuMTI0czYzLjgyMi03LjY2NiA2My44MjItMTcuMTI0YzAtNC4wODg5LTUuMzQtNy44NDIyLTE0LjI1MS0xMC43ODYgMC4wNDYyLTAuMDMxMiAwLjA5LTAuMDYwMSAwLjEzNS0wLjA5MTQgOS4wMjUgMi45NjM5IDE0LjQzOCA2Ljc1MSAxNC40MzggMTAuODc4IDAgOS41MDQ5LTI4LjcxOCAxNy4yMS02NC4xNDQgMTcuMjEiIGZpbGw9IiNlYmViZWEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzMzIiBkPSJtMzE5Ljg5IDkxNy4yYy0zNS4yNDkgMC02My44MjQtNy42NjYtNjMuODI0LTE3LjEyNCAwLTQuMDg4OSA1LjM0MTItNy44NDIyIDE0LjI1Mi0xMC43ODYgMC4wNDM3IDAuMDI5NyAwLjA5MTMgMC4wNjI1IDAuMTM1IDAuMDkxMi04Ljc5ODggMi45MjM0LTE0LjA2NiA2LjY0NTItMTQuMDY2IDEwLjY5NSAwIDkuNDEwMSAyOC40MzEgMTcuMDM4IDYzLjUwMiAxNy4wMzhzNjMuNTAyLTcuNjI3NSA2My41MDItMTcuMDM4YzAtNC4wNDk4LTUuMjY3NS03Ljc3LTE0LjA2Ni0xMC42OTUgMC4wNDM3LTAuMDI4NyAwLjA5MTMtMC4wNjE1IDAuMTM1LTAuMDkxMiA4LjkxMTIgMi45NDM5IDE0LjI1MSA2LjY5NzIgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzQgMTcuMTI0LTYzLjgyMiAxNy4xMjQiIGZpbGw9IiNlYWVhZTkiLz4KICAgPHBhdGggaWQ9InBhdGg1NzM1IiBkPSJtMzE5Ljg5IDkxNy4xMmMtMzUuMDcxIDAtNjMuNTAyLTcuNjI3NS02My41MDItMTcuMDM4IDAtNC4wNDk4IDUuMjY3NS03Ljc3MTUgMTQuMDY2LTEwLjY5NSAwLjA0NjIgMC4wMzEyIDAuMDkxMyAwLjA2MTEgMC4xMzYyNSAwLjA5MjQtOC42ODUgMi45MDIyLTEzLjg4MSA2LjU4OTgtMTMuODgxIDEwLjYwMiAwIDkuMzYyMiAyOC4yODYgMTYuOTUxIDYzLjE4MSAxNi45NTEgMzQuODk0IDAgNjMuMTgxLTcuNTg4OSA2My4xODEtMTYuOTUxIDAtNC4wMTI4LTUuMTk2Mi03LjY5ODgtMTMuODgxLTEwLjYwMiAwLjA0NjMtMC4wMzEyIDAuMDktMC4wNjExIDAuMTM2MjUtMC4wOTI0IDguNzk4OCAyLjkyNDkgMTQuMDY2IDYuNjQ1MSAxNC4wNjYgMTAuNjk1IDAgOS40MTAxLTI4LjQzMSAxNy4wMzgtNjMuNTAyIDE3LjAzOCIgZmlsbD0iI2VhZWFlOCIvPgogICA8cGF0aCBpZD0icGF0aDU3MzciIGQ9Im0zMTkuODkgOTE3LjAzYy0zNC44OTUgMC02My4xODEtNy41ODg5LTYzLjE4MS0xNi45NTEgMC00LjAxMjggNS4xOTYyLTcuNzAwMiAxMy44ODEtMTAuNjAyIDAuMDQ2MyAwLjAzMTIgMC4wOTEzIDAuMDYxIDAuMTM3NSAwLjA5MTMtOC41NzI1IDIuODgyNC0xMy42OTkgNi41Mzc2LTEzLjY5OSAxMC41MTEgMCA5LjMxNSAyOC4xNDQgMTYuODY1IDYyLjg2MSAxNi44NjUgMzQuNzE4IDAgNjIuODYxLTcuNTQ5OCA2Mi44NjEtMTYuODY1IDAtMy45NzM2LTUuMTI2Mi03LjYyNzUtMTMuNjk5LTEwLjUxMSAwLjA0NjItMC4wMzAzIDAuMDkxMy0wLjA2IDAuMTM3NS0wLjA5MTMgOC42ODUgMi45MDM4IDEzLjg4MSA2LjU4OTggMTMuODgxIDEwLjYwMiAwIDkuMzYyMi0yOC4yODggMTYuOTUxLTYzLjE4MSAxNi45NTEiIGZpbGw9IiNlOWU5ZTgiLz4KICAgPHBhdGggaWQ9InBhdGg1NzM5IiBkPSJtMzE5Ljg5IDkxNi45NGMtMzQuNzE4IDAtNjIuODYxLTcuNTQ5OC02Mi44NjEtMTYuODY1IDAtMy45NzM2IDUuMTI2Mi03LjYyODkgMTMuNjk5LTEwLjUxMSAwLjA0NjIgMC4wMzEyIDAuMDkxMyAwLjA2MSAwLjEzNzUgMC4wOTIzLTguNDYxMiAyLjg2MjktMTMuNTE1IDYuNDgzLTEzLjUxNSAxMC40MTkgMCA5LjI2NzYgMjggMTYuNzc5IDYyLjU0IDE2Ljc3OXM2Mi41NC03LjUxMTIgNjIuNTQtMTYuNzc5YzAtMy45MzYtNS4wNTM4LTcuNTU2MS0xMy41MTUtMTAuNDE5IDAuMDQ2Mi0wLjAzMTIgMC4wOTEyLTAuMDYxIDAuMTM3NS0wLjA5MjMgOC41NzI1IDIuODgzOCAxMy42OTkgNi41Mzc2IDEzLjY5OSAxMC41MTEgMCA5LjMxNS0yOC4xNDQgMTYuODY1LTYyLjg2MSAxNi44NjUiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg1NzQxIiBkPSJtMzE5Ljg5IDkxNi44NmMtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTUtMTAuNDE5IDAuMDQ2MiAwLjAzMTIgMC4wOTEzIDAuMDYxNiAwLjEzNzUgMC4wOTE0LTguMzUgMi44NDI4LTEzLjMzMSA2LjQyODgtMTMuMzMxIDEwLjMyOCAwIDkuMjIwMiAyNy44NTYgMTYuNjk0IDYyLjIxOSAxNi42OTQgMzQuMzYyIDAgNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0IDAtMy44OTg5LTQuOTgxMi03LjQ4NDktMTMuMzMxLTEwLjMyNiAwLjA0NjMtMC4wMzEyIDAuMDkxMy0wLjA2MTYgMC4xMzc1LTAuMDkyOSA4LjQ2MTIgMi44NjI5IDEzLjUxNSA2LjQ4MyAxMy41MTUgMTAuNDE5IDAgOS4yNjc2LTI4IDE2Ljc3OS02Mi41NCAxNi43NzkiIGZpbGw9IiNlOGU4ZTYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzQzIiBkPSJtMzE5Ljg5IDkxNi43N2MtMzQuMzYyIDAtNjIuMjE5LTcuNDczNi02Mi4yMTktMTYuNjk0IDAtMy44OTg5IDQuOTgxMi03LjQ4NDkgMTMuMzMxLTEwLjMyOCAwLjA0NjIgMC4wMzEyIDAuMDkyNSAwLjA2MTUgMC4xMzg3NSAwLjA5MjgtOC4yMzc1IDIuODE5OS0xMy4xNSA2LjM3MzUtMTMuMTUgMTAuMjM1IDAgOS4xNzI0IDI3LjcxMiAxNi42MDcgNjEuODk5IDE2LjYwNyAzNC4xODUgMCA2MS44OTktNy40MzUxIDYxLjg5OS0xNi42MDcgMC0zLjg2MTQtNC45MTI1LTcuNDE1LTEzLjE1LTEwLjIzNSAwLjA0NjItMC4wMzAzIDAuMDkyNS0wLjA2MTUgMC4xMzg3NS0wLjA5MTMgOC4zNSAyLjg0MTMgMTMuMzMxIDYuNDI3MyAxMy4zMzEgMTAuMzI2IDAgOS4yMjAyLTI3Ljg1NiAxNi42OTQtNjIuMjE5IDE2LjY5NCIgZmlsbD0iI2U3ZTdlNiIvPgogICA8cGF0aCBpZD0icGF0aDU3NDUiIGQ9Im0zMTkuODkgOTE2LjY5Yy0zNC4xODYgMC02MS44OTktNy40MzUxLTYxLjg5OS0xNi42MDcgMC0zLjg2MTQgNC45MTI1LTcuNDE1IDEzLjE1LTEwLjIzNSAwLjA0NjIgMC4wMzEyIDAuMDkyNSAwLjA2MSAwLjEzODc1IDAuMDkyMi04LjEyNzUgMi43OTg5LTEyLjk2OCA2LjMxODktMTIuOTY4IDEwLjE0MyAwIDkuMTIzNSAyNy41NjkgMTYuNTIxIDYxLjU3OCAxNi41MjFzNjEuNTc4LTcuMzk4IDYxLjU3OC0xNi41MjFjMC0zLjgyMjgtNC44NDEyLTcuMzQzOC0xMi45NjgtMTAuMTQzIDAuMDQ2My0wLjAzMTIgMC4wOTI1LTAuMDYxIDAuMTM4NzUtMC4wOTIyIDguMjM3NSAyLjgxOTkgMTMuMTUgNi4zNzM1IDEzLjE1IDEwLjIzNSAwIDkuMTcyNC0yNy43MTQgMTYuNjA3LTYxLjg5OSAxNi42MDciIGZpbGw9IiNlN2U2ZTUiLz4KICAgPHBhdGggaWQ9InBhdGg1NzQ3IiBkPSJtMzE5Ljg5IDkxNi42Yy0zNC4wMDkgMC02MS41NzgtNy4zOTgtNjEuNTc4LTE2LjUyMSAwLTMuODIzOCA0Ljg0LTcuMzQzOCAxMi45NjgtMTAuMTQzIDAuMDQ2MyAwLjAzMDQgMC4wOTM3IDAuMDYxNiAwLjE0IDAuMDkxNC04LjAxNSAyLjc3ODgtMTIuNzg4IDYuMjY2MS0xMi43ODggMTAuMDUxIDAgOS4wNzc2IDI3LjQyNiAxNi40MzUgNjEuMjU4IDE2LjQzNSAzMy44MzEgMCA2MS4yNTgtNy4zNTc0IDYxLjI1OC0xNi40MzUgMC0zLjc4NTEtNC43NzEyLTcuMjcyNS0xMi43ODgtMTAuMDUxIDAuMDQ2Mi0wLjAyOTggMC4wOTM3LTAuMDYxIDAuMTQtMC4wOTE0IDguMTI2MiAyLjc5ODkgMTIuOTY4IDYuMzE5OSAxMi45NjggMTAuMTQzIDAgOS4xMjM1LTI3LjU2OSAxNi41MjEtNjEuNTc4IDE2LjUyMSIgZmlsbD0iI2U2ZTZlNCIvPgogICA8cGF0aCBpZD0icGF0aDU3NDkiIGQ9Im0zMTkuODkgOTE2LjUyYy0zMy44MzEgMC02MS4yNTgtNy4zNTc0LTYxLjI1OC0xNi40MzUgMC0zLjc4NTEgNC43NzI1LTcuMjcyNSAxMi43ODgtMTAuMDUxIDAuMDQ2MiAwLjAzMTIgMC4wOTM3IDAuMDYyNSAwLjE0IDAuMDkyMi03LjkwNjIgMi43NTc5LTEyLjYwNiA2LjIxMTUtMTIuNjA2IDkuOTU5IDAgOS4wMjg4IDI3LjI4MiAxNi4zNDkgNjAuOTM2IDE2LjM0OXM2MC45MzYtNy4zMTk5IDYwLjkzNi0xNi4zNDljMC0zLjc0NzUtNC43LTcuMjAxMS0xMi42MDYtOS45NTkgMC4wNDYzLTAuMDI5NyAwLjA5MzctMC4wNjEgMC4xNC0wLjA5MjIgOC4wMTYyIDIuNzc4OCAxMi43ODggNi4yNjYxIDEyLjc4OCAxMC4wNTEgMCA5LjA3NzYtMjcuNDI2IDE2LjQzNS02MS4yNTggMTYuNDM1IiBmaWxsPSIjZTVlNWUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc1MSIgZD0ibTMxOS44OSA5MTYuNDNjLTMzLjY1NCAwLTYwLjkzNi03LjMxOTktNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NSA0LjctNy4yMDExIDEyLjYwNi05Ljk1OSAwLjA0NzUgMC4wMzAzIDAuMDk1IDAuMDYxNSAwLjE0MTI1IDAuMDkyOC03Ljc5NSAyLjczNjQtMTIuNDI4IDYuMTU2Mi0xMi40MjggOS44NjYyIDAgOC45ODE1IDI3LjEzOSAxNi4yNjMgNjAuNjE2IDE2LjI2MyAzMy40NzggMCA2MC42MTUtNy4yODEyIDYwLjYxNS0xNi4yNjMgMC0zLjcxLTQuNjMxMi03LjEyOTktMTIuNDI2LTkuODY2MiAwLjA0NjItMC4wMzEyIDAuMDkzNy0wLjA2MjUgMC4xNDEyNS0wLjA5MjggNy45MDYyIDIuNzU3OSAxMi42MDYgNi4yMTE1IDEyLjYwNiA5Ljk1OSAwIDkuMDI4OC0yNy4yODIgMTYuMzQ5LTYwLjkzNiAxNi4zNDkiIGZpbGw9IiNlNGU0ZTMiLz4KICAgPHBhdGggaWQ9InBhdGg1NzUzIiBkPSJtMzE5Ljg5IDkxNi4zNGMtMzMuNDc4IDAtNjAuNjE2LTcuMjgxMi02MC42MTYtMTYuMjYzIDAtMy43MSA0LjYzMjUtNy4xMjk5IDEyLjQyOC05Ljg2NjIgMC4wNDc1IDAuMDI5OSAwLjA5NSAwLjA2MTEgMC4xNDI1IDAuMDkxNC03LjY4NjIgMi43MTQ5LTEyLjI0OSA2LjEwMjUtMTIuMjQ5IDkuNzc0OSAwIDguOTMzNiAyNi45OTUgMTYuMTc2IDYwLjI5NSAxNi4xNzZzNjAuMjk1LTcuMjQyNiA2MC4yOTUtMTYuMTc2YzAtMy42NzI0LTQuNTYyNS03LjA1ODYtMTIuMjQ5LTkuNzc0OSAwLjA0NzUtMC4wMzAyIDAuMDk1LTAuMDYxNSAwLjE0MjUtMC4wOTE0IDcuNzk1IDIuNzM2NCAxMi40MjYgNi4xNTYyIDEyLjQyNiA5Ljg2NjIgMCA4Ljk4MTUtMjcuMTM4IDE2LjI2My02MC42MTUgMTYuMjYzIiBmaWxsPSIjZTRlM2UyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc1NSIgZD0ibTMxOS44OSA5MTYuMjZjLTMzLjMgMC02MC4yOTUtNy4yNDI2LTYwLjI5NS0xNi4xNzYgMC0zLjY3MjQgNC41NjI1LTcuMDYgMTIuMjQ5LTkuNzc0OSAwLjA0ODcgMC4wMzIzIDAuMDkyNSAwLjA2MSAwLjE0MjUgMC4wOTIyLTcuNTc3NSAyLjY5MzktMTIuMDcgNi4wNDc5LTEyLjA3IDkuNjgyNiAwIDguODg2MiAyNi44NTEgMTYuMDkxIDU5Ljk3NCAxNi4wOTEgMzMuMTIyIDAgNTkuOTc0LTcuMjA1MSA1OS45NzQtMTYuMDkxIDAtMy42MzQ4LTQuNDkyNS02Ljk4ODgtMTIuMDY5LTkuNjgyNiAwLjA0ODctMC4wMzEyIDAuMDkyNS0wLjA2IDAuMTQxMjUtMC4wOTIyIDcuNjg2MiAyLjcxNjIgMTIuMjQ5IDYuMTAyNSAxMi4yNDkgOS43NzQ5IDAgOC45MzM2LTI2Ljk5NSAxNi4xNzYtNjAuMjk1IDE2LjE3NiIgZmlsbD0iI2UzZTNlMSIvPgogICA8cGF0aCBpZD0icGF0aDU3NTciIGQ9Im0zMTkuODkgOTE2LjE3Yy0zMy4xMjIgMC01OS45NzQtNy4yMDUxLTU5Ljk3NC0xNi4wOTEgMC0zLjYzNDggNC40OTI1LTYuOTg4OCAxMi4wNy05LjY4MjYgMC4wNDYzIDAuMDMwMyAwLjA5NjIgMC4wNjI1IDAuMTQyNSAwLjA5MjgtNy40Njc1IDIuNjcyNC0xMS44OTIgNS45OTIyLTExLjg5MiA5LjU4OTkgMCA4LjgzODkgMjYuNzA5IDE2LjAwNSA1OS42NTQgMTYuMDA1czU5LjY1Mi03LjE2NiA1OS42NTItMTYuMDA1YzAtMy41OTc2LTQuNDIzOC02LjkxNzUtMTEuODkxLTkuNTg5OSAwLjA0NjMtMC4wMzAzIDAuMDk2Mi0wLjA2MjUgMC4xNDM3NS0wLjA5MjggNy41NzYyIDIuNjkzOSAxMi4wNjkgNi4wNDc5IDEyLjA2OSA5LjY4MjYgMCA4Ljg4NjItMjYuODUxIDE2LjA5MS01OS45NzQgMTYuMDkxIiBmaWxsPSIjZTJlMmUwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc1OSIgZD0ibTMxOS44OSA5MTYuMDhjLTMyLjk0NSAwLTU5LjY1NC03LjE2Ni01OS42NTQtMTYuMDA1IDAtMy41OTc2IDQuNDI1LTYuOTE3NSAxMS44OTItOS41ODk5IDAuMDQ4NyAwLjAzMTIgMC4wOTUgMC4wNjAxIDAuMTQzNzUgMC4wOTA5LTcuMzU4OCAyLjY1MjgtMTEuNzE1IDUuOTM5LTExLjcxNSA5LjQ5OSAwIDguNzkxIDI2LjU2NCAxNS45MTkgNTkuMzMyIDE1LjkxOSAzMi43NjkgMCA1OS4zMzItNy4xMjggNTkuMzMyLTE1LjkxOSAwLTMuNTYtNC4zNTYyLTYuODQ2Mi0xMS43MTUtOS40OTc1IDAuMDQ4OC0wLjAzMjIgMC4wOTUtMC4wNjExIDAuMTQzNzUtMC4wOTI0IDcuNDY3NSAyLjY3MjQgMTEuODkxIDUuOTkyMiAxMS44OTEgOS41ODk5IDAgOC44Mzg5LTI2LjcwOCAxNi4wMDUtNTkuNjUyIDE2LjAwNSIgZmlsbD0iI2UyZTFkZiIvPgogICA8cGF0aCBpZD0icGF0aDU3NjEiIGQ9Im0zMTkuODkgOTE2Yy0zMi43NjkgMC01OS4zMzItNy4xMjgtNTkuMzMyLTE1LjkxOSAwLTMuNTYgNC4zNTYyLTYuODQ2MiAxMS43MTUtOS40OTkgMC4wNDYzIDAuMDMwMyAwLjA5NzUgMC4wNjMgMC4xNDM3NSAwLjA5MjgtNy4yNSAyLjYyOTktMTEuNTM4IDUuODgzOC0xMS41MzggOS40MDYyIDAgOC43NDM2IDI2LjQyIDE1LjgzMyA1OS4wMTEgMTUuODMzczU5LjAxMS03LjA4ODkgNTkuMDExLTE1LjgzM2MwLTMuNTIyNS00LjI4NjItNi43NzY0LTExLjUzOC05LjQwNjIgMC4wNDYyLTAuMDI5OCAwLjA5NzUtMC4wNjEgMC4xNDM3NS0wLjA5MTMgNy4zNTg4IDIuNjUxMiAxMS43MTUgNS45Mzc1IDExLjcxNSA5LjQ5NzUgMCA4Ljc5MS0yNi41NjQgMTUuOTE5LTU5LjMzMiAxNS45MTkiIGZpbGw9IiNlMWUwZGYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzYzIiBkPSJtMzE5Ljg5IDkxNS45MWMtMzIuNTkxIDAtNTkuMDExLTcuMDg4OS01OS4wMTEtMTUuODMzIDAtMy41MjI1IDQuMjg3NS02Ljc3NjQgMTEuNTM4LTkuNDA2MiAwLjA1IDAuMDMxMiAwLjA5NjIgMC4wNjEgMC4xNDUgMC4wOTIyLTcuMTQyNSAyLjYwODktMTEuMzYyIDUuODI5MS0xMS4zNjIgOS4zMTQgMCA4LjY5NjIgMjYuMjc4IDE1Ljc0NiA1OC42OTEgMTUuNzQ2IDMyLjQxNCAwIDU4LjY5MS03LjA0OTggNTguNjkxLTE1Ljc0NiAwLTMuNDg0OS00LjIyLTYuNzA1MS0xMS4zNjItOS4zMTQgMC4wNDg4LTAuMDMxMiAwLjA5NS0wLjA2MSAwLjE0NS0wLjA5MjIgNy4yNTEyIDIuNjI5OSAxMS41MzggNS44ODM4IDExLjUzOCA5LjQwNjIgMCA4Ljc0MzYtMjYuNDIgMTUuODMzLTU5LjAxMSAxNS44MzMiIGZpbGw9IiNlMGUwZGUiLz4KICAgPHBhdGggaWQ9InBhdGg1NzY1IiBkPSJtMzE5Ljg5IDkxNS44M2MtMzIuNDE0IDAtNTguNjkxLTcuMDQ5OC01OC42OTEtMTUuNzQ2IDAtMy40ODQ5IDQuMjItNi43MDUxIDExLjM2Mi05LjMxNCAwLjA0NzUgMC4wMzAzIDAuMDk4OCAwLjA2MjUgMC4xNDYyNSAwLjA5MjgtNy4wMzYyIDIuNTg2LTExLjE4OCA1Ljc3MzUtMTEuMTg4IDkuMjIxMiAwIDguNjQ4OSAyNi4xMzIgMTUuNjYgNTguMzcgMTUuNjYgMzIuMjM2IDAgNTguMzctNy4wMTEzIDU4LjM3LTE1LjY2IDAtMy40NDc4LTQuMTUyNS02LjYzNTItMTEuMTg4LTkuMjIxMiAwLjA0NzUtMC4wMzAzIDAuMDk4Ny0wLjA2MjUgMC4xNDYyNS0wLjA5MjggNy4xNDI1IDIuNjA4OSAxMS4zNjIgNS44MjkxIDExLjM2MiA5LjMxNCAwIDguNjk2Mi0yNi4yNzggMTUuNzQ2LTU4LjY5MSAxNS43NDYiIGZpbGw9IiNlMGRmZGQiLz4KICAgPHBhdGggaWQ9InBhdGg1NzY3IiBkPSJtMzE5Ljg5IDkxNS43NGMtMzIuMjM4IDAtNTguMzctNy4wMTEzLTU4LjM3LTE1LjY2IDAtMy40NDc4IDQuMTUxMi02LjYzNTIgMTEuMTg4LTkuMjIxMiAwLjA0ODggMC4wMzEyIDAuMDk2MiAwLjA2MTEgMC4xNDYyNSAwLjA5MjQtNi45Mjc1IDIuNTY0OS0xMS4wMTIgNS43MTg4LTExLjAxMiA5LjEyODkgMCA4LjYwMTEgMjUuOTg5IDE1LjU3NSA1OC4wNDkgMTUuNTc1czU4LjA0OS02Ljk3NDEgNTguMDQ5LTE1LjU3NWMwLTMuNDEwMS00LjA4NS02LjU2NC0xMS4wMTItOS4xMjg5IDAuMDUtMC4wMzEyIDAuMDk3NS0wLjA2MTEgMC4xNDYyNS0wLjA5MjQgNy4wMzUgMi41ODYgMTEuMTg4IDUuNzczNSAxMS4xODggOS4yMjEyIDAgOC42NDg5LTI2LjEzNCAxNS42Ni01OC4zNyAxNS42NiIgZmlsbD0iI2RmZGVkYyIvPgogICA8cGF0aCBpZD0icGF0aDU3NjkiIGQ9Im0zMTkuODkgOTE1LjY2Yy0zMi4wNiAwLTU4LjA0OS02Ljk3NDEtNTguMDQ5LTE1LjU3NSAwLTMuNDEwMSA0LjA4NS02LjU2NCAxMS4wMTItOS4xMjg5IDAuMDQ4NyAwLjAzMTIgMC4wOTc1IDAuMDYxNSAwLjE0NjI1IDAuMDkyOC02LjgyMTIgMi41NDI1LTEwLjgzOSA1LjY2MzYtMTAuODM5IDkuMDM2MSAwIDguNTUzOCAyNS44NDYgMTUuNDg5IDU3LjcyOSAxNS40ODkgMzEuODgyIDAgNTcuNzI5LTYuOTM1IDU3LjcyOS0xNS40ODkgMC0zLjM3MjUtNC4wMTc1LTYuNDkyNi0xMC44MzktOS4wMzYxIDAuMDQ4OC0wLjAzMTIgMC4wOTc1LTAuMDYxNSAwLjE0NjI1LTAuMDkyOCA2LjkyNzUgMi41NjQ5IDExLjAxMiA1LjcxODggMTEuMDEyIDkuMTI4OSAwIDguNjAxMS0yNS45ODkgMTUuNTc1LTU4LjA0OSAxNS41NzUiIGZpbGw9IiNkZWRkZGIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzcxIiBkPSJtMzE5Ljg5IDkxNS41N2MtMzEuODgyIDAtNTcuNzI5LTYuOTM1LTU3LjcyOS0xNS40ODkgMC0zLjM3MjUgNC4wMTc1LTYuNDkzNiAxMC44MzktOS4wMzYxIDAuMDUgMC4wMzEyIDAuMDk4NyAwLjA2MSAwLjE0ODc1IDAuMDkyMi02LjcxNSAyLjUyMTUtMTAuNjY2IDUuNjA4OS0xMC42NjYgOC45NDM5IDAgOC41MDY0IDI1LjcwMiAxNS40MDIgNTcuNDA4IDE1LjQwMiAzMS43MDUgMCA1Ny40MDgtNi44OTYgNTcuNDA4LTE1LjQwMiAwLTMuMzM1LTMuOTUtNi40MjI0LTEwLjY2Ni04Ljk0MzkgMC4wNS0wLjAzMTIgMC4wOTg4LTAuMDYxIDAuMTQ4NzUtMC4wOTIyIDYuODIxMiAyLjU0MzUgMTAuODM5IDUuNjYzNiAxMC44MzkgOS4wMzYxIDAgOC41NTM4LTI1Ljg0NiAxNS40ODktNTcuNzI5IDE1LjQ4OSIgZmlsbD0iI2RkZGNkYSIvPgogICA8cGF0aCBpZD0icGF0aDU3NzMiIGQ9Im0zMTkuODkgOTE1LjQ4Yy0zMS43MDUgMC01Ny40MDgtNi44OTYtNTcuNDA4LTE1LjQwMiAwLTMuMzM1IDMuOTUxMi02LjQyMjQgMTAuNjY2LTguOTQzOSAwLjA0ODcgMC4wMzAzIDAuMDk4NyAwLjA2MTUgMC4xNDg3NSAwLjA5MjgtNi42MSAyLjQ5ODYtMTAuNDk1IDUuNTUzOC0xMC40OTUgOC44NTExIDAgOC40NTkgMjUuNTU5IDE1LjMxNiA1Ny4wODggMTUuMzE2IDMxLjUyOCAwIDU3LjA4OC02Ljg1NzQgNTcuMDg4LTE1LjMxNiAwLTMuMjk3NC0zLjg4NS02LjM1MjUtMTAuNDk0LTguODUxMSAwLjA0ODctMC4wMzEyIDAuMDk4Ny0wLjA2MjUgMC4xNDc1LTAuMDkyOCA2LjcxNjIgMi41MjE1IDEwLjY2NiA1LjYwODkgMTAuNjY2IDguOTQzOSAwIDguNTA2NC0yNS43MDIgMTUuNDAyLTU3LjQwOCAxNS40MDIiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg1Nzc1IiBkPSJtMzE5Ljg5IDkxNS40Yy0zMS41MjkgMC01Ny4wODgtNi44NTc0LTU3LjA4OC0xNS4zMTYgMC0zLjI5NzQgMy44ODUtNi4zNTI1IDEwLjQ5NS04Ljg1MTEgMC4wNDg3IDAuMDI5OSAwLjA5ODcgMC4wNjExIDAuMTQ4NzUgMC4wOTI0LTYuNTAzOCAyLjQ3NzUtMTAuMzIyIDUuNDk5LTEwLjMyMiA4Ljc1ODggMCA4LjQxMTEgMjUuNDE1IDE1LjIzIDU2Ljc2NiAxNS4yM3M1Ni43NjYtNi44MTg4IDU2Ljc2Ni0xNS4yM2MwLTMuMjU5OC0zLjgxODgtNi4yODEyLTEwLjMyMi04Ljc1ODggMC4wNS0wLjAzMTIgMC4xLTAuMDYyNSAwLjE1LTAuMDkyNCA2LjYwODggMi40OTg2IDEwLjQ5NCA1LjU1MzggMTAuNDk0IDguODUxMSAwIDguNDU5LTI1LjU2IDE1LjMxNi01Ny4wODggMTUuMzE2IiBmaWxsPSIjZGNkYmQ5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTc3NyIgZD0ibTMxOS44OSA5MTUuMzFjLTMxLjM1MSAwLTU2Ljc2Ni02LjgxODgtNTYuNzY2LTE1LjIzIDAtMy4yNTk4IDMuODE4OC02LjI4MTIgMTAuMzIyLTguNzU4OCAwLjA0ODcgMC4wMzAzIDAuMSAwLjA2MSAwLjE1IDAuMDkyMy02LjM5ODggMi40NTUxLTEwLjE1MSA1LjQ0MzktMTAuMTUxIDguNjY2NSAwIDguMzYzOCAyNS4yNzEgMTUuMTQ0IDU2LjQ0NSAxNS4xNDRzNTYuNDQ1LTYuNzc5OCA1Ni40NDUtMTUuMTQ0YzAtMy4yMjI2LTMuNzUyNS02LjIxMTQtMTAuMTUxLTguNjY2NSAwLjA1LTAuMDMxMiAwLjEwMTI1LTAuMDYyIDAuMTUtMC4wOTIzIDYuNTAzOCAyLjQ3NzUgMTAuMzIyIDUuNDk5IDEwLjMyMiA4Ljc1ODggMCA4LjQxMTEtMjUuNDE1IDE1LjIzLTU2Ljc2NiAxNS4yMyIgZmlsbD0iI2RiZGFkOCIvPgogICA8cGF0aCBpZD0icGF0aDU3NzkiIGQ9Im0zMTkuODkgOTE1LjIyYy0zMS4xNzQgMC01Ni40NDUtNi43Nzk4LTU2LjQ0NS0xNS4xNDQgMC0zLjIyMjYgMy43NTI1LTYuMjExNCAxMC4xNTEtOC42NjY1IDAuMDQ4NyAwLjAzMDMgMC4xIDAuMDYxNSAwLjE1IDAuMDkyOC02LjI5MzggMi40MzI2LTkuOTgxMiA1LjM4NzgtOS45ODEyIDguNTczOCAwIDguMzE2NCAyNS4xMjkgMTUuMDU4IDU2LjEyNSAxNS4wNThzNTYuMTI1LTYuNzQxMiA1Ni4xMjUtMTUuMDU4YzAtMy4xODYtMy42ODc1LTYuMTQxMS05Ljk4MTItOC41NzM4IDAuMDUtMC4wMzEyIDAuMTAxMjUtMC4wNjI1IDAuMTUtMC4wOTI4IDYuMzk4OCAyLjQ1NTEgMTAuMTUxIDUuNDQzOSAxMC4xNTEgOC42NjY1IDAgOC4zNjM4LTI1LjI3MSAxNS4xNDQtNTYuNDQ1IDE1LjE0NCIgZmlsbD0iI2RhZDlkNyIvPgogICA8cGF0aCBpZD0icGF0aDU3ODEiIGQ9Im0zMTkuODkgOTE1LjE0Yy0zMC45OTYgMC01Ni4xMjUtNi43NDEyLTU2LjEyNS0xNS4wNTggMC0zLjE4NiAzLjY4NzUtNi4xNDExIDkuOTgxMi04LjU3MzggMC4wNSAwLjAyOTggMC4xMDEyNSAwLjA2MSAwLjE1MTI1IDAuMDkyMy02LjE4NzUgMi40MTE2LTkuODExMiA1LjMzMjYtOS44MTEyIDguNDgxNSAwIDguMjY4NSAyNC45ODQgMTQuOTczIDU1LjgwNCAxNC45NzNzNTUuODA0LTYuNzA0MiA1NS44MDQtMTQuOTczYzAtMy4xNDg5LTMuNjIyNS02LjA2OTktOS44MTEyLTguNDgxNSAwLjA1LTAuMDI5OCAwLjEwMTI1LTAuMDYyNSAwLjE1MTI1LTAuMDkyMyA2LjI5MzggMi40MzI2IDkuOTgxMiA1LjM4NzggOS45ODEyIDguNTczOCAwIDguMzE2NC0yNS4xMjkgMTUuMDU4LTU2LjEyNSAxNS4wNTgiIGZpbGw9IiNkOWQ4ZDYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzgzIiBkPSJtMzE5Ljg5IDkxNS4wNWMtMzAuODIgMC01NS44MDQtNi43MDQyLTU1LjgwNC0xNC45NzMgMC0zLjE0ODkgMy42MjM4LTYuMDY5OSA5LjgxMTItOC40ODE1IDAuMDUyNSAwLjAzMTIgMC4xIDAuMDYwMSAwLjE1MjUgMC4wOTI5LTYuMDg1IDIuMzg4Ni05LjY0MzggNS4yNzczLTkuNjQzOCA4LjM4ODYgMCA4LjIyMTIgMjQuODQxIDE0Ljg4NiA1NS40ODQgMTQuODg2IDMwLjY0MiAwIDU1LjQ4NC02LjY2NSA1NS40ODQtMTQuODg2IDAtMy4xMTE0LTMuNTU4OC02LTkuNjQzOC04LjM4ODYgMC4wNTI1LTAuMDMxMiAwLjEtMC4wNjE2IDAuMTUyNS0wLjA5MjkgNi4xODg4IDIuNDExNiA5LjgxMTIgNS4zMzI2IDkuODExMiA4LjQ4MTUgMCA4LjI2ODUtMjQuOTg0IDE0Ljk3My01NS44MDQgMTQuOTczIiBmaWxsPSIjZDhkN2Q2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTc4NSIgZD0ibTMxOS44OSA5MTQuOTdjLTMwLjY0MiAwLTU1LjQ4NC02LjY2NS01NS40ODQtMTQuODg2IDAtMy4xMTE0IDMuNTU4OC02IDkuNjQzOC04LjM4ODYgMC4wNSAwLjAyOTggMC4xMDI1IDAuMDYyNSAwLjE1MjUgMC4wOTIzLTUuOTgxMiAyLjM2NjMtOS40NzUgNS4yMjExLTkuNDc1IDguMjk2NCAwIDguMTczOSAyNC42OTggMTQuOCA1NS4xNjIgMTQuOCAzMC40NjUgMCA1NS4xNjItNi42MjU5IDU1LjE2Mi0xNC44IDAtMy4wNzUyLTMuNDkzOC01LjkzMDEtOS40NzUtOC4yOTY0IDAuMDUtMC4wMjk4IDAuMTAyNS0wLjA2MjUgMC4xNTI1LTAuMDkyMyA2LjA4NSAyLjM4ODYgOS42NDM4IDUuMjc3MyA5LjY0MzggOC4zODg2IDAgOC4yMjEyLTI0Ljg0MSAxNC44ODYtNTUuNDg0IDE0Ljg4NiIgZmlsbD0iI2Q3ZDZkNSIvPgogICA8cGF0aCBpZD0icGF0aDU3ODciIGQ9Im0zMTkuODkgOTE0Ljg4Yy0zMC40NjUgMC01NS4xNjItNi42MjU5LTU1LjE2Mi0xNC44IDAtMy4wNzUyIDMuNDkzOC01LjkzMDEgOS40NzUtOC4yOTY0IDAuMDUyNSAwLjAzMTIgMC4xMDEyNSAwLjA2MTUgMC4xNTM3NSAwLjA5MjgtNS44Nzc1IDIuMzQzOC05LjMwNzUgNS4xNjYtOS4zMDc1IDguMjAzNiAwIDguMTI2NSAyNC41NTIgMTQuNzE0IDU0Ljg0MSAxNC43MTQgMzAuMjg4IDAgNTQuODQxLTYuNTg3NCA1NC44NDEtMTQuNzE0IDAtMy4wMzc2LTMuNDMtNS44NTk5LTkuMzA3NS04LjIwMzYgMC4wNTI1LTAuMDMxMiAwLjEwMTI1LTAuMDYgMC4xNTM3NS0wLjA5MjggNS45ODEyIDIuMzY2MyA5LjQ3NSA1LjIyMTEgOS40NzUgOC4yOTY0IDAgOC4xNzM5LTI0LjY5OCAxNC44LTU1LjE2MiAxNC44IiBmaWxsPSIjZDZkNmQ0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTc4OSIgZD0ibTMxOS44OSA5MTQuNzljLTMwLjI4OSAwLTU0Ljg0MS02LjU4NzQtNTQuODQxLTE0LjcxNCAwLTMuMDM3NiAzLjQzLTUuODU5OSA5LjMwNzUtOC4yMDM2IDAuMDUgMC4wMjk3IDAuMTA1IDAuMDYyNSAwLjE1NSAwLjA5MjItNS43NzYyIDIuMzIyNy05LjE0MjUgNS4xMTE0LTkuMTQyNSA4LjExMTQgMCA4LjA3ODYgMjQuNDEgMTQuNjI3IDU0LjUyMSAxNC42MjdzNTQuNTIxLTYuNTQ4OCA1NC41MjEtMTQuNjI3YzAtMy0zLjM2NjItNS43ODg2LTkuMTQyNS04LjEwOTkgMC4wNTEyLTAuMDMwMyAwLjEwNS0wLjA2NCAwLjE1NS0wLjA5MzcgNS44Nzc1IDIuMzQzOCA5LjMwNzUgNS4xNjYgOS4zMDc1IDguMjAzNiAwIDguMTI2NS0yNC41NTQgMTQuNzE0LTU0Ljg0MSAxNC43MTQiIGZpbGw9IiNkNWQ1ZDMiLz4KICAgPHBhdGggaWQ9InBhdGg1NzkxIiBkPSJtMzE5Ljg5IDkxNC43MWMtMzAuMTExIDAtNTQuNTIxLTYuNTQ4OC01NC41MjEtMTQuNjI3IDAtMyAzLjM2NjItNS43ODg2IDkuMTQyNS04LjExMTQgMC4wNTI1IDAuMDMyOCAwLjEwMjUgMC4wNjI1IDAuMTU1IDAuMDkzNy01LjY3MjUgMi4yOTg5LTguOTc2MiA1LjA1NTItOC45NzYyIDguMDE3NiAwIDguMDMxMiAyNC4yNjYgMTQuNTQxIDU0LjIgMTQuNTQxczU0LjItNi41MDk4IDU0LjItMTQuNTQxYzAtMi45NjI0LTMuMzAzOC01LjcxODgtOC45NzYyLTguMDE3NiAwLjA1MjUtMC4wMzEyIDAuMTAyNS0wLjA2MSAwLjE1NS0wLjA5MjIgNS43NzYyIDIuMzIxMiA5LjE0MjUgNS4xMDk5IDkuMTQyNSA4LjEwOTkgMCA4LjA3ODYtMjQuNDEgMTQuNjI3LTU0LjUyMSAxNC42MjciIGZpbGw9IiNkNGQ0ZDIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzkzIiBkPSJtMzE5Ljg5IDkxNC42MmMtMjkuOTM0IDAtNTQuMi02LjUwOTgtNTQuMi0xNC41NDEgMC0yLjk2MjQgMy4zMDM4LTUuNzE4OCA4Ljk3NjItOC4wMTc2IDAuMDUyNSAwLjAzMTIgMC4xMDM3NSAwLjA2MTYgMC4xNTYyNSAwLjA5MjktNS41NzEyIDIuMjc1OS04LjgxMjUgNC45OTg1LTguODEyNSA3LjkyNDggMCA3Ljk4MzkgMjQuMTIyIDE0LjQ1NSA1My44OCAxNC40NTUgMjkuNzU2IDAgNTMuODc5LTYuNDcxMiA1My44NzktMTQuNDU1IDAtMi45MjYyLTMuMjQtNS42NDg5LTguODExMi03LjkyNDggMC4wNTM4LTAuMDMxMiAwLjEwMzc1LTAuMDYxNiAwLjE1NjI1LTAuMDkyOSA1LjY3MjUgMi4yOTg5IDguOTc2MiA1LjA1NTIgOC45NzYyIDguMDE3NiAwIDguMDMxMi0yNC4yNjYgMTQuNTQxLTU0LjIgMTQuNTQxIiBmaWxsPSIjZDRkM2QxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc5NSIgZD0ibTMxOS44OSA5MTQuNTRjLTI5Ljc1OCAwLTUzLjg4LTYuNDcxMi01My44OC0xNC40NTUgMC0yLjkyNjIgMy4yNDEyLTUuNjQ4OSA4LjgxMjUtNy45MjQ4IDAuMDUyNSAwLjAzMTIgMC4xMDM3NSAwLjA2MSAwLjE1NjI1IDAuMDkyMy01LjQ3IDIuMjUzOS04LjY0NzUgNC45NDM5LTguNjQ3NSA3LjgzMjUgMCA3LjkzNiAyMy45NzkgMTQuMzcgNTMuNTU5IDE0LjM3IDI5LjU3OSAwIDUzLjU1OS02LjQzNDEgNTMuNTU5LTE0LjM3IDAtMi44ODg2LTMuMTc3NS01LjU3ODYtOC42NDc1LTcuODMyNSAwLjA1MjUtMC4wMjk4IDAuMTAzNzUtMC4wNjEgMC4xNTYyNS0wLjA5MjMgNS41NzEyIDIuMjc1OSA4LjgxMTIgNC45OTg1IDguODExMiA3LjkyNDggMCA3Ljk4MzktMjQuMTIyIDE0LjQ1NS01My44NzkgMTQuNDU1IiBmaWxsPSIjZDNkMmQwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc5NyIgZD0ibTMxOS44OSA5MTQuNDVjLTI5LjU4IDAtNTMuNTU5LTYuNDM0MS01My41NTktMTQuMzcgMC0yLjg4ODYgMy4xNzc1LTUuNTc4NiA4LjY0NzUtNy44MzI1IDAuMDUgMC4wMjk3IDAuMTA3NSAwLjA2NCAwLjE1NzUgMC4wOTM3LTUuMzY4OCAyLjIzLTguNDgzOCA0Ljg4NjItOC40ODM4IDcuNzM4OCAwIDcuODg4NiAyMy44MzUgMTQuMjg0IDUzLjIzOCAxNC4yODQgMjkuNDAyIDAgNTMuMjM4LTYuMzk1MSA1My4yMzgtMTQuMjg0IDAtMi44NTI1LTMuMTE1LTUuNTA4OC04LjQ4MzgtNy43Mzg4IDAuMDUtMC4wMjk4IDAuMTA3NS0wLjA2NCAwLjE1NzUtMC4wOTM3IDUuNDcgMi4yNTM5IDguNjQ3NSA0Ljk0MzkgOC42NDc1IDcuODMyNSAwIDcuOTM2LTIzLjk4IDE0LjM3LTUzLjU1OSAxNC4zNyIgZmlsbD0iI2QyZDFjZiIvPgogICA8cGF0aCBpZD0icGF0aDU3OTkiIGQ9Im0zMTkuODkgOTE0LjM2Yy0yOS40MDIgMC01My4yMzgtNi4zOTUxLTUzLjIzOC0xNC4yODQgMC0yLjg1MjUgMy4xMTUtNS41MDg4IDguNDgzOC03LjczODggMC4wNTI1IDAuMDMxMiAwLjEwNjI1IDAuMDYxIDAuMTU4NzUgMC4wOTIyLTUuMjY4OCAyLjIwNzUtOC4zMjI1IDQuODMxNS04LjMyMjUgNy42NDY1IDAgNy44NDEyIDIzLjY5MiAxNC4xOTcgNTIuOTE4IDE0LjE5NyAyOS4yMjUgMCA1Mi45MTgtNi4zNTYgNTIuOTE4LTE0LjE5NyAwLTIuODE1LTMuMDUzOC01LjQzOS04LjMyMjUtNy42NDY1IDAuMDUyNS0wLjAyOTcgMC4xMDYyNS0wLjA2MSAwLjE1ODc1LTAuMDkyMiA1LjM2ODggMi4yMyA4LjQ4MzggNC44ODYyIDguNDgzOCA3LjczODggMCA3Ljg4ODYtMjMuODM1IDE0LjI4NC01My4yMzggMTQuMjg0IiBmaWxsPSIjZDFkMGNmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgwMSIgZD0ibTMxOS44OSA5MTQuMjhjLTI5LjIyNSAwLTUyLjkxOC02LjM1Ni01Mi45MTgtMTQuMTk3IDAtMi44MTUgMy4wNTM4LTUuNDM5IDguMzIyNS03LjY0NjUgMC4wNTUgMC4wMzI4IDAuMTAzNzUgMC4wNjE1IDAuMTU4NzUgMC4wOTM3LTUuMTY3NSAyLjE4NDEtOC4xNiA0Ljc3NC04LjE2IDcuNTUyOCAwIDcuNzk0IDIzLjU0OCAxNC4xMTEgNTIuNTk2IDE0LjExMSAyOS4wNDkgMCA1Mi41OTYtNi4zMTczIDUyLjU5Ni0xNC4xMTEgMC0yLjc3ODgtMi45OTI1LTUuMzY4Ni04LjE2LTcuNTUyOCAwLjA1NS0wLjAzMjIgMC4xMDM3NS0wLjA2MSAwLjE1ODc1LTAuMDkzNyA1LjI2ODggMi4yMDc1IDguMzIyNSA0LjgzMTUgOC4zMjI1IDcuNjQ2NSAwIDcuODQxMi0yMy42OTIgMTQuMTk3LTUyLjkxOCAxNC4xOTciIGZpbGw9IiNkMGNmY2QiLz4KICAgPHBhdGggaWQ9InBhdGg1ODAzIiBkPSJtMzE5Ljg5IDkxNC4xOWMtMjkuMDQ5IDAtNTIuNTk2LTYuMzE3My01Mi41OTYtMTQuMTExIDAtMi43Nzg4IDIuOTkyNS01LjM2ODYgOC4xNi03LjU1MjggMC4wNTM3IDAuMDMwMyAwLjEwNzUgMC4wNjI1IDAuMTYxMjUgMC4wOTI4LTUuMDY4OCAyLjE2MTEtOCA0LjcxODgtOCA3LjQ2IDAgNy43NDYxIDIzLjQwNCAxNC4wMjUgNTIuMjc1IDE0LjAyNXM1Mi4yNzUtNi4yNzg4IDUyLjI3NS0xNC4wMjVjMC0yLjc0MTItMi45MzEyLTUuMjk4OS04LTcuNDYgMC4wNTM3LTAuMDMwMyAwLjEwNzUtMC4wNjI1IDAuMTYxMjUtMC4wOTI4IDUuMTY3NSAyLjE4NDEgOC4xNiA0Ljc3NCA4LjE2IDcuNTUyOCAwIDcuNzk0LTIzLjU0OCAxNC4xMTEtNTIuNTk2IDE0LjExMSIgZmlsbD0iI2NmY2VjYyIvPgogICA8cGF0aCBpZD0icGF0aDU4MDUiIGQ9Im0zMTkuODkgOTE0LjFjLTI4Ljg3MSAwLTUyLjI3NS02LjI3ODgtNTIuMjc1LTE0LjAyNSAwLTIuNzQxMiAyLjkzMTItNS4yOTg5IDgtNy40NiAwLjA1MjUgMC4wMzEyIDAuMTA3NSAwLjA2MjUgMC4xNiAwLjA5MzctNC45Njg4IDIuMTM3Mi03Ljg0IDQuNjYxMS03Ljg0IDcuMzY2MiAwIDcuNjk4OCAyMy4yNjEgMTMuOTQgNTEuOTU1IDEzLjk0czUxLjk1NS02LjI0MTIgNTEuOTU1LTEzLjk0YzAtMi43MDUxLTIuODcxMi01LjIyOS03Ljg0LTcuMzY2MiAwLjA1MjUtMC4wMzEyIDAuMTA3NS0wLjA2MjUgMC4xNi0wLjA5MzcgNS4wNjg4IDIuMTYxMSA4IDQuNzE4OCA4IDcuNDYgMCA3Ljc0NjEtMjMuNDA0IDE0LjAyNS01Mi4yNzUgMTQuMDI1IiBmaWxsPSIjY2VjZGNiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgwNyIgZD0ibTMxOS44OSA5MTQuMDJjLTI4LjY5NCAwLTUxLjk1NS02LjI0MTItNTEuOTU1LTEzLjk0IDAtMi43MDUxIDIuODcxMi01LjIyOSA3Ljg0LTcuMzY2MiAwLjA1MzggMC4wMjk5IDAuMTA4NzUgMC4wNjI1IDAuMTYyNSAwLjA5MjQtNC44NyAyLjExMzgtNy42ODEyIDQuNjA2NC03LjY4MTIgNy4yNzM5IDAgNy42NTE0IDIzLjExOCAxMy44NTQgNTEuNjM0IDEzLjg1NHM1MS42MzQtNi4yMDIxIDUxLjYzNC0xMy44NTRjMC0yLjY2NzUtMi44MTEyLTUuMTU4OC03LjY4MTItNy4yNzM5IDAuMDUzOC0wLjAyOTkgMC4xMDg3NS0wLjA2MjUgMC4xNjI1LTAuMDkyNCA0Ljk2ODggMi4xMzcyIDcuODQgNC42NjExIDcuODQgNy4zNjYyIDAgNy42OTg4LTIzLjI2MSAxMy45NC01MS45NTUgMTMuOTQiIGZpbGw9IiNjZGNjY2EiLz4KICAgPHBhdGggaWQ9InBhdGg1ODA5IiBkPSJtMzE5Ljg5IDkxMy45M2MtMjguNTE2IDAtNTEuNjM0LTYuMjAyMS01MS42MzQtMTMuODU0IDAtMi42Njc1IDIuODExMi01LjE2MDEgNy42ODEyLTcuMjczOSAwLjA1NSAwLjAzMjYgMC4xMDc1IDAuMDYxNSAwLjE2MjUgMC4wOTM3LTQuNzcyNSAyLjA5MDItNy41MjM4IDQuNTQ4Ny03LjUyMzggNy4xODAxIDAgNy42MDM1IDIyLjk3NCAxMy43NjggNTEuMzE0IDEzLjc2OHM1MS4zMTItNi4xNjQxIDUxLjMxMi0xMy43NjhjMC0yLjYzMTQtMi43NTEyLTUuMDg5OS03LjUyMjUtNy4xODAxIDAuMDU1LTAuMDMyMyAwLjEwNzUtMC4wNjExIDAuMTYyNS0wLjA5MzcgNC44NyAyLjExNTEgNy42ODEyIDQuNjA2NCA3LjY4MTIgNy4yNzM5IDAgNy42NTE0LTIzLjExOCAxMy44NTQtNTEuNjM0IDEzLjg1NCIgZmlsbD0iI2NjY2JjOSIvPgogICA8cGF0aCBpZD0icGF0aDU4MTEiIGQ9Im0zMTkuODkgOTEzLjg1Yy0yOC4zNCAwLTUxLjMxNC02LjE2NDEtNTEuMzE0LTEzLjc2OCAwLTIuNjMxNCAyLjc1MTItNS4wODk5IDcuNTIzOC03LjE4MDEgMC4wNTM3IDAuMDMwMiAwLjExIDAuMDYyNSAwLjE2Mzc1IDAuMDkyNy00LjY3NSAyLjA2NzQtNy4zNjYyIDQuNDkyMS03LjM2NjIgNy4wODc0IDAgNy41NTYxIDIyLjgzIDEzLjY4MSA1MC45OTIgMTMuNjgxczUwLjk5Mi02LjEyNSA1MC45OTItMTMuNjgxYzAtMi41OTM4LTIuNjkyNS01LjAyLTcuMzY2Mi03LjA4NjQgMC4wNTM3LTAuMDMxMiAwLjExLTAuMDYzNSAwLjE2Mzc1LTAuMDkzNyA0Ljc3MTIgMi4wOTAyIDcuNTIyNSA0LjU0ODcgNy41MjI1IDcuMTgwMSAwIDcuNjAzNS0yMi45NzIgMTMuNzY4LTUxLjMxMiAxMy43NjgiIGZpbGw9IiNjYmM5YzgiLz4KICAgPHBhdGggaWQ9InBhdGg1ODEzIiBkPSJtMzE5Ljg5IDkxMy43NmMtMjguMTYyIDAtNTAuOTkyLTYuMTI1LTUwLjk5Mi0xMy42ODEgMC0yLjU5NTIgMi42OTEyLTUuMDIgNy4zNjYyLTcuMDg3NCAwLjA1NjIgMC4wMzIzIDAuMTA4NzUgMC4wNjI1IDAuMTY1IDAuMDkzNy00LjU3NzUgMi4wNDM0LTcuMjEgNC40MzYtNy4yMSA2Ljk5MzYgMCA3LjUwODggMjIuNjg2IDEzLjU5NSA1MC42NzEgMTMuNTk1czUwLjY3MS02LjA4NjUgNTAuNjcxLTEzLjU5NWMwLTIuNTU3Ni0yLjYzMjUtNC45NTAyLTcuMjEtNi45OTM2IDAuMDU2My0wLjAzMTIgMC4xMS0wLjA2MTUgMC4xNjUtMC4wOTI3IDQuNjczOCAyLjA2NjQgNy4zNjYyIDQuNDkyNiA3LjM2NjIgNy4wODY0IDAgNy41NTYxLTIyLjgzIDEzLjY4MS01MC45OTIgMTMuNjgxIiBmaWxsPSIjY2FjOGM3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTgxNSIgZD0ibTMxOS44OSA5MTMuNjhjLTI3Ljk4NSAwLTUwLjY3MS02LjA4NjUtNTAuNjcxLTEzLjU5NSAwLTIuNTU3NiAyLjYzMjUtNC45NTAyIDcuMjEtNi45OTM2IDAuMDU1IDAuMDMxMiAwLjEwODc1IDAuMDYyNSAwLjE2NSAwLjA5MzctNC40ODEyIDIuMDItNy4wNTUgNC4zNzg0LTcuMDU1IDYuODk5OSAwIDcuNDYxNCAyMi41NDIgMTMuNTA5IDUwLjM1MSAxMy41MDkgMjcuODA4IDAgNTAuMzUxLTYuMDQ3NCA1MC4zNTEtMTMuNTA5IDAtMi41MjE1LTIuNTczOC00Ljg3OTktNy4wNTUtNi44OTk5IDAuMDU2My0wLjAzMTIgMC4xMS0wLjA2MTUgMC4xNjUtMC4wOTM3IDQuNTc3NSAyLjA0MzQgNy4yMSA0LjQzNiA3LjIxIDYuOTkzNiAwIDcuNTA4OC0yMi42ODYgMTMuNTk1LTUwLjY3MSAxMy41OTUiIGZpbGw9IiNjOWM3YzYiLz4KICAgPHBhdGggaWQ9InBhdGg1ODE3IiBkPSJtMzE5Ljg5IDkxMy41OWMtMjcuODA5IDAtNTAuMzUxLTYuMDQ3NC01MC4zNTEtMTMuNTA5IDAtMi41MjE1IDIuNTczOC00Ljg3OTkgNy4wNTUtNi44OTk5IDAuMDU2MiAwLjAzMTIgMC4xMSAwLjA2MjUgMC4xNjYyNSAwLjA5MzctNC4zODUgMS45OTQ2LTYuOSA0LjMyMjItNi45IDYuODA2MSAwIDcuNDEzNiAyMi4zOTkgMTMuNDIyIDUwLjAzIDEzLjQyMnM1MC4wMy02LjAwODcgNTAuMDMtMTMuNDIyYzAtMi40ODM5LTIuNTE1LTQuODExNS02LjktNi44MDYxIDAuMDU2My0wLjAzMTIgMC4xMS0wLjA2MjUgMC4xNjYyNS0wLjA5MzcgNC40ODEyIDIuMDIgNy4wNTUgNC4zNzg0IDcuMDU1IDYuODk5OSAwIDcuNDYxNC0yMi41NDQgMTMuNTA5LTUwLjM1MSAxMy41MDkiIGZpbGw9IiNjOGM2YzUiLz4KICAgPHBhdGggaWQ9InBhdGg1ODE5IiBkPSJtMzE5Ljg5IDkxMy41Yy0yNy42MzEgMC01MC4wMy02LjAwODctNTAuMDMtMTMuNDIyIDAtMi40ODM5IDIuNTE1LTQuODExNSA2LjktNi44MDYxIDAuMDUzOCAwLjAyOTcgMC4xMTM3NSAwLjA2MzUgMC4xNjc1IDAuMDkzNy00LjI5IDEuOTcxMS02Ljc0NzUgNC4yNjQ2LTYuNzQ3NSA2LjcxMjQgMCA3LjM2NjIgMjIuMjU2IDEzLjMzNyA0OS43MSAxMy4zMzdzNDkuNzA5LTUuOTcxMiA0OS43MDktMTMuMzM3YzAtMi40NDc4LTIuNDU3NS00Ljc0MTItNi43NDYyLTYuNzEyNCAwLjA1NjItMC4wMzEyIDAuMTExMjUtMC4wNjI1IDAuMTY3NS0wLjA5MzcgNC4zODUgMS45OTQ2IDYuOSA0LjMyMjIgNi45IDYuODA2MSAwIDcuNDEzNi0yMi4zOTkgMTMuNDIyLTUwLjAzIDEzLjQyMiIgZmlsbD0iI2M3YzVjMyIvPgogICA8cGF0aCBpZD0icGF0aDU4MjEiIGQ9Im0zMTkuODkgOTEzLjQyYy0yNy40NTQgMC00OS43MS01Ljk3MTItNDkuNzEtMTMuMzM3IDAtMi40NDc4IDIuNDU3NS00Ljc0MTIgNi43NDc1LTYuNzEyNCAwLjA1ODcgMC4wMzIzIDAuMTEgMC4wNiAwLjE2ODc1IDAuMDkyMi00LjE5NSAxLjk0ODgtNi41OTUgNC4yMDktNi41OTUgNi42MjAxIDAgNy4zMTg5IDIyLjExMiAxMy4yNTEgNDkuMzg5IDEzLjI1MSAyNy4yNzYgMCA0OS4zODktNS45MzI2IDQ5LjM4OS0xMy4yNTEgMC0yLjQxMTEtMi40LTQuNjcxNC02LjU5NS02LjYxODYgMC4wNTYzLTAuMDMxMiAwLjExMjUtMC4wNjI1IDAuMTY4NzUtMC4wOTM3IDQuMjg4OCAxLjk3MTEgNi43NDYyIDQuMjY0NiA2Ljc0NjIgNi43MTI0IDAgNy4zNjYyLTIyLjI1NSAxMy4zMzctNDkuNzA5IDEzLjMzNyIgZmlsbD0iI2M2YzRjMiIvPgogICA8cGF0aCBpZD0icGF0aDU4MjMiIGQ9Im0zMTkuODkgOTEzLjMzYy0yNy4yNzYgMC00OS4zODktNS45MzI2LTQ5LjM4OS0xMy4yNTEgMC0yLjQxMTEgMi40LTQuNjcxNCA2LjU5NS02LjYyMDEgMC4wNTYyIDAuMDMxMiAwLjExMjUgMC4wNjQgMC4xNjg3NSAwLjA5MzctNC4xIDEuOTIzOS02LjQ0MjUgNC4xNTE0LTYuNDQyNSA2LjUyNjQgMCA3LjI3MSAyMS45NjggMTMuMTY1IDQ5LjA2OCAxMy4xNjVzNDkuMDY4LTUuODk0IDQ5LjA2OC0xMy4xNjVjMC0yLjM3NS0yLjM0MjUtNC42MDI1LTYuNDQyNS02LjUyNjQgMC4wNTYzLTAuMDI5NyAwLjExMjUtMC4wNjI1IDAuMTY4NzUtMC4wOTIyIDQuMTk1IDEuOTQ3MiA2LjU5NSA0LjIwNzUgNi41OTUgNi42MTg2IDAgNy4zMTg5LTIyLjExMiAxMy4yNTEtNDkuMzg5IDEzLjI1MSIgZmlsbD0iI2M0YzNjMSIvPgogICA8cGF0aCBpZD0icGF0aDU4MjUiIGQ9Im0zMTkuODkgOTEzLjI1Yy0yNy4xIDAtNDkuMDY4LTUuODk0LTQ5LjA2OC0xMy4xNjUgMC0yLjM3NSAyLjM0MjUtNC42MDI1IDYuNDQyNS02LjUyNjQgMC4wNTYzIDAuMDMxMiAwLjExNSAwLjA2NCAwLjE3MTI1IDAuMDkzNy00LjAwNjIgMS44OTk5LTYuMjkzOCA0LjA5NTMtNi4yOTM4IDYuNDMyNiAwIDcuMjIzNiAyMS44MjUgMTMuMDc5IDQ4Ljc0OCAxMy4wNzkgMjYuOTIyIDAgNDguNzQ4LTUuODU1IDQ4Ljc0OC0xMy4wNzkgMC0yLjMzNzQtMi4yODc1LTQuNTMyOC02LjI5MzgtNi40MzI2IDAuMDU2My0wLjAyOTcgMC4xMTUtMC4wNjI1IDAuMTcxMjUtMC4wOTM3IDQuMSAxLjkyMzkgNi40NDI1IDQuMTUxNCA2LjQ0MjUgNi41MjY0IDAgNy4yNzEtMjEuOTY4IDEzLjE2NS00OS4wNjggMTMuMTY1IiBmaWxsPSIjYzNjMmMwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgyNyIgZD0ibTMxOS44OSA5MTMuMTZjLTI2LjkyMiAwLTQ4Ljc0OC01Ljg1NS00OC43NDgtMTMuMDc5IDAtMi4zMzc0IDIuMjg3NS00LjUzMjggNi4yOTM4LTYuNDMyNiAwLjA1NjMgMC4wMzEyIDAuMTE1IDAuMDY0IDAuMTcxMjUgMC4wOTM3LTMuOTEyNSAxLjg3NS02LjE0MzggNC4wMzc2LTYuMTQzOCA2LjMzODkgMCA3LjE3NjIgMjEuNjgxIDEyLjk5MyA0OC40MjYgMTIuOTkzczQ4LjQyNi01LjgxNjQgNDguNDI2LTEyLjk5M2MwLTIuMzAxMi0yLjIzMTItNC40NjM5LTYuMTQzOC02LjMzODkgMC4wNTYzLTAuMDI5NyAwLjExNS0wLjA2MjUgMC4xNzEyNS0wLjA5MzcgNC4wMDYyIDEuODk5OSA2LjI5MzggNC4wOTUzIDYuMjkzOCA2LjQzMjYgMCA3LjIyMzYtMjEuODI1IDEzLjA3OS00OC43NDggMTMuMDc5IiBmaWxsPSIjYzJjMWJmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgyOSIgZD0ibTMxOS44OSA5MTMuMDdjLTI2Ljc0NSAwLTQ4LjQyNi01LjgxNjQtNDguNDI2LTEyLjk5MyAwLTIuMzAxMiAyLjIzMTItNC40NjM5IDYuMTQzOC02LjMzODkgMC4wNTg3IDAuMDMyOCAwLjExMzc1IDAuMDYyNSAwLjE3MjUgMC4wOTM3LTMuODIxMiAxLjg1MTYtNS45OTYyIDMuOTgtNS45OTYyIDYuMjQ1MSAwIDcuMTI4OSAyMS41MzkgMTIuOTA2IDQ4LjEwNiAxMi45MDYgMjYuNTY4IDAgNDguMTA1LTUuNzc3NCA0OC4xMDUtMTIuOTA2IDAtMi4yNjUxLTIuMTc1LTQuMzkzNS01Ljk5NS02LjI0MzYgMC4wNTg4LTAuMDMyOCAwLjExMzc1LTAuMDYyNSAwLjE3MjUtMC4wOTUzIDMuOTEyNSAxLjg3NSA2LjE0MzggNC4wMzc2IDYuMTQzOCA2LjMzODkgMCA3LjE3NjItMjEuNjgxIDEyLjk5My00OC40MjYgMTIuOTkzIiBmaWxsPSIjYzFjMGJlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgzMSIgZD0ibTMxOS44OSA5MTIuOTljLTI2LjU2OCAwLTQ4LjEwNi01Ljc3NzQtNDguMTA2LTEyLjkwNiAwLTIuMjY1MSAyLjE3NS00LjM5MzUgNS45OTYyLTYuMjQ1MSAwLjA1NjMgMC4wMzEyIDAuMTE2MjUgMC4wNjQgMC4xNzI1IDAuMDk1My0zLjcyNzUgMS44MjQ2LTUuODQ3NSAzLjkyMTQtNS44NDc1IDYuMTQ5OSAwIDcuMDgxIDIxLjM5NCAxMi44MiA0Ny43ODUgMTIuODJzNDcuNzg1LTUuNzM4OCA0Ny43ODUtMTIuODJjMC0yLjIyODUtMi4xMi00LjMyNTItNS44NDc1LTYuMTQ5OSAwLjA1NjMtMC4wMzEyIDAuMTE2MjUtMC4wNjQgMC4xNzI1LTAuMDkzNyAzLjgyIDEuODUwMSA1Ljk5NSAzLjk3ODUgNS45OTUgNi4yNDM2IDAgNy4xMjg5LTIxLjUzOCAxMi45MDYtNDguMTA1IDEyLjkwNiIgZmlsbD0iI2MwYmZiZCIvPgogICA8cGF0aCBpZD0icGF0aDU4MzMiIGQ9Im0zMTkuODkgOTEyLjljLTI2LjM5MSAwLTQ3Ljc4NS01LjczODgtNDcuNzg1LTEyLjgyIDAtMi4yMjg1IDIuMTItNC4zMjUyIDUuODQ3NS02LjE0OTkgMC4wNTg4IDAuMDMxMiAwLjExNjI1IDAuMDYxIDAuMTc1IDAuMDkzNy0zLjYzNjIgMS44MDEyLTUuNzAxMiAzLjg2MzgtNS43MDEyIDYuMDU2MSAwIDcuMDMyOCAyMS4yNSAxMi43MzUgNDcuNDY0IDEyLjczNXM0Ny40NjQtNS43MDIxIDQ3LjQ2NC0xMi43MzVjMC0yLjE5MjQtMi4wNjUtNC4yNTQ5LTUuNzAxMi02LjA1NjEgMC4wNTg4LTAuMDMxMiAwLjExNjI1LTAuMDYyNSAwLjE3NS0wLjA5MzcgMy43Mjc1IDEuODI0NiA1Ljg0NzUgMy45MjE0IDUuODQ3NSA2LjE0OTkgMCA3LjA4MS0yMS4zOTQgMTIuODItNDcuNzg1IDEyLjgyIiBmaWxsPSIjYmZiZWJjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgzNSIgZD0ibTMxOS44OSA5MTIuODFjLTI2LjIxNCAwLTQ3LjQ2NC01LjcwMjEtNDcuNDY0LTEyLjczNSAwLTIuMTkyNCAyLjA2NS00LjI1NDkgNS43MDEyLTYuMDU2MSAwLjA1ODcgMC4wMzEyIDAuMTE2MjUgMC4wNjI1IDAuMTc1IDAuMDkzNy0zLjU0NSAxLjc3NTktNS41NTYyIDMuODA2MS01LjU1NjIgNS45NjI0IDAgNi45ODQ5IDIxLjEwOCAxMi42NDkgNDcuMTQ0IDEyLjY0OXM0Ny4xNDItNS42NjQgNDcuMTQyLTEyLjY0OWMwLTIuMTU2Mi0yLjAxLTQuMTg2NS01LjU1NS01Ljk2MjQgMC4wNTg3LTAuMDMxMiAwLjExNjI1LTAuMDYyNSAwLjE3NS0wLjA5MzcgMy42MzYyIDEuODAxMiA1LjcwMTIgMy44NjM4IDUuNzAxMiA2LjA1NjEgMCA3LjAzMjgtMjEuMjUgMTIuNzM1LTQ3LjQ2NCAxMi43MzUiIGZpbGw9IiNiZWJkYmIiLz4KICAgPHBhdGggaWQ9InBhdGg1ODM3IiBkPSJtMzE5Ljg5IDkxMi43M2MtMjYuMDM2IDAtNDcuMTQ0LTUuNjY0LTQ3LjE0NC0xMi42NDkgMC0yLjE1NjIgMi4wMTEyLTQuMTg2NSA1LjU1NjItNS45NjI0IDAuMDYgMC4wMzEyIDAuMTE3NSAwLjA2MjUgMC4xNzc1IDAuMDkzNy0zLjQ1NjIgMS43NTI0LTUuNDEyNSAzLjc0ODUtNS40MTI1IDUuODY4NiAwIDYuOTM3NSAyMC45NjIgMTIuNTYyIDQ2LjgyMiAxMi41NjIgMjUuODU5IDAgNDYuODIyLTUuNjI1IDQ2LjgyMi0xMi41NjIgMC0yLjEyMDEtMS45NTYyLTQuMTE2Mi01LjQxMjUtNS44Njc2IDAuMDYtMC4wMzIzIDAuMTE3NS0wLjA2MzUgMC4xNzc1LTAuMDk0OCAzLjU0NSAxLjc3NTkgNS41NTUgMy44MDYxIDUuNTU1IDUuOTYyNCAwIDYuOTg0OS0yMS4xMDYgMTIuNjQ5LTQ3LjE0MiAxMi42NDkiIGZpbGw9IiNiZGJjYmEiLz4KICAgPHBhdGggaWQ9InBhdGg1ODM5IiBkPSJtMzE5Ljg5IDkxMi42NGMtMjUuODYgMC00Ni44MjItNS42MjUtNDYuODIyLTEyLjU2MiAwLTIuMTIwMSAxLjk1NjItNC4xMTYyIDUuNDEyNS01Ljg2ODYgMC4wNTg3IDAuMDMyMyAwLjExODc1IDAuMDYzNSAwLjE3NzUgMC4wOTQ4LTMuMzY2MiAxLjcyNjUtNS4yNyAzLjY4OTktNS4yNyA1Ljc3MzkgMCA2Ljg5MDEgMjAuODIgMTIuNDc2IDQ2LjUwMiAxMi40NzZzNDYuNTAxLTUuNTg2IDQ2LjUwMS0xMi40NzZjMC0yLjA4NC0xLjkwMjUtNC4wNDc0LTUuMjY4OC01Ljc3MzkgMC4wNTg4LTAuMDMxMiAwLjExODc1LTAuMDYyNSAwLjE3NzUtMC4wOTM3IDMuNDU2MiAxLjc1MTQgNS40MTI1IDMuNzQ3NSA1LjQxMjUgNS44Njc2IDAgNi45Mzc1LTIwLjk2NCAxMi41NjItNDYuODIyIDEyLjU2MiIgZmlsbD0iI2JjYmJiOSIvPgogICA8cGF0aCBpZD0icGF0aDU4NDEiIGQ9Im0zMTkuODkgOTEyLjU2Yy0yNS42ODIgMC00Ni41MDItNS41ODYtNDYuNTAyLTEyLjQ3NiAwLTIuMDg0IDEuOTAzOC00LjA0NzQgNS4yNy01Ljc3MzkgMC4wNTg3IDAuMDMxMiAwLjEyIDAuMDYyNSAwLjE3ODc1IDAuMDkzNy0zLjI3NzUgMS43MDExLTUuMTI3NSAzLjYzMjgtNS4xMjc1IDUuNjgwMSAwIDYuODQyMiAyMC42NzYgMTIuMzkgNDYuMTgxIDEyLjM5czQ2LjE4MS01LjU0NzkgNDYuMTgxLTEyLjM5YzAtMi4wNDY0LTEuODUxMi0zLjk3OS01LjEyNzUtNS42ODAxIDAuMDU4OC0wLjAzMTIgMC4xMi0wLjA2MjUgMC4xNzg3NS0wLjA5MzcgMy4zNjYyIDEuNzI2NSA1LjI2ODggMy42ODk5IDUuMjY4OCA1Ljc3MzkgMCA2Ljg5MDEtMjAuODE5IDEyLjQ3Ni00Ni41MDEgMTIuNDc2IiBmaWxsPSIjYmJiYWI4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg0MyIgZD0ibTMxOS44OSA5MTIuNDdjLTI1LjUwNSAwLTQ2LjE4MS01LjU0NzktNDYuMTgxLTEyLjM5IDAtMi4wNDc0IDEuODUtMy45NzkgNS4xMjc1LTUuNjgwMSAwLjA1ODcgMC4wMzEyIDAuMTIgMC4wNjM5IDAuMTggMC4wOTUxLTMuMTg4OCAxLjY3NjItNC45ODYyIDMuNTczOC00Ljk4NjIgNS41ODUgMCA2Ljc5NDkgMjAuNTMyIDEyLjMwNCA0NS44NiAxMi4zMDRzNDUuODYtNS41MDg4IDQ1Ljg2LTEyLjMwNGMwLTIuMDA5OC0xLjc5ODgtMy45MDg4LTQuOTg2Mi01LjU4NSAwLjA2LTAuMDMxMiAwLjEyMTI1LTAuMDYzOSAwLjE4LTAuMDk1MSAzLjI3NjIgMS43MDExIDUuMTI3NSAzLjYzMzcgNS4xMjc1IDUuNjgwMSAwIDYuODQyMi0yMC42NzYgMTIuMzktNDYuMTgxIDEyLjM5IiBmaWxsPSIjYmFiOWI3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg0NSIgZD0ibTMxOS44OSA5MTIuMzhjLTI1LjMyOCAwLTQ1Ljg2LTUuNTA4OC00NS44Ni0xMi4zMDQgMC0yLjAxMTIgMS43OTc1LTMuOTA4OCA0Ljk4NjItNS41ODUgMC4wNjEyIDAuMDMyNyAwLjExODc1IDAuMDYyNSAwLjE4MTI1IDAuMDkzNy0zLjEwMTIgMS42NTE0LTQuODQ3NSAzLjUxNjEtNC44NDc1IDUuNDkxMiAwIDYuNzQ3NSAyMC4zODkgMTIuMjE5IDQ1LjU0IDEyLjIxOSAyNS4xNSAwIDQ1LjUzOS01LjQ3MTIgNDUuNTM5LTEyLjIxOSAwLTEuOTc1MS0xLjc0NS0zLjgzOTktNC44NDYyLTUuNDkxMiAwLjA2MjUtMC4wMzEyIDAuMTItMC4wNjEgMC4xODEyNS0wLjA5MzcgMy4xODc1IDEuNjc2MiA0Ljk4NjIgMy41NzUyIDQuOTg2MiA1LjU4NSAwIDYuNzk0OS0yMC41MzIgMTIuMzA0LTQ1Ljg2IDEyLjMwNCIgZmlsbD0iI2I5YjhiNiIvPgogICA8cGF0aCBpZD0icGF0aDU4NDciIGQ9Im0zMTkuODkgOTEyLjNjLTI1LjE1MSAwLTQ1LjU0LTUuNDcxMi00NS41NC0xMi4yMTkgMC0xLjk3NTEgMS43NDYyLTMuODM5OSA0Ljg0NzUtNS40OTEyIDAuMDU4OCAwLjAzMTIgMC4xMjI1IDAuMDYzNSAwLjE4MjUgMC4wOTUyLTMuMDEzOCAxLjYyNDUtNC43MDg4IDMuNDU3LTQuNzA4OCA1LjM5NiAwIDYuNzAwMiAyMC4yNDUgMTIuMTMyIDQ1LjIxOSAxMi4xMzJzNDUuMjE5LTUuNDMyMSA0NS4yMTktMTIuMTMyYzAtMS45MzktMS42OTUtMy43NzE1LTQuNzA4OC01LjM5NiAwLjA2LTAuMDMxNyAwLjEyMzc1LTAuMDY0IDAuMTgyNS0wLjA5NTIgMy4xMDEyIDEuNjUxNCA0Ljg0NjIgMy41MTYxIDQuODQ2MiA1LjQ5MTIgMCA2Ljc0NzUtMjAuMzg5IDEyLjIxOS00NS41MzkgMTIuMjE5IiBmaWxsPSIjYjdiN2I1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg0OSIgZD0ibTMxOS44OSA5MTIuMjFjLTI0Ljk3NCAwLTQ1LjIxOS01LjQzMjEtNDUuMjE5LTEyLjEzMiAwLTEuOTM5IDEuNjk1LTMuNzcxNSA0LjcwODgtNS4zOTYgMC4wNjEzIDAuMDMyMyAwLjEyMTI1IDAuMDYyIDAuMTgzNzUgMC4wOTQ4LTIuOTI3NSAxLjU5ODYtNC41NzI1IDMuMzk4OS00LjU3MjUgNS4zMDEyIDAgNi42NTI0IDIwLjEwMiAxMi4wNDYgNDQuODk5IDEyLjA0NiAyNC43OTYgMCA0NC44OTgtNS4zOTQgNDQuODk4LTEyLjA0NiAwLTEuOTAyNC0xLjY0MzgtMy43MDI2LTQuNTcxMi01LjMwMTIgMC4wNjI1LTAuMDMyOCAwLjEyMjUtMC4wNjI1IDAuMTgzNzUtMC4wOTQ4IDMuMDEzOCAxLjYyNDUgNC43MDg4IDMuNDU3IDQuNzA4OCA1LjM5NiAwIDYuNzAwMi0yMC4yNDUgMTIuMTMyLTQ1LjIxOSAxMi4xMzIiIGZpbGw9IiNiNmI2YjQiLz4KICAgPHBhdGggaWQ9InBhdGg1ODUxIiBkPSJtMzE5Ljg5IDkxMi4xM2MtMjQuNzk2IDAtNDQuODk5LTUuMzk0LTQ0Ljg5OS0xMi4wNDYgMC0xLjkwMjQgMS42NDUtMy43MDI2IDQuNTcyNS01LjMwMTIgMC4wNjEyIDAuMDMxMiAwLjEyMjUgMC4wNjI1IDAuMTg1IDAuMDkzNy0yLjg0MjUgMS41NzUxLTQuNDM2MiAzLjM0MTItNC40MzYyIDUuMjA3NSAwIDYuNjA1IDE5Ljk1OCAxMS45NiA0NC41NzggMTEuOTYgMjQuNjE5IDAgNDQuNTc4LTUuMzU1IDQ0LjU3OC0xMS45NiAwLTEuODY2Mi0xLjU5NS0zLjYzMjQtNC40MzYyLTUuMjA3NSAwLjA2MjUtMC4wMzEyIDAuMTIzNzUtMC4wNjI1IDAuMTg1LTAuMDkzNyAyLjkyNzUgMS41OTg2IDQuNTcxMiAzLjM5ODkgNC41NzEyIDUuMzAxMiAwIDYuNjUyNC0yMC4xMDEgMTIuMDQ2LTQ0Ljg5OCAxMi4wNDYiIGZpbGw9IiNiNWI0YjMiLz4KICAgPHBhdGggaWQ9InBhdGg1ODUzIiBkPSJtMzE5Ljg5IDkxMi4wNGMtMjQuNjIgMC00NC41NzgtNS4zNTUtNDQuNTc4LTExLjk2IDAtMS44NjYyIDEuNTkzOC0zLjYzMjQgNC40MzYyLTUuMjA3NSAwLjA1ODggMC4wMzEyIDAuMTI2MjUgMC4wNjQ5IDAuMTg1IDAuMDk1Mi0yLjc1NjIgMS41NDgyLTQuMyAzLjI4MjEtNC4zIDUuMTEyMiAwIDYuNTU3NiAxOS44MTQgMTEuODc0IDQ0LjI1NiAxMS44NzRzNDQuMjU2LTUuMzE1OSA0NC4yNTYtMTEuODc0YzAtMS44MzAxLTEuNTQzOC0zLjU2NC00LjMtNS4xMTIyIDAuMDYxMy0wLjAzMTIgMC4xMjM3NS0wLjA2MjUgMC4xODUtMC4wOTUyIDIuODQxMiAxLjU3NTEgNC40MzYyIDMuMzQxMiA0LjQzNjIgNS4yMDc1IDAgNi42MDUtMTkuOTU5IDExLjk2LTQ0LjU3OCAxMS45NiIgZmlsbD0iI2I0YjNiMiIvPgogICA8cGF0aCBpZD0icGF0aDU4NTUiIGQ9Im0zMTkuODkgOTExLjk1Yy0yNC40NDIgMC00NC4yNTYtNS4zMTU5LTQ0LjI1Ni0xMS44NzQgMC0xLjgzMDEgMS41NDM4LTMuNTY0IDQuMy01LjExMjIgMC4wNjUgMC4wMzM2IDAuMTIyNSAwLjA2MjUgMC4xODc1IDAuMDk0Ni0yLjY3MjUgMS41MjI1LTQuMTY3NSAzLjIyMzYtNC4xNjc1IDUuMDE3NiAwIDYuNTA5OCAxOS42NzEgMTEuNzg4IDQzLjkzNiAxMS43ODhzNDMuOTM1LTUuMjc3OCA0My45MzUtMTEuNzg4YzAtMS43OTQtMS40OTM4LTMuNDk1MS00LjE2NjItNS4wMTc2IDAuMDYyNS0wLjAzMTIgMC4xMjUtMC4wNjI1IDAuMTg3NS0wLjA5NDYgMi43NTYyIDEuNTQ4MiA0LjMgMy4yODIxIDQuMyA1LjExMjIgMCA2LjU1NzYtMTkuODE0IDExLjg3NC00NC4yNTYgMTEuODc0IiBmaWxsPSIjYjNiMmIxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTg1NyIgZD0ibTMxOS44OSA5MTEuODdjLTI0LjI2NSAwLTQzLjkzNi01LjI3NzgtNDMuOTM2LTExLjc4OCAwLTEuNzk0IDEuNDk1LTMuNDk1MSA0LjE2NzUtNS4wMTc2IDAuMDYyNSAwLjAzMTIgMC4xMjYyNSAwLjA2NCAwLjE4ODc1IDAuMDk1My0yLjU5IDEuNDk2MS00LjAzNSAzLjE2MzYtNC4wMzUgNC45MjI0IDAgNi40NjI0IDE5LjUyOCAxMS43MDEgNDMuNjE1IDExLjcwMSAyNC4wODggMCA0My42MTUtNS4yMzg4IDQzLjYxNS0xMS43MDEgMC0xLjc1ODgtMS40NDUtMy40MjYyLTQuMDM1LTQuOTIyNCAwLjA2MjUtMC4wMzEyIDAuMTI2MjUtMC4wNjQgMC4xODg3NS0wLjA5NTMgMi42NzI1IDEuNTIyNSA0LjE2NjIgMy4yMjM2IDQuMTY2MiA1LjAxNzYgMCA2LjUwOTgtMTkuNjcgMTEuNzg4LTQzLjkzNSAxMS43ODgiIGZpbGw9IiNiMWIxYjAiLz4KICAgPHBhdGggaWQ9InBhdGg1ODU5IiBkPSJtMzE5Ljg5IDkxMS43OGMtMjQuMDg4IDAtNDMuNjE1LTUuMjM4OC00My42MTUtMTEuNzAxIDAtMS43NTg4IDEuNDQ1LTMuNDI2MiA0LjAzNS00LjkyMjQgMC4wNjI1IDAuMDMxMiAwLjEyNzUgMC4wNjM1IDAuMTkgMC4wOTQ3LTIuNTA3NSAxLjQ3MDItMy45MDM4IDMuMTA1LTMuOTAzOCA0LjgyNzYgMCA2LjQxNSAxOS4zODIgMTEuNjE2IDQzLjI5NCAxMS42MTYgMjMuOTEgMCA0My4yOTQtNS4yMDEyIDQzLjI5NC0xMS42MTYgMC0xLjcyMjYtMS4zOTYyLTMuMzU3NC0zLjkwMzgtNC44Mjc2IDAuMDYyNS0wLjAzMTIgMC4xMjc1LTAuMDYzNSAwLjE5LTAuMDk0NyAyLjU5IDEuNDk2MSA0LjAzNSAzLjE2MzYgNC4wMzUgNC45MjI0IDAgNi40NjI0LTE5LjUyOCAxMS43MDEtNDMuNjE1IDExLjcwMSIgZmlsbD0iI2IwYjBhZiIvPgogICA8cGF0aCBpZD0icGF0aDU4NjEiIGQ9Im0zMTkuODkgOTExLjdjLTIzLjkxMSAwLTQzLjI5NC01LjIwMTItNDMuMjk0LTExLjYxNiAwLTEuNzIyNiAxLjM5NjItMy4zNTc0IDMuOTAzOC00LjgyNzYgMC4wNjUgMC4wMzI3IDAuMTI2MjUgMC4wNjI1IDAuMTkxMjUgMC4wOTUyLTIuNDI1IDEuNDQzOC0zLjc3NSAzLjA0NTktMy43NzUgNC43MzI0IDAgNi4zNjc2IDE5LjI0IDExLjUzIDQyLjk3NCAxMS41M3M0Mi45NzQtNS4xNjIyIDQyLjk3NC0xMS41M2MwLTEuNjg2NS0xLjM1LTMuMjg4Ni0zLjc3NS00LjczMjQgMC4wNjUtMC4wMzEyIDAuMTI2MjUtMC4wNjI1IDAuMTkxMjUtMC4wOTUyIDIuNTA3NSAxLjQ3MDIgMy45MDM4IDMuMTA1IDMuOTAzOCA0LjgyNzYgMCA2LjQxNS0xOS4zODQgMTEuNjE2LTQzLjI5NCAxMS42MTYiIGZpbGw9IiNhZmFmYWQiLz4KICAgPHBhdGggaWQ9InBhdGg1ODYzIiBkPSJtMzE5Ljg5IDkxMS42MWMtMjMuNzM0IDAtNDIuOTc0LTUuMTYyMi00Mi45NzQtMTEuNTMgMC0xLjY4NjUgMS4zNS0zLjI4ODYgMy43NzUtNC43MzI0IDAuMDYyNSAwLjAzMTIgMC4xMyAwLjA2NDkgMC4xOTI1IDAuMDk2MS0yLjM0MzggMS40MTYtMy42NDYyIDIuOTg2NC0zLjY0NjIgNC42MzYyIDAgNi4zMTk5IDE5LjA5NiAxMS40NDQgNDIuNjUyIDExLjQ0NHM0Mi42NTItNS4xMjQgNDIuNjUyLTExLjQ0NGMwLTEuNjQ5OS0xLjMwMjUtMy4yMTg4LTMuNjQ2Mi00LjYzNjIgMC4wNjI1LTAuMDMxMiAwLjEzLTAuMDY0OSAwLjE5MjUtMC4wOTYxIDIuNDI1IDEuNDQzOCAzLjc3NSAzLjA0NTkgMy43NzUgNC43MzI0IDAgNi4zNjc2LTE5LjI0IDExLjUzLTQyLjk3NCAxMS41MyIgZmlsbD0iI2FlYWVhYyIvPgogICA8cGF0aCBpZD0icGF0aDU4NjUiIGQ9Im0zMTkuODkgOTExLjUyYy0yMy41NTYgMC00Mi42NTItNS4xMjQtNDIuNjUyLTExLjQ0NCAwLTEuNjQ5OSAxLjMwMjUtMy4yMjAyIDMuNjQ2Mi00LjYzNjIgMC4wNjYyIDAuMDMxMiAwLjEyODc1IDAuMDYyNSAwLjE5NSAwLjA5NDctMi4yNjUgMS4zOTE2LTMuNTIxMiAyLjkyNjItMy41MjEyIDQuNTQxNSAwIDYuMjcyNSAxOC45NTIgMTEuMzU3IDQyLjMzMiAxMS4zNTcgMjMuMzc5IDAgNDIuMzMxLTUuMDg0OSA0Mi4zMzEtMTEuMzU3IDAtMS42MTUyLTEuMjU1LTMuMTQ5OS0zLjUyLTQuNTQxNSAwLjA2NjMtMC4wMzIyIDAuMTI4NzUtMC4wNjM1IDAuMTk1LTAuMDk0NyAyLjM0MzggMS40MTc1IDMuNjQ2MiAyLjk4NjQgMy42NDYyIDQuNjM2MiAwIDYuMzE5OS0xOS4wOTYgMTEuNDQ0LTQyLjY1MiAxMS40NDQiIGZpbGw9IiNhZGFjYWIiLz4KICAgPHBhdGggaWQ9InBhdGg1ODY3IiBkPSJtMzE5Ljg5IDkxMS40NGMtMjMuMzggMC00Mi4zMzItNS4wODQ5LTQyLjMzMi0xMS4zNTcgMC0xLjYxNTIgMS4yNTYyLTMuMTQ5OSAzLjUyMTItNC41NDE1IDAuMDY1IDAuMDMxNyAwLjEzIDAuMDY0IDAuMTk1IDAuMDk1Mi0yLjE4NSAxLjM2NTItMy4zOTUgMi44Njc2LTMuMzk1IDQuNDQ2MiAwIDYuMjI1MSAxOC44MDkgMTEuMjcxIDQyLjAxMSAxMS4yNzEgMjMuMjAxIDAgNDIuMDExLTUuMDQ2NCA0Mi4wMTEtMTEuMjcxIDAtMS41Nzg2LTEuMjEtMy4wODEtMy4zOTUtNC40NDYyIDAuMDY1LTAuMDMxMiAwLjEzLTAuMDYzNSAwLjE5NS0wLjA5NTIgMi4yNjUgMS4zOTE2IDMuNTIgMi45MjYyIDMuNTIgNC41NDE1IDAgNi4yNzI1LTE4Ljk1MiAxMS4zNTctNDIuMzMxIDExLjM1NyIgZmlsbD0iI2FjYWJhYSIvPgogICA8cGF0aCBpZD0icGF0aDU4NjkiIGQ9Im0zMTkuODkgOTExLjM1Yy0yMy4yMDIgMC00Mi4wMTEtNS4wNDY0LTQyLjAxMS0xMS4yNzEgMC0xLjU3ODYgMS4yMS0zLjA4MSAzLjM5NS00LjQ0NjIgMC4wNjUgMC4wMzI2IDAuMTMxMjUgMC4wNjM5IDAuMTk3NSAwLjA5NjEtMi4xMDc1IDEuMzM3NC0zLjI3MTIgMi44MDc2LTMuMjcxMiA0LjM1MDEgMCA2LjE3NzIgMTguNjY1IDExLjE4NSA0MS42OSAxMS4xODVzNDEuNjktNS4wMDc4IDQxLjY5LTExLjE4NWMwLTEuNTQyNS0xLjE2MzgtMy4wMTI4LTMuMjcxMi00LjM1MDEgMC4wNjYyLTAuMDMyMiAwLjEzMjUtMC4wNjM1IDAuMTk3NS0wLjA5NjEgMi4xODUgMS4zNjUyIDMuMzk1IDIuODY3NiAzLjM5NSA0LjQ0NjIgMCA2LjIyNTEtMTguODEgMTEuMjcxLTQyLjAxMSAxMS4yNzEiIGZpbGw9IiNhYWFhYTkiLz4KICAgPHBhdGggaWQ9InBhdGg1ODcxIiBkPSJtMzE5Ljg5IDkxMS4yN2MtMjMuMDI1IDAtNDEuNjktNS4wMDc4LTQxLjY5LTExLjE4NSAwLTEuNTQyNSAxLjE2MzgtMy4wMTI4IDMuMjcxMi00LjM1MDEgMC4wNjUgMC4wMzEyIDAuMTMyNSAwLjA2NCAwLjE5NzUgMC4wOTUzLTIuMDI4OCAxLjMxMS0zLjE0ODggMi43NDc1LTMuMTQ4OCA0LjI1NDkgMCA2LjEyOTkgMTguNTIyIDExLjA5OSA0MS4zNyAxMS4wOTlzNDEuMzctNC45Njg4IDQxLjM3LTExLjA5OWMwLTEuNTA3NC0xLjEyLTIuOTQzOS0zLjE0ODgtNC4yNTQ5IDAuMDY1LTAuMDMxMiAwLjEzMjUtMC4wNjQgMC4xOTc1LTAuMDk1MyAyLjEwNzUgMS4zMzc0IDMuMjcxMiAyLjgwNzYgMy4yNzEyIDQuMzUwMSAwIDYuMTc3Mi0xOC42NjUgMTEuMTg1LTQxLjY5IDExLjE4NSIgZmlsbD0iI2E5YTlhOCIvPgogICA8cGF0aCBpZD0icGF0aDU4NzMiIGQ9Im0zMTkuODkgOTExLjE4Yy0yMi44NDggMC00MS4zNy00Ljk2ODgtNDEuMzctMTEuMDk5IDAtMS41MDc0IDEuMTItMi45NDM5IDMuMTQ4OC00LjI1NDkgMC4wNjg4IDAuMDMyMiAwLjEzMjUgMC4wNjM1IDAuMiAwLjA5NjEtMS45NTEyIDEuMjgyMi0zLjAyNzUgMi42ODc1LTMuMDI3NSA0LjE1ODggMCA2LjA4MjUgMTguMzc4IDExLjAxNCA0MS4wNDkgMTEuMDE0IDIyLjY3IDAgNDEuMDQ5LTQuOTMxMSA0MS4wNDktMTEuMDE0IDAtMS40NzEyLTEuMDc2Mi0yLjg3NS0zLjAyNzUtNC4xNTg4IDAuMDY3NS0wLjAzMjYgMC4xMzEyNS0wLjA2MzkgMC4yLTAuMDk2MSAyLjAyODggMS4zMTEgMy4xNDg4IDIuNzQ3NSAzLjE0ODggNC4yNTQ5IDAgNi4xMjk5LTE4LjUyMiAxMS4wOTktNDEuMzcgMTEuMDk5IiBmaWxsPSIjYThhOGE3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg3NSIgZD0ibTMxOS44OSA5MTEuMDljLTIyLjY3MSAwLTQxLjA0OS00LjkzMTEtNDEuMDQ5LTExLjAxNCAwLTEuNDcxMiAxLjA3NjItMi44NzY1IDMuMDI3NS00LjE1ODggMC4wNjYzIDAuMDMxMiAwLjEzNzUgMC4wNjUgMC4yMDI1IDAuMDk2My0xLjg3NSAxLjI1NDktMi45MDYyIDIuNjI1LTIuOTA2MiA0LjA1ODYgMCA2LjA0MTUgMTguMjM2IDEwLjkzNSA0MC43MjUgMTAuOTM1czQwLjcyNS00Ljg5MzUgNDAuNzI1LTEwLjkzNWMwLTEuNDMzNi0xLjAzMTItMi44MDM4LTIuOTA2Mi00LjA1ODYgMC4wNjYyLTAuMDMxMiAwLjEzNjI1LTAuMDY1IDAuMjAyNS0wLjA5NjMgMS45NTEyIDEuMjgzOCAzLjAyNzUgMi42ODc1IDMuMDI3NSA0LjE1ODggMCA2LjA4MjUtMTguMzc5IDExLjAxNC00MS4wNDkgMTEuMDE0IiBmaWxsPSIjYTdhNmE1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg3NyIgZD0ibTMyNi42MiA5MDAuMDhjMCAwLjk5NzUtMy4wMTI1IDEuODA1MS02LjcyNzUgMS44MDUxcy02LjcyNzUtMC44MDc2Mi02LjcyNzUtMS44MDUxYzAtMC45OTYxMiAzLjAxMjUtMS44MDUxIDYuNzI3NS0xLjgwNTFzNi43Mjc1IDAuODA5IDYuNzI3NSAxLjgwNTEiIGZpbGw9IiMxMDBmMGQiLz4KICA8L2c+CiAgPGcgaWQ9Imc1OTYxIiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNTk2MyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTk2NSkiPgogICAgPHBhdGggaWQ9InBhdGg1OTc3IiBkPSJtNjc2OC45IDEzMjYuOGMwLTEwNS4yNi04Ni4xMy0xOTEuMzktMTkxLjM5LTE5MS4zOWgtNzMuNDRjLTEwNS4yNiAwLTE5MS4zOSA4Ni4xMy0xOTEuMzkgMTkxLjM5djQzMzUuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDMzNS4yIiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTk2OSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU5NzkiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IC00OTcuNyA5MjMuODMpIj4KICAgPGcgaWQ9Imc1OTgxIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1OTgzKSI+CiAgICA8cGF0aCBpZD0icGF0aDU5OTMiIGQ9Im02MzEyLjYgMTQwMC43djQyNjEuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYy03MC4wOCAyOS43Ni0xNDcuMTggNDYuMjItMjI4LjExIDQ2LjIycy0xNTguMDMtMTYuNDYtMjI4LjExLTQ2LjIybTEwNS4yMyA0MzQ0Yy0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3IDI0LjY5LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ2IDAgNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2NDEwMi43YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNiIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDU5ODcpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1OTk1IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNTk5NyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTk5OSkiPgogICAgPHBhdGggaWQ9InBhdGg2MDA5IiBkPSJtNzEyNC45IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMThzLTU4NC4xOCAyNjEuNTQtNTg0LjE4IDU4NC4xOGMwIDMyMi42MyAyNjEuNTUgNTg0LjE4IDU4NC4xOCA1ODQuMThzNTg0LjE4LTI2MS41NSA1ODQuMTgtNTg0LjE4IiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NjAzNSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzYwMTEiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IC00OTcuNyA5MjMuODMpIj4KICAgPGcgaWQ9Imc2MDEzIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg2MDE1KSI+CiAgICA8cGF0aCBpZD0icGF0aDYwMjUiIGQ9Im02ODgzLjIgMTE3MS44djIuNDdjMC4wMS0wLjQxIDAuMDEtMC44MiAwLjAxLTEuMjNzMC0wLjgzLTAuMDEtMS4yNCIgZmlsbD0idXJsKCNyYWRpYWxHcmFkaWVudDYwMzUpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc2MDI3IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNjAyOSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNjAzMSkiPgogICAgPHBhdGggaWQ9InBhdGg2MDQxIiBkPSJtNjc2Ni44IDEwMDkuOWM3MS4zOCAzOS43OSAxMTYuNDIgOTguMTIgMTE2LjQyIDE2My4xMnYtMS4yNGMtMC41Ni02NC41LTQ1LjQ5LTEyMi4zNC0xMTYuNDItMTYxLjg4bTExNi40MiAxNjMuMTJjMCAzOC44OC0xNi4xMiA3NS4zOC00NC4zNSAxMDYuOTQgMjcuOTQtMzEuMjMgNDQuMDItNjcuMjkgNDQuMzUtMTA1Ljcxdi0xLjIzIiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NjAzNSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzYwNDMiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IC00OTcuNyA5MjMuODMpIj4KICAgPGcgaWQ9Imc2MDQ1IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg2MDQ3KSI+CiAgICA8ZyBpZD0iZzYwNTEiIHRyYW5zZm9ybT0ibWF0cml4KDcwMS4yLDAsMCw0NTMuMiw2MTkxLjksOTQ1LjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNjA1MyIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFKSUFBQUJlQ0FZQUFBRElVVTFCQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBRys5SlJFRlVlSnpsWGR1VzlDQ0xCYis4LytQT21qVi9SK1pDd1EyaVNhcXFEN09HaSs2cXhMTUlHMFNMLytlLy8wdm90K2tYVzhEeW1jcUYrU1BsdkVTL1dMWFNVYyt2YjYvazVUR3UzejlHdjcrSzdwRVFFWlhkeTAzZUgramtJYlYrYXdWTXp5Y0xtZWV0TVJEUGhwK1NQcythQUczZ04rcy9lNW12dE9POW1pL3BxUFg4YUlGUittUWQ0QXZleFR6dmp2MnkzRzhhV3ZrbUdTb1BpcFdWNUlycFBqZ0VoM3lRa1ZqMm5LOU10bTEvWUxLN2ZYM0tjSDlGcFQxaEVFYzdab0VwdldLV2wrc1A5QlpHV2trZkZpTFpTSjJydHIvU054MndUMHF3N3lTYndCYzZLMFNPV2RMM0MrSVNtQ2NrZmxWS3ZZNlJSSEsxeFl1T0FIQk84OEhETzMzaEJZSjNlUjkyN2JGVWU0WGpWWkpJK0kvbFBwak5xelpFa0s0TGZGbkZpNWJSY1Q2UVNGa2RFZThJYlNaazFjYUswbXpma1IwejNxVVZQdnFVTU52aXBBZElZdGNld1VGZXFEbGpsbEJuTnNRU0daeWVTYWZiR0NsYVg5d0hTMElhWkFwN0xsTWJ4N3NOT09kWHpQOGJuZjh6MnUraWMxRURPUkNOblRoZndFSWxqSU5aaEhOQmQ4YnJOaU1KZGVhcEF0L3pDdkU1MXl0VzJEZHpxKzgvNXJsNEYzRit5S2w1WlcxdCs3dnVnMm5STXRxWllkaXBGNFZUeHNyb3FBdU1GSnNWTFRKT0doT3RzcEpJb3FpNkpvbTJlRGZ5ajZjMWtkRy80U3Q2bGFJM1BMYjlqcmRja2s4dDd6d09mQkxGZFQwTkYwcXFjNVprcTlGZFNpU1RMdm82S2RCMVhIakNIaWxXY0RxNHZkKzVCYWJCemRKY3ZIZTBZUDUzS2VYZkt3a1RGdG9lRTIwa2pscXN3RHdwRGtyVkZodjJFT1laVDVsVTJBUDd5U0dwYlpsQWJjVTBIaC9wdDBvZDF4aGo2RWpONjhaTi9qbStSV3RzeGx2M2RIamE1enZpL0pPMEtQeHVuWjU1d29MQ2NlaWlYeVJMU2ROcUVVRTFLakE5eWxCUnVwR05uY1Rublk1NmVrWkNkWlJhWHpVeHZjV3ZCT3pqRWlNbERPRmtXbjlmYnVDWFYzMUhuOTVubmJxMHdEU1RDbCswQXg5WG5FTFdwU3RUUFN2SmhmaUlpSWpRV0o4WVRiekxZTkZPbkZxbjJncmdJQloyMG1NOGIzZzcrbjNpWkpwVWd0cm5QR3UxcGZrcmVWeVZNVTBtNWU3UVV5L0ZWYjZuZEZXT216aFVXd3NHYlV5VVNCTWk0L0xJN0JNRzB1K0FqMkk3eFZUaGVIWkl0OEpLZHpBNnZGS1pXSitEdVcvTkJZYUtrNWxhZFRMS3NUU0wwWng4UGVMcnp5akhXSXZFRy9vdWRYZlhnZW5xN3gzd1NtNHVTSmdtTGhFU2w5R3BwVlg3T0taRGJjTW1iYVFYWFR2REhGS2JqSk9LTERENllHSk5nWEd3M2dwdWJpYytKQ1NudW1vZUdhQXA0cUxMWENpVDFaalU2YVRjTHdUdTdNem5xZThKT0Y5Wi9Da3pFVTBBUDlZL1NSZElYNTJLeElhQkJES3MxQmQyVjVrSDFXck13WUtxU0NYUktGbWxFOUhBU1ZVQVYwbGNPWWs2czFiVEk5TGtidmp1MnFiMkdoYkpOL0xVYlEvRUJEWDNrMzZ2OG5YZWRMakVZNTJwRGR5c092K1VpYmlTOExEcUQvazZ5WHBrZXJUSmprbGlTR3NOUGkvdzJhUlZuM1hjZDV1ODIwRjZxV1M3VWsrcGFndFc1T2p1Zldud0U3VGNPb2xXVXBaM2tiWEd4RkVpT1NieGFmUWRDQnBYdnlCb3huYnBYQUhvUDZoVzQwd3ZRVGhsQXBhVzNsbDNoSitCSTFCVVVzSUFXTFpNNDduTXV3VFhVY3I5dkNiYlU5WS9XSWtwQSsySzR6QXVnU09tdkIwRHRVVXNMbDBzQjMxS2pobk5SUUJsRW9TUnpHYTZkSFhXS2lyaHRYRTMyUHZPdXV1ZmtVSENnaGlkVU41RFgxVm9UYllrWndtMGY2OFVMZUZQMCtXdUVOQXUzSWFJOXRKS0pVdm9UeXhTT0hmNnVzK3F3dnJFYVJkVTRqVWdMZ01iWWQ0aWROQlo3WVdDWlZWSmhwMXFhMXhCWUNGUUExWVdTY3NNSFkzcXFrUTlHZ1puS1lVeXVzSkszOHhJVEd0VjVCSmRVTnJNSUpvbjFSYlNERFdWbE9td1JmOENjMXdWNmpqODRzZFBDaEdkUk1mNTlSOWlZZys0YVVpVVFtUFY2RXBEeTQySnFZbzRYMDhKSmlTTERLc3Y5R0cyRmNtWUw5dGgySXA2cTIrZitEZndrWkhoaStkWm5hU1pyTE9aQmtvUnNNcDdXYmI0aFppWkJEalMvRWVnUXJSdWlXbjZpNE9xT0JaenpDUnQyOE84eStBbWQzNHVUYS9KSkZFZnFkazFXallpSE1IUHRNU215YkJodnY3L1V5b3NjMU84U3BQYVM0RGhjbDhOazU2TkNXdzhkNzZQd0hRMU1JcjZqeWJTZEdjcnpCOWt3UFlTSGZTZkwrcTgwQ1FUQjZ6U21ZbG9BR21VV3ZwZDIxMUFoK3FFRzVOaEFGdnNySDRWNy93TWJmYlBrOW5OSWkxZmNVcE84eDNLZkVLU2pIKzZxWHFEZjlwM1hYWHdNTE42UzJjV0ZRQTh0QUp1dVhnd1BiNHpDMGd1bUpNQ1ROakxPWVNxaSt0cERLQVZpbmZpaWFTbXVCQ3MvbzA2c1hJbG1XMmp3TWtiR295U1NLTmJKVnlYL1FuQ01WczZHSjl3T3lkc0RhYThUZm9HRERxNEc2RUlQQnRWS1pabUt4ZW4vS0N2MDZTQVNwSEJJQzNUYkdhemhTdzQwLzlrYjRWUklnME1mRWVwQXl0RXZBODY0cTJVd21PY3NML2dCVmhPNmFaeG1Zb1Rvc1NTdzRYVTM5M3hKeEdSL1BQK1FzV3lMZ2lPaUloYmtOdkFTRHAvVGIwZUVqemJMck1NaVlQK0phTGF6WHJ1R01xM1VIR1Q2L09UQlFmNXAzYzNuanl0YjZyN0pyMFUvTC9RMDVuek5Hc0s4ODE2QXliMVd4N3c3QXhTa210TGNIcHBKMW5iOVIwVEhYU2VReEtaUEZOcHd5VEpuaGdENkM3UU1zRU8wUEMxWlhnbzRoK3RkZzcrajZ2UDA1VksvYzdqU1kvNGFKVllEWlR3UG0vMndEaWlQcDlkbmdTYjJmUVVmQ2Z1WFhRVktDNHlYQldsRm5OakpFM2NKRk5qcGlKUVZKUXVSQ0dBRGRNTUhHVE1ncXJPT1IxSFQ3TURBamNPU21SSWRDcjdEcjE2OHZidXlkcXhZQktnVERUdC9VMFRyL2tEYzB6ZktSODBDWlBoOEpQaUt5SHpDMDF0RkdpVHFveVREZHNkOG5YYUJxeUlVQUdScUFCWnVwOEl3YldaL0xIek10N2hDUmNuZVNwWWRpVHduQnhOSVNlVTBEM0lkRW5mS0xqdTBjclZFYjY3clEwWGZPWlRTcEhKU3JiaEJYTS9DdlMyY1IvR293eGp3SVdQbUdjZDk5cWdzdElMWTJGd25jTC9hQzlpaDN0RlRucUZkMHZxRVFiNDNiMG12M0t6c3RUZ2UySzF2UXZHbnpDaHJkUFF3QmllczdJcDFNZ1J4cmxaVk1RZWxoUUtVNWZncHRxM0hjelk2bVV4RWRWL0FXWkFjTzBoNTlsVUdaVG45c2VxMzJjekZkZ1pUU2ZYVFViWUZuSFNaQlZEVkRrOTQrWlVqbmdGSkVSRGdvYnlJZ2JiMFU5SW82djIxQlhuSytPRW5DYjlOVjQ3NWt1MlJLS1VjZGhYeURrNE1US0FxR000eGNZbHFGNG1PdVNzZnA4ck9aUW9aM3pHdlg5Q0JaZE9EYlZyYWhubEkxT2g1eG1qUklkVW1RVTdTaXpQdktHRkQ4VE1Kd0Q1bFNYbDRBYlcxd2VobkdIUzQxRWxFdWRFWkdXRUxoVUtnY2VheUlmeXdMU0kxbisydW0zaGNkOG00NkEraWZ4ZVd5OWIxU3B6azJLSGhzV3BwRUhzMC80elNKaFdNZGY1NkpIbWNLWjdNa0Z1MjBMQ1IyQ0drbVFlanRLTVF2cWJnWFB2cXJXWENDczk1OGNPT3dLWEZ5S3FhaUZyUGhoUUJvYXBDbElGR0Nnd1ZCc2pDVTFpb3VKQ0FMdjZjZkpyRXJFSGY1MURTb0RlTmFrSmpEV3dEaHZvY2t5bnpzeGt3dEZsZ0cyWkxEUGN6NE5WaWY2b3Jab1E5eStsZDczZUdjVzIzT0xqUkpYSGQyaVpUYzdoc2c2UFphSzJIMWZHZDErK1RGSlBla0x1T01uN2wwSWRaWGk0bVlnT0VvRDJIWU1VY0lFekVaV0svQW9jRE1jTW1NUWtDbWUyUEpINXBFSi8zUC9RN3IwWnY0dGZXdEN0azdpWGV1cTZqTlZ0S1ZOVitHVnh0cjh4UVJLeFdqMkFqallRb3lsUDVEYlZpWWk0MUlHOXVNM2JHSjR4OGhncE9hUloyOTNRS1RqcWVUckpFNDhrTVluYnNqREpnOXNZbWhjK3UxQVRORU54MVFSSmljd1l0YU9OTWVoK3BORm0ydFAzM25RNDZJYllpL3lxOGM5aWc5a29iWElaUVdhV0h4a0p4dCs1Q2RRdzZ1QVpWVjRyeEVzcUlTS3VNbFNxT3F0NUFPM0tSQWQvaldhYWl0TEo2czRrdzBNT1dMY1VhTzFOb2JVZ21HSkl4eVFaTmpGSU5nSmFENWF6U2ovbGQvK1c5TEV0RWp4M3Vram5BSGg0N2lJd2trYlpjVVRub1I0Z1BRUDMyWTBrQ2xrcWRaQ04xaHlQK1B3bWZRQ0VGMWRNOTJ6RElKdEtBZEFzVVFyVU9XYmJxa2gyK0l0ais2RTJHNU42M3hHMlFjbXB0K1ErcGl0eVBxM3RqdmhENkwwcHk2M3FWeEI5WkJCYWpOSFo2b3BuMk55WHNJbzFyRFpDQTZZbVhXeUxKS2dEVTQzS2VWWWUwVUUxWGpraExuN2JPZmhRSUlGL0NWVVl0cHViYzhKM0FqNFg3ZnNDSjJrSExhUGtqTE9hcC9KNEJoK0lJMG9BckhzbkxoMlJiaTNjckZrUDkwUkxDOG9iY1VNald4WXZydUM1bGNFZVMwVVZ3T1JDcm9rNm9BOStKNWVQQmZiYWFsLzMwdlNoWXFVbVRieWNpQmJZVUlVSWtvRVNVT3hPajJqajVqRUkwbVRxOHpiOUUxSHdpdEI0U2lXN25BSHFqLzFmSHFkZUVRL3d2UVR4TkRiVDR5YXR0Z09mQ2JWUTNHRWRpb2t1cTZOSWMwaTZVQS9ZN1VkcFFZYVhGTFVEVS9CZ0p0Z2RiUDh5Sm9KeUhjN0QzaVlXM2tSM0JRaWNFdjQwN2RvbzA2cDVvekNDazB2SkJROEQxNFR2MUVBODNqN1NIalo4SzRYTUorWEs3aEt4bFRNbXc2Q01TaWh1K1EvNittcjdheFZTb2Y1VGt4NmFnWnVyNnZ2cFdONnNyd2EySS9DTHdwbG1nRDYrYm1PWjFtNEJtZVQ3VDBpYmpKYVN0aVRqUURNVzR2REJ1bFhuTXIySzB0bG9xcXdTd1JtMWFPMEpsWkpZaHNXcXNqYWpXdE9UUlpyMm9MTzZtQjVCcjNVLzVJUWhJZEZhY3l0Qy9KNGRFUkYzRVlpZWF1bmd1N2hCQTlXcDZaNzRpWDdLclA4RW9VVjM0U1p3MWh6c253MG03VXh6ZHE5M3VPQktnNlVGckNPVkZYWVMraHhaekhvN1VlcHcweEQ5NkxhY2JOc3IwclZSODJ3TEFGT0dGUzNpUW1NVmdCa1lJOUJrbWZTUTlPUFlhRjI4ejlSaHFoVUVtVS9XNlY2a2pIbGZLWCtYeDBtSDZZeDlYc2JxcmtuRlIrc05ZUDladE53RTFQdFR1OUpEU1hxaTBoak54bDh4VXBGeDZTVEQvZ3Qxb08yeGl3cE1oUDY5TGRqYkRPUFU0THdkNnRxVlkzMWRXWEVKdm5KcExtYmI1WHZJR1V2SjZJZGpteWM3VmFMNUdCNDZhUVQ1K0FRR0lQSmVDRzRid0pYSVdWcnVmMGxBdVRWRXhRYTV1RzBTSnVidUNUZk0xUmV2Q0IxOG5qMVVwT09aanBXc1hKQSsyZEZxMi8zSGRIYXBBekFjNGsyaHhLc05uMitxS2N2M2YwbXRaWlJJa0MxL2I5U2hubEZqYXZNazA2cGhGOGVrZ0Z2dk9VSm13MXZkR0R6cFRTczFEM2dWSVc0WXFYTlZyV0ZTaHFQUTFLNjcvd1FxTVFrQkQ5V2RRRDcwSTdOaFhXZ0lISVZ5YVNLR0NpUDl5Q045UCttM2tKTkNUQTZqcU1yeGtrcGNQblhWV0dSQWR0b0VUdm5FdXlpNWhCS0ZxRkNscXBLSTFkSUQwWDlxUFlCMVZlSUowMEhubCtFakpnMERZZEtmaUZDd2JKZEowR0N1VkhKa3M5VExpNkd6SldIR3JKd3JkYmVzTjFDcWxyNFpWRjBXbitSQk5iYkxZNU91ZFFXVjJiemRQQTJhd0UxR3J0Z3l5cXRkaFhGcCs2b1k2RTlFUkVWNkRGV0wyejdvcTE5VG81V0kxNmR1MzQwSXZGaWcveFFQVFlmR1ZmVjRDODA2Qk8vM1F6NlVlVnl0Mi9IZU1PZWQvRThwNjBFOGlEaTlUd3dPVjA0UXYzTWRBSUJwSHB0S1RCeWlLRlZLcVJXR3ZpTjlWN2hTWlNZNXhacFFNY0N0aC9McTF4SFlCcmhGRzhPMXhad1VaV3A5bjFobVRzSWtaVGtLNWZqNGJsUmg0cGpzOGFUZndFNFIwRDZoeDhMc1ppQVU0a1g3WGh6aW5OWGRZb1V4a1oxZHM3UmxhQmppSml4aS9MWlFYd1NtL2JoSm9HNzJxOXRSKzJTYnRrMTlDV2xNa2s2Zm11cFNrM0VRUEJ5cFplQmVHeVJOTmdpeG5MaXBtMjBhM1RreWRQV2pPRVRQOE5TMm5NMjdWUHJnNzZnbGpZaFowcU5PSUhuMHN2VXB2dHFsSC85c25jTkpJWGVrTEtoR3RmeVVIMm9oRjc3V1F6YUp1RHNralhYcWFENExPVXVzV01uRFVqSVZLSzZQRGxQaGMyeXJXMGtUY0diaTVQeC9IUHBvRVk0Q1k3cWZwNWtwTmdtY0RTTnBFdnRlbStvUkw4WWRRemhuY1ozWFpHUWV4VVZNVFZyWnZKVVdybUpUWHp1T2NtMXFrdEpDYlowVTZaUHI5RGVlSmxHR0VRRGhRZDBaTUE4N2lHNUFRY3B0Qjc3bTQzNVhzbndTVDMrS011WjJ3Qm1mRTAzcXdDWWV5cW9sNzJzejhkdWI1djMyYlRCbzJ5V003ZnAvTmNZeHRjaERNMVVtKzIwVEpyM1ZWaHRVQjllN1JzZmQvczVva1FsRVpvWnlJQW82bHZtU3NDeDltR01zamtXNlVuNURBajBoMzI2ZVhrUzF4elJiV2ltdzcrTTFIZjltc2d2WTdYZzI0QXM4ZXFiU3pWZ2dTQ0dWV3ZhL3YyZ25iVW5NQlJCQmN5dk1pMXVwN003MWk4Z1UrelAvNE0xb1dCeU1HUy81cnp0cE5lVmRsYnVqZDBYV3pZclNhaEx0ZkprdmdNeFo1Wk03MTlZc01kMGJZNnkyeFVqRlp6R2M5V1FuTld4SDRoeE1mN0JaYmVMYnAzZ0lnTGhlOGpBT0FQVC9Na3BISFUxRTg1WUw1Y3hoSFVud1ZFYXJ5SURMTS84eHpPVVRKTW1YeEVub2pJOHdKb1piTXBBZXExRklrYVMxTkVHQ0RRbWlsOU93MVNzTTZKdklBdUhFR0lJNzAxRGJkaEcvM2lGQzBsdEs2aUlhbm0yVlZqTHdUeitTTk1kaWV3Q09BNUgxbVBHN3BFbHlwcnNyUlo1Y01mdWRGQVpxMWFvdGMwVHFFODB4VFdRZ2ZRL014VkNYcVVOalpqRzhoai9EUlhwbE1yYXhOSW5XYnJWVkowUzNsSmpJN2ZCUEc3QXFsYlQ4d0JUeHU1TkFHZTRKR0F6elRpNkhKTzJjQ3hQOEVhaWRIZ2FZMjJhdmtxaEY4OTNFN0RHK0d3WThMa2JuYjVMQlVOeWYxNDV0RU1pcmxhaFNiZVJqb3U2d1BQajhHcXZXMkhjd2laTmYxRlVjc1pNNitqbXF0UmhHNjlKcmYxQnlSU21WNUJ0cC80aWsrUVlTQ015MzhXTVBqWVJBZXRYQkVJN2l1Z3JUbktYVE1DSDlDVDIvWjBjV3lOUkQxVHB6eWZCc0Y2Rnh3QkhEYUZYTmtZQTBFUHJuZWo3U0x2RlBHQmpNdDNvZUYrNHoxcm5DU3ZlUzNhWkg1ZTJsNUtTMmVZeHRmRGFWRmxTZHUrRXR3aENlbTgwQzJndWtUOFg2V096aWtOcXZ6eGtYdHRzSmtIR1hwS28xSW5KN2NQL0lOeUNPbmRETWpPNWRrc2MyaTJOQm55SXI2eWNsV2FiSDM2Q0FmWWpGUVVCVGEwaytVelpSclBXdjZQRXhKWVNZQ2wwQXpPTkVUUC9YckxZUXNXZ3FSK0orUGRseEtDWXZnZUwrMjFCZFhwQVdvdG15RXFMSU5SOVJYV2taUDR5WnZENStxNmhtcWZsVmFXUHAxSlk0NW1BUVh3YXNhYVFmYWd4eEViYS9nV3hXUzA3RzdvajZtZHBlVzRVR2RUU21ZRG9DWldzem5uY2p3RWN5bUVCWHgrUzRoSUhCb3UwMmpjQjhqdTdNeFIvQjExdTZXaWVMOXdNenlTUjk0cnFKeDQ2TTJUcGVxWkRPbUE2OTR3R3dONTdpb2Vxb01TMmQvZWIveGhpcXcxcklCd0psazBaZ01mbFlJcmJuVFVxSlNhWDFFV3pQcEFpOHg5cDVRSzh5ejUzVHRTdmdONlY3b1JFdkNkNCtRcHgwT3dKc0dvd2k0ZjN3WFFHZ1Z1OTJmeURzTXhXV0h1TUcxMmdYYmplMm9lOUhxSUd0ZjcxUXhEbW94akI5WXl4L1p4SVlGT2JFSEpLSVk1K0h0M1FhdEF0NnFnSmo4aytHNldaZzhYYmVaMHlZUWI2Qmd3VFM2VTE0a21Db3hqRVNHVFAwUTdkTXVJejhEdEpLeDBqb0pUWUdFRkJKeWkzMjNhc2V4c01Eb2NQdVp5UUV3Mjh4RGIxR0MyQzVwWjkwVUY1MTdKWDJKOG1td3dUc0djb01wWERMQ0pubWFRbjA0djZHbXdCVHdlMXd5R2hZMTFGcVdKTDlYSnRKbURyaWs0YWFFc2QwMDBrVHJVVDg5K3l5aU9XNDdlWmdOd0hadTNDM3dZL1JYYUFkREpXbitBbGRBMnEzNENSYk92WWczRlVQUUZpNFFSYzdNMXVwWTVrZXZxSUpldVpDUk1kNXR0MS81aGJFTml5MkFhQWoyRWI4aE9NVDc0bHl2cU1NRTFFWXR4MHpST1IzUlR2d2NFVlhTUi94WWtqOFJDREcxUmpmT1V3NVBXN2ZPVEJadU1yRzh1RHdtc1FaNGJoMjFTRFRPTnZJL1hnYkVSMUUxVFpqZFplZjBleEhCb0NyM21JUG9uckxMblBJVHBzNEpuSkxKWXo0SlpCZE1GclcyQ3Y2THFFVloveE9aUlBqQlRFdkNmTlF4MGJVaHg3Qk5Gdys0NlFXU2pIQXF4VWpBNWljQzBLSXFaU21CZy9XU3lRQ1RzcWtEZ0pzZDgwTmlKWEdsSDRNOENnM3ZuQUtKd1c5TjZTUVZiSlk2aitveVI3UnNEejg5NHcyVEljWGtOcDBRREtCZERaTlBJWkw5OTZVNmVMNngrY0M5Wm41TCszWndWWEdSR01vU0dBZUxWbHZ0dlhTaUJ4Z3o3cGRDZFFrcUU0b3hZK1EySjlBU2JwbDJwdjA3bSszdjJMMlQyMUlDM2Ivc25UVFQybnhZQ3JuclE3ejVSYXpqRE1XK3V1ZkZWbXo3K282ZGRqbnI1MUdJVHE0bnNGcWEyQ3FReXZTUzlrUkswbjNQWlV1RjdYS0REdzdxU2JKbUt3MmdseHVwQWVUdHVTUDhHSjNnLzVMOVd6S1c3MjYxWVJOSXBicGJVSEpreFNGK0xRd1VhVnhQS2tRKzUrU1lMakZSRG14WSs0ZWp6UnVCaHlYYTZIVUdaOVZPbWxRbldFaUlaZUd5RE1WTW93YmN5ZnVhS1FXVEJBeVBmRWIvWlJheTJicFhjcmlrTzFkOGhCaXJlMnRrSGNjaGx5cXRrd2E5ZmNWSWg4TlovV3lpRlFkdHNuV2Q0ZlVrOWdtVDhDVTVDbmtGa055dFZWNHNOTDZHS2gxSkhrVGU2WWxUTDFOQ2szcFRSWDE0L1JBY3MyaVBNOFRNUk9vZlpUOVE5S0E1N3NuYU1IOVRRSkpUOU9BOXhBZUEzMjNad2RKUDJtcjZzeE9ka0JBcG1PbzBRRlRhYTVQOFNwbGZRKytwdFMzdjZBNzZiS2Y1dndwYWZTVTBwWDJzTEU3VEdqaUIrN2wxclVQZGJkM1E2cEVqZEx3a3RpUEEyZ1lpZUVrcGk2VjJzTVdqeVRRUCtuQVdGUTllWGtaR1FmSHhUemU1aWFZZjFLVWlTdzRLaCtncE9EUUJvcXZzakZkenMxUFNhMUZBMWJWcCtvclNYQ1pqZ2lOSmlJQ0xhUFNCTEVPQUdmTnJoSklCdlBwWldzVnM2bzg0SDd6ZnhaM3JTVWF4TUkwN3Yzb2o1REFUditzbFJudFMvTXZMTkFnRnJ6a2lnZVNiU3I0bCtpeStnWEQ3SjdoR09HNHdZcFhoQ28wYm9lSnY4dlc4TkJndVVuT3M5aWhBeFdLaGRxdmxyYmdmNG8vZ2p3bUZ5VVZUcHd4Q2tvbmNWbk40ak53UHNVOUpBT0RIWHVpQXYrZlVQckwyUXNHYTJQZXZ6TEJhUkV5LzVHanJoSjEza1huTEtZRHRTbjlxcHZEZVFJdHNtbHNxa3AvM2xUZFlIZWhrYVpkdWo0YU9DdzIrQm1uRlVNSTVXcU9Gb1AyVWZxMG12dE5ycC9yMXBBUWZSMVYyRkJQQzNXZ21LaURJenNDcFZLcEY5aHUvbmVNMVA4cGh1b0swaTRnc0swVGp2VTU2YVhIQXh3K2tpN0ZWbWJka2lMS0N1L3VZSWEvU010MnI0RGlReVlWLzhFTUk5TGxMUU96NjNTQ21sUUZaRnBIaHRxTHpha1VmaDFKQ0xZeTNKMUZuWDFrU0JvRDNqU0Q2aExBM2loQi9QMHFsN1N4L1ZVTXg0Y2JPUFVydEozL01ERVRLRmtCeDV0MWNqSkVSQU5rYTFLUlpLN0lmbnRrWEhmZFh4Umd3cDdoRUJuUmtQNFVTQy9FWkJuRWF0dnp0TzA5blRKZmpyMnVDWmdvYzFyR3Zia3JSdm5MV0V2Z2Z5YVpYZzJEUVJjQU5UeURoMXlWa0grbFN4NlVWaXEvREdURDhTZlZWTWZxWGtpdExkMnBsMVp3bEVReC8yUndwVEU1Ri9Zd0tuVWlqeUF4Kzl1TThxckkraENIWnN5RWQwOWZObS9SRGx6aGdiR2lWMlc2dkNzcFZsV2p3Wm1lK0lpZEdCSUgrZ1dOOFphbUJDblZrN0V2YjkvWEI4d1ZtZWhoOXJucU56QklSbmREUGErU0Rjd3cranZsdWQ5ZUxVYWxpTTVMWEtOVC9WcDFINllHdG1ITVdDVVkwWUdYUlJBTjlWWFFFcU9tb3BvazZyZmRNbUttWENwN2hIWFYwOWlUSk9PdHpkVW5Mb01QNjd0SHhWMGtkZ082eWJLQmtUR2JTUkVhYW14WkZ1TlhOc2lDeDhiUmlqdHMwcFVwZ0J0OVB3YmdIcXpvWGprR1lyckJRTmo0YkRBa3BQRk5XZEJmQmtNM0thcTVYWmZ1cEZrVXJ4ZG0rYndEeDZMalVkL1l2aHd3RWF0RTB1QWtBbWJTdWVVd21ZTkpCaVRuTGlrcVZCYWFkZDJySjJuZVVXZC9rYTRZNVozOEZFMy9XVUxablVuaTMyUFpxdDVRS1poMUp4MGpZYlNqVU50bncvZ2prMVJMbGNYZU91UDdjT0VqOUhCRi9qbjY0YmFYQmRTTVRPUmdDd2dSL0trYVRYaVk4N0ZuaUw4VWlYbzFvM0VmdU9KNWRnbzRVL1VmcDlWRS9DVUcrNWgxK1p5ODVocjZpdTB2dVRrMmlhVVNLa3lnT3FpTjhiejVMMDRsOVV1N1VpWncrS2VYWmtGUHBGR1ZFRTd5VFlPWC9XaXdvNy9DUkVTMzJpTGZlVlVQNEoyVmgzcEpnYUdtUTVha0VvbUkvaEYzak5Oa1NIcW45YTcrTEFMeWJrTmZwTlhBdDkrdi81dWdhVVFaL256N1RFRERFWHNFMDVrUjV5WVVNQlM2bVUyVHhmUDV1azhXTGE4WS94dmZyejcvTlAxVkppSnFiZnVWOWlVZ09YNmVzaXc4S1F5Q1J2UC9MME11U2RHeGtqSzVBQUFBQUVsRlRrU3VRbUNDIiB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwtMSwwLDEpIiBoZWlnaHQ9IjEiIHdpZHRoPSIxIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIi8+CiAgICA8L2c+CiAgIDwvZz4KICA8L2c+CiAgPHBhdGggaWQ9InBhdGg2MDU1IiBkPSJtMzI0LjQ4IDE3Ni42N2gtOS4xOGMtMjEuNzM4IDAtMzkuNDIyIDE3LjY4NS0zOS40MjIgMzkuNDIydjUyMy4wN2MtMjcuMzQxIDE1LjY0NC00NC41MDkgNDQuNzc1LTQ0LjUwOSA3Ni44MjYgMCA0OC44MTEgMzkuNzEgODguNTIyIDg4LjUyMSA4OC41MjJzODguNTIyLTM5LjcxMiA4OC41MjItODguNTIyYzAtMzIuMDUyLTE3LjE2OS02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4Ni0zOS40MjItMzkuNDI0LTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTI0IDEwLjc2NSAyMy45MjQgMjMuOTIydjUzMi42NWMyNi4xNiAxMS4xMDYgNDQuNTA5IDM3LjAzMSA0NC41MDkgNjcuMjQyIDAgNDAuMzMtMzIuNjk0IDczLjAyMi03My4wMjIgNzMuMDIyLTQwLjMyOSAwLTczLjAyMi0zMi42OTItNzMuMDIyLTczLjAyMiAwLTMwLjIxMSAxOC4zNDktNTYuMTM2IDQ0LjUwOS02Ny4yNDJ2LTUzMi42NWMwLTEzLjE1OCAxMC43NjYtMjMuOTIyIDIzLjkyNC0yMy45MjJoOS4xOCIgZmlsbD0iIzEwMGYwZCIvPgogIDxnIGlkPSJnNjA1NyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgLTQ5Ny43IDkyMy44MykiPgogICA8ZyBpZD0iZzYwNTkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYwNjEpIj4KICAgIDxwYXRoIGlkPSJwYXRoNjA3NSIgZD0ibTY1NzcuNSA1OTQxLjloLTczLjQ0Yy0xNTQuMzcgMC0yNzkuOTYtMTI1LjU5LTI3OS45Ni0yNzkuOTZ2LTQyMDUuNWMtMjE4LjEtMTE2LjItMzU2LjEtMzQzLjItMzU2LjEtNTkzLjY1IDAtMzcwLjk2IDMwMS44LTY3Mi43NSA2NzIuOC02NzIuNzUgMzcwLjk2IDAgNjcyLjc1IDMwMS43OSA2NzIuNzUgNjcyLjc1IDAgMjUwLjQ4LTEzNy45NiA0NzcuNDgtMzU2LjA3IDU5My43djQyMDUuNWMwIDE1NC4zNy0xMjUuNTkgMjc5Ljk2LTI3OS45NiAyNzkuOTZtMC04OC41OGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYzIwOS4yOC04OC44NSAzNTYuMDctMjk2LjI1IDM1Ni4wNy01MzcuOTQgMC0zMjIuNjMtMjYxLjU1LTU4NC4xOC01ODQuMTgtNTg0LjE4cy01ODQuMTggMjYxLjU1LTU4NC4xOCA1ODQuMThjMCAyNDEuNjkgMTQ2Ljc5IDQ0OS4wOSAzNTYuMDcgNTM3Ljk0djQyNjEuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDYwNjUpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc2MDc3IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNjA3OSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNjA4MSkiPgogICAgPHBhdGggaWQ9InBhdGg2MDkxIiBkPSJtNjQxNy45IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnY0MTAyLjdjMCAzMC40NiAyNC42OSA1NS4xNiA1NS4xNiA1NS4xNiAzMC40NiAwIDU1LjE2LTI0LjcgNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3LTI0LjctNTUuMTYtNTUuMTYtNTUuMTYiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ2MDg1KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNjEyOSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgLTQ5Ny43IDkyMy44MykiPgogICA8ZyBpZD0iZzYxMzEiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYxMzMpIj4KICAgIDxnIGlkPSJnNjEzNyIgdHJhbnNmb3JtPSJtYXRyaXgoNjYxLjIsMCwwLDE4NC4yLDYyMTAuOSw5OC45KSI+CiAgICAgPGltYWdlIGlkPSJpbWFnZTYxMzkiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBUk1BQUFCTUNBWUFBQUJPQmxNdUFBQUFCSE5DU1ZRSUNBZ0lmQWhraUFBQUdVNUpSRUZVZUp6dG5XMnNiVWRaeC84enN6L1hVb3VsTFczdEd5M1NsRGJGcHFEV1lCTWhRaXloRVJ0ejFTc0ZtbFpxYlNtcENKSUdRU1RWbXZwU0lwQWlvbHh2M3d1Tk1SbzFRYU9SU0ZDRGtVUy8rc2tYMU85N3hnOHp6OHd6czU2Wk5XdWRmZTQrcDNmK3ljM1plODNyV2ozejYvOTUxcXgxbExYV1lXaVJyRHNhbDh4WnU2ait2VGRjMUZWUDlZemRPZVp2ZitQZk8ydk9qTmQ1cmozemNvMy9ma3V2YVZlZmxiTGFXRko5NlhkT3FpY2ZzeDExWFB0N01WZUhzajZnQmt5VzY3akNaTGEvZzdZL3hPc3lZSEwwWWFJbnZRNE5EUTJ0a0Q3djNIUDJQWWVob2FGanJ1KzU5aHBzQU9DOGM4L0JmLy9QLysxN1BrTkRROGRNcjczbW12aDVReC9Jb1F5b0RBME56WWxEaERUSm1aeDM3amtZb2MvUTBGQk4xMTd6R3ZINFJqeUs0VlNHaG9aeTFTQkNxc0tFTktBeU5IUjI2NXFycitxcU53c1Qwb0RLME5EWnBWNklrTHBoUWhwUUdScDZlZXMxVjEyNXF0MWltSkFHVklhR1hsNWFDeEhTYXBpUUJsU0dobzZ2cnI3eWlwMzFkV0NZa0FaVWhvYU9qM1lKRWRMT1lFTGllMVFHV0lhR2pwYXV1dUx5USt0NzV6RGhHbTVsYU9obzZEQWhRanBVbUpBR1ZJYUc5cU1yTDcvc2pJMTFSbUJDR2xBWkdqcDhuVW1BY0oxUm1KQkdYbVZvYVBlNjRyc3YzZXY0ZTRFSjEzQXJRMFBydFcrQWNPbFRwMC92ZXc0QTB0UEs0NG5sb2FGNVhYN1pKVWNLSktkT240WjY2U3RmZHM0NU9PZndVejk1WXQ5em11Z29PcGJ4RHRoSysvRU8yRVZsUzk4QmUvbGxsNnlhMjJIcWkzL3doMURLdjRKY2ZlWExMMGFZMEwrZk9mblRlNTdpVkVjSktnTW1sZllESm92S2VtQnkyYVd2WGpXZnc5YnZmZUgzRTBTVTh2OWVmT0Y1QnlDQ3hES292UGV1ZCs5MXdqWHRHeXdESnBYMkF5YUx5bXBqWFhySnhhdm1jQ2IwdVNjL0Q4QURSQk5FNk44THp6L25DQ0lBTW9maXJJVjFEdmZjL2I1OXpyK3BmWUJsd0tUU2ZzQmtVUmtmNnlnREJBQis5ek9mQlFBb3JUT0FaRUI1L3JsbkhYY2pBREtRT0dmaHJJZk5mVDk3N3o3UFoxWm5DaXdESnBYMkF5YUx5aTY1K01KVlk1MUovYzRUbndZQUtLMmdsUGJ3RUlBQ0FPclpaNTl4cFJzQk1BR0pjdzdXV2xobjhlRDk5Ky90NUhwMW1HQVpNS20wSHpDWkxUc09BSG5zOGNlaGxZYlcvaFhSMFlGVWdFSjExRFBQUE8zS3NBWkFGU1RSd1ZpSGh6N3c0TjVPZUtsMkNaY0JrMHI3QVJOUnI3N29WYXY2UE5QNnRWOS9ETkFxZ3dSQnBRVVVJTURrNmFlZmNtVllBMkFXSk50dzdheTFnSFA0ME1NZjNNc0ZXS09EZ21YQXBOSit3QVRBOFlFSDZaT2ZlaFJRQ2xwckdJWDFRSG5xcWROdXFTTXBRZUxnWUxlK3ppOTkrRVA3dUI0SDBsSzRESmhVMnAvRk1MbjR3Z3RXdGR1bmZ2a1RuL1NBTUJvSzZzQkF5V0RTQXhJQTJEb1pKTTc1UDJmc3JNVWpILzNJL3E3U0FUVUhsd0dUU3Z1ekNDYkhFUjRBOE1qSFB1NFhQNUNGS0hOQTBjcm5UNXBBT2YxSHAxd0xKQUFtcnFRRkVnQXNaUEw5Zk9Kamo1enhpN1pMbFhBWk1LbTBmeG5ENUxqQ2cvVGhqejVTZ0NBQmhXQUNZQllvSlV3QUpLQ2NPdlVsdHlSUHdrRUNRSFFsdkEvSFBuL3FWejYraDh1NGUvM250LzkzMzFNQU1HQWkxdXVwTXdPVDR3NE8wc08vK0pHNCtHTzRNZ09VQ1V5QVdhQk1ZTklFQ2REbFNqaElBRXhnWW0xcTk5aWp2M3JHTCs1aGFoK0FHVEFSNnZYVUtlWjUwUVd2WERHam82c0hQL2dMRVF4YXF3d29Fa3lBR2FCVVlBSWdBMHJmS3dncXJxVFpwQUlTMGdNUFBReHJMUjUvN05HbDErcEk2dnhYZk1mazJGRnhNRU5lRjM3WCtmdWV3cUhwdmdjZWdnNEFxSW5Xb3RZYUZvQzJGbUJBYVNrYUJGakFJZ01LYVZPNmtsNUpybVJKT3h0Y3pQdC8vZ1ArdUxWNDRqZC9vM3Y4NHlBSk1NQ0F6R0hyNVF5TlV2ZiszQU5WZ0RnNCtHVnB4Y1ZmazRPREl2T2dOWXgxZ0o1dk8zRW1QUEc2VkR4TVd0UXV1Smg3M244L3RxSHRaNTc0clZWek9BNnFRUVlZb09uVnE4NGlZSlI2MzczM3dXZ05GSEN3emdIa1BMWTJKbFY3RkozSHduYXB2YTJIT1c2aFU2bE5ibTNidSs3Mnp3RTU2L0RrWnorOXVxL2pwaFpvU0djRGNDNDQvN3g5VCtGSTZkM3Z2UWRLMThNWTUxeTM4NWkwQmJwQ25XcDc2NkMwMnY5ckczdDA4cTY3NFp6REY1Nzh6TDZuY2lUVUE1eFMvL0ZmM3o2RW1jeHJRR0c5VHB4OEQ4eUM4R1Fmc3M3RkIvMk9MRXkwMXBOdzZjVEo5OFF3NnRRWFA3K25tUjFQdmZJN1g3SHZLUXgxNk00VEorTmRsK01nelVCWGhRblJjRTI0UWlCWTA5Wm9qVzFIMnp0UG5JVGRidUdjdzlaYVBQZlVseGFQTlRSMEZQUmpkNTRJdDJYTmFoZkNYd1d3dUMxd0lQZERtOWN5bUVRaVdpeE93aXF0b2EyRnhUSUEwWmpXV29SWHZuVzMxY2JBYnJjd1d1T2Q3L29Kd0Zsc3JjT0x6eDZObDJRUERkVjAreDAvRHFNVm9LWU9oTzY4ckFseDRqNFMyaSt5UU9VK2syVnROVFpLYVVCYmFOdS9UVnpCRDJxM0FUaXVaK2RKM3M1RHkzYU5TYkFoMXpMbmZINzBuZThDNE84U3ZmVENNMTNuTkRSMG1IcmI3WGRrbThZSUpEM0EwRnJINUN0QnBuZXhTNXZXZWxWdVdwdFRNMmNTUXgyTmNLOVp4NDFyYysxNHpzT2ZnTis0MXBvNHVaTU1HdkQ3WUdyMXVUdlpXc0JvaTYxTjd1aHR0OStSdmZEcFQxNTZvVG4zb2FGZDZhMXZmMGYyVkcxcnJ3ZUZPS1VyYVMzK2NrY3JkeVV0MWJiVHo2bjFmQTRRWUpLNUU2QXIxSkhjQ1NqVUVXQ1RnSUtwTzhFME5DcUJvcEc3azdJKy9RZmdRSWxBMHpvQzVhMXZmNGVIbHJXdzF1TFAvL1NQWnkvaTBGQ1BidnZoSHdrdXdpKzQ4bjBmcE5LVmxDQXBKYm1TT1RpMXR0TFgxTnBLM3hJOW03UFJTdFVYdnczYlp6SHZUcFJTT1ZDUTM1RXBGNzhFRkNsM0VsMk9BSlRTbmN3QlJkUGRJZHBPRFA4TFFDK0gybHFMci83bG56VXYzTkFRNmMyM3ZRV0t2OXF3QWhMKytzTWVrSEJYVWdNSlYrbEtTcENVbW52SVQxTHJJVC9TSmlWT2MzZkNGMzh0M0ZFdVFTR3JYd0VLZHljT2JoWW9CSTBlb0pqTnh0ODJGa0llcXFPTWdYTXVQcE5BUUNIWWJMVEdtMjk3aTNjK3dmMzg5VmYvb3Z1WGEramxxKysvOVljQWhNV25Bd3l5UlovZTl5R0ZOb2JDZ1FaSXpBUk1kWkRVd2hzT0VsTHRBYi95OVFPa250Y1BaUFZEbno3TXFRQkZESGUwcWdJRlFFekc3aElvQUpvaGp3bVFxT1ZRQUlXdFVtbXJQN2tVcmYyRFRpenNnZFl3QUxid3Q4eCs0QWR2OCsyYzlkQ0V3OS85elYrdCtvVWNPajY2NVUyM3hrV21sWjVBQktpN2theXNjQ094YkNaSDBncHRZaDhkanFRWEpEMVBDbk5KYjFyYnFMRElab0dDQkpTV1EvRVZna3VaQVFxZ1lHMGRLRUI2YmtmS29kREZvcjBtWmRpek1RYk9CZUF3bHdJQVZpbW9FTjZRU3pIYTUxWnN1RURPMmdnVlpmMUZ0YzdpbGpmZDZrOFRMdVpmL3Y1cmY3dnkxM1pvMzNyRHpXK01RS0JjSU5BUEVWOTN1UnVodnFTd2hwZVZPUkp5QmkyUVNBN21vQ0FwdzV2eUxmVWJ2amx0VjBDaEJkN0tvWkNia0pLeUZIcFk1OFN3eHhnRHA0TVRxYmdVbWdPNUZFQUR5c2E5S0R6MFVXRkxzSFd1Q2hWUXlNV2dRdjhobk5iNDNsdSt6MThYUzA5aFczemo2MS9iMGEvNzBDNTA0MDAzeDljUEtxMHlHQkJFQ0NDK3pqcUlBSEtTTmV0RDVmdEkxb1kxY2U2TlpHdFBqbVF1dEVuOXlDREpZQkxWQUFvdDNGcFNWcmtBaWZBQ0pGOHBCd29QU2Vna0NDaThiYzJsR09VVHZlUkVQQ09VZjlpSXVaUXVxQkQwa0p4S1RFano4SWY5Y2xHaVZ0bjBvbDNyL0hoMmErSElIY0hnRFRlL01kN1d0czVtZTJQKzZSKyt2bVFkREMzUTlUZmNsQzNDSG9BQWVUN0UxeTlEanhUT2xPVkxJWklkS3lIaUN4ZTVrVGovU2xpVGxhOThvNXJ2by82SHVQemRIS1J3SWN4NEFoU0VSVmE3eTJPTWppOVFLc01lQmNDRmszS0ZTMGtYZ0Y2aWxOb2FZNkRadTAraXMxRnFBaFVMQzZOTkYxUW8vT0ZPSlVJdVFNVmZXd1Vib0FLYXUwcHhjUTlZZ0hCTnJQSmpCTmR5NDAwM1ozQ3hiSXgvL3VZL2RpK2NzMUd2dSs3MUFKQ0RvUUVQT2s0N1FpV0FBTWlTcXJGTnhZWEVjcFZ5SXJHUEZVNkUycFlRb1hGTGlQZytscnNSb0IzVytMYjFWelBTZFM5Qm9wVENSbXZ0Y3dmQjRrY0ZvRUFaV09WZk9DMkZQVW9wT09YYjk3b1VBNzlvNGdKbkxzVWJFdXZEaDlDV1F5VnJ3NkZDTUJTY0NvQll6bytwN1RaQkJXaTZGUUJkWU5Fd2NOYkdYMWJyL0xsQW0vVGUzT0I0Q0M1VXozcDdodXR2dUNuV283Nno3MkYrMy9xWGIrTGxwbXRmZTEyMjRBQmtRQUF3S2EvQkEwanVnK3FWSVF4OW5nTUlqVnR6SWJHOGtSTkovYStEU0R5ZkJibVJOSzlsYmlTZHV4eld4SDRaVkxJd2g5S2lWTkdGaFNXNUZLVVV0QlBDSGdCR1RWMEtrTCtkTGNGS1pZQnd3WUgwUWdXWWhqK1VVeUdveFBQaDBHSEgxSGJyNTE4RGl6SGhYSUtEc1JZMlFERnVnQXUvU05SM0JoZk5IQWgzTGd3dUJDSGZOdFdsY2orL2RQdmRXb3ZycnI4eGU4TWRmOHE2ckZ1S2p2M2J2MzVyVXJaV1YxMTlMWUMwa0VtVE93SDhkNjRBUm5aTXFEY0hEZ0JkOEtEK3l4REd0NU1kQ05BR0NLOHJoVEtUY3I0b1Z6Z1IzbDlQU0VQMWwvd1ppL0o2bEc2RWo1WEJSSVgvRS90Ym9XeVRXWFFUYXVKUy9BTFgrVXVvaGRBSHprMmhvalVjVUlXS01TYUdQd1FWQUZCYmxZVS9QQTlqNkh3SUhEcTBZVzdGRnlld21BQ0xPYkJRTzZzVWRQaXNsZkt3WVE3TEJZZWhXUmhEaTU3MnNCaHRNc0RRZFFHUVFZYTM5Wi96TnJ3ZXpVODZYdlpEZXQxMXIxLzhaanhKSlRCSVN0ZkJJc0VDeUlGUjl0RUxEZ0JWNTBISFd1NkRQcGQzWkdJZElZeWhNWE5JMVYwSXpZY1daM2JYWkZjUThRY25FSW5qTE1pTnBEWUpLcnhzczlrWWJMbGpvUDlvWVJMMEtzYmNUZmpRUjlIN1kxRWthSjNOUXA5ZXFFamhUNG9IMDJJbHNCQlVBRFRkQ29BSUZzcHY5SURGQU9FMUJ4ckdlQkRBV2NCb2JLM0xYQXZCaFVCaXFYMkFSNnhYdUJjQThWV1ZUdWNPcElRTU1BVU43eWQ5WjBBUkhvbm9oWWYweUVLUEpMQVFJR0pmN0prT0NSYkFGQmk4bnhZNHFMd1dKcFh3b0xMU2ZWQy9zWTdnUUhpZEVpRFVmNjhMb1hPZFM2enljMWpxUk9JY2hFMW96YjhqWEVDRUgxZEt3UmlORGNLSExTVkx3NktpZnhUNnhPTXFoVDRUcUZDSUlrRUZlZmdEY2hURm44eWdDMFJRQVpDQnhZZEJhTG9WZmg1QUFSWmp2RHZRRGdZQkhEYUhDSGNoTWx5Y0NCYytWODNhOHdjTmFUNUFnZ0J0ODY5QkJwdDBMSE1lM0oxc0M2QVV6M0hYQUxMTFA1ZFJ1aERTSk14aGo4WlBkbDhXc09EOUdnNmFTdjVFQWdjZG45VHRnQWZWbThDaFBGNXhJTHplSEVEODNOb3VKSjRMa05mcENHZm8rczVCQk1oQklZVTBIQ0tod0NkZ25YTXA1eUJBaFNkb2U2RWloVDh3Q3NhNVBLY0NCV05VbHFnbHQwSy9UdHl0QU1tRmFNMitCN0FZK0h6RXJHUHhBM2x3bUxEUEpyZ1dZMW5JMGdFWEFEQWhYeE1tREFBWllHaHMvbjBPTWh1aERTLzNZMHlCRStzVnpzS1AxUWVQRWs2azNwY05sMDRFbURxYkVqNHRXQUR0L0FwZmFMeWRDQTVmTVlPQ1B6Y2p0bXZCbzZ3amhUQTB2eDZBOEhGNlhBaUFyc1FxbjA4cm5NbjY3NFFJSGR2d3lyMVF5Y0lmYW9lVVV5R28wRUFFRlFEUnJSQlVnUFMzaTAyNEV0eXRBUGx0WlQ0bjdrSktzRkEvVWlnRUpBZXhDVERscm9YZ0VqZllGWENKN29iQkJmQkFBWExBQUZnTkdYKzk1QnlJQkp4WVZrQ2s1ajYybGVPeDQ0VXl1ZzZhRWh3bFdMUUFrdko0Q1F6ZVR3c2FmRzV6cnFPc3krRkI0N1RjUi93c0FXVUZRQUFzZGlGcER2WGRxM3lNTW5TWkMyZkN3ZXc0QUd5TTB2RDNGZkk3SEJ3cVplaEQ1WlNvamJBSnUwa0pLZ0FpV09LRmNIa0lCQ0J6SzBBRExMUm5CQWhKVEQzNUM0TCtoQk1Nak5hZ1B3Q210c3BEajlXbnhib0VMandzQXV0UEFrenVZRXc4WDZvZk9nemZtZnNRSU5LQ1JQbmtkMDlZVStQRlhFNmxsbXd0SllVOWNqNUZ6cDlJcm9RZk55enYwZ01OM284RURpcm40SmpVcmNDRHpxTk1vZ0tJT1pEWWY4TzlyQUVJV0YzSmhmakxrMEladXM0bERNckVLdS9YbVB6YzR6K2t0aHR0d2o0VDZ5SlVPRHl5SkdhUnFNMUNJSGJNS1JVM2ZuRzM0cis3NkZZQVpHRFpoUFpMd2VMN25ib09DdUdpYTlIcEJVMDljQUhnZDhIU1FpMGcwQXNZQUhEaGw1ckdBYVpPWmlPMEM1M0hqeHc0ZkJ6ZWI2MThVcmJEZkFtcGxqY3BuUWpRdm1Vc2djSWZGMXdKdTdaTG9PRzdidGN2d3hhcW82VmpEWGpFT2tJZlVnamp6NnNDRUtDWkMvSGY2eTZFajZPVkFBaDJqQ0F5Y1Nrb1FSWENIS1g4eHJNSUZlZmlNUTRQN2xhY3k2R2oyQ0xrYm9IQTRrSStJZ3VENktMT2dBV29oMEkwcHRQK21SNkh1bXNodU1SakJWd28zNUxsVjBJLzhkeTRlL0VIL2Z3S2lQQVFpWC9uZFZLK1l6TnB4OEVDeU1BaFRjS1ZTbDZrQkZGTkxRQ1JKRGhJeXFBUUcwK0JJMEdDMUlKRldmOGcwQkRiVlZ5SG45YzhQTXBqa3Z1Z3VtVU9oTnBLRHNUM0pRUEUxNnNuVkdOZENSSktEbVZpSFpRNUY1WXpvUXNVUXhqRndFQnVSYm1zVHE5YkFaQVdkRmlBRVN6YWw4MkJCVHBBSllRUC9POGVtNDJPODhqZ0VpQW53WVhteU9IQ2oxRllSSDBDbUFDR3pvdmFBVWc3WXl1UTRXVWxhUGl4ckEvSXdDbnJTNER4ODk1bTMzbFkwd09NdGFxQmhrT2hWcmZ0VnRvaEVML2RQQWNOWHJZVUhMN050RTR0OUtuQmcvcU5kN2RZKzU0UUp2Vi9NSURRNTVZTDRXTkpBRkpLWVdPMDhWdStWWjRYQVpDNUZRQVJMSHdCN2hvc1FBcUZEUEtIQW5QWG9vQXdyeEl1SEFMK1BISzQrREdtSVEwUFVTYnVSQUNNMUxiczB4VDloZzhndFdBREJGQlVvSlAxS2ZTSHphWmFyNmJlUFNpOWVSTkFob3VVckcyQlJRSkYyV1lwTU1yK2xvS2piRHU1b3lUVlh3QVBQODlwRXBXdVJ4bkM4TFlsUUtReURoQ3h2T0ZDK0hmYU1PaHpKclNSeTdsMEd4ZTVXd0dRNVZZQVZNTWdvQTZXTkU2NlU1UEt3bFoxRlJaK2NDM2tLbXF1WlE0dXdEUXNRdGlhNzh0azkwSmo4ZTg4UlBMbGJjandQbmlTdEhRMFpSczBJRkdDb1lRUGtBT28xQzUydkxiVUFvMFN3cDZXR3dFSzhBaWdLUHRZQWd3Z2g0WlV0eGNjVkZhRFVnc2VxZDgrOTBGalN5NmoxNEZJWlZJdXBBYWo5TDRYLzkzZkdpYWdRL2x0NTlGSlRJSGc0a0puWUZGK01jMDVsdGlIWTMwSXJrWEg0eVk2Q0FrdUUxY1Fub0dKY1BFZHgyU3VpdHZqWmZjQ0lBTU1rSktVU3lIajYrU2d5Y2FyaEN3U2NJRGM0VWpsZEY2U21yZUFXLzNOcURkdkFsUnVHd3Z0bSs2a0xHdkFBaEFXTTZiQTRHM21YTXhTY0ZCZlBHazZhZGNCajNSTVQ5cG5ZR3NzL0I0SDRzK3hEeUJVbDErblNjNEVPbDJRcFdDQkNsQ1FRaUgvSmU1ZjZYSXR6dDhaU21Pa2tFaUNpeFFXUVlQMW5ic1hmeUVTWUFEQllTeUVERkFIRFNERGhvL0x4eTdiOHI3ajl3b0FTaEMxK2pnTU5kMkpBSkh5dmFKbEh4T1FDTTdDdDZuRGduOFc0ZE9BUnRaMklUaXl0a1hZUXVQdUNoNzB1VXlpVHVwMEFDU05Wd2RJa1RNSm9VT1pMK2tBQzlXanVpa1Brb09GMTh2MmNhRHRXdWhZR1JLbDhxbHpBUUJuODNNQ2N2Y0NGSUR4bGYxOERnZ1pQMzQ5ck9GektZL0h6eHNCR3VVdDRYSzdmR1hINnE1ekphVzY5NXhVWEl5MG81YkR3WS9SNTFhV2hEMDFZR1QxRzlBQXNCb2Nmdnc4NTVITnFRRVBxYnpsUHVMbklvbks2OGloVWg5QVVwOGFHM3FyZTN3Zkt2SkZXSE1zVkViYnN6TVE2U0tVNFhEQk1yZ0FZR0VQQ3llS3NDajJhZTNFdlFBNVlPTGNDd2VUUXFSdzU4Z1grbm5OUUFhWWdvYnZZZkYxdzgvR1pyTWFkTXF5eVhkMnE2WUZodFlmUXR1MVNnamtaZk1KV09sWURTVFZqVzlDWDNPaDBCcG84UG9TT1B4eExZS0RmazdhRlF1OVBGYkNZOUpHY0IrOFR1aytZaGwvQ0hNR0lMeHNvNVNHVW1IUlpjbFF4UC9ybDJCUkpyd0hCQW9hR3M0eUJ6RGpXbnkvZWE2RjE1WGdrdlVWUGtzNUZ3RHg3V1ljTVB4YzVnQURGRG1NR1NjRENLRHhGZjBQS1h4aHQ2Ukw0UGcyN0hOSFdOTnlIMnVmRUY2aTN0eEp6Y1gwaEQyVDdmaU45cjJ3NEhNcWdjSDdXUU9OL0xpYzBLMjVEdnBaSG1zNUR6ckhGanpLejVMN2FOZFhRdEkzL1hlSkQvb3BSYTgzOUFYTzJXSW5hMzRydFJVTzBYSHVXckoydW5BYlBjN0ZId1JRZHkvbFdCa2tDZ2RENXdSd21PU1F5Y3NLMEJpa2wwcVhvUEVONDBlQ2pUSEY0czVjU3lWc1lUbXRNR3oydlZRTENZZXg0N1dsMm03WVdDNGQ2M0VubFh4SnFKenFzZkZic09DZlMyRGtaZXVoa2RYckJVYzRIN0UrcG5PYTY3ZlhmYVQ2NmJ4cUFPRnROa3I3aVhubmtFQUE2QXdzK1YyV0JseEM4cFc3RnFyVGdrc3RMQUl3dVZOMEVNQUE4eTZHemkrcjJ3RWFRSWFOenVyb0hBUUNkQUFCUE94Y3MwTU5mRXp5S0pYRmZSQlhRdXAySjVXbmppZFE4SjNtYll2NW03TEpEQ2g4bFRvc2ZMbjBmK28rYVBEUGMza08vcE9EbzFxM2t2Tm85ZDBEajdKOVBmRXJBRVNsNzFwcGJPSjdTS043dC9BUmlBSWNtcTRsTFg0NTF3SzA0VUxqVGRwb0lWZGlDeEIwQWlicnR4TXlmZzc5b0tGcjRtVW1jeTNiQTNYbzVIVzEyQmFWdk1lMk9NdzNuSjZKT3ppUzVoSzBFeUFBR1JSSXJWREdOMG5qOU9WVjZyRHc1ZE83UFhOT1E2cXoxSEVBNkhZZDlMTjBWWWNHRHorNXlYZzB4NDNXQms2bFJhZXR6c0F5NTFvQXhIZWNTbkNoTmh3dXFSOTB1UmMvaG5DblJ3QU1iNk9LRUdFT01rWTRscm1Pd3RGSTlmMjhhdnRHK0lKTzBDbnJsZjBCQlF3NEpGaWZmTmxPM2NiaHVSSlNyenVwMVpQZWdUS1hxSjNtVnFhUUtPdlZua3p1Y1JqOGMrMG5JTHNOOGVlTTQ2ajluTDV4Ymo1c1NlUE13eVArTE54SHJKUGRFZkxITjBZcldMTHVDbkE2L2RJcnB5YXVoZUNDdUQ3bjRRSk13eUw2S1NWMGZmMDhxVnUyOCtOVW5NWkt5QUIxMFBCeGF1VVJDT3pWakpNN09pVTBaaEtzM1BId2N5dFYyMjlTRy9kTWFnNHkwaDRUYWFlc0ttRFRTdEsybkl3WTFzeEFSVHBXQTRiVVRuSWJZcjBaeDhIYnpyVnYzM0plQmc4YWw3Zm5WMWhyK2xNWHpDMVk2K0pBVGlIa00rak5ZdE9RU0lJTGhVVWtubk9oT3JWTlh5VmdBR1NBNFhWM0JSbGVad0lhL3dWQUFrMVozZ01PRVRwQUJoNnBqOGxjK1BGSzZESUhsdFR2YmtPZmNySFhKTUVqOXJIeWxuSFRyY3lBb3ZiWjhQek9BbUR3T1N4eEc3N2RPbkRRbVBVazhPN2hRZTJvenY4RG5nRS95eTZSUUpZQUFBQUFTVVZPUks1Q1lJST0iIHRyYW5zZm9ybT0ibWF0cml4KDEsMCwwLC0xLDAsMSkiIGhlaWdodD0iMSIgd2lkdGg9IjEiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiLz4KICAgIDwvZz4KICAgPC9nPgogIDwvZz4KIDwvZz4KPC9zdmc+Cg==\"],\"markerImageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.floor(4 * percent);\\n res.url = images[index];\\n}\\nreturn res;\"}],\"fitMapBounds\":true},\"title\":\"OpenStreetMap\"}"}', -'OpenStreetMap' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'speed_gauge_canvas_gauges', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, ''radialGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueRadialGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 50 - 25;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 220) {\\n\\tvalue = 220;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":180,\"startAngle\":45,\"ticksAngle\":270,\"showBorder\":false,\"defaultColor\":\"#e65100\",\"needleCircleSize\":7,\"highlights\":[{\"from\":80,\"to\":120,\"color\":\"#fdd835\"},{\"color\":\"#e57373\",\"from\":120,\"to\":180}],\"showUnitTitle\":false,\"colorPlate\":\"#fff\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"minorTicks\":2,\"valueInt\":3,\"minValue\":0,\"valueDec\":0,\"highlightsWidth\":15,\"valueBox\":true,\"animation\":true,\"animationDuration\":1500,\"animationRule\":\"linear\",\"colorNeedleShadowUp\":\"rgba(2, 255, 255, 0)\",\"colorNeedleShadowDown\":\"rgba(188, 143, 143, 0.78)\",\"units\":\"MPH\",\"majorTicksCount\":9,\"numbersFont\":{\"family\":\"Roboto\",\"size\":22,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#888\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":28,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"valueFont\":{\"size\":32,\"style\":\"normal\",\"weight\":\"normal\",\"shadowColor\":\"rgba(0, 0, 0, 0.49)\",\"color\":\"#444\",\"family\":\"Segment7Standard\"},\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\"},\"title\":\"Speed gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Speed gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie', -'{"type":"latest","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.pie-label {\n font-size: 12px;\n font-family: ''Roboto'';\n font-weight: bold;\n text-align: center;\n padding: 2px;\n color: white;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''pie''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.pieSettingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.pieDatakeySettingsSchema;\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}\n","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.6114638304362894,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.9955906536344441,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.9430835931647599,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"radius\":1,\"fontColor\":\"#545454\",\"fontSize\":10,\"decimals\":1,\"legend\":{\"show\":true,\"position\":\"nw\",\"labelBoxBorderColor\":\"#CCCCCC\",\"backgroundColor\":\"#F0F0F0\",\"backgroundOpacity\":0.85},\"innerRadius\":0,\"showLabels\":true,\"stroke\":{\"width\":5},\"tilt\":1,\"animatedPie\":false},\"title\":\"Pie - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Pie - Flot' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries_bars_flot', -'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''bar''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":true,\"tooltipIndividual\":false},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', -'Timeseries Bars - Flot' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'basic_timeseries', -'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":false,\"tooltipIndividual\":false},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', -'Timeseries - Flot' ); - -/** SYSTEM **/ +}' ); \ No newline at end of file From 89a701226df0c65355108f187d29ac3b5051e2f8 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 10 Oct 2018 17:11:05 +0300 Subject: [PATCH 196/307] Misc fixes after review --- .../engine/action/TbCopyAttributesToEntityViewNode.java | 2 +- ui/src/app/entity/entity-subtype-list.directive.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 40e00ec009..51386dc942 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -48,7 +48,7 @@ import static org.thingsboard.rule.engine.api.TbRelationTypes.SUCCESS; @Slf4j @RuleNode( type = ComponentType.ACTION, - name = "copy attributes", + name = "copy to view", configClazz = EmptyNodeConfiguration.class, nodeDescription = "Copy attributes from asset/device to entity view and changes message originator to related entity view", nodeDetails = "Copy attributes from asset/device to related entity view according to entity view configuration. \n " + diff --git a/ui/src/app/entity/entity-subtype-list.directive.js b/ui/src/app/entity/entity-subtype-list.directive.js index 2da948d435..43bb21f7a9 100644 --- a/ui/src/app/entity/entity-subtype-list.directive.js +++ b/ui/src/app/entity/entity-subtype-list.directive.js @@ -47,6 +47,12 @@ export default function EntitySubtypeListDirective($compile, $templateCache, $q, scope.secondaryPlaceholder = '+' + $translate.instant('device.device-type'); scope.noSubtypesMathingText = 'device.no-device-types-matching'; scope.subtypeListEmptyText = 'device.device-type-list-empty'; + } else if (scope.entityType == types.entityType.entityView) { + scope.placeholder = scope.tbRequired ? $translate.instant('entity-view.enter-entity-view-type') + : $translate.instant('entity-view.any-entity-view'); + scope.secondaryPlaceholder = '+' + $translate.instant('entity-view.entity-view-type'); + scope.noSubtypesMathingText = 'entity-view.no-entity-view-types-matching'; + scope.subtypeListEmptyText = 'entity-view.entity-view-type-list-empty'; } scope.$watch('tbRequired', function () { From 2cdca18bf4ada9a47e1968b9c6374767f1118d6a Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 10 Oct 2018 17:48:56 +0300 Subject: [PATCH 197/307] Icon updated --- ui/src/app/entity-view/entity-view.routes.js | 4 ++-- ui/src/app/services/menu.service.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/src/app/entity-view/entity-view.routes.js b/ui/src/app/entity-view/entity-view.routes.js index c14d430f32..99e89dd908 100644 --- a/ui/src/app/entity-view/entity-view.routes.js +++ b/ui/src/app/entity-view/entity-view.routes.js @@ -42,7 +42,7 @@ export default function EntityViewRoutes($stateProvider, types) { pageTitle: 'entity-view.entity-views' }, ncyBreadcrumb: { - label: '{"icon": "view_stream", "label": "entity-view.entity-views"}' + label: '{"icon": "view_quilt", "label": "entity-view.entity-views"}' } }) .state('home.customers.entityViews', { @@ -65,7 +65,7 @@ export default function EntityViewRoutes($stateProvider, types) { pageTitle: 'customer.entity-views' }, ncyBreadcrumb: { - label: '{"icon": "view_stream", "label": "{{ vm.customerEntityViewsTitle }}", "translate": "false"}' + label: '{"icon": "view_quilt", "label": "{{ vm.customerEntityViewsTitle }}", "translate": "false"}' } }); diff --git a/ui/src/app/services/menu.service.js b/ui/src/app/services/menu.service.js index 869a25d860..5a7add7881 100644 --- a/ui/src/app/services/menu.service.js +++ b/ui/src/app/services/menu.service.js @@ -171,7 +171,7 @@ function Menu(userService, $state, $rootScope) { name: 'entity-view.entity-views', type: 'link', state: 'home.entityViews', - icon: 'view_stream' + icon: 'view_quilt' }, { name: 'widget.widget-library', @@ -238,7 +238,7 @@ function Menu(userService, $state, $rootScope) { places: [ { name: 'entity-view.entity-views', - icon: 'view_stream', + icon: 'view_quilt', state: 'home.entityViews' } ] @@ -293,7 +293,7 @@ function Menu(userService, $state, $rootScope) { name: 'entity-view.entity-views', type: 'link', state: 'home.entityViews', - icon: 'view_stream' + icon: 'view_quilt' }, { name: 'dashboard.dashboards', @@ -328,7 +328,7 @@ function Menu(userService, $state, $rootScope) { places: [ { name: 'entity-view.entity-views', - icon: 'view_stream', + icon: 'view_quilt', state: 'home.entityViews' } ] From ee84dbdd840a6adf93aa978bd56c920541f8f0a8 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 10 Oct 2018 17:54:37 +0300 Subject: [PATCH 198/307] Fixed search subtype --- ui/src/app/entity/entity-subtype-select.directive.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/src/app/entity/entity-subtype-select.directive.js b/ui/src/app/entity/entity-subtype-select.directive.js index d6163daae6..8508f15116 100644 --- a/ui/src/app/entity/entity-subtype-select.directive.js +++ b/ui/src/app/entity/entity-subtype-select.directive.js @@ -102,6 +102,9 @@ export default function EntitySubtypeSelect($compile, $templateCache, $translate } else if (scope.entityType == types.entityType.device) { scope.entitySubtypeTitle = 'device.device-type'; scope.entitySubtypeRequiredText = 'device.device-type-required'; + } else if (scope.entityType == types.entityType.entityView) { + scope.entitySubtypeTitle = 'entity-view.entity-view-type'; + scope.entitySubtypeRequiredText = 'entity-view.entity-view-type-required'; } scope.entitySubtypes.length = 0; if (scope.entitySubtypesList && scope.entitySubtypesList.length) { @@ -118,6 +121,10 @@ export default function EntitySubtypeSelect($compile, $templateCache, $translate scope.$on('deviceSaved', function() { loadSubTypes(); }); + } else if (scope.entityType == types.entityType.entityView) { + scope.$on('entityViewSaved', function() { + loadSubTypes(); + }); } } } From dbc7fe7abe543d1a1433300c781238dc7e5bc137 Mon Sep 17 00:00:00 2001 From: ShvaykaD Date: Wed, 10 Oct 2018 19:39:01 +0300 Subject: [PATCH 199/307] Fix Sub QoS topics handling --- ...tractMqttServerSideRpcIntegrationTest.java | 25 ++++++-- .../AbstractMqttTelemetryIntegrationTest.java | 64 ++++++++++++++++++- .../transport/mqtt/MqttTopicMatcher.java | 55 ++++++++++++++++ .../transport/mqtt/MqttTransportHandler.java | 7 +- .../mqtt/session/DeviceSessionCtx.java | 10 +-- .../mqtt/session/GatewayDeviceSessionCtx.java | 3 +- .../mqtt/session/GatewaySessionCtx.java | 3 +- .../MqttDeviceAwareSessionContext.java | 21 ++++-- 8 files changed, 163 insertions(+), 25 deletions(-) create mode 100644 transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopicMatcher.java diff --git a/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java index f3e29dc1ad..74ecdb2a76 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.mqtt.rpc; import com.datastax.driver.core.utils.UUIDs; +import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; @@ -23,19 +24,19 @@ import org.eclipse.paho.client.mqttv3.MqttAsyncClient; import org.eclipse.paho.client.mqttv3.MqttCallback; import org.eclipse.paho.client.mqttv3.MqttConnectOptions; import org.eclipse.paho.client.mqttv3.MqttMessage; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; +import org.junit.*; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.mqtt.telemetry.AbstractMqttTelemetryIntegrationTest; import org.thingsboard.server.service.security.AccessValidator; import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -101,13 +102,19 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC MqttConnectOptions options = new MqttConnectOptions(); options.setUserName(accessToken); client.connect(options).waitForCompletion(); - client.subscribe("v1/devices/me/rpc/request/+", 1); - client.setCallback(new TestMqttCallback(client)); + + TestMqttCallback callback = new TestMqttCallback(client); + client.setCallback(callback); + CountDownLatch latch = new CountDownLatch(1); + latch.countDown(); + client.subscribe("v1/devices/me/rpc/request/+", MqttQoS.AT_MOST_ONCE.value()); String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}"; String deviceId = savedDevice.getId().getId().toString(); String result = doPostAsync("/api/plugins/rpc/oneway/" + deviceId, setGpioRequest, String.class, status().isOk()); Assert.assertTrue(StringUtils.isEmpty(result)); + latch.await(3, TimeUnit.SECONDS); + assertEquals(MqttQoS.AT_MOST_ONCE.value(), callback.getQoS()); } @Test @@ -204,11 +211,16 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC private static class TestMqttCallback implements MqttCallback { private final MqttAsyncClient client; + private Integer qoS; TestMqttCallback(MqttAsyncClient client) { this.client = client; } + int getQoS() { + return qoS; + } + @Override public void connectionLost(Throwable throwable) { } @@ -219,6 +231,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC MqttMessage message = new MqttMessage(); String responseTopic = requestTopic.replace("request", "response"); message.setPayload("{\"value1\":\"A\", \"value2\":\"B\"}".getBytes("UTF-8")); + qoS = mqttMessage.getQos(); client.publish(responseTopic, message); } diff --git a/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java b/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java index d02d07dff9..df9601e735 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java @@ -15,10 +15,9 @@ */ package org.thingsboard.server.mqtt.telemetry; +import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; -import org.eclipse.paho.client.mqttv3.MqttAsyncClient; -import org.eclipse.paho.client.mqttv3.MqttConnectOptions; -import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.*; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; @@ -30,9 +29,12 @@ import org.thingsboard.server.dao.service.DaoNoSqlTest; import java.net.URI; import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author Valerii Sosliuk @@ -94,4 +96,60 @@ public abstract class AbstractMqttTelemetryIntegrationTest extends AbstractContr assertEquals("3.0", values.get("key3").get(0).get("value")); assertEquals("4", values.get("key4").get(0).get("value")); } + + @Test + public void testMqttQoSLevel() throws Exception { + String clientId = MqttAsyncClient.generateClientId(); + MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, clientId); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options).waitForCompletion(3000); + TestMqttCallback callback = new TestMqttCallback(client); + client.setCallback(callback); + CountDownLatch latch = new CountDownLatch(1); + latch.countDown(); + client.subscribe("v1/devices/me/attributes", MqttQoS.AT_MOST_ONCE.value()); + String payload = "{\"key\":\"value\"}"; + String result = doPostAsync("/api/plugins/telemetry/" + savedDevice.getId() + "/SHARED_SCOPE", payload, String.class, status().isOk()); + latch.await(3, TimeUnit.SECONDS); + assertEquals(payload, callback.getPayload()); + assertEquals(MqttQoS.AT_MOST_ONCE.value(), callback.getQoS()); + } + + private static class TestMqttCallback implements MqttCallback { + + private final MqttAsyncClient client; + private Integer qoS; + private String payload; + + String getPayload() { + return payload; + } + + TestMqttCallback(MqttAsyncClient client) { + this.client = client; + } + + int getQoS() { + return qoS; + } + + @Override + public void connectionLost(Throwable throwable) { + } + + @Override + public void messageArrived(String requestTopic, MqttMessage mqttMessage) { + payload = new String(mqttMessage.getPayload()); + qoS = mqttMessage.getQos(); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken iMqttDeliveryToken) { + + } + } + + } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopicMatcher.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopicMatcher.java new file mode 100644 index 0000000000..b4a20283f7 --- /dev/null +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopicMatcher.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import java.util.regex.Pattern; + +public class MqttTopicMatcher { + + private final String topic; + private final Pattern topicRegex; + + MqttTopicMatcher(String topic) { + if(topic == null){ + throw new NullPointerException("topic"); + } + this.topic = topic; + this.topicRegex = Pattern.compile(topic.replace("+", "[^/]+").replace("#", ".+") + "$"); + } + + public String getTopic() { + return topic; + } + + public boolean matches(String topic){ + return this.topicRegex.matcher(topic).matches(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + MqttTopicMatcher that = (MqttTopicMatcher) o; + + return topic.equals(that.topic); + } + + @Override + public int hashCode() { + return topic.hashCode(); + } +} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index ed0c8c642c..3c8365e27d 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -54,6 +54,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.regex.Pattern; import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.*; import static io.netty.handler.codec.mqtt.MqttMessageType.*; @@ -78,7 +79,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private final RelationService relationService; private final QuotaService quotaService; private final SslHandler sslHandler; - private final ConcurrentMap mqttQoSMap; + private final ConcurrentMap mqttQoSMap; private volatile boolean connected; private volatile InetSocketAddress address; @@ -278,7 +279,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { grantedQoSList.add(getMinSupportedQos(reqQoS)); - mqttQoSMap.put(topic, getMinSupportedQos(reqQoS)); + mqttQoSMap.put(new MqttTopicMatcher(topic), getMinSupportedQos(reqQoS)); } private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { @@ -287,7 +288,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); for (String topicName : mqttMsg.payload().topics()) { - mqttQoSMap.remove(topicName); + mqttQoSMap.remove(new MqttTopicMatcher(topicName)); try { switch (topicName) { case DEVICE_ATTRIBUTES_TOPIC: { diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index 3dbb3efe0e..256ed119da 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -16,7 +16,10 @@ package org.thingsboard.server.transport.mqtt.session; import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.mqtt.*; +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; @@ -27,10 +30,9 @@ import org.thingsboard.server.common.msg.session.ex.SessionException; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; +import org.thingsboard.server.transport.mqtt.MqttTopicMatcher; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; -import java.util.Map; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; @@ -46,7 +48,7 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { private volatile boolean allowAttributeResponses; private AtomicInteger msgIdSeq = new AtomicInteger(0); - public DeviceSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, MqttTransportAdaptor adaptor, ConcurrentMap mqttQoSMap) { + public DeviceSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, MqttTransportAdaptor adaptor, ConcurrentMap mqttQoSMap) { super(processor, authService, mqttQoSMap); this.adaptor = adaptor; this.sessionId = new MqttSessionId(); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java index e5be5c71a1..bbc9c086a8 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.msg.session.*; import org.thingsboard.server.common.msg.session.ex.SessionException; import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; +import org.thingsboard.server.transport.mqtt.MqttTopicMatcher; import org.thingsboard.server.transport.mqtt.MqttTopics; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; @@ -58,7 +59,7 @@ public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext { private volatile boolean closed; private AtomicInteger msgIdSeq = new AtomicInteger(0); - public GatewayDeviceSessionCtx(GatewaySessionCtx parent, Device device, ConcurrentMap mqttQoSMap) { + public GatewayDeviceSessionCtx(GatewaySessionCtx parent, Device device, ConcurrentMap mqttQoSMap) { super(parent.getProcessor(), parent.getAuthService(), device, mqttQoSMap); this.parent = parent; this.sessionId = new MqttSessionId(); diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java index 98ad6d2c2c..b9291b794a 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -39,6 +39,7 @@ import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.transport.mqtt.MqttTopicMatcher; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; @@ -64,7 +65,7 @@ public class GatewaySessionCtx { private final DeviceAuthService authService; private final RelationService relationService; private final Map devices; - private final ConcurrentMap mqttQoSMap; + private final ConcurrentMap mqttQoSMap; private ChannelHandlerContext channel; public GatewaySessionCtx(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, DeviceSessionCtx gatewaySessionCtx) { diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java index f085064016..0752331f5a 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java @@ -20,35 +20,42 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; +import org.thingsboard.server.transport.mqtt.MqttTopicMatcher; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; /** * Created by ashvayka on 30.08.18. */ public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionContext { - private final ConcurrentMap mqttQoSMap; + private final ConcurrentMap mqttQoSMap; - public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, ConcurrentMap mqttQoSMap) { + public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, ConcurrentMap mqttQoSMap) { super(processor, authService); this.mqttQoSMap = mqttQoSMap; } - public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, Device device, ConcurrentMap mqttQoSMap) { + public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, Device device, ConcurrentMap mqttQoSMap) { super(processor, authService, device); this.mqttQoSMap = mqttQoSMap; } - public ConcurrentMap getMqttQoSMap() { + public ConcurrentMap getMqttQoSMap() { return mqttQoSMap; } public MqttQoS getQoSForTopic(String topic) { - Integer qos = mqttQoSMap.get(topic); - if (qos != null) { - return MqttQoS.valueOf(qos); + List qosList = mqttQoSMap.entrySet() + .stream() + .filter(entry -> entry.getKey().matches(topic)) + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + if (!qosList.isEmpty()) { + return MqttQoS.valueOf(qosList.get(0)); } else { return MqttQoS.AT_LEAST_ONCE; } From 2cccbc7dca182c0456f50e634b61cef1ec93f9ff Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 10 Oct 2018 20:00:33 +0300 Subject: [PATCH 200/307] MQTT tests: fix CountDown latch --- .../AbstractMqttServerSideRpcIntegrationTest.java | 13 ++++++++----- .../AbstractMqttTelemetryIntegrationTest.java | 10 ++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java index 74ecdb2a76..7453682644 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java @@ -103,10 +103,10 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC options.setUserName(accessToken); client.connect(options).waitForCompletion(); - TestMqttCallback callback = new TestMqttCallback(client); - client.setCallback(callback); CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); + TestMqttCallback callback = new TestMqttCallback(client, latch); + client.setCallback(callback); + client.subscribe("v1/devices/me/rpc/request/+", MqttQoS.AT_MOST_ONCE.value()); String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}"; @@ -163,7 +163,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC options.setUserName(accessToken); client.connect(options).waitForCompletion(); client.subscribe("v1/devices/me/rpc/request/+", 1); - client.setCallback(new TestMqttCallback(client)); + client.setCallback(new TestMqttCallback(client, new CountDownLatch(1))); String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}"; String deviceId = savedDevice.getId().getId().toString(); @@ -211,10 +211,12 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC private static class TestMqttCallback implements MqttCallback { private final MqttAsyncClient client; + private final CountDownLatch latch; private Integer qoS; - TestMqttCallback(MqttAsyncClient client) { + TestMqttCallback(MqttAsyncClient client, CountDownLatch latch) { this.client = client; + this.latch = latch; } int getQoS() { @@ -233,6 +235,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC message.setPayload("{\"value1\":\"A\", \"value2\":\"B\"}".getBytes("UTF-8")); qoS = mqttMessage.getQos(); client.publish(responseTopic, message); + latch.countDown(); } @Override diff --git a/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java b/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java index df9601e735..f7a7ceafd6 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java @@ -105,10 +105,9 @@ public abstract class AbstractMqttTelemetryIntegrationTest extends AbstractContr MqttConnectOptions options = new MqttConnectOptions(); options.setUserName(accessToken); client.connect(options).waitForCompletion(3000); - TestMqttCallback callback = new TestMqttCallback(client); - client.setCallback(callback); CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); + TestMqttCallback callback = new TestMqttCallback(client, latch); + client.setCallback(callback); client.subscribe("v1/devices/me/attributes", MqttQoS.AT_MOST_ONCE.value()); String payload = "{\"key\":\"value\"}"; String result = doPostAsync("/api/plugins/telemetry/" + savedDevice.getId() + "/SHARED_SCOPE", payload, String.class, status().isOk()); @@ -120,6 +119,7 @@ public abstract class AbstractMqttTelemetryIntegrationTest extends AbstractContr private static class TestMqttCallback implements MqttCallback { private final MqttAsyncClient client; + private final CountDownLatch latch; private Integer qoS; private String payload; @@ -127,8 +127,9 @@ public abstract class AbstractMqttTelemetryIntegrationTest extends AbstractContr return payload; } - TestMqttCallback(MqttAsyncClient client) { + TestMqttCallback(MqttAsyncClient client, CountDownLatch latch) { this.client = client; + this.latch = latch; } int getQoS() { @@ -143,6 +144,7 @@ public abstract class AbstractMqttTelemetryIntegrationTest extends AbstractContr public void messageArrived(String requestTopic, MqttMessage mqttMessage) { payload = new String(mqttMessage.getPayload()); qoS = mqttMessage.getQos(); + latch.countDown(); } @Override From ee2bd0ed88c984120403de308fcbf1c24db546a8 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 10 Oct 2018 20:05:31 +0300 Subject: [PATCH 201/307] Version set to 2.1.2 --- application/pom.xml | 2 +- common/data/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/transport/pom.xml | 2 +- dao/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- ui/package.json | 2 +- ui/pom.xml | 2 +- 18 files changed, 19 insertions(+), 19 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 9e448380c6..72e7e7d7c2 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard application diff --git a/common/data/pom.xml b/common/data/pom.xml index b4fb1b3a31..aa68b1adf6 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index d5a218f3c1..4da27e28f6 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index d39ac085b4..4ee5937e7a 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard org.thingsboard diff --git a/common/transport/pom.xml b/common/transport/pom.xml index a19cb5cfc6..efd63b5240 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index e8dda2e810..8cc83e9393 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard dao diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index a0bfd61def..f7e32b53e7 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,12 +19,12 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard org.thingsboard netty-mqtt - 2.1.2-SNAPSHOT + 2.1.2 jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 9cf60b2ccc..981482b35a 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 2.1.2-SNAPSHOT + 2.1.2 pom Thingsboard diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index cf0ad4700a..5c5fe56075 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 925d8e2b8c..4b4f33624f 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index fb8a38b61e..ebab903190 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index b209b970ae..8a9481d0b9 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard org.thingsboard diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index ecc8181c69..537a9bee06 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 3f0b5ce3c0..24e4ff94af 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index f890777d77..f5c1104b6a 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index b880a69f31..7858383d82 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard org.thingsboard diff --git a/ui/package.json b/ui/package.json index ca4b6272dd..8c347c5ccb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard", "private": true, - "version": "2.1.1", + "version": "2.1.2", "description": "Thingsboard UI", "licenses": [ { diff --git a/ui/pom.xml b/ui/pom.xml index 087065ad77..a2eadb5445 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2-SNAPSHOT + 2.1.2 thingsboard org.thingsboard From 950ec8d622cd5c0c851de63f715959e1a3a4e168 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 11 Oct 2018 14:04:16 +0300 Subject: [PATCH 202/307] MQTT API implementation --- .../device/DeviceActorMessageProcessor.java | 187 +++++++++++------- .../actors/device/PendingSessionMsgData.java | 41 ---- .../device/ToServerRpcRequestMetadata.java | 12 +- .../common/transport/SessionMsgListener.java | 17 +- .../common/transport/TransportService.java | 6 + .../transport/adaptor/JsonConverter.java | 36 +++- .../transport/src/main/proto/transport.proto | 29 ++- .../coap/adaptors/JsonCoapAdaptor.java | 7 +- .../http/session/HttpSessionCtx.java | 2 +- .../transport/mqtt/MqttTransportHandler.java | 71 +++---- .../mqtt/adaptors/JsonMqttAdaptor.java | 76 ++++--- .../mqtt/adaptors/MqttTransportAdaptor.java | 26 ++- .../mqtt/service/MqttTransportService.java | 35 +++- 13 files changed, 333 insertions(+), 212 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 7abcca2cab..c590104665 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -32,7 +32,6 @@ import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -44,18 +43,33 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.core.AttributesUpdateNotification; import org.thingsboard.server.common.msg.core.RuleEngineError; import org.thingsboard.server.common.msg.core.RuleEngineErrorMsg; -import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; -import org.thingsboard.server.common.msg.kv.BasicAttributeKVMsg; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionType; import org.thingsboard.server.common.msg.session.ToDeviceMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvListProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; import org.thingsboard.server.service.rpc.FromDeviceRpcResponse; import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; @@ -76,8 +90,6 @@ import java.util.UUID; import java.util.function.Consumer; import java.util.stream.Collectors; -import org.thingsboard.server.gen.transport.TransportProtos.*; - /** * @author Andrew Shvayka */ @@ -123,11 +135,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso void processRpcRequest(ActorContext context, ToDeviceRpcRequestActorMsg msg) { ToDeviceRpcRequest request = msg.getMsg(); ToDeviceRpcRequestBody body = request.getBody(); - ToDeviceRpcRequestMsg rpcRequest = new ToDeviceRpcRequestMsg( - rpcSeq++, - body.getMethod(), - body.getParams() - ); + ToDeviceRpcRequestMsg rpcRequest = ToDeviceRpcRequestMsg.newBuilder().setRequestId( + rpcSeq++).setMethodName(body.getMethod()).setParams(body.getParams()).build(); long timeout = request.getExpirationTime() - System.currentTimeMillis(); if (timeout <= 0) { @@ -136,13 +145,12 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } boolean sent = rpcSubscriptions.size() > 0; - Set syncSessionSet = new HashSet<>(); + Set syncSessionSet = new HashSet<>(); rpcSubscriptions.entrySet().forEach(sub -> { -// ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sub.getKey()); -// sendMsgToSessionActor(response, sub.getValue().getServer()); -// if (SessionType.SYNC == sub.getValue().getType()) { -// syncSessionSet.add(sub.getKey()); -// } + sendToTransport(rpcRequest, sub.getKey(), sub.getValue().getNodeId()); + if (TransportProtos.SessionType.SYNC == sub.getValue().getType()) { + syncSessionSet.add(sub.getKey()); + } }); syncSessionSet.forEach(rpcSubscriptions::remove); @@ -175,10 +183,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void sendPendingRequests(ActorContext context, SessionId sessionId, SessionType type, Optional server) { + private void sendPendingRequests(ActorContext context, UUID sessionId, SessionInfoProto sessionInfo) { + TransportProtos.SessionType sessionType = getSessionType(sessionId); if (!toDeviceRpcPendingMap.isEmpty()) { logger.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId); - if (type == SessionType.SYNC) { + if (sessionType == TransportProtos.SessionType.SYNC) { logger.debug("[{}] Cleanup sync rpc session [{}]", deviceId, sessionId); rpcSubscriptions.remove(sessionId); } @@ -186,16 +195,16 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso logger.debug("[{}] No pending RPC messages for new async session [{}]", deviceId, sessionId); } Set sentOneWayIds = new HashSet<>(); - if (type == SessionType.ASYNC) { - toDeviceRpcPendingMap.entrySet().forEach(processPendingRpc(context, sessionId, server, sentOneWayIds)); + if (sessionType == TransportProtos.SessionType.ASYNC) { + toDeviceRpcPendingMap.entrySet().forEach(processPendingRpc(context, sessionId, sessionInfo.getNodeId(), sentOneWayIds)); } else { - toDeviceRpcPendingMap.entrySet().stream().findFirst().ifPresent(processPendingRpc(context, sessionId, server, sentOneWayIds)); + toDeviceRpcPendingMap.entrySet().stream().findFirst().ifPresent(processPendingRpc(context, sessionId, sessionInfo.getNodeId(), sentOneWayIds)); } sentOneWayIds.forEach(toDeviceRpcPendingMap::remove); } - private Consumer> processPendingRpc(ActorContext context, SessionId sessionId, Optional server, Set sentOneWayIds) { + private Consumer> processPendingRpc(ActorContext context, UUID sessionId, String nodeId, Set sentOneWayIds) { return entry -> { ToDeviceRpcRequestActorMsg requestActorMsg = entry.getValue().getMsg(); ToDeviceRpcRequest request = entry.getValue().getMsg().getMsg(); @@ -204,19 +213,14 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso sentOneWayIds.add(entry.getKey()); systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(request.getId(), requestActorMsg.getServerAddress(), null, null)); } - ToDeviceRpcRequestMsg rpcRequest = new ToDeviceRpcRequestMsg( - entry.getKey(), - body.getMethod(), - body.getParams() - ); -// ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sessionId); -// sendMsgToSessionActor(response, server); + ToDeviceRpcRequestMsg rpcRequest = ToDeviceRpcRequestMsg.newBuilder().setRequestId( + entry.getKey()).setMethodName(body.getMethod()).setParams(body.getParams()).build(); + sendToTransport(rpcRequest, sessionId, nodeId); }; } void process(ActorContext context, TransportToDeviceActorMsgWrapper wrapper) { TransportToDeviceActorMsg msg = wrapper.getMsg(); -// processRpcResponses(context, msg); if (msg.hasSessionEvent()) { processSessionStateMsgs(msg.getSessionInfo(), msg.getSessionEvent()); } @@ -237,15 +241,13 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso if (msg.hasGetAttributes()) { handleGetAttributesRequest(context, msg.getSessionInfo(), msg.getGetAttributes()); } -// SessionMsgType sessionMsgType = msg.getPayload().getMsgType(); -// if (sessionMsgType.requiresRulesProcessing()) { -// switch (sessionMsgType) { -// case TO_SERVER_RPC_REQUEST: -// handleClientSideRPCRequest(context, msg); -// reportActivity(); -// break; -// } -// } + if (msg.hasToDeviceRPCCallResponse()) { + processRpcResponses(context, msg.getSessionInfo(), msg.getToDeviceRPCCallResponse()); + } + if (msg.hasToServerRPCCallRequest()) { + handleClientSideRPCRequest(context, msg.getSessionInfo(), msg.getToServerRPCCallRequest()); + reportActivity(); + } } private void reportActivity() { @@ -314,36 +316,42 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } -// private void handleClientSideRPCRequest(ActorContext context, DeviceToDeviceActorMsg src) { -// ToServerRpcRequestMsg request = (ToServerRpcRequestMsg) src.getPayload(); -// -// JsonObject json = new JsonObject(); -// json.addProperty("method", request.getMethod()); -// json.add("params", jsonParser.parse(request.getParams())); -// -// TbMsgMetaData requestMetaData = defaultMetaData.copy(); -// requestMetaData.putValue("requestId", Integer.toString(request.getRequestId())); -// TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.TO_SERVER_RPC_REQUEST.name(), deviceId, requestMetaData, TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); -// PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), SessionMsgType.TO_SERVER_RPC_REQUEST, request.getRequestId(), false, 1); -// pushToRuleEngineWithTimeout(context, tbMsg, msgData); -// -// scheduleMsgWithDelay(context, new DeviceActorClientSideRpcTimeoutMsg(request.getRequestId(), systemContext.getClientSideRpcTimeout()), systemContext.getClientSideRpcTimeout()); -// toServerRpcPendingMap.put(request.getRequestId(), new ToServerRpcRequestMetadata(src.getSessionId(), src.getSessionType(), src.getServerAddress())); -// } + private void handleClientSideRPCRequest(ActorContext context, SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg request) { + UUID sessionId = getSessionId(sessionInfo); + JsonObject json = new JsonObject(); + json.addProperty("method", request.getMethodName()); + json.add("params", jsonParser.parse(request.getParams())); + + TbMsgMetaData requestMetaData = defaultMetaData.copy(); + requestMetaData.putValue("requestId", Integer.toString(request.getRequestId())); + TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.TO_SERVER_RPC_REQUEST.name(), deviceId, requestMetaData, TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); + context.parent().tell(new DeviceActorToRuleEngineMsg(context.self(), tbMsg), context.self()); + + scheduleMsgWithDelay(context, new DeviceActorClientSideRpcTimeoutMsg(request.getRequestId(), systemContext.getClientSideRpcTimeout()), systemContext.getClientSideRpcTimeout()); + toServerRpcPendingMap.put(request.getRequestId(), new ToServerRpcRequestMetadata(sessionId, getSessionType(sessionId), sessionInfo.getNodeId())); + } - public void processClientSideRpcTimeout(ActorContext context, DeviceActorClientSideRpcTimeoutMsg msg) { + private TransportProtos.SessionType getSessionType(UUID sessionId) { + return sessions.containsKey(sessionId) ? TransportProtos.SessionType.ASYNC : TransportProtos.SessionType.SYNC; + } + + void processClientSideRpcTimeout(ActorContext context, DeviceActorClientSideRpcTimeoutMsg msg) { ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(msg.getId()); if (data != null) { logger.debug("[{}] Client side RPC request [{}] timeout detected!", deviceId, msg.getId()); - ToDeviceMsg toDeviceMsg = new RuleEngineErrorMsg(SessionMsgType.TO_SERVER_RPC_REQUEST, RuleEngineError.TIMEOUT); -// sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServer()); + sendToTransport(TransportProtos.ToServerRpcResponseMsg.newBuilder() + .setRequestId(msg.getId()).setError("timeout").build() + , data.getSessionId(), data.getNodeId()); } } void processToServerRPCResponse(ActorContext context, ToServerRpcResponseActorMsg msg) { - ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(msg.getMsg().getRequestId()); + int requestId = msg.getMsg().getRequestId(); + ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(requestId); if (data != null) { -// sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(msg.getMsg(), data.getSessionId()), data.getServer()); + sendToTransport(TransportProtos.ToServerRpcResponseMsg.newBuilder() + .setRequestId(requestId).setPayload(msg.getMsg().getData()).build() + , data.getSessionId(), data.getNodeId()); } } @@ -382,7 +390,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso if (hasNotificationData) { AttributeUpdateNotificationMsg finalNotification = notification.build(); attributeSubscriptions.entrySet().forEach(sub -> { - sendToTransport(finalNotification, sub.getKey(), sub.getValue()); + sendToTransport(finalNotification, sub.getKey(), sub.getValue().getNodeId()); }); } } else { @@ -390,6 +398,19 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } + private void processRpcResponses(ActorContext context, SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg responseMsg) { + UUID sessionId = getSessionId(sessionInfo); + logger.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); + ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); + boolean success = requestMd != null; + if (success) { + systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), + requestMd.getMsg().getServerAddress(), responseMsg.getPayload(), null)); + } else { + logger.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); + } + } + // private void processRpcResponses(ActorContext context, DeviceToDeviceActorMsg msg) { // SessionId sessionId = msg.getSessionId(); // FromDeviceMsg inMsg = msg.getPayload(); @@ -424,7 +445,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg subscribeCmd) { - UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + UUID sessionId = getSessionId(sessionInfo); if (subscribeCmd.getUnsubscribe()) { logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); attributeSubscriptions.remove(sessionId); @@ -438,8 +459,12 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } + private UUID getSessionId(SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + } + private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToRPCMsg subscribeCmd) { - UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + UUID sessionId = getSessionId(sessionInfo); if (subscribeCmd.getUnsubscribe()) { logger.debug("[{}] Canceling rpc subscription for session [{}]", deviceId, sessionId); rpcSubscriptions.remove(sessionId); @@ -450,11 +475,12 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } logger.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId); rpcSubscriptions.put(sessionId, session); + sendPendingRequests(context, sessionId, sessionInfo); } } private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { - UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + UUID sessionId = getSessionId(sessionInfo); if (msg.getEvent() == SessionEvent.OPEN) { logger.debug("[{}] Processing new session [{}]", deviceId, sessionId); if (sessions.size() >= systemContext.getMaxConcurrentSessionsPerDevice()) { @@ -548,14 +574,31 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); } - private void sendToTransport(AttributeUpdateNotificationMsg notificationMsg, UUID sessionId, SessionInfo sessionInfo) { + private void sendToTransport(AttributeUpdateNotificationMsg notificationMsg, UUID sessionId, String nodeId) { DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() .setSessionIdMSB(sessionId.getMostSignificantBits()) .setSessionIdLSB(sessionId.getLeastSignificantBits()) .setAttributeUpdateNotification(notificationMsg).build(); - systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); + systemContext.getRuleEngineTransportService().process(nodeId, msg); } + private void sendToTransport(ToDeviceRpcRequestMsg rpcMsg, UUID sessionId, String nodeId) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToDeviceRequest(rpcMsg).build(); + systemContext.getRuleEngineTransportService().process(nodeId, msg); + } + + private void sendToTransport(TransportProtos.ToServerRpcResponseMsg rpcMsg, UUID sessionId, String nodeId) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToServerResponse(rpcMsg).build(); + systemContext.getRuleEngineTransportService().process(nodeId, msg); + } + + private List toTsKvProtos(@Nullable List result) { List clientAttributes; if (result == null || result.isEmpty()) { diff --git a/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java b/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java deleted file mode 100644 index dfa07cf065..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.device; - -import lombok.AllArgsConstructor; -import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -import java.util.Optional; -import java.util.UUID; - -/** - * Created by ashvayka on 17.04.18. - */ -@Data -@AllArgsConstructor -public final class PendingSessionMsgData { - - private final UUID sessionId; - private final Optional serverAddress; - private final SessionMsgType sessionMsgType; - private final int requestId; - private final boolean replyOnQueueAck; - private int ackMsgCount; - -} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java b/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java index f82a8c2481..669d94b17d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java @@ -16,18 +16,16 @@ package org.thingsboard.server.actors.device; import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.SessionType; +import org.thingsboard.server.gen.transport.TransportProtos; -import java.util.Optional; +import java.util.UUID; /** * @author Andrew Shvayka */ @Data public class ToServerRpcRequestMetadata { - private final SessionId sessionId; - private final SessionType type; - private final Optional server; + private final UUID sessionId; + private final TransportProtos.SessionType type; + private final String nodeId; } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index 17d4a1f42c..09d1bcb08f 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,8 +15,11 @@ */ package org.thingsboard.server.common.transport; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; /** * Created by ashvayka on 04.10.18. @@ -26,4 +29,10 @@ public interface SessionMsgListener { void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); void onAttributeUpdate(AttributeUpdateNotificationMsg attributeUpdateNotification); + + void onRemoteSessionCloseCommand(SessionCloseNotificationProto sessionCloseNotification); + + void onToDeviceRpcRequest(ToDeviceRpcRequestMsg toDeviceRequest); + + void onToServerRpcResponse(ToServerRpcResponseMsg toServerResponse); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index bf33b1d8a5..cd3041939b 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.transport; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; @@ -49,6 +51,10 @@ public interface TransportService { void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback); + void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener); void deregisterSession(SessionInfoProto sessionInfo); diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index 872dfaa03e..db7ee884dc 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,6 +22,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import com.google.gson.JsonSyntaxException; +import org.springframework.util.StringUtils; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; @@ -95,6 +96,15 @@ public class JsonConverter { } } + public static JsonElement toJson(TransportProtos.ToDeviceRpcRequestMsg msg, boolean includeRequestId) { + JsonObject result = new JsonObject(); + if (includeRequestId) { + result.addProperty("id", msg.getRequestId()); + } + result.addProperty("method", msg.getMethodName()); + result.add("params", new JsonParser().parse(msg.getParams())); + return result; + } private static void parseObject(PostTelemetryMsg.Builder builder, long systemTs, JsonElement jsonObject) { JsonObject jo = jsonObject.getAsJsonObject(); @@ -112,14 +122,14 @@ public class JsonConverter { request.addTsKvList(builder.build()); } - public static void parseWithTs(PostTelemetryMsg.Builder request, JsonObject jo) { + private static void parseWithTs(PostTelemetryMsg.Builder request, JsonObject jo) { TsKvListProto.Builder builder = TsKvListProto.newBuilder(); builder.setTs(jo.get("ts").getAsLong()); builder.addAllKv(parseProtoValues(jo.get("values").getAsJsonObject())); request.addTsKvList(builder.build()); } - public static List parseProtoValues(JsonObject valuesObject) { + private static List parseProtoValues(JsonObject valuesObject) { List result = new ArrayList<>(); for (Entry valueEntry : valuesObject.entrySet()) { JsonElement element = valueEntry.getValue(); @@ -172,9 +182,9 @@ public class JsonConverter { return request; } - public static ToServerRpcRequestMsg convertToServerRpcRequest(JsonElement json, int requestId) throws JsonSyntaxException { + public static TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(JsonElement json, int requestId) throws JsonSyntaxException { JsonObject object = json.getAsJsonObject(); - return new ToServerRpcRequestMsg(requestId, object.get("method").getAsString(), GSON.toJson(object.get("params"))); + return TransportProtos.ToServerRpcRequestMsg.newBuilder().setRequestId(requestId).setMethodName(object.get("method").getAsString()).setParams(GSON.toJson(object.get("params"))).build(); } private static void parseObject(BasicTelemetryUploadRequest request, long systemTs, JsonElement jsonObject) { @@ -368,8 +378,14 @@ public class JsonConverter { return result; } - public static JsonElement toJson(ToServerRpcResponseMsg msg) { - return new JsonParser().parse(msg.getData()); + public static JsonElement toJson(TransportProtos.ToServerRpcResponseMsg msg) { + if (StringUtils.isEmpty(msg.getError())) { + return new JsonParser().parse(msg.getPayload()); + } else { + JsonObject errorMsg = new JsonObject(); + errorMsg.addProperty("error", msg.getError()); + return errorMsg; + } } public static JsonElement toErrorJson(String errorMsg) { diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index 66c0d8183a..bcf5f529b9 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -137,6 +137,29 @@ message SubscribeToRPCMsg { bool unsubscribe = 1; } +message ToDeviceRpcRequestMsg { + int32 requestId = 1; + string methodName = 2; + string params = 3; +} + +message ToDeviceRpcResponseMsg { + int32 requestId = 1; + string payload = 2; +} + +message ToServerRpcRequestMsg { + int32 requestId = 1; + string methodName = 2; + string params = 3; +} + +message ToServerRpcResponseMsg { + int32 requestId = 1; + string payload = 2; + string error = 3; +} + message TransportToDeviceActorMsg { SessionInfoProto sessionInfo = 1; SessionEventMsg sessionEvent = 2; @@ -144,7 +167,9 @@ message TransportToDeviceActorMsg { PostAttributeMsg postAttributes = 4; GetAttributeRequestMsg getAttributes = 5; SubscribeToAttributeUpdatesMsg subscribeToAttributes = 6; - SubscribeToRPCMsg subscribeToRPC= 7; + SubscribeToRPCMsg subscribeToRPC = 7; + ToDeviceRpcResponseMsg toDeviceRPCCallResponse = 8; + ToServerRpcRequestMsg toServerRPCCallRequest = 9; } message DeviceActorToTransportMsg { @@ -153,6 +178,8 @@ message DeviceActorToTransportMsg { SessionCloseNotificationProto sessionCloseNotification = 3; GetAttributeResponseMsg getAttributesResponse = 4; AttributeUpdateNotificationMsg attributeUpdateNotification = 5; + ToDeviceRpcRequestMsg toDeviceRequest = 6; + ToServerRpcResponseMsg toServerResponse = 7; } /** diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java index 1c9631129f..3cd414289c 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java @@ -98,7 +98,8 @@ public class JsonCoapAdaptor implements CoapTransportAdaptor { String payload = validatePayload(ctx, inbound); - return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), 0); +// return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), 0); + return null; } @Override @@ -225,8 +226,8 @@ public class JsonCoapAdaptor implements CoapTransportAdaptor { private Response convertToServerRpcResponse(SessionContext ctx, ToServerRpcResponseMsg msg) { if (msg.isSuccess()) { Response response = new Response(ResponseCode.CONTENT); - JsonElement result = JsonConverter.toJson(msg); - response.setPayload(result.toString()); +// JsonElement result = JsonConverter.toJson(msg); +// response.setPayload(result.toString()); return response; } else { return convertError(Optional.of(new RuntimeException("Server RPC response is empty!"))); diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java index e503409b23..5269285d25 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java @@ -114,7 +114,7 @@ public class HttpSessionCtx extends DeviceAwareSessionContext { } private void reply(ToServerRpcResponseMsg msg) { - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); +// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); } private void reply(AttributesUpdateNotification msg) { diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index b1ee037dc3..3099b24b87 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -223,43 +223,18 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } else if (topicName.startsWith(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { TransportProtos.GetAttributeRequestMsg getAttributeMsg = adaptor.convertToGetAttributes(deviceSessionCtx, mqttMsg); transportService.process(sessionInfo, getAttributeMsg, getPubAckCallback(ctx, msgId, getAttributeMsg)); + } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC)){ + TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = adaptor.convertToDeviceRpcResponse(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, rpcResponseMsg, getPubAckCallback(ctx, msgId, rpcResponseMsg)); + } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC)){ + TransportProtos.ToServerRpcRequestMsg rpcRequestMsg = adaptor.convertToServerRpcRequest(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, rpcRequestMsg, getPubAckCallback(ctx, msgId, rpcRequestMsg)); } } catch (AdaptorException e) { log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); ctx.close(); } -// AdaptorToSessionActorMsg msg = null; -// try { -// if (topicName.equals(DEVICE_TELEMETRY_TOPIC)) { -// msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_TELEMETRY_REQUEST, mqttMsg); -// } else if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { -// msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_ATTRIBUTES_REQUEST, mqttMsg); -// } else if (topicName.startsWith(DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { -// msg = adaptor.convertToActorMsg(deviceSessionCtx, GET_ATTRIBUTES_REQUEST, mqttMsg); -// if (msgId >= 0) { -// ctx.writeAndFlush(createMqttPubAckMsg(msgId)); -// } -// } else if (topicName.startsWith(DEVICE_RPC_RESPONSE_TOPIC)) { -// msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_DEVICE_RPC_RESPONSE, mqttMsg); -// if (msgId >= 0) { -// ctx.writeAndFlush(createMqttPubAckMsg(msgId)); -// } -// } else if (topicName.startsWith(DEVICE_RPC_REQUESTS_TOPIC)) { -// msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_SERVER_RPC_REQUEST, mqttMsg); -// if (msgId >= 0) { -// ctx.writeAndFlush(createMqttPubAckMsg(msgId)); -// } -// } -// } catch (AdaptorException e) { -// log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); -// } -// if (msg != null) { -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); -// } else { -// log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); -// ctx.close(); -// } } private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final int msgId, final T msg) { @@ -555,4 +530,30 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement log.trace("[{}] Failed to convert device attributes update to MQTT msg", sessionId, e); } } + + @Override + public void onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto sessionCloseNotification) { + log.trace("[{}] Received the remote command to close the session", sessionId); + processDisconnect(deviceSessionCtx.getChannel()); + } + + @Override + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg rpcRequest) { + log.trace("[{}] Received RPC command to device", sessionId); + try { + adaptor.convertToPublish(deviceSessionCtx, rpcRequest).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device RPC commandto MQTT msg", sessionId, e); + } + } + + @Override + public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg rpcResponse) { + log.trace("[{}] Received RPC command to device", sessionId); + try { + adaptor.convertToPublish(deviceSessionCtx, rpcResponse).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device RPC commandto MQTT msg", sessionId, e); + } + } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index d4ab033cee..a2be169656 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,7 +17,6 @@ package org.thingsboard.server.transport.mqtt.adaptors; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import io.netty.buffer.ByteBuf; @@ -98,6 +97,31 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } } + @Override + public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + String topicName = inbound.variableHeader().topicName(); + try { + Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC.length())); + String payload = inbound.payload().toString(UTF8); + return TransportProtos.ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId).setPayload(payload).build(); + } catch (RuntimeException e) { + log.warn("Failed to decode get attributes request", e); + throw new AdaptorException(e); + } + } + + @Override + public TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + String topicName = inbound.variableHeader().topicName(); + String payload = validatePayload(ctx.getSessionId(), inbound.payload()); + try { + Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC.length())); + return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), requestId); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + @Override public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { if (!StringUtils.isEmpty(responseMsg.getError())) { @@ -115,9 +139,17 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { @Override public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException { - return Optional.of(createMqttPublishMsg(ctx, - MqttTopics.DEVICE_ATTRIBUTES_TOPIC, - JsonConverter.toJson(notificationMsg))); + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_ATTRIBUTES_TOPIC, JsonConverter.toJson(notificationMsg))); + } + + @Override + public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), JsonConverter.toJson(rpcRequest, false))); + } + + @Override + public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.ToServerRpcResponseMsg rpcResponse) { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), JsonConverter.toJson(rpcResponse))); } @Override @@ -149,7 +181,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { msg = convertToRpcCommandResponse(ctx, (MqttPublishMessage) inbound); break; case TO_SERVER_RPC_REQUEST: - msg = convertToServerRpcRequest(ctx, (MqttPublishMessage) inbound); + msg = null;//convertToServerRpcRequest(ctx, (MqttPublishMessage) inbound); break; default: log.warn("[{}] Unsupported msg type: {}!", ctx.getSessionId(), type); @@ -181,13 +213,12 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { break; case TO_DEVICE_RPC_REQUEST: ToDeviceRpcRequestMsg rpcRequest = (ToDeviceRpcRequestMsg) msg; - result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), - rpcRequest); + result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), rpcRequest); break; case TO_SERVER_RPC_RESPONSE: - ToServerRpcResponseMsg rpcResponse = (ToServerRpcResponseMsg) msg; - result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), - rpcResponse); +// ToServerRpcResponseMsg rpcResponse = (ToServerRpcResponseMsg) msg; +// result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), +// rpcResponse); break; case RULE_ENGINE_ERROR: RuleEngineErrorMsg errorMsg = (RuleEngineErrorMsg) msg; @@ -232,7 +263,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg, false)); } - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, ToServerRpcResponseMsg msg) { + private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, TransportProtos.ToServerRpcResponseMsg msg) { return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg)); } @@ -290,7 +321,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { private AttributesUpdateRequest convertToUpdateAttributesRequest(SessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String payload = validatePayload(ctx.getSessionId(), inbound.payload()); try { - return JsonConverter.convertToAttributes(new JsonParser().parse(payload), inbound.variableHeader().messageId()); + return JsonConverter.convertToAttributes(new JsonParser().parse(payload), inbound.variableHeader().packetId()); } catch (IllegalStateException | JsonSyntaxException ex) { throw new AdaptorException(ex); } @@ -299,18 +330,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { private TelemetryUploadRequest convertToTelemetryUploadRequest(SessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String payload = validatePayload(ctx.getSessionId(), inbound.payload()); try { - return JsonConverter.convertToTelemetry(new JsonParser().parse(payload), inbound.variableHeader().messageId()); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private FromDeviceMsg convertToServerRpcRequest(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { - String topicName = inbound.variableHeader().topicName(); - String payload = validatePayload(ctx.getSessionId(), inbound.payload()); - try { - Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC.length())); - return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), requestId); + return JsonConverter.convertToTelemetry(new JsonParser().parse(payload), inbound.variableHeader().packetId()); } catch (IllegalStateException | JsonSyntaxException ex) { throw new AdaptorException(ex); } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index 602fde12f6..48173f968a 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -19,7 +19,15 @@ import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import org.thingsboard.server.common.transport.TransportAdaptor; import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; import java.util.Optional; @@ -29,15 +37,21 @@ import java.util.Optional; */ public interface MqttTransportAdaptor extends TransportAdaptor { - TransportProtos.PostTelemetryMsg convertToPostTelemetry(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + PostTelemetryMsg convertToPostTelemetry(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; - TransportProtos.PostAttributeMsg convertToPostAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + PostAttributeMsg convertToPostAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; - TransportProtos.GetAttributeRequestMsg convertToGetAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + GetAttributeRequestMsg convertToGetAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; - Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException; + ToDeviceRpcResponseMsg convertToDeviceRpcResponse(DeviceSessionCtx ctx, MqttPublishMessage mqttMsg) throws AdaptorException; - Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + ToServerRpcRequestMsg convertToServerRpcRequest(DeviceSessionCtx ctx, MqttPublishMessage mqttMsg) throws AdaptorException; + Optional convertToPublish(DeviceSessionCtx ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException; + Optional convertToPublish(DeviceSessionCtx ctx, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + + Optional convertToPublish(DeviceSessionCtx ctx, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + + Optional convertToPublish(DeviceSessionCtx ctx, ToServerRpcResponseMsg rpcResponse); } diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index 648b01c9c3..15392386c4 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -164,6 +164,15 @@ public class MqttTransportService implements TransportService { if (toSessionMsg.hasAttributeUpdateNotification()) { listener.onAttributeUpdate(toSessionMsg.getAttributeUpdateNotification()); } + if (toSessionMsg.hasSessionCloseNotification()) { + listener.onRemoteSessionCloseCommand(toSessionMsg.getSessionCloseNotification()); + } + if (toSessionMsg.hasToDeviceRequest()) { + listener.onToDeviceRpcRequest(toSessionMsg.getToDeviceRequest()); + } + if (toSessionMsg.hasToServerResponse()) { + listener.onToServerRpcResponse(toSessionMsg.getToServerResponse()); + } }); } else { //TODO: should we notify the device actor about missed session? @@ -272,6 +281,24 @@ public class MqttTransportService implements TransportService { send(sessionInfo, toRuleEngineMsg, callback); } + @Override + public void process(SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToDeviceRPCCallResponse(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToServerRPCCallRequest(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + @Override public void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener) { sessions.putIfAbsent(toId(sessionInfo), listener); From e5a1fb58654cb5c6b03e8007eae34f5422625a7e Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 11 Oct 2018 19:29:21 +0300 Subject: [PATCH 203/307] Refactoring of Gateway API --- .../device/DeviceActorMessageProcessor.java | 23 - .../actors/shared/SessionTimeoutMsg.java | 1 - .../transport/RemoteTransportApiService.java | 72 ++- .../server/common/data/id/SessionId.java | 24 - .../common/msg/aware/SessionAwareMsg.java | 2 - .../common/msg/core/SessionOpenMsg.java | 3 - .../common/msg/session/BasicSessionMsg.java | 2 - ...BasicTransportToDeviceSessionActorMsg.java | 1 - .../common/msg/session/SessionContext.java | 24 +- .../msg/session/ctrl/SessionCloseMsg.java | 1 - .../common/transport/TransportAdaptor.java | 11 - .../common/transport/TransportService.java | 4 + .../transport/adaptor/JsonConverter.java | 62 ++- .../session/DeviceAwareSessionContext.java | 9 +- .../transport/src/main/proto/transport.proto | 13 + .../transport/coap/CoapTransportResource.java | 1 - .../coap/session/CoapSessionCtx.java | 4 - .../transport/coap/session/CoapSessionId.java | 2 - .../http/session/HttpSessionCtx.java | 1 - .../transport/http/session/HttpSessionId.java | 2 - .../transport/mqtt/MqttTransportContext.java | 23 +- .../transport/mqtt/MqttTransportHandler.java | 76 ++- .../mqtt/adaptors/JsonMqttAdaptor.java | 141 ++---- .../mqtt/adaptors/MqttTransportAdaptor.java | 33 +- .../mqtt/session/DeviceSessionCtx.java | 23 +- .../mqtt/session/GatewayDeviceSessionCtx.java | 129 +++-- .../mqtt/session/GatewaySessionCtx.java | 450 ++++++++++-------- .../MqttDeviceAwareSessionContext.java | 7 +- .../transport/mqtt/session/MqttSessionId.java | 3 - .../mqtt/service/MqttTransportService.java | 47 +- 30 files changed, 635 insertions(+), 559 deletions(-) delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/SessionId.java diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index c590104665..1cfdcd202a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -411,29 +411,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } -// private void processRpcResponses(ActorContext context, DeviceToDeviceActorMsg msg) { -// SessionId sessionId = msg.getSessionId(); -// FromDeviceMsg inMsg = msg.getPayload(); -// if (inMsg.getMsgType() == SessionMsgType.TO_DEVICE_RPC_RESPONSE) { -// logger.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); -// ToDeviceRpcResponseMsg responseMsg = (ToDeviceRpcResponseMsg) inMsg; -// ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); -// boolean success = requestMd != null; -// if (success) { -// systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), -// requestMd.getMsg().getServerAddress(), responseMsg.getData(), null)); -// } else { -// logger.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); -// } -// if (msg.getSessionType() == SessionType.SYNC) { -// BasicCommandAckResponse response = success -// ? BasicCommandAckResponse.onSuccess(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId()) -// : BasicCommandAckResponse.onError(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId(), new TimeoutException()); -// sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, msg.getSessionId()), msg.getServerAddress()); -// } -// } -// } - void processClusterEventMsg(ClusterEventMsg msg) { // if (!msg.isAdded()) { // logger.debug("[{}] Clearing attributes/rpc subscription for server [{}]", deviceId, msg.getServerAddress()); diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java b/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java index d015fe01de..d89848b8a8 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java @@ -16,7 +16,6 @@ package org.thingsboard.server.actors.shared; import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java index 6e9b2efa0a..5bcd8ac888 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -24,11 +24,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; @@ -40,8 +45,10 @@ import org.thingsboard.server.kafka.TBKafkaProducerTemplate; import org.thingsboard.server.kafka.TbKafkaResponseTemplate; import org.thingsboard.server.kafka.TbKafkaSettings; import org.thingsboard.server.service.cluster.discovery.DiscoveryService; +import org.thingsboard.server.service.state.DeviceStateService; import javax.annotation.PostConstruct; +import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -77,9 +84,15 @@ public class RemoteTransportApiService implements TransportApiService { @Autowired private DeviceService deviceService; + @Autowired + private RelationService relationService; + @Autowired private DeviceCredentialsService deviceCredentialsService; + @Autowired + private DeviceStateService deviceStateService; + private ExecutorService transportCallbackExecutor; private TbKafkaResponseTemplate transportApiTemplate; @@ -123,6 +136,8 @@ public class RemoteTransportApiService implements TransportApiService { } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); return validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { + return handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } return getEmptyTransportApiResponseFuture(); } @@ -137,6 +152,32 @@ public class RemoteTransportApiService implements TransportApiService { } } + private ListenableFuture handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { + DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); + ListenableFuture gatewayFuture = deviceService.findDeviceByIdAsync(gatewayId); + return Futures.transform(gatewayFuture, gateway -> { + Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), gateway.getName()); + if (device == null) { + device = new Device(); + device.setTenantId(gateway.getTenantId()); + device.setName(requestMsg.getDeviceName()); + device.setType(requestMsg.getDeviceType()); + device.setCustomerId(gateway.getCustomerId()); + device = deviceService.saveDevice(device); + relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); + deviceStateService.onDeviceAdded(device); + } + try { + return TransportApiResponseMsg.newBuilder() + .setGetOrCreateDeviceResponseMsg(GetOrCreateDeviceFromGatewayResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to lookup device by gateway id and name", gatewayId, requestMsg.getDeviceName(), e); + throw new RuntimeException(e); + } + }, transportCallbackExecutor); + } + + private ListenableFuture getDeviceInfo(DeviceId deviceId) { return Futures.transform(deviceService.findDeviceByIdAsync(deviceId), device -> { if (device == null) { @@ -144,17 +185,8 @@ public class RemoteTransportApiService implements TransportApiService { return getEmptyTransportApiResponse(); } try { - DeviceInfoProto deviceInfoProto = DeviceInfoProto.newBuilder() - .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) - .setTenantIdLSB(device.getTenantId().getId().getLeastSignificantBits()) - .setDeviceIdMSB(device.getId().getId().getMostSignificantBits()) - .setDeviceIdLSB(device.getId().getId().getLeastSignificantBits()) - .setDeviceName(device.getName()) - .setDeviceType(device.getType()) - .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())) - .build(); return TransportApiResponseMsg.newBuilder() - .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.newBuilder().setDeviceInfo(deviceInfoProto).build()).build(); + .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); } catch (JsonProcessingException e) { log.warn("[{}] Failed to lookup device by id", deviceId, e); return getEmptyTransportApiResponse(); @@ -162,6 +194,18 @@ public class RemoteTransportApiService implements TransportApiService { }); } + private DeviceInfoProto getDeviceInfoProto(Device device) throws JsonProcessingException { + return DeviceInfoProto.newBuilder() + .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(device.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(device.getId().getId().getMostSignificantBits()) + .setDeviceIdLSB(device.getId().getId().getLeastSignificantBits()) + .setDeviceName(device.getName()) + .setDeviceType(device.getType()) + .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())) + .build(); + } + private ListenableFuture getEmptyTransportApiResponseFuture() { return Futures.immediateFuture(getEmptyTransportApiResponse()); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/SessionId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/SessionId.java deleted file mode 100644 index 1b4772768f..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/SessionId.java +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.data.id; - -import java.io.Serializable; - -public interface SessionId extends Serializable { - - String toUidStr(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java index db6ff6f632..1c258239d4 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java @@ -15,8 +15,6 @@ */ package org.thingsboard.server.common.msg.aware; -import org.thingsboard.server.common.data.id.SessionId; - public interface SessionAwareMsg { SessionId getSessionId(); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java index 28e319e525..d8af5a953c 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java @@ -16,11 +16,8 @@ package org.thingsboard.server.common.msg.core; import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; import org.thingsboard.server.common.msg.session.FromDeviceMsg; import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; /** * @author Andrew Shvayka diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java index 855cce53d7..84c5052662 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java @@ -15,8 +15,6 @@ */ package org.thingsboard.server.common.msg.session; -import org.thingsboard.server.common.data.id.SessionId; - public class BasicSessionMsg implements SessionMsg { private final SessionContext ctx; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java index 0704e6e1bf..4a81fd4578 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java @@ -18,7 +18,6 @@ package org.thingsboard.server.common.msg.session; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java index 2f2d88047e..6607295ee3 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,21 +15,11 @@ */ package org.thingsboard.server.common.msg.session; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.session.ex.SessionException; +import java.util.UUID; public interface SessionContext { - SessionId getSessionId(); - - SessionType getSessionType(); - - void onMsg(SessionActorToAdaptorMsg msg) throws SessionException; - - void onMsg(SessionCtrlMsg msg) throws SessionException; - - boolean isClosed(); - - long getTimeout(); + UUID getSessionId(); + int nextMsgId(); } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java index 49fad13236..22a13d03b5 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.msg.session.ctrl; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.session.SessionCtrlMsg; diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java index 86067d536e..b59d49362b 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java @@ -15,19 +15,8 @@ */ package org.thingsboard.server.common.transport; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.gen.transport.TransportProtos; - -import java.util.Optional; public interface TransportAdaptor { - AdaptorToSessionActorMsg convertToActorMsg(C ctx, SessionMsgType type, T inbound) throws AdaptorException; - - Optional convertToAdaptorMsg(C ctx, SessionActorToAdaptorMsg msg) throws AdaptorException; - } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java index cd3041939b..c9a04c4daf 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; @@ -39,6 +40,9 @@ public interface TransportService { void process(ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback); + void process(TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg msg, + TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback); void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback); diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index db7ee884dc..e02bb4bf34 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -52,7 +52,8 @@ import java.util.stream.Collectors; public class JsonConverter { private static final Gson GSON = new Gson(); - public static final String CAN_T_PARSE_VALUE = "Can't parse value: "; + private static final String CAN_T_PARSE_VALUE = "Can't parse value: "; + private static final String DEVICE_PROPERTY = "device"; public static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject) throws JsonSyntaxException { return convertToTelemetry(jsonObject, BasicRequest.DEFAULT_REQUEST_ID); @@ -318,6 +319,57 @@ public class JsonConverter { return result; } + public static JsonObject getJsonObjectForGateway(TransportProtos.GetAttributeResponseMsg responseMsg) { + JsonObject result = new JsonObject(); + result.addProperty("id", responseMsg.getRequestId()); + if (responseMsg.getClientAttributeListCount() > 0) { + addValues(result, responseMsg.getClientAttributeListList()); + } + if (responseMsg.getSharedAttributeListCount() > 0) { + addValues(result, responseMsg.getSharedAttributeListList()); + } + return result; + } + + public static JsonObject getJsonObjectForGateway(String deviceName, AttributeUpdateNotificationMsg notificationMsg) { + JsonObject result = new JsonObject(); + result.addProperty(DEVICE_PROPERTY, deviceName); + result.add("data", toJson(notificationMsg)); + return result; + } + + private static void addValues(JsonObject result, List kvList) { + if (kvList.size() == 1) { + addValueToJson(result, "value", kvList.get(0).getKv()); + } else { + JsonObject values; + if (result.has("values")) { + values = result.get("values").getAsJsonObject(); + } else { + values = new JsonObject(); + result.add("values", values); + } + kvList.forEach(value -> addValueToJson(values, value.getKv().getKey(), value.getKv())); + } + } + + private static void addValueToJson(JsonObject json, String name, TransportProtos.KeyValueProto entry) { + switch (entry.getType()) { + case BOOLEAN_V: + json.addProperty(name, entry.getBoolV()); + break; + case STRING_V: + json.addProperty(name, entry.getStringV()); + break; + case DOUBLE_V: + json.addProperty(name, entry.getDoubleV()); + break; + case LONG_V: + json.addProperty(name, entry.getLongV()); + break; + } + } + private static Consumer addToObject(JsonArray result) { return key -> result.add(key.getAttributeKey()); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index b46cfb81df..93921d347f 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -31,16 +31,9 @@ public abstract class DeviceAwareSessionContext implements SessionContext { @Getter private volatile DeviceId deviceId; + @Getter private volatile DeviceInfoProto deviceInfo; - public long getDeviceIdMSB() { - return deviceInfo.getDeviceIdMSB(); - } - - public long getDeviceIdLSB() { - return deviceInfo.getDeviceIdLSB(); - } - public DeviceId getDeviceId() { return deviceId; } diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/src/main/proto/transport.proto index bcf5f529b9..3099c5c7f6 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/src/main/proto/transport.proto @@ -125,6 +125,17 @@ message ValidateDeviceCredentialsResponseMsg { DeviceInfoProto deviceInfo = 1; } +message GetOrCreateDeviceFromGatewayRequestMsg { + int64 gatewayIdMSB = 1; + int64 gatewayIdLSB = 2; + string deviceName = 3; + string deviceType = 4; +} + +message GetOrCreateDeviceFromGatewayResponseMsg { + DeviceInfoProto deviceInfo = 1; +} + message SessionCloseNotificationProto { string message = 1; } @@ -196,8 +207,10 @@ message ToTransportMsg { message TransportApiRequestMsg { ValidateDeviceTokenRequestMsg validateTokenRequestMsg = 1; ValidateDeviceX509CertRequestMsg validateX509CertRequestMsg = 2; + GetOrCreateDeviceFromGatewayRequestMsg getOrCreateDeviceRequestMsg = 3; } message TransportApiResponseMsg { ValidateDeviceCredentialsResponseMsg validateTokenResponseMsg = 1; + GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 2; } \ No newline at end of file diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java index 0853124acb..88fb69ecd0 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -27,7 +27,6 @@ import org.eclipse.californium.core.network.Exchange; import org.eclipse.californium.core.network.ExchangeObserver; import org.eclipse.californium.core.server.resources.CoapExchange; import org.eclipse.californium.core.server.resources.Resource; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; import org.thingsboard.server.common.data.security.DeviceTokenCredentials; import org.thingsboard.server.common.msg.session.*; diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java index c43a4ded7a..efabc4eac2 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java @@ -20,20 +20,16 @@ import org.eclipse.californium.core.coap.CoAP.ResponseCode; import org.eclipse.californium.core.coap.Request; import org.eclipse.californium.core.coap.Response; import org.eclipse.californium.core.server.resources.CoapExchange; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; import org.thingsboard.server.common.msg.session.SessionCtrlMsg; import org.thingsboard.server.common.msg.session.SessionType; import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.msg.session.ex.SessionAuthException; import org.thingsboard.server.common.msg.session.ex.SessionException; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.concurrent.atomic.AtomicInteger; diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java index 888fb2fc30..2bfcf1d2c6 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java @@ -15,8 +15,6 @@ */ package org.thingsboard.server.transport.coap.session; -import org.thingsboard.server.common.data.id.SessionId; - public final class CoapSessionId implements SessionId { private final String clientAddress; diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java index 5269285d25..62999cc64b 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java @@ -20,7 +20,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.core.*; import org.thingsboard.server.common.msg.session.*; import org.thingsboard.server.common.msg.session.ex.SessionException; diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java index 3211765334..a5426ca051 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java @@ -15,8 +15,6 @@ */ package org.thingsboard.server.transport.http.session; -import org.thingsboard.server.common.data.id.SessionId; - import java.util.UUID; /** diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index b500a31e69..2c8ff59f1f 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -18,6 +18,7 @@ package org.thingsboard.server.transport.mqtt; import com.fasterxml.jackson.databind.ObjectMapper; import io.netty.handler.ssl.SslHandler; import lombok.Data; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -30,8 +31,11 @@ import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaServi import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * Created by ashvayka on 04.10.18. @@ -64,6 +68,9 @@ public class MqttTransportContext { private SslHandler sslHandler; + @Getter + private ExecutorService executor; + @PostConstruct public void init() { if (StringUtils.isEmpty(nodeId)) { @@ -74,6 +81,14 @@ public class MqttTransportContext { } } log.info("Current NodeId: {}", nodeId); + executor = Executors.newCachedThreadPool(); + } + + @PreDestroy + public void stop() { + if (executor != null) { + executor.shutdownNow(); + } } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 3099b24b87..21d33354cd 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -108,7 +108,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement this.quotaService = context.getQuotaService(); this.sslHandler = context.getSslHandler(); this.mqttQoSMap = new ConcurrentHashMap<>(); - this.deviceSessionCtx = new DeviceSessionCtx(mqttQoSMap); + this.deviceSessionCtx = new DeviceSessionCtx(sessionId, mqttQoSMap); } @Override @@ -176,42 +176,40 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement if (topicName.startsWith(MqttTopics.BASE_GATEWAY_API_TOPIC)) { if (gatewaySessionCtx != null) { - gatewaySessionCtx.setChannel(ctx); -// handleMqttPublishMsg(topicName, msgId, mqttMsg); + handleGatewayPublishMsg(topicName, msgId, mqttMsg); } } else { processDevicePublish(ctx, mqttMsg, topicName, msgId); } } - // -// private void handleMqttPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { -// try { -// switch (topicName) { -// case GATEWAY_TELEMETRY_TOPIC: -// gatewaySessionCtx.onDeviceTelemetry(mqttMsg); -// break; -// case GATEWAY_ATTRIBUTES_TOPIC: -// gatewaySessionCtx.onDeviceAttributes(mqttMsg); -// break; -// case GATEWAY_ATTRIBUTES_REQUEST_TOPIC: -// gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); -// break; -// case GATEWAY_RPC_TOPIC: -// gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); -// break; -// case GATEWAY_CONNECT_TOPIC: -// gatewaySessionCtx.onDeviceConnect(mqttMsg); -// break; -// case GATEWAY_DISCONNECT_TOPIC: -// gatewaySessionCtx.onDeviceDisconnect(mqttMsg); -// break; -// } -// } catch (RuntimeException | AdaptorException e) { -// log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); -// } -// } -// + private void handleGatewayPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { + try { + switch (topicName) { + case MqttTopics.GATEWAY_TELEMETRY_TOPIC: + gatewaySessionCtx.onDeviceTelemetry(mqttMsg); + break; + case MqttTopics.GATEWAY_ATTRIBUTES_TOPIC: + gatewaySessionCtx.onDeviceAttributes(mqttMsg); + break; + case MqttTopics.GATEWAY_ATTRIBUTES_REQUEST_TOPIC: + gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); + break; + case MqttTopics.GATEWAY_RPC_TOPIC: + gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); + break; + case MqttTopics.GATEWAY_CONNECT_TOPIC: + gatewaySessionCtx.onDeviceConnect(mqttMsg); + break; + case MqttTopics.GATEWAY_DISCONNECT_TOPIC: + gatewaySessionCtx.onDeviceDisconnect(mqttMsg); + break; + } + } catch (RuntimeException | AdaptorException e) { + log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); + } + } + private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { try { if (topicName.equals(MqttTopics.DEVICE_TELEMETRY_TOPIC)) { @@ -223,10 +221,10 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } else if (topicName.startsWith(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { TransportProtos.GetAttributeRequestMsg getAttributeMsg = adaptor.convertToGetAttributes(deviceSessionCtx, mqttMsg); transportService.process(sessionInfo, getAttributeMsg, getPubAckCallback(ctx, msgId, getAttributeMsg)); - } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC)){ + } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC)) { TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = adaptor.convertToDeviceRpcResponse(deviceSessionCtx, mqttMsg); transportService.process(sessionInfo, rpcResponseMsg, getPubAckCallback(ctx, msgId, rpcResponseMsg)); - } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC)){ + } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC)) { TransportProtos.ToServerRpcRequestMsg rpcRequestMsg = adaptor.convertToServerRpcRequest(deviceSessionCtx, mqttMsg); transportService.process(sessionInfo, rpcRequestMsg, getPubAckCallback(ctx, msgId, rpcRequestMsg)); } @@ -469,7 +467,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement if (infoNode != null) { JsonNode gatewayNode = infoNode.get("gateway"); if (gatewayNode != null && gatewayNode.asBoolean()) { - gatewaySessionCtx = new GatewaySessionCtx(deviceSessionCtx); + gatewaySessionCtx = new GatewaySessionCtx(context, deviceSessionCtx, sessionId); } } } catch (IOException e) { @@ -477,7 +475,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - private SessionEventMsg getSessionEventMsg(SessionEvent event) { + public static SessionEventMsg getSessionEventMsg(SessionEvent event) { return SessionEventMsg.newBuilder() .setSessionType(TransportProtos.SessionType.ASYNC) .setEvent(event).build(); diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index a2be169656..85dc813428 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt.adaptors; import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import io.netty.buffer.ByteBuf; @@ -26,7 +27,8 @@ import io.netty.handler.codec.mqtt.*; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.id.SessionId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.msg.core.*; import org.thingsboard.server.common.msg.kv.AttributesKVMsg; import org.thingsboard.server.common.msg.session.*; @@ -36,12 +38,15 @@ import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.MqttTopics; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; +import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionContext; import java.nio.charset.Charset; import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; /** * @author Andrew Shvayka @@ -55,7 +60,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); @Override - public TransportProtos.PostTelemetryMsg convertToPostTelemetry(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + public TransportProtos.PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String payload = validatePayload(ctx.getSessionId(), inbound.payload()); try { return JsonConverter.convertToTelemetryProto(new JsonParser().parse(payload)); @@ -65,7 +70,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } @Override - public TransportProtos.PostAttributeMsg convertToPostAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + public TransportProtos.PostAttributeMsg convertToPostAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String payload = validatePayload(ctx.getSessionId(), inbound.payload()); try { return JsonConverter.convertToAttributesProto(new JsonParser().parse(payload)); @@ -75,7 +80,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } @Override - public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String topicName = inbound.variableHeader().topicName(); try { TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); @@ -98,7 +103,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } @Override - public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String topicName = inbound.variableHeader().topicName(); try { Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC.length())); @@ -111,7 +116,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } @Override - public TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + public TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String topicName = inbound.variableHeader().topicName(); String payload = validatePayload(ctx.getSessionId(), inbound.payload()); try { @@ -123,7 +128,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } @Override - public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { if (!StringUtils.isEmpty(responseMsg.getError())) { throw new AdaptorException(responseMsg.getError()); } else { @@ -138,99 +143,37 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } @Override - public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException { - return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_ATTRIBUTES_TOPIC, JsonConverter.toJson(notificationMsg))); + public Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { + if (!StringUtils.isEmpty(responseMsg.getError())) { + throw new AdaptorException(responseMsg.getError()); + } else { + JsonObject result = JsonConverter.getJsonObjectForGateway(responseMsg); + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.GATEWAY_ATTRIBUTES_RESPONSE_TOPIC, result)); + } } @Override - public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException { - return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), JsonConverter.toJson(rpcRequest, false))); + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_ATTRIBUTES_TOPIC, JsonConverter.toJson(notificationMsg))); } @Override - public Optional convertToPublish(DeviceSessionCtx ctx, TransportProtos.ToServerRpcResponseMsg rpcResponse) { - return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), JsonConverter.toJson(rpcResponse))); + public Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException { + JsonObject result = JsonConverter.getJsonObjectForGateway(deviceName, notificationMsg); + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.GATEWAY_ATTRIBUTES_TOPIC, result)); } @Override - public AdaptorToSessionActorMsg convertToActorMsg(DeviceSessionCtx ctx, SessionMsgType type, MqttMessage inbound) throws AdaptorException { - FromDeviceMsg msg; - switch (type) { - case POST_TELEMETRY_REQUEST: - msg = convertToTelemetryUploadRequest(ctx, (MqttPublishMessage) inbound); - break; - case POST_ATTRIBUTES_REQUEST: - msg = convertToUpdateAttributesRequest(ctx, (MqttPublishMessage) inbound); - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesSubscribeMsg(); - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesUnsubscribeMsg(); - break; - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcSubscribeMsg(); - break; - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcUnsubscribeMsg(); - break; - case GET_ATTRIBUTES_REQUEST: - msg = convertToGetAttributesRequest(ctx, (MqttPublishMessage) inbound); - break; - case TO_DEVICE_RPC_RESPONSE: - msg = convertToRpcCommandResponse(ctx, (MqttPublishMessage) inbound); - break; - case TO_SERVER_RPC_REQUEST: - msg = null;//convertToServerRpcRequest(ctx, (MqttPublishMessage) inbound); - break; - default: - log.warn("[{}] Unsupported msg type: {}!", ctx.getSessionId(), type); - throw new AdaptorException(new IllegalArgumentException("Unsupported msg type: " + type + "!")); - } - return new BasicAdaptorToSessionActorMsg(ctx, msg); + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), JsonConverter.toJson(rpcRequest, false))); } @Override - public Optional convertToAdaptorMsg(DeviceSessionCtx ctx, SessionActorToAdaptorMsg sessionMsg) throws AdaptorException { - MqttMessage result = null; - ToDeviceMsg msg = sessionMsg.getMsg(); - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - case GET_ATTRIBUTES_RESPONSE: - ResponseMsg responseMsg = (ResponseMsg) msg; - Optional responseError = responseMsg.getError(); - if (responseMsg.isSuccess()) { - result = convertResponseMsg(ctx, msg, responseMsg, responseError); - } else { - if (responseError.isPresent()) { - throw new AdaptorException(responseError.get()); - } - } - break; - case ATTRIBUTES_UPDATE_NOTIFICATION: - AttributesUpdateNotification notification = (AttributesUpdateNotification) msg; - result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_ATTRIBUTES_TOPIC, notification.getData(), false); - break; - case TO_DEVICE_RPC_REQUEST: - ToDeviceRpcRequestMsg rpcRequest = (ToDeviceRpcRequestMsg) msg; - result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), rpcRequest); - break; - case TO_SERVER_RPC_RESPONSE: -// ToServerRpcResponseMsg rpcResponse = (ToServerRpcResponseMsg) msg; -// result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), -// rpcResponse); - break; - case RULE_ENGINE_ERROR: - RuleEngineErrorMsg errorMsg = (RuleEngineErrorMsg) msg; - result = createMqttPublishMsg(ctx, "errors", JsonConverter.toErrorJson(errorMsg.getErrorMsg())); - break; - default: - break; - } - return Optional.ofNullable(result); + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToServerRpcResponseMsg rpcResponse) { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), JsonConverter.toJson(rpcResponse))); } - private MqttMessage convertResponseMsg(DeviceSessionCtx ctx, ToDeviceMsg msg, + private MqttMessage convertResponseMsg(MqttDeviceAwareSessionContext ctx, ToDeviceMsg msg, ResponseMsg responseMsg, Optional responseError) throws AdaptorException { MqttMessage result = null; SessionMsgType requestMsgType = responseMsg.getRequestMsgType(); @@ -255,19 +198,19 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { return result; } - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, AttributesKVMsg msg, boolean asMap) { + private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, AttributesKVMsg msg, boolean asMap) { return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg, asMap)); } - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, ToDeviceRpcRequestMsg msg) { + private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, ToDeviceRpcRequestMsg msg) { return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg, false)); } - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, TransportProtos.ToServerRpcResponseMsg msg) { + private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, TransportProtos.ToServerRpcResponseMsg msg) { return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg)); } - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, JsonElement json) { + private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, JsonElement json) { MqttFixedHeader mqttFixedHeader = new MqttFixedHeader(MqttMessageType.PUBLISH, false, ctx.getQoSForTopic(topic), false, 0); MqttPublishVariableHeader header = new MqttPublishVariableHeader(topic, ctx.nextMsgId()); @@ -276,7 +219,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { return new MqttPublishMessage(mqttFixedHeader, header, payload); } - private FromDeviceMsg convertToGetAttributesRequest(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + private FromDeviceMsg convertToGetAttributesRequest(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String topicName = inbound.variableHeader().topicName(); try { Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX.length())); @@ -295,7 +238,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } } - private FromDeviceMsg convertToRpcCommandResponse(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { + private FromDeviceMsg convertToRpcCommandResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { String topicName = inbound.variableHeader().topicName(); try { Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC.length())); @@ -336,7 +279,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } } - public static JsonElement validateJsonPayload(SessionId sessionId, ByteBuf payloadData) throws AdaptorException { + public static JsonElement validateJsonPayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException { String payload = validatePayload(sessionId, payloadData); try { return new JsonParser().parse(payload); @@ -345,11 +288,11 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } } - public static String validatePayload(SessionId sessionId, ByteBuf payloadData) throws AdaptorException { + public static String validatePayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException { try { String payload = payloadData.toString(UTF8); if (payload == null) { - log.warn("[{}] Payload is empty!", sessionId.toUidStr()); + log.warn("[{}] Payload is empty!", sessionId); throw new AdaptorException(new IllegalArgumentException("Payload is empty!")); } return payload; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index 48173f968a..37b5395b69 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -17,41 +17,44 @@ package org.thingsboard.server.transport.mqtt.adaptors; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; -import org.thingsboard.server.common.transport.TransportAdaptor; import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; -import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; +import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionContext; import java.util.Optional; /** * @author Andrew Shvayka */ -public interface MqttTransportAdaptor extends TransportAdaptor { +public interface MqttTransportAdaptor { + + PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException; + + PostAttributeMsg convertToPostAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException; - PostTelemetryMsg convertToPostTelemetry(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + GetAttributeRequestMsg convertToGetAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException; - PostAttributeMsg convertToPostAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + ToDeviceRpcResponseMsg convertToDeviceRpcResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage mqttMsg) throws AdaptorException; - GetAttributeRequestMsg convertToGetAttributes(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException; + ToServerRpcRequestMsg convertToServerRpcRequest(MqttDeviceAwareSessionContext ctx, MqttPublishMessage mqttMsg) throws AdaptorException; - ToDeviceRpcResponseMsg convertToDeviceRpcResponse(DeviceSessionCtx ctx, MqttPublishMessage mqttMsg) throws AdaptorException; + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException; - ToServerRpcRequestMsg convertToServerRpcRequest(DeviceSessionCtx ctx, MqttPublishMessage mqttMsg) throws AdaptorException; + Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException; - Optional convertToPublish(DeviceSessionCtx ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException; + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; - Optional convertToPublish(DeviceSessionCtx ctx, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; - Optional convertToPublish(DeviceSessionCtx ctx, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; - Optional convertToPublish(DeviceSessionCtx ctx, ToServerRpcResponseMsg rpcResponse); + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, ToServerRpcResponseMsg rpcResponse) throws AdaptorException; } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index 6bbb9fa6fd..6195fdd440 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -19,19 +19,14 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.mqtt.*; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; import org.thingsboard.server.common.msg.session.SessionCtrlMsg; import org.thingsboard.server.common.msg.session.SessionType; import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; -import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; -import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; @@ -41,14 +36,14 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { - private final MqttSessionId sessionId; + private final UUID sessionId; @Getter private ChannelHandlerContext channel; private AtomicInteger msgIdSeq = new AtomicInteger(0); - public DeviceSessionCtx(ConcurrentMap mqttQoSMap) { + public DeviceSessionCtx(UUID sessionId, ConcurrentMap mqttQoSMap) { super(null, null, mqttQoSMap); - this.sessionId = new MqttSessionId(); + this.sessionId = sessionId; } @Override @@ -94,7 +89,7 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { } @Override - public SessionId getSessionId() { + public UUID getSessionId() { return sessionId; } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java index 793836d793..387d9cf439 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,70 +16,80 @@ package org.thingsboard.server.transport.mqtt.session; import com.google.gson.Gson; -import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.sun.xml.internal.bind.v2.TODO; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.UnpooledByteBufAllocator; -import io.netty.handler.codec.mqtt.*; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.SessionId; +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttPublishMessage; +import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.core.*; +import org.thingsboard.server.common.msg.core.AttributesUpdateNotification; +import org.thingsboard.server.common.msg.core.GetAttributesResponse; +import org.thingsboard.server.common.msg.core.ResponseMsg; +import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.msg.session.ex.SessionException; +import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; +import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.common.msg.session.ToDeviceMsg; +import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; import org.thingsboard.server.transport.mqtt.MqttTopics; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import java.nio.charset.Charset; import java.util.List; -import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; /** * Created by ashvayka on 19.01.17. */ -public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext { +@Slf4j +public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext implements SessionMsgListener { private static final Gson GSON = new Gson(); private static final Charset UTF8 = Charset.forName("UTF-8"); private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); - public static final String DEVICE_PROPERTY = "device"; - private GatewaySessionCtx parent; - private final MqttSessionId sessionId; + private final GatewaySessionCtx parent; + private final UUID sessionId; + private final SessionInfoProto sessionInfo; private volatile boolean closed; private AtomicInteger msgIdSeq = new AtomicInteger(0); - public GatewayDeviceSessionCtx(GatewaySessionCtx parent, Device device, ConcurrentMap mqttQoSMap) { - super(parent.getProcessor(), parent.getAuthService(), device, mqttQoSMap); + public GatewayDeviceSessionCtx(GatewaySessionCtx parent, DeviceInfoProto deviceInfo, ConcurrentMap mqttQoSMap) { + super(mqttQoSMap); this.parent = parent; - this.sessionId = new MqttSessionId(); + this.sessionId = UUID.randomUUID(); + this.sessionInfo = SessionInfoProto.newBuilder() + .setNodeId(parent.getNodeId()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setDeviceIdMSB(deviceInfo.getDeviceIdMSB()) + .setDeviceIdLSB(deviceInfo.getDeviceIdLSB()) + .setTenantIdMSB(deviceInfo.getTenantIdMSB()) + .setTenantIdLSB(deviceInfo.getTenantIdLSB()) + .build(); + setDeviceInfo(deviceInfo); } @Override - public SessionId getSessionId() { + public UUID getSessionId() { return sessionId; } - @Override - public SessionType getSessionType() { - return SessionType.ASYNC; - } - - @Override - public void onMsg(SessionActorToAdaptorMsg sessionMsg) throws SessionException { - Optional message = getToDeviceMsg(sessionMsg); - message.ifPresent(parent::writeAndFlush); - } - private Optional getToDeviceMsg(SessionActorToAdaptorMsg sessionMsg) { ToDeviceMsg msg = sessionMsg.getMsg(); switch (msg.getSessionMsgType()) { @@ -113,25 +123,6 @@ public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext { return Optional.empty(); } - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - //Do nothing - } - - @Override - public boolean isClosed() { - return closed; - } - - public void setClosed(boolean closed) { - this.closed = closed; - } - - @Override - public long getTimeout() { - return 0; - } - private MqttMessage createMqttPublishMsg(String topic, GetAttributesResponse response) { JsonObject result = new JsonObject(); result.addProperty("id", response.getRequestId()); @@ -204,4 +195,40 @@ public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext { return new MqttPublishMessage(mqttFixedHeader, header, payload); } + SessionInfoProto getSessionInfo() { + return sessionInfo; + } + + @Override + public void onGetAttributesResponse(TransportProtos.GetAttributeResponseMsg response) { + try { + parent.getAdaptor().convertToGatewayPublish(this, response).ifPresent(parent::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } + } + + @Override + public void onAttributeUpdate(TransportProtos.AttributeUpdateNotificationMsg notification) { + try { + parent.getAdaptor().convertToGatewayPublish(this, getDeviceInfo().getDeviceName(), notification).ifPresent(parent::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } + } + + @Override + public void onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto sessionCloseNotification) { + parent.deregisterSession(getDeviceInfo().getDeviceName()); + } + + @Override + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg toDeviceRequest) { + + } + + @Override + public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg toServerResponse) { + TODO + } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java index fafbc4ec01..3788d57e54 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,6 +15,11 @@ */ package org.thingsboard.server.transport.mqtt.session; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; @@ -25,28 +30,28 @@ import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.transport.SessionMsgProcessor; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.transport.mqtt.MqttTransportContext; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; +import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; -import java.util.*; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.stream.Collectors; - -import static org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor.validateJsonPayload; /** * Created by ashvayka on 19.01.17. @@ -55,184 +60,235 @@ import static org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor.val public class GatewaySessionCtx { private static final String DEFAULT_DEVICE_TYPE = "default"; - public static final String CAN_T_PARSE_VALUE = "Can't parse value: "; - public static final String DEVICE_PROPERTY = "device"; -// private final Device gateway; -// private final SessionId gatewaySessionId; -// private final SessionMsgProcessor processor; -// private final DeviceService deviceService; -// private final DeviceAuthService authService; -// private final RelationService relationService; -// private final Map devices; -// private final ConcurrentMap mqttQoSMap; - private ChannelHandlerContext channel; - -// public GatewaySessionCtx(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, DeviceSessionCtx gatewaySessionCtx) { -// this.processor = processor; -// this.deviceService = deviceService; -// this.authService = authService; -// this.relationService = relationService; -// this.gateway = gatewaySessionCtx.getDevice(); -// this.gatewaySessionId = gatewaySessionCtx.getSessionId(); -// this.devices = new HashMap<>(); -// this.mqttQoSMap = gatewaySessionCtx.getMqttQoSMap(); -// } - - public GatewaySessionCtx(DeviceSessionCtx deviceSessionCtx) { + private static final String CAN_T_PARSE_VALUE = "Can't parse value: "; + private static final String DEVICE_PROPERTY = "device"; + + private final MqttTransportContext context; + private final TransportService transportService; + private final DeviceInfoProto gateway; + private final UUID sessionId; + private final Map devices; + private final ConcurrentMap mqttQoSMap; + private final ChannelHandlerContext channel; + public GatewaySessionCtx(MqttTransportContext context, DeviceSessionCtx deviceSessionCtx, UUID sessionId) { + this.context = context; + this.transportService = context.getTransportService(); + this.gateway = deviceSessionCtx.getDeviceInfo(); + this.sessionId = sessionId; + this.devices = new ConcurrentHashMap<>(); + this.mqttQoSMap = deviceSessionCtx.getMqttQoSMap(); + this.channel = deviceSessionCtx.getChannel(); } public void onDeviceConnect(MqttPublishMessage msg) throws AdaptorException { JsonElement json = getJson(msg); String deviceName = checkDeviceName(getDeviceName(json)); String deviceType = getDeviceType(json); - onDeviceConnect(deviceName, deviceType); - ack(msg); + Futures.addCallback(onDeviceConnect(deviceName, deviceType), new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx result) { + ack(msg); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process device connect command: {}", sessionId, deviceName, t); + + } + }, context.getExecutor()); } - private void onDeviceConnect(String deviceName, String deviceType) { -// if (!devices.containsKey(deviceName)) { -// Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), deviceName); -// if (device == null) { -// device = new Device(); -// device.setTenantId(gateway.getTenantId()); -// device.setName(deviceName); -// device.setType(deviceType); -// device.setCustomerId(gateway.getCustomerId()); -// device = deviceService.saveDevice(device); -// relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); -// processor.onDeviceAdded(device); -// } -// GatewayDeviceSessionCtx ctx = new GatewayDeviceSessionCtx(this, device, mqttQoSMap); -// devices.put(deviceName, ctx); -// log.debug("[{}] Added device [{}] to the gateway session", gatewaySessionId, deviceName); -// processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new AttributesSubscribeMsg()))); -// processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new RpcSubscribeMsg()))); -// } + private ListenableFuture onDeviceConnect(String deviceName, String deviceType) { + SettableFuture future = SettableFuture.create(); + GatewayDeviceSessionCtx result = devices.get(deviceName); + if (result == null) { + transportService.process(GetOrCreateDeviceFromGatewayRequestMsg.newBuilder() + .setDeviceName(deviceName) + .setDeviceType(deviceType) + .setGatewayIdMSB(gateway.getDeviceIdMSB()) + .setGatewayIdLSB(gateway.getDeviceIdLSB()).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(GetOrCreateDeviceFromGatewayResponseMsg msg) { + GatewayDeviceSessionCtx deviceSessionCtx = new GatewayDeviceSessionCtx(GatewaySessionCtx.this, msg.getDeviceInfo(), mqttQoSMap); + if (devices.putIfAbsent(deviceName, deviceSessionCtx) == null) { + SessionInfoProto deviceSessionInfo = deviceSessionCtx.getSessionInfo(); + transportService.process(deviceSessionInfo, MqttTransportHandler.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); + transportService.registerSession(deviceSessionInfo, deviceSessionCtx); + } + future.set(devices.get(deviceName)); + } + + @Override + public void onError(Throwable e) { + log.warn("[{}] Failed to process device connect command: {}", sessionId, deviceName, e); + future.setException(e); + } + }); + } else { + future.set(result); + } + return future; } public void onDeviceDisconnect(MqttPublishMessage msg) throws AdaptorException { -// String deviceName = checkDeviceName(getDeviceName(getJson(msg))); -// GatewayDeviceSessionCtx deviceSessionCtx = devices.remove(deviceName); -// if (deviceSessionCtx != null) { -// processor.process(SessionCloseMsg.onDisconnect(deviceSessionCtx.getSessionId())); -// deviceSessionCtx.setClosed(true); -// log.debug("[{}] Removed device [{}] from the gateway session", gatewaySessionId, deviceName); -// } else { -// log.debug("[{}] Device [{}] was already removed from the gateway session", gatewaySessionId, deviceName); -// } -// ack(msg); + String deviceName = checkDeviceName(getDeviceName(getJson(msg))); + deregisterSession(deviceName); + ack(msg); + } + + void deregisterSession(String deviceName) { + GatewayDeviceSessionCtx deviceSessionCtx = devices.remove(deviceName); + if (deviceSessionCtx != null) { + deregisterSession(deviceName, deviceSessionCtx); + } else { + log.debug("[{}] Device [{}] was already removed from the gateway session", sessionId, deviceName); + } } public void onGatewayDisconnect() { -// devices.forEach((k, v) -> { -// processor.process(SessionCloseMsg.onDisconnect(v.getSessionId())); -// }); + devices.forEach(this::deregisterSession); } public void onDeviceTelemetry(MqttPublishMessage mqttMsg) throws AdaptorException { -// JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); -// int requestId = mqttMsg.variableHeader().messageId(); -// if (json.isJsonObject()) { -// JsonObject jsonObj = json.getAsJsonObject(); -// for (Map.Entry deviceEntry : jsonObj.entrySet()) { -// String deviceName = checkDeviceConnected(deviceEntry.getKey()); -// if (!deviceEntry.getValue().isJsonArray()) { -// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); -// } -// BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); -// JsonArray deviceData = deviceEntry.getValue().getAsJsonArray(); -// for (JsonElement element : deviceData) { -// JsonConverter.parseWithTs(request, element.getAsJsonObject()); -// } -// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), -// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); -// } -// } else { -// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); -// } - } + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + int msgId = mqttMsg.variableHeader().packetId(); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + for (Map.Entry deviceEntry : jsonObj.entrySet()) { + String deviceName = deviceEntry.getKey(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + if (!deviceEntry.getValue().isJsonArray()) { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + TransportProtos.PostTelemetryMsg postTelemetryMsg = JsonConverter.convertToTelemetryProto(deviceEntry.getValue().getAsJsonArray()); + transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getPubAckCallback(channel, deviceName, msgId, postTelemetryMsg)); + } - public void onDeviceRpcResponse(MqttPublishMessage mqttMsg) throws AdaptorException { -// JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); -// if (json.isJsonObject()) { -// JsonObject jsonObj = json.getAsJsonObject(); -// String deviceName = checkDeviceConnected(jsonObj.get(DEVICE_PROPERTY).getAsString()); -// Integer requestId = jsonObj.get("id").getAsInt(); -// String data = jsonObj.get("data").toString(); -// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), -// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new ToDeviceRpcResponseMsg(requestId, data)))); -// ack(mqttMsg); -// } else { -// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); -// } + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } } public void onDeviceAttributes(MqttPublishMessage mqttMsg) throws AdaptorException { -// JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); -// int requestId = mqttMsg.variableHeader().messageId(); -// if (json.isJsonObject()) { -// JsonObject jsonObj = json.getAsJsonObject(); -// for (Map.Entry deviceEntry : jsonObj.entrySet()) { -// String deviceName = checkDeviceConnected(deviceEntry.getKey()); -// if (!deviceEntry.getValue().isJsonObject()) { -// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); -// } -// long ts = System.currentTimeMillis(); -// BasicAttributesUpdateRequest request = new BasicAttributesUpdateRequest(requestId); -// JsonObject deviceData = deviceEntry.getValue().getAsJsonObject(); -// request.add(JsonConverter.parseValues(deviceData).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); -// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), -// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); -// } -// } else { -// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); -// } + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + int msgId = mqttMsg.variableHeader().packetId(); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + for (Map.Entry deviceEntry : jsonObj.entrySet()) { + String deviceName = deviceEntry.getKey(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + if (!deviceEntry.getValue().isJsonObject()) { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(deviceEntry.getValue().getAsJsonObject()); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + } + + public void onDeviceRpcResponse(MqttPublishMessage mqttMsg) throws AdaptorException { + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + int msgId = mqttMsg.variableHeader().packetId(); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + Integer requestId = jsonObj.get("id").getAsInt(); + String data = jsonObj.get("data").toString(); + TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() + .setRequestId(requestId).setPayload(data).build(); + transportService.process(deviceCtx.getSessionInfo(), rpcResponseMsg, getPubAckCallback(channel, deviceName, msgId, rpcResponseMsg)); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } } public void onDeviceAttributesRequest(MqttPublishMessage msg) throws AdaptorException { -// JsonElement json = validateJsonPayload(gatewaySessionId, msg.payload()); -// if (json.isJsonObject()) { -// JsonObject jsonObj = json.getAsJsonObject(); -// int requestId = jsonObj.get("id").getAsInt(); -// String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); -// boolean clientScope = jsonObj.get("client").getAsBoolean(); -// Set keys; -// if (jsonObj.has("key")) { -// keys = Collections.singleton(jsonObj.get("key").getAsString()); -// } else { -// JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); -// keys = new HashSet<>(); -// for (JsonElement keyObj : keysArray) { -// keys.add(keyObj.getAsString()); -// } -// } -// -// BasicGetAttributesRequest request; -// if (clientScope) { -// request = new BasicGetAttributesRequest(requestId, keys, null); -// } else { -// request = new BasicGetAttributesRequest(requestId, null, keys); -// } -// GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); -// processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), -// new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); -// ack(msg); -// } else { -// throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); -// } + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, msg.payload()); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + int requestId = jsonObj.get("id").getAsInt(); + String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); + boolean clientScope = jsonObj.get("client").getAsBoolean(); + Set keys; + if (jsonObj.has("key")) { + keys = Collections.singleton(jsonObj.get("key").getAsString()); + } else { + JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); + keys = new HashSet<>(); + for (JsonElement keyObj : keysArray) { + keys.add(keyObj.getAsString()); + } + } + TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); + result.setRequestId(requestId); + + if (clientScope) { + result.addAllClientAttributeNames(keys); + } else { + result.addAllSharedAttributeNames(keys); + } + int msgId = msg.variableHeader().packetId(); + TransportProtos.GetAttributeRequestMsg requestMsg = result.build(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + transportService.process(deviceCtx.getSessionInfo(), requestMsg, getPubAckCallback(channel, deviceName, msgId, requestMsg)); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + ack(msg); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } } - private String checkDeviceConnected(String deviceName) { -// if (!devices.containsKey(deviceName)) { -// log.debug("[{}] Missing device [{}] for the gateway session", gatewaySessionId, deviceName); -// onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); -// } -// return deviceName; - return null; + private ListenableFuture checkDeviceConnected(String deviceName) { + GatewayDeviceSessionCtx ctx = devices.get(deviceName); + if (ctx == null) { + log.debug("[{}] Missing device [{}] for the gateway session", sessionId, deviceName); + return onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); + } else { + return Futures.immediateFuture(ctx); + } } private String checkDeviceName(String deviceName) { @@ -253,32 +309,52 @@ public class GatewaySessionCtx { } private JsonElement getJson(MqttPublishMessage mqttMsg) throws AdaptorException { -// return JsonMqttAdaptor.validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - return null; + return JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + } + + private void ack(MqttPublishMessage msg) { + if (msg.variableHeader().packetId() > 0) { + writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msg.variableHeader().packetId())); + } } - protected SessionMsgProcessor getProcessor() { -// return processor; - return null; + void writeAndFlush(MqttMessage mqttMessage) { + channel.writeAndFlush(mqttMessage); } - DeviceAuthService getAuthService() { -// return authService; - return null; + public String getNodeId() { + return context.getNodeId(); } - public void setChannel(ChannelHandlerContext channel) { - this.channel = channel; + private void deregisterSession(String deviceName, GatewayDeviceSessionCtx deviceSessionCtx) { + transportService.deregisterSession(deviceSessionCtx.getSessionInfo()); + transportService.process(deviceSessionCtx.getSessionInfo(), MqttTransportHandler.getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + log.debug("[{}] Removed device [{}] from the gateway session", sessionId, deviceName); } - private void ack(MqttPublishMessage msg) { - if (msg.variableHeader().messageId() > 0) { - writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msg.variableHeader().messageId())); - } + private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final String deviceName, final int msgId, final T msg) { + return new TransportServiceCallback() { + @Override + public void onSuccess(Void dummy) { + log.trace("[{}][{}] Published msg: {}", sessionId, deviceName, msg); + if (msgId > 0) { + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msgId)); + } + } + + @Override + public void onError(Throwable e) { + log.trace("[{}] Failed to publish msg: {}", sessionId, deviceName, msg, e); + ctx.close(); + } + }; } - void writeAndFlush(MqttMessage mqttMessage) { - channel.writeAndFlush(mqttMessage); + public MqttTransportContext getContext() { + return context; } + public MqttTransportAdaptor getAdaptor() { + return context.getAdaptor(); + } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java index c01e103889..9e66566383 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java @@ -31,12 +31,7 @@ public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionCo private final ConcurrentMap mqttQoSMap; - public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, ConcurrentMap mqttQoSMap) { - super(); - this.mqttQoSMap = mqttQoSMap; - } - - public MqttDeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, Device device, ConcurrentMap mqttQoSMap) { + public MqttDeviceAwareSessionContext(ConcurrentMap mqttQoSMap) { super(); this.mqttQoSMap = mqttQoSMap; } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java index 03006128bf..9256e5d8c0 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.transport.mqtt.session; -import org.thingsboard.server.common.data.id.SessionId; - -import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; /** diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index 15392386c4..bfc69efdc4 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -25,7 +25,9 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; -import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.*; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; @@ -153,7 +155,7 @@ public class MqttTransportService implements TransportService { try { ToTransportMsg toTransportMsg = mainConsumer.decode(record); if (toTransportMsg.hasToDeviceSessionMsg()) { - TransportProtos.DeviceActorToTransportMsg toSessionMsg = toTransportMsg.getToDeviceSessionMsg(); + DeviceActorToTransportMsg toSessionMsg = toTransportMsg.getToDeviceSessionMsg(); UUID sessionId = new UUID(toSessionMsg.getSessionIdMSB(), toSessionMsg.getSessionIdLSB()); SessionMsgListener listener = sessions.get(sessionId); if (listener != null) { @@ -227,10 +229,17 @@ public class MqttTransportService implements TransportService { response -> callback.onSuccess(response.getValidateTokenResponseMsg()), callback::onError, transportCallbackExecutor); } + @Override + public void process(GetOrCreateDeviceFromGatewayRequestMsg msg, TransportServiceCallback callback) { + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getTenantIdMSB() + msg.getTenantIdLSB() + msg.getDeviceName(), + TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(msg).build()), + response -> callback.onSuccess(response.getGetOrCreateDeviceResponseMsg()), callback::onError, transportCallbackExecutor); + } + @Override public void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setSessionEvent(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); @@ -239,7 +248,7 @@ public class MqttTransportService implements TransportService { @Override public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setPostTelemetry(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); @@ -248,52 +257,52 @@ public class MqttTransportService implements TransportService { @Override public void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setPostAttributes(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback callback) { + public void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setGetAttributes(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + public void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setSubscribeToAttributes(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback callback) { + public void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setSubscribeToRPC(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + public void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setToDeviceRPCCallResponse(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + public void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportProtos.TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setToServerRPCCallRequest(msg).build() ).build(); send(sessionInfo, toRuleEngineMsg, callback); From cc5b18f30e51e983d6bd7292875b553f26f0a4b5 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 12 Oct 2018 11:18:41 +0300 Subject: [PATCH 204/307] MQTT Gateway API Implementation --- .../transport/adaptor/JsonConverter.java | 17 +- .../transport/mqtt/MqttTransportHandler.java | 4 +- .../mqtt/adaptors/JsonMqttAdaptor.java | 109 ++----------- .../mqtt/adaptors/MqttTransportAdaptor.java | 3 + .../mqtt/session/GatewayDeviceSessionCtx.java | 151 ++---------------- .../mqtt/session/GatewaySessionCtx.java | 7 + 6 files changed, 47 insertions(+), 244 deletions(-) diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index e02bb4bf34..1fcd60730c 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -37,11 +37,16 @@ import org.thingsboard.server.common.msg.core.BasicRequest; import org.thingsboard.server.common.msg.core.BasicTelemetryUploadRequest; import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; -import org.thingsboard.server.common.msg.core.ToServerRpcRequestMsg; -import org.thingsboard.server.common.msg.core.ToServerRpcResponseMsg; import org.thingsboard.server.common.msg.kv.AttributesKVMsg; import org.thingsboard.server.gen.transport.TransportProtos; -import org.thingsboard.server.gen.transport.TransportProtos.*; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvListProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; import java.util.ArrayList; import java.util.List; @@ -446,4 +451,10 @@ public class JsonConverter { return error; } + public static JsonElement toGatewayJson(String deviceName, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) { + JsonObject result = new JsonObject(); + result.addProperty(DEVICE_PROPERTY, deviceName); + result.add("data", JsonConverter.toJson(rpcRequest, true)); + return result; + } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 21d33354cd..1f1288c6da 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -85,7 +85,7 @@ import static io.netty.handler.codec.mqtt.MqttQoS.FAILURE; @Slf4j public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener>, SessionMsgListener { - public static final MqttQoS MAX_SUPPORTED_QOS_LVL = AT_LEAST_ONCE; + private static final MqttQoS MAX_SUPPORTED_QOS_LVL = AT_LEAST_ONCE; private final UUID sessionId; private final MqttTransportContext context; @@ -100,7 +100,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private volatile DeviceSessionCtx deviceSessionCtx; private volatile GatewaySessionCtx gatewaySessionCtx; - public MqttTransportHandler(MqttTransportContext context) { + MqttTransportHandler(MqttTransportContext context) { this.sessionId = UUID.randomUUID(); this.context = context; this.transportService = context.getTransportService(); diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 85dc813428..4bde5a35fe 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -23,27 +23,23 @@ import com.google.gson.JsonSyntaxException; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.UnpooledByteBufAllocator; -import io.netty.handler.codec.mqtt.*; +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttPublishMessage; +import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.*; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.MqttTopics; -import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; -import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionContext; import java.nio.charset.Charset; import java.util.Arrays; import java.util.HashSet; -import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -169,45 +165,13 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } @Override - public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToServerRpcResponseMsg rpcResponse) { - return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), JsonConverter.toJson(rpcResponse))); + public Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.GATEWAY_RPC_TOPIC, JsonConverter.toGatewayJson(deviceName, rpcRequest))); } - private MqttMessage convertResponseMsg(MqttDeviceAwareSessionContext ctx, ToDeviceMsg msg, - ResponseMsg responseMsg, Optional responseError) throws AdaptorException { - MqttMessage result = null; - SessionMsgType requestMsgType = responseMsg.getRequestMsgType(); - Integer requestId = responseMsg.getRequestId(); - if (requestId >= 0) { - if (requestMsgType == SessionMsgType.POST_ATTRIBUTES_REQUEST || requestMsgType == SessionMsgType.POST_TELEMETRY_REQUEST) { - result = MqttTransportHandler.createMqttPubAckMsg(requestId); - } else if (requestMsgType == SessionMsgType.GET_ATTRIBUTES_REQUEST) { - GetAttributesResponse response = (GetAttributesResponse) msg; - Optional responseData = response.getData(); - if (response.isSuccess() && responseData.isPresent()) { - result = createMqttPublishMsg(ctx, - MqttTopics.DEVICE_ATTRIBUTES_RESPONSE_TOPIC_PREFIX + requestId, - responseData.get(), true); - } else { - if (responseError.isPresent()) { - throw new AdaptorException(responseError.get()); - } - } - } - } - return result; - } - - private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, AttributesKVMsg msg, boolean asMap) { - return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg, asMap)); - } - - private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, ToDeviceRpcRequestMsg msg) { - return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg, false)); - } - - private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, TransportProtos.ToServerRpcResponseMsg msg) { - return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg)); + @Override + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToServerRpcResponseMsg rpcResponse) { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), JsonConverter.toJson(rpcResponse))); } private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, JsonElement json) { @@ -219,39 +183,6 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { return new MqttPublishMessage(mqttFixedHeader, header, payload); } - private FromDeviceMsg convertToGetAttributesRequest(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - String topicName = inbound.variableHeader().topicName(); - try { - Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX.length())); - String payload = inbound.payload().toString(UTF8); - JsonElement requestBody = new JsonParser().parse(payload); - Set clientKeys = toStringSet(requestBody, "clientKeys"); - Set sharedKeys = toStringSet(requestBody, "sharedKeys"); - if (clientKeys == null && sharedKeys == null) { - return new BasicGetAttributesRequest(requestId); - } else { - return new BasicGetAttributesRequest(requestId, clientKeys, sharedKeys); - } - } catch (RuntimeException e) { - log.warn("Failed to decode get attributes request", e); - throw new AdaptorException(e); - } - } - - private FromDeviceMsg convertToRpcCommandResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - String topicName = inbound.variableHeader().topicName(); - try { - Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC.length())); - String payload = inbound.payload().toString(UTF8); - return new ToDeviceRpcResponseMsg( - requestId, - payload); - } catch (RuntimeException e) { - log.warn("Failed to decode get attributes request", e); - throw new AdaptorException(e); - } - } - private Set toStringSet(JsonElement requestBody, String name) { JsonElement element = requestBody.getAsJsonObject().get(name); if (element != null) { @@ -261,24 +192,6 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } } - private AttributesUpdateRequest convertToUpdateAttributesRequest(SessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - String payload = validatePayload(ctx.getSessionId(), inbound.payload()); - try { - return JsonConverter.convertToAttributes(new JsonParser().parse(payload), inbound.variableHeader().packetId()); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private TelemetryUploadRequest convertToTelemetryUploadRequest(SessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - String payload = validatePayload(ctx.getSessionId(), inbound.payload()); - try { - return JsonConverter.convertToTelemetry(new JsonParser().parse(payload), inbound.variableHeader().packetId()); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - public static JsonElement validateJsonPayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException { String payload = validatePayload(sessionId, payloadData); try { @@ -288,7 +201,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } } - public static String validatePayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException { + private static String validatePayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException { try { String payload = payloadData.toString(UTF8); if (payload == null) { diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index 37b5395b69..a653e4c93e 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -18,6 +18,7 @@ package org.thingsboard.server.transport.mqtt.adaptors; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; @@ -56,5 +57,7 @@ public interface MqttTransportAdaptor { Optional convertToPublish(MqttDeviceAwareSessionContext ctx, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, ToServerRpcResponseMsg rpcResponse) throws AdaptorException; } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java index 387d9cf439..32d9e55832 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -15,43 +15,14 @@ */ package org.thingsboard.server.transport.mqtt.session; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.sun.xml.internal.bind.v2.TODO; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufAllocator; -import io.netty.buffer.UnpooledByteBufAllocator; -import io.netty.handler.codec.mqtt.MqttFixedHeader; -import io.netty.handler.codec.mqtt.MqttMessage; -import io.netty.handler.codec.mqtt.MqttMessageType; -import io.netty.handler.codec.mqtt.MqttPublishMessage; -import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.core.AttributesUpdateNotification; -import org.thingsboard.server.common.msg.core.GetAttributesResponse; -import org.thingsboard.server.common.msg.core.ResponseMsg; -import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; import org.thingsboard.server.common.transport.SessionMsgListener; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; -import org.thingsboard.server.transport.mqtt.MqttTopics; -import org.thingsboard.server.transport.mqtt.MqttTransportHandler; -import java.nio.charset.Charset; -import java.util.List; -import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicInteger; /** * Created by ashvayka on 19.01.17. @@ -59,15 +30,9 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext implements SessionMsgListener { - private static final Gson GSON = new Gson(); - private static final Charset UTF8 = Charset.forName("UTF-8"); - private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); - private final GatewaySessionCtx parent; private final UUID sessionId; private final SessionInfoProto sessionInfo; - private volatile boolean closed; - private AtomicInteger msgIdSeq = new AtomicInteger(0); public GatewayDeviceSessionCtx(GatewaySessionCtx parent, DeviceInfoProto deviceInfo, ConcurrentMap mqttQoSMap) { super(mqttQoSMap); @@ -90,109 +55,9 @@ public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext imple return sessionId; } - private Optional getToDeviceMsg(SessionActorToAdaptorMsg sessionMsg) { - ToDeviceMsg msg = sessionMsg.getMsg(); - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - ResponseMsg responseMsg = (ResponseMsg) msg; - if (responseMsg.isSuccess()) { - SessionMsgType requestMsgType = responseMsg.getRequestMsgType(); - Integer requestId = responseMsg.getRequestId(); - if (requestId >= 0 && (requestMsgType == SessionMsgType.POST_ATTRIBUTES_REQUEST || requestMsgType == SessionMsgType.POST_TELEMETRY_REQUEST)) { - return Optional.of(MqttTransportHandler.createMqttPubAckMsg(requestId)); - } - } - break; - case GET_ATTRIBUTES_RESPONSE: - GetAttributesResponse response = (GetAttributesResponse) msg; - if (response.isSuccess()) { - return Optional.of(createMqttPublishMsg(MqttTopics.GATEWAY_ATTRIBUTES_RESPONSE_TOPIC, response)); - } else { - //TODO: push error handling to the gateway - } - break; - case ATTRIBUTES_UPDATE_NOTIFICATION: - AttributesUpdateNotification notification = (AttributesUpdateNotification) msg; - return Optional.of(createMqttPublishMsg(MqttTopics.GATEWAY_ATTRIBUTES_TOPIC, notification.getData())); - case TO_DEVICE_RPC_REQUEST: - ToDeviceRpcRequestMsg rpcRequest = (ToDeviceRpcRequestMsg) msg; - return Optional.of(createMqttPublishMsg(MqttTopics.GATEWAY_RPC_TOPIC, rpcRequest)); - default: - break; - } - return Optional.empty(); - } - - private MqttMessage createMqttPublishMsg(String topic, GetAttributesResponse response) { - JsonObject result = new JsonObject(); - result.addProperty("id", response.getRequestId()); -// result.addProperty(DEVICE_PROPERTY, device.getName()); - Optional responseData = response.getData(); - if (responseData.isPresent()) { - AttributesKVMsg msg = responseData.get(); - if (msg.getClientAttributes() != null) { - addValues(result, msg.getClientAttributes()); - } - if (msg.getSharedAttributes() != null) { - addValues(result, msg.getSharedAttributes()); - } - } - return createMqttPublishMsg(topic, result); - } - - private void addValues(JsonObject result, List kvList) { - if (kvList.size() == 1) { - addValueToJson(result, "value", kvList.get(0)); - } else { - JsonObject values; - if (result.has("values")) { - values = result.get("values").getAsJsonObject(); - } else { - values = new JsonObject(); - result.add("values", values); - } - kvList.forEach(value -> addValueToJson(values, value.getKey(), value)); - } - } - - private void addValueToJson(JsonObject json, String name, KvEntry entry) { - switch (entry.getDataType()) { - case BOOLEAN: - entry.getBooleanValue().ifPresent(aBoolean -> json.addProperty(name, aBoolean)); - break; - case STRING: - entry.getStrValue().ifPresent(aString -> json.addProperty(name, aString)); - break; - case DOUBLE: - entry.getDoubleValue().ifPresent(aDouble -> json.addProperty(name, aDouble)); - break; - case LONG: - entry.getLongValue().ifPresent(aLong -> json.addProperty(name, aLong)); - break; - } - } - - private MqttMessage createMqttPublishMsg(String topic, AttributesKVMsg data) { - JsonObject result = new JsonObject(); -// result.addProperty(DEVICE_PROPERTY, device.getName()); - result.add("data", JsonConverter.toJson(data, false)); - return createMqttPublishMsg(topic, result); - } - - private MqttMessage createMqttPublishMsg(String topic, ToDeviceRpcRequestMsg data) { - JsonObject result = new JsonObject(); -// result.addProperty(DEVICE_PROPERTY, device.getName()); - result.add("data", JsonConverter.toJson(data, true)); - return createMqttPublishMsg(topic, result); - } - - private MqttPublishMessage createMqttPublishMsg(String topic, JsonElement json) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(MqttMessageType.PUBLISH, false, getQoSForTopic(topic), false, 0); - MqttPublishVariableHeader header = new MqttPublishVariableHeader(topic, msgIdSeq.incrementAndGet()); - ByteBuf payload = ALLOCATOR.buffer(); - payload.writeBytes(GSON.toJson(json).getBytes(UTF8)); - return new MqttPublishMessage(mqttFixedHeader, header, payload); + @Override + public int nextMsgId() { + return parent.nextMsgId(); } SessionInfoProto getSessionInfo() { @@ -223,12 +88,16 @@ public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext imple } @Override - public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg toDeviceRequest) { - + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg request) { + try { + parent.getAdaptor().convertToGatewayPublish(this, getDeviceInfo().getDeviceName(), request).ifPresent(parent::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } } @Override public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg toServerResponse) { - TODO + // This feature is not supported in the TB IoT Gateway yet. } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java index 3788d57e54..e038a4d269 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -52,6 +52,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; /** * Created by ashvayka on 19.01.17. @@ -70,10 +71,12 @@ public class GatewaySessionCtx { private final Map devices; private final ConcurrentMap mqttQoSMap; private final ChannelHandlerContext channel; + private final DeviceSessionCtx deviceSessionCtx; public GatewaySessionCtx(MqttTransportContext context, DeviceSessionCtx deviceSessionCtx, UUID sessionId) { this.context = context; this.transportService = context.getTransportService(); + this.deviceSessionCtx = deviceSessionCtx; this.gateway = deviceSessionCtx.getDeviceInfo(); this.sessionId = sessionId; this.devices = new ConcurrentHashMap<>(); @@ -357,4 +360,8 @@ public class GatewaySessionCtx { public MqttTransportAdaptor getAdaptor() { return context.getAdaptor(); } + + public int nextMsgId() { + return deviceSessionCtx.nextMsgId(); + } } From 52ef95ac1be6988d5be7ef8e93fd98ff7911d53e Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 12 Oct 2018 12:20:22 +0300 Subject: [PATCH 205/307] Refactoring of MQTT Transport and removing data structures --- .../device/DeviceActorMessageProcessor.java | 15 -- .../actors/service/DefaultActorService.java | 1 - .../actors/shared/SessionTimeoutMsg.java | 35 ---- .../controller/TelemetryController.java | 7 +- .../rpc/ToServerRpcResponseActorMsg.java | 3 - .../transport/RemoteTransportApiService.java | 8 +- .../common/msg/aware/SessionAwareMsg.java | 22 -- .../ActorSystemToDeviceSessionActorMsg.java | 30 --- .../msg/core/AttributesSubscribeMsg.java | 29 --- .../msg/core/AttributesUnsubscribeMsg.java | 30 --- .../core/AttributesUpdateNotification.java | 47 ----- .../msg/core/AttributesUpdateRequest.java | 28 --- .../core/BasicAttributesUpdateRequest.java | 63 ------ .../msg/core/BasicCommandAckResponse.java | 45 ----- .../msg/core/BasicGetAttributesRequest.java | 59 ------ .../msg/core/BasicGetAttributesResponse.java | 40 ---- .../server/common/msg/core/BasicRequest.java | 36 ---- .../common/msg/core/BasicResponseMsg.java | 79 -------- .../msg/core/BasicStatusCodeResponse.java | 42 ---- .../msg/core/BasicTelemetryUploadRequest.java | 66 ------ .../common/msg/core/GetAttributesRequest.java | 29 --- .../msg/core/GetAttributesResponse.java | 22 -- .../server/common/msg/core/ResponseMsg.java | 33 --- .../common/msg/core/RpcSubscribeMsg.java | 30 --- .../common/msg/core/RpcUnsubscribeMsg.java | 30 --- .../common/msg/core/RuleEngineError.java | 43 ---- .../common/msg/core/RuleEngineErrorMsg.java | 53 ----- .../common/msg/core/SessionCloseMsg.java | 30 --- .../msg/core/SessionCloseNotification.java | 38 ---- .../common/msg/core/SessionOpenMsg.java | 32 --- .../common/msg/core/StatusCodeResponse.java | 20 -- .../msg/core/TelemetryUploadRequest.java | 29 --- .../msg/core/ToDeviceRpcRequestMsg.java | 41 ---- .../msg/core/ToDeviceRpcResponseMsg.java | 36 ---- .../msg/core/ToServerRpcRequestMsg.java | 36 ---- .../msg/core/ToServerRpcResponseMsg.java | 11 +- .../msg/session/AdaptorToSessionActorMsg.java | 22 -- .../BasicAdaptorToSessionActorMsg.java | 32 --- .../BasicSessionActorToAdaptorMsg.java | 32 --- .../common/msg/session/BasicSessionMsg.java | 42 ---- ...BasicTransportToDeviceSessionActorMsg.java | 73 ------- .../common/msg/session/FromDeviceMsg.java | 24 --- .../msg/session/FromDeviceRequestMsg.java | 25 --- .../msg/session/SessionActorToAdaptorMsg.java | 22 -- .../common/msg/session/SessionContext.java | 8 +- .../common/msg/session/SessionCtrlMsg.java | 23 --- .../server/common/msg/session/SessionMsg.java | 24 --- .../common/msg/session/SessionType.java | 22 -- .../common/msg/session/ToDeviceMsg.java | 26 --- .../TransportToDeviceSessionActorMsg.java | 28 --- .../msg/session/ctrl/SessionCloseMsg.java | 67 ------ .../common/transport/SessionMsgProcessor.java | 1 - .../transport/adaptor/JsonConverter.java | 190 ++++++++---------- .../session/DeviceAwareSessionContext.java | 2 + .../TbCopyAttributesToEntityViewNode.java | 2 +- .../engine/telemetry/TbMsgAttributesNode.java | 2 +- .../engine/telemetry/TbMsgTimeseriesNode.java | 7 +- .../coap/adaptors/JsonCoapAdaptor.java | 6 - .../coap/session/CoapSessionCtx.java | 4 - .../server/transport/coap/CoapServerTest.java | 11 - .../transport/http/DeviceApiController.java | 14 -- .../transport/mqtt/MqttTransportContext.java | 8 +- .../transport/mqtt/MqttTransportHandler.java | 8 +- .../mqtt/adaptors/JsonMqttAdaptor.java | 8 +- .../mqtt/session/DeviceSessionCtx.java | 56 +----- .../mqtt/session/GatewayDeviceSessionCtx.java | 12 +- .../mqtt/session/GatewaySessionCtx.java | 8 +- .../MqttDeviceAwareSessionContext.java | 5 +- .../transport/mqtt/session/MqttSessionId.java | 60 ------ .../mqtt/service/MqttTransportService.java | 10 +- transport/pom.xml | 4 +- 71 files changed, 136 insertions(+), 1950 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/ActorSystemToDeviceSessionActorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesSubscribeMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUnsubscribeMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateRequest.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicRequest.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesRequest.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesResponse.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/ResponseMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcSubscribeMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcUnsubscribeMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineError.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineErrorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseNotification.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/StatusCodeResponse.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/TelemetryUploadRequest.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcRequestMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcResponseMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcRequestMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/AdaptorToSessionActorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicAdaptorToSessionActorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceRequestMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionActorToAdaptorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionCtrlMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionType.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/ToDeviceMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/TransportToDeviceSessionActorMsg.java delete mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java delete mode 100644 transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 1cfdcd202a..64480863f5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -42,12 +42,8 @@ import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.core.RuleEngineError; -import org.thingsboard.server.common.msg.core.RuleEngineErrorMsg; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.gen.transport.TransportProtos; @@ -481,17 +477,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void sendMsgToSessionActor(ActorSystemToDeviceSessionActorMsg response, Optional sessionAddress) { - if (sessionAddress.isPresent()) { - ServerAddress address = sessionAddress.get(); - logger.debug("{} Forwarding msg: {}", address, response); - systemContext.getRpcService().tell(systemContext.getEncodingService() - .convertToProtoDataMessage(sessionAddress.get(), response)); - } else { -// systemContext.getSessionManagerActor().tell(response, ActorRef.noSender()); - } - } - void processCredentialsUpdate() { sessions.forEach(this::closeSession); attributeSubscriptions.clear(); diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java index b70d1ed480..c2e8fd048a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -37,7 +37,6 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java b/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java deleted file mode 100644 index d89848b8a8..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.shared; - -import lombok.Data; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.TbActorMsg; - -import java.io.Serializable; - -@Data -public class SessionTimeoutMsg implements Serializable, TbActorMsg { - - private static final long serialVersionUID = 1L; - - private final SessionId sessionId; - - @Override - public MsgType getMsgType() { - return MsgType.SESSION_TIMEOUT_MSG; - } -} diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index 7062ca97f9..b19d782805 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -59,7 +59,6 @@ import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; -import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -352,7 +351,7 @@ public class TelemetryController extends BaseController { } private DeferredResult saveTelemetry(EntityId entityIdSrc, String requestBody, long ttl) throws ThingsboardException { - TelemetryUploadRequest telemetryRequest; + Map> telemetryRequest; JsonElement telemetryJson; try { telemetryJson = new JsonParser().parse(requestBody); @@ -360,12 +359,12 @@ public class TelemetryController extends BaseController { return getImmediateDeferredResult("Unable to parse timeseries payload: Invalid JSON body!", HttpStatus.BAD_REQUEST); } try { - telemetryRequest = JsonConverter.convertToTelemetry(telemetryJson); + telemetryRequest = JsonConverter.convertToTelemetry(telemetryJson, System.currentTimeMillis()); } catch (Exception e) { return getImmediateDeferredResult("Unable to parse timeseries payload. Invalid JSON body: " + e.getMessage(), HttpStatus.BAD_REQUEST); } List entries = new ArrayList<>(); - for (Map.Entry> entry : telemetryRequest.getData().entrySet()) { + for (Map.Entry> entry : telemetryRequest.entrySet()) { for (KvEntry kv : entry.getValue()) { entries.add(new BasicTsKvEntry(entry.getKey(), kv)); } diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java b/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java index 201f65613a..d33a3380f4 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java @@ -22,11 +22,8 @@ import org.thingsboard.rule.engine.api.msg.ToDeviceActorNotificationMsg; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.core.ToServerRpcResponseMsg; -import java.util.Optional; - /** * Created by ashvayka on 16.04.18. */ diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java index 5bcd8ac888..945317f768 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java deleted file mode 100644 index 1c258239d4..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.aware; - -public interface SessionAwareMsg { - - SessionId getSessionId(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ActorSystemToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ActorSystemToDeviceSessionActorMsg.java deleted file mode 100644 index 6bb5c9911d..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ActorSystemToDeviceSessionActorMsg.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -import java.io.Serializable; - -/** - * @author Andrew Shvayka - */ -public interface ActorSystemToDeviceSessionActorMsg extends SessionAwareMsg, Serializable, TbActorMsg { - - ToDeviceMsg getMsg(); -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesSubscribeMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesSubscribeMsg.java deleted file mode 100644 index cadaf3ce56..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesSubscribeMsg.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -public class AttributesSubscribeMsg implements FromDeviceMsg { - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUnsubscribeMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUnsubscribeMsg.java deleted file mode 100644 index c98ad2ae26..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUnsubscribeMsg.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -public class AttributesUnsubscribeMsg implements FromDeviceMsg { - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java deleted file mode 100644 index 1f0f9ddc99..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -@ToString -public class AttributesUpdateNotification implements ToDeviceMsg { - - private static final long serialVersionUID = 1L; - - private AttributesKVMsg data; - - public AttributesUpdateNotification(AttributesKVMsg data) { - this.data = data; - } - - @Override - public boolean isSuccess() { - return true; - } - - public SessionMsgType getSessionMsgType() { - return SessionMsgType.ATTRIBUTES_UPDATE_NOTIFICATION; - } - - public AttributesKVMsg getData() { - return data; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateRequest.java deleted file mode 100644 index d4cb4b1ae6..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateRequest.java +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.Set; - -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; - -public interface AttributesUpdateRequest extends FromDeviceRequestMsg { - - Set getAttributes(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java deleted file mode 100644 index 76efca0f07..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.Set; - -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -public class BasicAttributesUpdateRequest extends BasicRequest implements AttributesUpdateRequest { - - private static final long serialVersionUID = 1L; - - private final Set data; - - public BasicAttributesUpdateRequest() { - this(DEFAULT_REQUEST_ID); - } - - public BasicAttributesUpdateRequest(Integer requestId) { - super(requestId); - this.data = new LinkedHashSet<>(); - } - - public void add(AttributeKvEntry entry) { - this.data.add(entry); - } - - public void add(Collection entries) { - this.data.addAll(entries); - } - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.POST_ATTRIBUTES_REQUEST; - } - - @Override - public Set getAttributes() { - return data; - } - - @Override - public String toString() { - return "BasicAttributesUpdateRequest [data=" + data + "]"; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java deleted file mode 100644 index 3ee04ba216..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -public class BasicCommandAckResponse extends BasicResponseMsg implements StatusCodeResponse { - - private static final long serialVersionUID = 1L; - - public static BasicCommandAckResponse onSuccess(SessionMsgType requestMsgType, Integer requestId) { - return BasicCommandAckResponse.onSuccess(requestMsgType, requestId, 200); - } - - public static BasicCommandAckResponse onSuccess(SessionMsgType requestMsgType, Integer requestId, Integer code) { - return new BasicCommandAckResponse(requestMsgType, requestId, true, null, code); - } - - public static BasicCommandAckResponse onError(SessionMsgType requestMsgType, Integer requestId, Exception error) { - return new BasicCommandAckResponse(requestMsgType, requestId, false, error, null); - } - - private BasicCommandAckResponse(SessionMsgType requestMsgType, Integer requestId, boolean success, Exception error, Integer code) { - super(requestMsgType, requestId, SessionMsgType.TO_DEVICE_RPC_RESPONSE_ACK, success, error, code); - } - - @Override - public String toString() { - return "BasicStatusCodeResponse []"; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java deleted file mode 100644 index e0f6d7ecc4..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -import java.util.Collections; -import java.util.Optional; -import java.util.Set; - -@ToString -public class BasicGetAttributesRequest extends BasicRequest implements GetAttributesRequest { - - private static final long serialVersionUID = 1L; - - private final Set clientKeys; - private final Set sharedKeys; - - public BasicGetAttributesRequest(Integer requestId) { - this(requestId, Collections.emptySet(), Collections.emptySet()); - } - - public BasicGetAttributesRequest(Integer requestId, Set clientKeys, Set sharedKeys) { - super(requestId); - this.clientKeys = clientKeys; - this.sharedKeys = sharedKeys; - } - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.GET_ATTRIBUTES_REQUEST; - } - - @Override - public Optional> getClientAttributeNames() { - return Optional.ofNullable(clientKeys); - } - - @Override - public Optional> getSharedAttributeNames() { - return Optional.ofNullable(sharedKeys); - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java deleted file mode 100644 index e3eb15d807..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -@ToString -public class BasicGetAttributesResponse extends BasicResponseMsg implements GetAttributesResponse { - - private static final long serialVersionUID = 1L; - - public static BasicGetAttributesResponse onSuccess(SessionMsgType requestMsgType, int requestId, AttributesKVMsg code) { - return new BasicGetAttributesResponse(requestMsgType, requestId, true, null, code); - } - - public static BasicGetAttributesResponse onError(SessionMsgType requestMsgType, int requestId, Exception error) { - return new BasicGetAttributesResponse(requestMsgType, requestId, false, error, null); - } - - private BasicGetAttributesResponse(SessionMsgType requestMsgType, int requestId, boolean success, Exception error, AttributesKVMsg code) { - super(requestMsgType, requestId, SessionMsgType.GET_ATTRIBUTES_RESPONSE, success, error, code); - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicRequest.java deleted file mode 100644 index 4fcde4456b..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicRequest.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.io.Serializable; - -/** - * @author Andrew Shvayka - */ -public class BasicRequest implements Serializable { - - public static final Integer DEFAULT_REQUEST_ID = 0; - - private final Integer requestId; - - public BasicRequest(Integer requestId) { - this.requestId = requestId; - } - - public Integer getRequestId() { - return requestId; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java deleted file mode 100644 index c61d8e5e03..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.io.Serializable; -import java.util.Optional; - -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - - -public class BasicResponseMsg implements ResponseMsg { - - private static final long serialVersionUID = 1L; - - private final SessionMsgType requestMsgType; - private final Integer requestId; - private final SessionMsgType sessionMsgType; - private final boolean success; - private final T data; - private final Exception error; - - protected BasicResponseMsg(SessionMsgType requestMsgType, Integer requestId, SessionMsgType sessionMsgType, boolean success, Exception error, T data) { - super(); - this.requestMsgType = requestMsgType; - this.requestId = requestId; - this.sessionMsgType = sessionMsgType; - this.success = success; - this.error = error; - this.data = data; - } - - @Override - public SessionMsgType getRequestMsgType() { - return requestMsgType; - } - - @Override - public Integer getRequestId() { - return requestId; - } - - @Override - public boolean isSuccess() { - return success; - } - - @Override - public Optional getError() { - return Optional.ofNullable(error); - } - - @Override - public Optional getData() { - return Optional.ofNullable(data); - } - - @Override - public String toString() { - return "BasicResponseMsg [success=" + success + ", data=" + data + ", error=" + error + "]"; - } - - public SessionMsgType getSessionMsgType() { - return sessionMsgType; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java deleted file mode 100644 index f21aa857bf..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -@ToString -public class BasicStatusCodeResponse extends BasicResponseMsg implements StatusCodeResponse { - - private static final long serialVersionUID = 1L; - - public static BasicStatusCodeResponse onSuccess(SessionMsgType requestMsgType, Integer requestId) { - return BasicStatusCodeResponse.onSuccess(requestMsgType, requestId, 0); - } - - public static BasicStatusCodeResponse onSuccess(SessionMsgType requestMsgType, Integer requestId, Integer code) { - return new BasicStatusCodeResponse(requestMsgType, requestId, true, null, code); - } - - public static BasicStatusCodeResponse onError(SessionMsgType requestMsgType, Integer requestId, Exception error) { - return new BasicStatusCodeResponse(requestMsgType, requestId, false, error, null); - } - - private BasicStatusCodeResponse(SessionMsgType requestMsgType, Integer requestId, boolean success, Exception error, Integer code) { - super(requestMsgType, requestId, SessionMsgType.STATUS_CODE_RESPONSE, success, error, code); - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java deleted file mode 100644 index 60faeb1829..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -public class BasicTelemetryUploadRequest extends BasicRequest implements TelemetryUploadRequest { - - private static final long serialVersionUID = 1L; - - private final Map> data; - - public BasicTelemetryUploadRequest() { - this(DEFAULT_REQUEST_ID); - } - - public BasicTelemetryUploadRequest(Integer requestId) { - super(requestId); - this.data = new HashMap<>(); - } - - public void add(long ts, KvEntry entry) { - List tsEntries = data.get(ts); - if (tsEntries == null) { - tsEntries = new ArrayList<>(); - data.put(ts, tsEntries); - } - tsEntries.add(entry); - } - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.POST_TELEMETRY_REQUEST; - } - - @Override - public Map> getData() { - return data; - } - - @Override - public String toString() { - return "BasicTelemetryUploadRequest [data=" + data + "]"; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesRequest.java deleted file mode 100644 index 241bb6ba1e..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.Optional; -import java.util.Set; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; - -public interface GetAttributesRequest extends FromDeviceRequestMsg { - - Optional> getClientAttributeNames(); - Optional> getSharedAttributeNames(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesResponse.java deleted file mode 100644 index 6242d46e8f..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; - -public interface GetAttributesResponse extends ResponseMsg { - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ResponseMsg.java deleted file mode 100644 index 3e70460dcb..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ResponseMsg.java +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.io.Serializable; -import java.util.Optional; - -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -public interface ResponseMsg extends ToDeviceMsg { - - SessionMsgType getRequestMsgType(); - - Integer getRequestId(); - - Optional getError(); - - Optional getData(); -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcSubscribeMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcSubscribeMsg.java deleted file mode 100644 index f8f24e8d20..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcSubscribeMsg.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -public class RpcSubscribeMsg implements FromDeviceMsg { - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcUnsubscribeMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcUnsubscribeMsg.java deleted file mode 100644 index 23eb238d79..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcUnsubscribeMsg.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -public class RpcUnsubscribeMsg implements FromDeviceMsg { - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineError.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineError.java deleted file mode 100644 index dcfde0fb52..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineError.java +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -/** - * @author Andrew Shvayka - */ - -public enum RuleEngineError { - - QUEUE_PUT_TIMEOUT(true), SERVER_ERROR(true), TIMEOUT; - - private final boolean critical; - - RuleEngineError() { - this(false); - } - - RuleEngineError(boolean critical) { - this.critical = critical; - } - - public boolean isCritical() { - return critical; - } - - public int getPriority() { - return ordinal(); - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineErrorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineErrorMsg.java deleted file mode 100644 index e0ff23b379..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineErrorMsg.java +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -/** - * @author Andrew Shvayka - */ -@Data -public class RuleEngineErrorMsg implements ToDeviceMsg { - - private final SessionMsgType inSessionMsgType; - private final RuleEngineError error; - - @Override - public boolean isSuccess() { - return false; - } - - public SessionMsgType getSessionMsgType() { - return SessionMsgType.RULE_ENGINE_ERROR; - } - - public String getErrorMsg() { - switch (error) { - case QUEUE_PUT_TIMEOUT: - return "Timeout during persistence of the message to the queue!"; - case SERVER_ERROR: - return "Error during processing of message by the server!"; - case TIMEOUT: - return "Timeout during processing of message by the server!"; - default: - throw new RuntimeException("Error " + error + " is not supported!"); - } - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseMsg.java deleted file mode 100644 index 6d3ad5e8be..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseMsg.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -public class SessionCloseMsg implements FromDeviceMsg { - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SESSION_CLOSE; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseNotification.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseNotification.java deleted file mode 100644 index bf36a9b3c3..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseNotification.java +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -@ToString -public class SessionCloseNotification implements ToDeviceMsg { - - private static final long serialVersionUID = 1L; - - @Override - public boolean isSuccess() { - return true; - } - - public SessionMsgType getSessionMsgType() { - return SessionMsgType.SESSION_CLOSE; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java deleted file mode 100644 index d8af5a953c..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -@Data -public class SessionOpenMsg implements FromDeviceMsg { - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SESSION_OPEN; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/StatusCodeResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/StatusCodeResponse.java deleted file mode 100644 index b2d8611d1f..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/StatusCodeResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -public interface StatusCodeResponse extends ResponseMsg{ - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/TelemetryUploadRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/TelemetryUploadRequest.java deleted file mode 100644 index 52c2f9e161..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/TelemetryUploadRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.List; -import java.util.Map; - -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; - -public interface TelemetryUploadRequest extends FromDeviceRequestMsg { - - Map> getData(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcRequestMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcRequestMsg.java deleted file mode 100644 index d1a1f96451..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcRequestMsg.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -/** - * @author Andrew Shvayka - */ -@Data -public class ToDeviceRpcRequestMsg implements ToDeviceMsg { - - private final int requestId; - private final String method; - private final String params; - - public SessionMsgType getSessionMsgType() { - return SessionMsgType.TO_DEVICE_RPC_REQUEST; - } - - @Override - public boolean isSuccess() { - return true; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcResponseMsg.java deleted file mode 100644 index 4fa3024947..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcResponseMsg.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -@Data -public class ToDeviceRpcResponseMsg implements FromDeviceMsg { - - private final int requestId; - private final String data; - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.TO_DEVICE_RPC_RESPONSE; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcRequestMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcRequestMsg.java deleted file mode 100644 index 3823acac70..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcRequestMsg.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -@Data -public class ToServerRpcRequestMsg implements FromDeviceRequestMsg { - - private final Integer requestId; - private final String method; - private final String params; - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.TO_SERVER_RPC_REQUEST; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java index 82f44e9113..fa9ef05fe8 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java @@ -16,25 +16,16 @@ package org.thingsboard.server.common.msg.core; import lombok.Data; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; /** * @author Andrew Shvayka */ @Data -public class ToServerRpcResponseMsg implements ToDeviceMsg { +public class ToServerRpcResponseMsg { private final int requestId; private final String data; - public SessionMsgType getSessionMsgType() { - return SessionMsgType.TO_SERVER_RPC_RESPONSE; - } - - @Override public boolean isSuccess() { return true; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/AdaptorToSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/AdaptorToSessionActorMsg.java deleted file mode 100644 index 5383a5253e..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/AdaptorToSessionActorMsg.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -public interface AdaptorToSessionActorMsg extends SessionMsg { - - FromDeviceMsg getMsg(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicAdaptorToSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicAdaptorToSessionActorMsg.java deleted file mode 100644 index 21c28d25be..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicAdaptorToSessionActorMsg.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -public class BasicAdaptorToSessionActorMsg extends BasicSessionMsg implements AdaptorToSessionActorMsg { - - private final FromDeviceMsg msg; - - public BasicAdaptorToSessionActorMsg(SessionContext ctx, FromDeviceMsg msg) { - super(ctx); - this.msg = msg; - } - - @Override - public FromDeviceMsg getMsg() { - return msg; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java deleted file mode 100644 index 0e1a087d6e..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -public class BasicSessionActorToAdaptorMsg extends BasicSessionMsg implements SessionActorToAdaptorMsg { - - private final ToDeviceMsg msg; - - public BasicSessionActorToAdaptorMsg(SessionContext ctx, ToDeviceMsg msg) { - super(ctx); - this.msg = msg; - } - - @Override - public ToDeviceMsg getMsg() { - return msg; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java deleted file mode 100644 index 84c5052662..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -public class BasicSessionMsg implements SessionMsg { - - private final SessionContext ctx; - - public BasicSessionMsg(SessionContext ctx) { - super(); - this.ctx = ctx; - } - - @Override - public SessionId getSessionId() { - return ctx.getSessionId(); - } - - @Override - public SessionContext getSessionContext() { - return ctx; - } - - @Override - public String toString() { - return "BasicSessionMsg [ctx=" + ctx + "]"; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java deleted file mode 100644 index 4a81fd4578..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.MsgType; - -public class BasicTransportToDeviceSessionActorMsg implements TransportToDeviceSessionActorMsg { - - private final TenantId tenantId; - private final CustomerId customerId; - private final DeviceId deviceId; - private final AdaptorToSessionActorMsg msg; - - public BasicTransportToDeviceSessionActorMsg(Device device, AdaptorToSessionActorMsg msg) { - super(); - this.tenantId = device.getTenantId(); - this.customerId = device.getCustomerId(); - this.deviceId = device.getId(); - this.msg = msg; - } - - @Override - public DeviceId getDeviceId() { - return deviceId; - } - - @Override - public CustomerId getCustomerId() { - return customerId; - } - - public TenantId getTenantId() { - return tenantId; - } - - @Override - public SessionId getSessionId() { - return msg.getSessionId(); - } - - @Override - public AdaptorToSessionActorMsg getSessionMsg() { - return msg; - } - - @Override - public String toString() { - return "BasicTransportToDeviceSessionActorMsg [tenantId=" + tenantId + ", customerId=" + customerId + ", deviceId=" + deviceId + ", msg=" + msg - + "]"; - } - - @Override - public MsgType getMsgType() { - return MsgType.TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceMsg.java deleted file mode 100644 index 71b605712f..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceMsg.java +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import java.io.Serializable; - -public interface FromDeviceMsg extends Serializable { - - SessionMsgType getMsgType(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceRequestMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceRequestMsg.java deleted file mode 100644 index 1ebeb671a6..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceRequestMsg.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -/** - * @author Andrew Shvayka - */ -public interface FromDeviceRequestMsg extends FromDeviceMsg { - - Integer getRequestId(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionActorToAdaptorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionActorToAdaptorMsg.java deleted file mode 100644 index fa1f6d5e8f..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionActorToAdaptorMsg.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -public interface SessionActorToAdaptorMsg extends SessionMsg { - - ToDeviceMsg getMsg(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java index 6607295ee3..7f7a669100 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionCtrlMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionCtrlMsg.java deleted file mode 100644 index 8082f72184..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionCtrlMsg.java +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; - -public interface SessionCtrlMsg extends SessionAwareMsg, TbActorMsg { - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsg.java deleted file mode 100644 index 6b0b0af15d..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsg.java +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; - -public interface SessionMsg extends SessionAwareMsg { - - SessionContext getSessionContext(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionType.java deleted file mode 100644 index 831682d165..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionType.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -public enum SessionType { - - SYNC, ASYNC; - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ToDeviceMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/ToDeviceMsg.java deleted file mode 100644 index 705e86420b..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ToDeviceMsg.java +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import java.io.Serializable; - -public interface ToDeviceMsg extends Serializable { - - boolean isSuccess(); - - SessionMsgType getSessionMsgType(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/TransportToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/TransportToDeviceSessionActorMsg.java deleted file mode 100644 index deaef3d3e5..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/TransportToDeviceSessionActorMsg.java +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.CustomerAwareMsg; -import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.aware.TenantAwareMsg; - -public interface TransportToDeviceSessionActorMsg extends DeviceAwareMsg, CustomerAwareMsg, TenantAwareMsg, SessionAwareMsg, TbActorMsg { - - AdaptorToSessionActorMsg getSessionMsg(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java deleted file mode 100644 index 22a13d03b5..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session.ctrl; - -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; - -public class SessionCloseMsg implements SessionCtrlMsg { - - private final SessionId sessionId; - private final boolean revoked; - private final boolean timeout; - - public static SessionCloseMsg onDisconnect(SessionId sessionId) { - return new SessionCloseMsg(sessionId, false, false); - } - - public static SessionCloseMsg onError(SessionId sessionId) { - return new SessionCloseMsg(sessionId, false, false); - } - - public static SessionCloseMsg onTimeout(SessionId sessionId) { - return new SessionCloseMsg(sessionId, false, true); - } - - public static SessionCloseMsg onCredentialsRevoked(SessionId sessionId) { - return new SessionCloseMsg(sessionId, true, false); - } - - private SessionCloseMsg(SessionId sessionId, boolean unauthorized, boolean timeout) { - super(); - this.sessionId = sessionId; - this.revoked = unauthorized; - this.timeout = timeout; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - public boolean isCredentialsRevoked() { - return revoked; - } - - public boolean isTimeout() { - return timeout; - } - - @Override - public MsgType getMsgType() { - return MsgType.SESSION_CTRL_MSG; - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java index 80b2690b21..992492f7d6 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java @@ -16,7 +16,6 @@ package org.thingsboard.server.common.transport; import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; public interface SessionMsgProcessor { diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index 1fcd60730c..7498e8ee2a 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -31,12 +31,6 @@ import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.common.msg.core.AttributesUpdateRequest; -import org.thingsboard.server.common.msg.core.BasicAttributesUpdateRequest; -import org.thingsboard.server.common.msg.core.BasicRequest; -import org.thingsboard.server.common.msg.core.BasicTelemetryUploadRequest; -import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; -import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; import org.thingsboard.server.common.msg.kv.AttributesKVMsg; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; @@ -49,8 +43,12 @@ import org.thingsboard.server.gen.transport.TransportProtos.TsKvListProto; import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Map.Entry; +import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -60,18 +58,6 @@ public class JsonConverter { private static final String CAN_T_PARSE_VALUE = "Can't parse value: "; private static final String DEVICE_PROPERTY = "device"; - public static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject) throws JsonSyntaxException { - return convertToTelemetry(jsonObject, BasicRequest.DEFAULT_REQUEST_ID); - } - - public static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, long ts) throws JsonSyntaxException { - return convertToTelemetry(jsonObject, ts, BasicRequest.DEFAULT_REQUEST_ID); - } - - public static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, int requestId) throws JsonSyntaxException { - return convertToTelemetry(jsonObject, System.currentTimeMillis(), requestId); - } - public static PostTelemetryMsg convertToTelemetryProto(JsonElement jsonObject) throws JsonSyntaxException { long systemTs = System.currentTimeMillis(); PostTelemetryMsg.Builder builder = PostTelemetryMsg.newBuilder(); @@ -170,74 +156,11 @@ public class JsonConverter { return result; } - private static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, long systemTs, int requestId) throws JsonSyntaxException { - BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); - if (jsonObject.isJsonObject()) { - parseObject(request, systemTs, jsonObject); - } else if (jsonObject.isJsonArray()) { - jsonObject.getAsJsonArray().forEach(je -> { - if (je.isJsonObject()) { - parseObject(request, systemTs, je.getAsJsonObject()); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + je); - } - }); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); - } - return request; - } - public static TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(JsonElement json, int requestId) throws JsonSyntaxException { JsonObject object = json.getAsJsonObject(); return TransportProtos.ToServerRpcRequestMsg.newBuilder().setRequestId(requestId).setMethodName(object.get("method").getAsString()).setParams(GSON.toJson(object.get("params"))).build(); } - private static void parseObject(BasicTelemetryUploadRequest request, long systemTs, JsonElement jsonObject) { - JsonObject jo = jsonObject.getAsJsonObject(); - if (jo.has("ts") && jo.has("values")) { - parseWithTs(request, jo); - } else { - parseWithoutTs(request, systemTs, jo); - } - } - - private static void parseWithoutTs(BasicTelemetryUploadRequest request, long systemTs, JsonObject jo) { - for (KvEntry entry : parseValues(jo)) { - request.add(systemTs, entry); - } - } - - public static void parseWithTs(BasicTelemetryUploadRequest request, JsonObject jo) { - long ts = jo.get("ts").getAsLong(); - JsonObject valuesObject = jo.get("values").getAsJsonObject(); - for (KvEntry entry : parseValues(valuesObject)) { - request.add(ts, entry); - } - } - - public static List parseValues(JsonObject valuesObject) { - List result = new ArrayList<>(); - for (Entry valueEntry : valuesObject.entrySet()) { - JsonElement element = valueEntry.getValue(); - if (element.isJsonPrimitive()) { - JsonPrimitive value = element.getAsJsonPrimitive(); - if (value.isString()) { - result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); - } else if (value.isBoolean()) { - result.add(new BooleanDataEntry(valueEntry.getKey(), value.getAsBoolean())); - } else if (value.isNumber()) { - parseNumericValue(result, valueEntry, value); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value); - } - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); - } - } - return result; - } - private static void parseNumericValue(List result, Entry valueEntry, JsonPrimitive value) { if (value.getAsString().contains(".")) { result.add(new DoubleDataEntry(valueEntry.getKey(), value.getAsDouble())); @@ -251,21 +174,6 @@ public class JsonConverter { } } - public static AttributesUpdateRequest convertToAttributes(JsonElement element) { - return convertToAttributes(element, BasicRequest.DEFAULT_REQUEST_ID); - } - - public static AttributesUpdateRequest convertToAttributes(JsonElement element, int requestId) { - if (element.isJsonObject()) { - BasicAttributesUpdateRequest request = new BasicAttributesUpdateRequest(requestId); - long ts = System.currentTimeMillis(); - request.add(parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); - return request; - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); - } - } - public static JsonObject toJson(GetAttributeResponseMsg payload) { JsonObject result = new JsonObject(); if (payload.getClientAttributeListCount() > 0) { @@ -425,16 +333,6 @@ public class JsonConverter { }; } - public static JsonObject toJson(ToDeviceRpcRequestMsg msg, boolean includeRequestId) { - JsonObject result = new JsonObject(); - if (includeRequestId) { - result.addProperty("id", msg.getRequestId()); - } - result.addProperty("method", msg.getMethod()); - result.add("params", new JsonParser().parse(msg.getParams())); - return result; - } - public static JsonElement toJson(TransportProtos.ToServerRpcResponseMsg msg) { if (StringUtils.isEmpty(msg.getError())) { return new JsonParser().parse(msg.getPayload()); @@ -457,4 +355,76 @@ public class JsonConverter { result.add("data", JsonConverter.toJson(rpcRequest, true)); return result; } + + public static Set convertToAttributes(JsonElement element) { + Set result = new HashSet<>(); + long ts = System.currentTimeMillis(); + result.addAll(parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); + return result; + } + + private static List parseValues(JsonObject valuesObject) { + List result = new ArrayList<>(); + for (Entry valueEntry : valuesObject.entrySet()) { + JsonElement element = valueEntry.getValue(); + if (element.isJsonPrimitive()) { + JsonPrimitive value = element.getAsJsonPrimitive(); + if (value.isString()) { + result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); + } else if (value.isBoolean()) { + result.add(new BooleanDataEntry(valueEntry.getKey(), value.getAsBoolean())); + } else if (value.isNumber()) { + parseNumericValue(result, valueEntry, value); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); + } + } + return result; + } + + public static Map> convertToTelemetry(JsonElement jsonObject, long systemTs) throws JsonSyntaxException { + Map> result = new HashMap<>(); + if (jsonObject.isJsonObject()) { + parseObject(result, systemTs, jsonObject); + } else if (jsonObject.isJsonArray()) { + jsonObject.getAsJsonArray().forEach(je -> { + if (je.isJsonObject()) { + parseObject(result, systemTs, je.getAsJsonObject()); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + je); + } + }); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); + } + return result; + } + + private static void parseObject(Map> result, long systemTs, JsonElement jsonObject) { + JsonObject jo = jsonObject.getAsJsonObject(); + if (jo.has("ts") && jo.has("values")) { + parseWithTs(result, jo); + } else { + parseWithoutTs(result, systemTs, jo); + } + } + + private static void parseWithoutTs(Map> result, long systemTs, JsonObject jo) { + for (KvEntry entry : parseValues(jo)) { + result.computeIfAbsent(systemTs, tmp -> new ArrayList<>()).add(entry); + } + } + + public static void parseWithTs(Map> result, JsonObject jo) { + long ts = jo.get("ts").getAsLong(); + JsonObject valuesObject = jo.get("values").getAsJsonObject(); + for (KvEntry entry : parseValues(valuesObject)) { + result.computeIfAbsent(ts, tmp -> new ArrayList<>()).add(entry); + } + } + + } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index 93921d347f..93c0f41ddd 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -29,6 +29,8 @@ import java.util.UUID; @Data public abstract class DeviceAwareSessionContext implements SessionContext { + @Getter + protected final UUID sessionId; @Getter private volatile DeviceId deviceId; @Getter diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 40e00ec009..9adc79b79e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -84,7 +84,7 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { long endTime = entityView.getEndTimeMs(); if ((endTime != 0 && endTime > now && startTime < now) || (endTime == 0 && startTime < now)) { Set attributes = - JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())).getAttributes(); + JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())); List filteredAttributes = attributes.stream() .filter(attr -> { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index 5cdb04ed5b..12dbc6dd89 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -63,7 +63,7 @@ public class TbMsgAttributesNode implements TbNode { } String src = msg.getData(); - Set attributes = JsonConverter.convertToAttributes(new JsonParser().parse(src)).getAttributes(); + Set attributes = JsonConverter.convertToAttributes(new JsonParser().parse(src)); ctx.getTelemetryService().saveAndNotify(msg.getOriginator(), config.getScope(), new ArrayList<>(attributes), new TelemetryNodeCallback(ctx, msg)); if (msg.getOriginator().getEntityType() == EntityType.DEVICE && DataConstants.SHARED_SCOPE.equals(config.getScope())) { ctx.getTelemetryService().onSharedAttributesUpdate(ctx.getTenantId(), new DeviceId(msg.getOriginator().getId()), attributes); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java index efdf4af3f2..4cb9999969 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java @@ -29,7 +29,6 @@ import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; import org.thingsboard.server.common.msg.session.SessionMsgType; import org.thingsboard.server.common.transport.adaptor.JsonConverter; @@ -68,13 +67,13 @@ public class TbMsgTimeseriesNode implements TbNode { if (!StringUtils.isEmpty(tsStr)) { try { ts = Long.parseLong(tsStr); - } catch (NumberFormatException e) {} + } catch (NumberFormatException e) { + } } else { ts = System.currentTimeMillis(); } String src = msg.getData(); - TelemetryUploadRequest telemetryUploadRequest = JsonConverter.convertToTelemetry(new JsonParser().parse(src), ts); - Map> tsKvMap = telemetryUploadRequest.getData(); + Map> tsKvMap = JsonConverter.convertToTelemetry(new JsonParser().parse(src), ts); if (tsKvMap == null) { ctx.tellFailure(msg, new IllegalArgumentException("Msg body is empty: " + src)); return; diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java index 3cd414289c..1c587f0fae 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java @@ -17,7 +17,6 @@ package org.thingsboard.server.transport.coap.adaptors; import java.util.*; -import com.google.gson.JsonElement; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.coap.CoAP.ResponseCode; import org.eclipse.californium.core.coap.Request; @@ -25,13 +24,8 @@ import org.eclipse.californium.core.coap.Response; import org.springframework.util.StringUtils; import org.thingsboard.server.common.msg.core.*; import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; import org.thingsboard.server.common.msg.session.ex.ProcessingTimeoutException; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.adaptor.JsonConverter; diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java index efabc4eac2..60d54087bf 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java +++ b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java @@ -20,10 +20,6 @@ import org.eclipse.californium.core.coap.CoAP.ResponseCode; import org.eclipse.californium.core.coap.Request; import org.eclipse.californium.core.coap.Response; import org.eclipse.californium.core.server.resources.CoapExchange; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; import org.thingsboard.server.common.msg.session.ex.SessionException; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.adaptor.AdaptorException; diff --git a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java index ec69cec673..84aeb848c8 100644 --- a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java +++ b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java @@ -34,26 +34,15 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.core.BasicGetAttributesResponse; -import org.thingsboard.server.common.msg.core.BasicRequest; -import org.thingsboard.server.common.msg.core.BasicStatusCodeResponse; -import org.thingsboard.server.common.msg.kv.BasicAttributeKVMsg; import org.thingsboard.server.common.msg.session.*; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.auth.DeviceAuthResult; import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; -import java.util.ArrayList; -import java.util.List; import java.util.Optional; import java.util.UUID; diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index add89b1e1a..bc7eeff6f7 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -15,34 +15,20 @@ */ package org.thingsboard.server.transport.http; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.transport.http.session.HttpSessionCtx; import javax.servlet.http.HttpServletRequest; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; /** * @author Andrew Shvayka diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 2c8ff59f1f..4742b3b520 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 1f1288c6da..b570c74ec4 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 4bde5a35fe..8393ca9e0c 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index 6195fdd440..5547071809 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,13 +16,9 @@ package org.thingsboard.server.transport.mqtt.session; import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.mqtt.*; +import io.netty.handler.codec.mqtt.MqttMessage; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; import org.thingsboard.server.common.msg.session.ex.SessionException; import org.thingsboard.server.common.transport.adaptor.AdaptorException; @@ -36,29 +32,12 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { - private final UUID sessionId; @Getter private ChannelHandlerContext channel; private AtomicInteger msgIdSeq = new AtomicInteger(0); public DeviceSessionCtx(UUID sessionId, ConcurrentMap mqttQoSMap) { - super(null, null, mqttQoSMap); - this.sessionId = sessionId; - } - - @Override - public SessionType getSessionType() { - return SessionType.ASYNC; - } - - @Override - public void onMsg(SessionActorToAdaptorMsg msg) throws SessionException { -// try { -// adaptor.convertToAdaptorMsg(this, msg).ifPresent(this::pushToNetwork); -// } catch (AdaptorException e) { -// //TODO: close channel with disconnect; -// logAndWrap(e); -// } + super(sessionId, mqttQoSMap); } private void logAndWrap(AdaptorException e) throws SessionException { @@ -70,29 +49,6 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { channel.writeAndFlush(msg); } - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - if (msg instanceof SessionCloseMsg) { - pushToNetwork(new MqttMessage(new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0))); - channel.close(); - } - } - - @Override - public boolean isClosed() { - return false; - } - - @Override - public long getTimeout() { - return 0; - } - - @Override - public UUID getSessionId() { - return sessionId; - } - public void setChannel(ChannelHandlerContext channel) { this.channel = channel; } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java index 32d9e55832..8560b35acf 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -31,13 +31,11 @@ import java.util.concurrent.ConcurrentMap; public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext implements SessionMsgListener { private final GatewaySessionCtx parent; - private final UUID sessionId; private final SessionInfoProto sessionInfo; public GatewayDeviceSessionCtx(GatewaySessionCtx parent, DeviceInfoProto deviceInfo, ConcurrentMap mqttQoSMap) { - super(mqttQoSMap); + super(UUID.randomUUID(), mqttQoSMap); this.parent = parent; - this.sessionId = UUID.randomUUID(); this.sessionInfo = SessionInfoProto.newBuilder() .setNodeId(parent.getNodeId()) .setSessionIdMSB(sessionId.getMostSignificantBits()) diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java index e038a4d269..fd5eeb7876 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java index 9e66566383..3409a0d335 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentMap; /** @@ -31,8 +32,8 @@ public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionCo private final ConcurrentMap mqttQoSMap; - public MqttDeviceAwareSessionContext(ConcurrentMap mqttQoSMap) { - super(); + public MqttDeviceAwareSessionContext(UUID sessionId, ConcurrentMap mqttQoSMap) { + super(sessionId); this.mqttQoSMap = mqttQoSMap; } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java deleted file mode 100644 index 9256e5d8c0..0000000000 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt.session; - -import java.util.concurrent.atomic.AtomicLong; - -/** - * @author Andrew Shvayka - */ -public class MqttSessionId implements SessionId { - - private static final AtomicLong idSeq = new AtomicLong(); - - private final long id; - - public MqttSessionId() { - this.id = idSeq.incrementAndGet(); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - MqttSessionId that = (MqttSessionId) o; - - return id == that.id; - - } - - @Override - public String toString() { - return "MqttSessionId{" + - "id=" + id + - '}'; - } - - @Override - public int hashCode() { - return (int) (id ^ (id >>> 32)); - } - - @Override - public String toUidStr() { - return "mqtt" + id; - } -} diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java index bfc69efdc4..4ce6b081aa 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -231,7 +231,7 @@ public class MqttTransportService implements TransportService { @Override public void process(GetOrCreateDeviceFromGatewayRequestMsg msg, TransportServiceCallback callback) { - AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getTenantIdMSB() + msg.getTenantIdLSB() + msg.getDeviceName(), + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getDeviceName(), TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(msg).build()), response -> callback.onSuccess(response.getGetOrCreateDeviceResponseMsg()), callback::onError, transportCallbackExecutor); } diff --git a/transport/pom.xml b/transport/pom.xml index 2401d3f845..671e3c1d63 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -35,8 +35,8 @@ - http - coap + + mqtt-common mqtt-transport From c6800dbd032b3666ae6eb2ab5c061dc6e5a2e39c Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 12 Oct 2018 13:35:26 +0300 Subject: [PATCH 206/307] BugFix for Gateway API and concurrent device creation --- application/pom.xml | 16 +- .../device/DeviceActorMessageProcessor.java | 4 + .../transport/RemoteTransportApiService.java | 28 +- .../transport/http/DeviceApiController.java | 32 +-- .../http/session/HttpSessionCtx.java | 246 +++++++++--------- .../transport/http/session/HttpSessionId.java | 35 --- .../transport/mqtt/MqttTransportHandler.java | 24 +- .../mqtt/session/GatewayDeviceSessionCtx.java | 4 +- ...ionCtx.java => GatewaySessionHandler.java} | 15 +- transport/pom.xml | 2 +- 10 files changed, 197 insertions(+), 209 deletions(-) delete mode 100644 transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java rename transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/{GatewaySessionCtx.java => GatewaySessionHandler.java} (96%) diff --git a/application/pom.xml b/application/pom.xml index bff96ec6fd..dd1bade336 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -60,14 +60,14 @@ org.thingsboard.common transport - - org.thingsboard.transport - http - - - org.thingsboard.transport - coap - + + + + + + + + org.thingsboard.transport mqtt-common diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 64480863f5..05902a6146 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -455,6 +455,10 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { UUID sessionId = getSessionId(sessionInfo); if (msg.getEvent() == SessionEvent.OPEN) { + if(sessions.containsKey(sessionId)){ + logger.debug("[{}] Received duplicate session open event [{}]", deviceId, sessionId); + return; + } logger.debug("[{}] Processing new session [{}]", deviceId, sessionId); if (sessions.size() >= systemContext.getMaxConcurrentSessionsPerDevice()) { UUID sessionIdToRemove = sessions.keySet().stream().findFirst().orElse(null); diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java index 945317f768..66a8226a98 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -51,6 +51,7 @@ import javax.annotation.PostConstruct; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.locks.ReentrantLock; /** * Created by ashvayka on 05.10.18. @@ -97,6 +98,8 @@ public class RemoteTransportApiService implements TransportApiService { private TbKafkaResponseTemplate transportApiTemplate; + private ReentrantLock deviceCreationLock = new ReentrantLock(); + @PostConstruct public void init() { this.transportCallbackExecutor = Executors.newCachedThreadPool(); @@ -156,23 +159,26 @@ public class RemoteTransportApiService implements TransportApiService { DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); ListenableFuture gatewayFuture = deviceService.findDeviceByIdAsync(gatewayId); return Futures.transform(gatewayFuture, gateway -> { - Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), gateway.getName()); - if (device == null) { - device = new Device(); - device.setTenantId(gateway.getTenantId()); - device.setName(requestMsg.getDeviceName()); - device.setType(requestMsg.getDeviceType()); - device.setCustomerId(gateway.getCustomerId()); - device = deviceService.saveDevice(device); - relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); - deviceStateService.onDeviceAdded(device); - } + deviceCreationLock.lock(); try { + Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); + if (device == null) { + device = new Device(); + device.setTenantId(gateway.getTenantId()); + device.setName(requestMsg.getDeviceName()); + device.setType(requestMsg.getDeviceType()); + device.setCustomerId(gateway.getCustomerId()); + device = deviceService.saveDevice(device); + relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); + deviceStateService.onDeviceAdded(device); + } return TransportApiResponseMsg.newBuilder() .setGetOrCreateDeviceResponseMsg(GetOrCreateDeviceFromGatewayResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); } catch (JsonProcessingException e) { log.warn("[{}] Failed to lookup device by gateway id and name", gatewayId, requestMsg.getDeviceName(), e); throw new RuntimeException(e); + } finally { + deviceCreationLock.unlock(); } }, transportCallbackExecutor); } diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index bc7eeff6f7..3c7c521b55 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -122,7 +122,8 @@ public class DeviceApiController { @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, HttpServletRequest request) { - return subscribe(deviceToken, timeout, new RpcSubscribeMsg(), request); +// return subscribe(deviceToken, timeout, new RpcSubscribeMsg(), request); + return null; } @RequestMapping(value = "/{deviceToken}/rpc/{requestId}", method = RequestMethod.POST) @@ -174,15 +175,15 @@ public class DeviceApiController { public DeferredResult subscribeToAttributes(@PathVariable("deviceToken") String deviceToken, @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, HttpServletRequest httpRequest) { - - return subscribe(deviceToken, timeout, new AttributesSubscribeMsg(), httpRequest); + return null; +// return subscribe(deviceToken, timeout, new AttributesSubscribeMsg(), httpRequest); } - private DeferredResult subscribe(String deviceToken, long timeout, FromDeviceMsg msg, HttpServletRequest httpRequest) { - DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } +// private DeferredResult subscribe(String deviceToken, long timeout, FromDeviceMsg msg, HttpServletRequest httpRequest) { +// DeferredResult responseWriter = new DeferredResult(); +// if (quotaExceeded(httpRequest, responseWriter)) { +// return responseWriter; +// } // HttpSessionCtx ctx = getHttpSessionCtx(responseWriter, timeout); // if (ctx.login(new DeviceTokenCredentials(deviceToken))) { // try { @@ -193,21 +194,22 @@ public class DeviceApiController { // } else { // responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); // } - return responseWriter; - } +// return responseWriter; +// } private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter) { return getHttpSessionCtx(responseWriter, defaultTimeout); } private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter, long timeout) { - return new HttpSessionCtx(processor, authService, responseWriter, timeout != 0 ? timeout : defaultTimeout); + return null; +// return new HttpSessionCtx(processor, authService, responseWriter, timeout != 0 ? timeout : defaultTimeout); } - private void process(HttpSessionCtx ctx, FromDeviceMsg request) { - AdaptorToSessionActorMsg msg = new BasicAdaptorToSessionActorMsg(ctx, request); -// processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); - } +// private void process(HttpSessionCtx ctx, FromDeviceMsg request) { +// AdaptorToSessionActorMsg msg = new BasicAdaptorToSessionActorMsg(ctx, request); +//// processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); +// } private boolean quotaExceeded(HttpServletRequest request, DeferredResult responseWriter) { if (quotaService.isQuotaExceeded(request.getRemoteAddr())) { diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java index 62999cc64b..0e087d4966 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; import java.util.Optional; +import java.util.UUID; import java.util.function.Consumer; /** @@ -37,127 +38,136 @@ import java.util.function.Consumer; @Slf4j public class HttpSessionCtx extends DeviceAwareSessionContext { - private final SessionId sessionId; - private final long timeout; - private final DeferredResult responseWriter; - - public HttpSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, DeferredResult responseWriter, long timeout) { - super(); - this.sessionId = new HttpSessionId(); - this.responseWriter = responseWriter; - this.timeout = timeout; - } - - @Override - public SessionType getSessionType() { - return SessionType.SYNC; + public HttpSessionCtx(UUID sessionId) { + super(sessionId); } @Override - public void onMsg(SessionActorToAdaptorMsg source) throws SessionException { - ToDeviceMsg msg = source.getMsg(); - switch (msg.getSessionMsgType()) { - case GET_ATTRIBUTES_RESPONSE: - reply((GetAttributesResponse) msg); - return; - case STATUS_CODE_RESPONSE: - reply((StatusCodeResponse) msg); - return; - case ATTRIBUTES_UPDATE_NOTIFICATION: - reply((AttributesUpdateNotification) msg); - return; - case TO_DEVICE_RPC_REQUEST: - reply((ToDeviceRpcRequestMsg) msg); - return; - case TO_SERVER_RPC_RESPONSE: - reply((ToServerRpcResponseMsg) msg); - return; - case RULE_ENGINE_ERROR: - reply((RuleEngineErrorMsg) msg); - return; - default: - break; - } - } - - private void reply(RuleEngineErrorMsg msg) { - HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; - switch (msg.getError()) { - case QUEUE_PUT_TIMEOUT: - status = HttpStatus.REQUEST_TIMEOUT; - break; - default: - if (msg.getInSessionMsgType() == SessionMsgType.TO_SERVER_RPC_REQUEST) { - status = HttpStatus.BAD_REQUEST; - } - break; - } - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toErrorJson(msg.getErrorMsg()).toString(), status)); - } - - private void reply(ResponseMsg msg, Consumer f) { - Optional msgError = msg.getError(); - if (!msgError.isPresent()) { - Optional msgData = msg.getData(); - if (msgData.isPresent()) { - f.accept(msgData.get()); - } - } else { - Exception e = msgError.get(); - responseWriter.setResult(new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR)); - } + public int nextMsgId() { + return 0; } - private void reply(ToDeviceRpcRequestMsg msg) { - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg, true).toString(), HttpStatus.OK)); - } - - private void reply(ToServerRpcResponseMsg msg) { -// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); - } - - private void reply(AttributesUpdateNotification msg) { - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg.getData(), false).toString(), HttpStatus.OK)); - } - - private void reply(GetAttributesResponse msg) { - reply(msg, payload -> { - if (payload.getClientAttributes().isEmpty() && payload.getSharedAttributes().isEmpty()) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.NOT_FOUND)); - } else { - JsonObject result = JsonConverter.toJson(payload, false); - responseWriter.setResult(new ResponseEntity<>(result.toString(), HttpStatus.OK)); - } - }); - } - - private void reply(StatusCodeResponse msg) { - reply(msg, payload -> { - if (payload == 0) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.valueOf(payload))); - } - }); - } - - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - //Do nothing - } - - @Override - public boolean isClosed() { - return false; - } - - @Override - public long getTimeout() { - return timeout; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } + // private final SessionId sessionId; +// private final long timeout; +// private final DeferredResult responseWriter; +// +// public HttpSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, DeferredResult responseWriter, long timeout) { +// super(); +// this.sessionId = new HttpSessionId(); +// this.responseWriter = responseWriter; +// this.timeout = timeout; +// } +// +// @Override +// public SessionType getSessionType() { +// return SessionType.SYNC; +// } +// +// @Override +// public void onMsg(SessionActorToAdaptorMsg source) throws SessionException { +// ToDeviceMsg msg = source.getMsg(); +// switch (msg.getSessionMsgType()) { +// case GET_ATTRIBUTES_RESPONSE: +// reply((GetAttributesResponse) msg); +// return; +// case STATUS_CODE_RESPONSE: +// reply((StatusCodeResponse) msg); +// return; +// case ATTRIBUTES_UPDATE_NOTIFICATION: +// reply((AttributesUpdateNotification) msg); +// return; +// case TO_DEVICE_RPC_REQUEST: +// reply((ToDeviceRpcRequestMsg) msg); +// return; +// case TO_SERVER_RPC_RESPONSE: +// reply((ToServerRpcResponseMsg) msg); +// return; +// case RULE_ENGINE_ERROR: +// reply((RuleEngineErrorMsg) msg); +// return; +// default: +// break; +// } +// } +// +// private void reply(RuleEngineErrorMsg msg) { +// HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; +// switch (msg.getError()) { +// case QUEUE_PUT_TIMEOUT: +// status = HttpStatus.REQUEST_TIMEOUT; +// break; +// default: +// if (msg.getInSessionMsgType() == SessionMsgType.TO_SERVER_RPC_REQUEST) { +// status = HttpStatus.BAD_REQUEST; +// } +// break; +// } +// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toErrorJson(msg.getErrorMsg()).toString(), status)); +// } +// +// private void reply(ResponseMsg msg, Consumer f) { +// Optional msgError = msg.getError(); +// if (!msgError.isPresent()) { +// Optional msgData = msg.getData(); +// if (msgData.isPresent()) { +// f.accept(msgData.get()); +// } +// } else { +// Exception e = msgError.get(); +// responseWriter.setResult(new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR)); +// } +// } +// +// private void reply(ToDeviceRpcRequestMsg msg) { +// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg, true).toString(), HttpStatus.OK)); +// } +// +// private void reply(ToServerRpcResponseMsg msg) { +//// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); +// } +// +// private void reply(AttributesUpdateNotification msg) { +// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg.getData(), false).toString(), HttpStatus.OK)); +// } +// +// private void reply(GetAttributesResponse msg) { +// reply(msg, payload -> { +// if (payload.getClientAttributes().isEmpty() && payload.getSharedAttributes().isEmpty()) { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.NOT_FOUND)); +// } else { +// JsonObject result = JsonConverter.toJson(payload, false); +// responseWriter.setResult(new ResponseEntity<>(result.toString(), HttpStatus.OK)); +// } +// }); +// } +// +// private void reply(StatusCodeResponse msg) { +// reply(msg, payload -> { +// if (payload == 0) { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); +// } else { +// responseWriter.setResult(new ResponseEntity<>(HttpStatus.valueOf(payload))); +// } +// }); +// } +// +// @Override +// public void onMsg(SessionCtrlMsg msg) throws SessionException { +// //Do nothing +// } +// +// @Override +// public boolean isClosed() { +// return false; +// } +// +// @Override +// public long getTimeout() { +// return timeout; +// } +// +// @Override +// public SessionId getSessionId() { +// return sessionId; +// } } diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java deleted file mode 100644 index a5426ca051..0000000000 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.http.session; - -import java.util.UUID; - -/** - * @author Andrew Shvayka - */ -public class HttpSessionId implements SessionId { - - private final UUID id; - - public HttpSessionId() { - this.id = UUID.randomUUID(); - } - - @Override - public String toUidStr() { - return id.toString(); - } -} diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index b570c74ec4..b1b176d032 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -54,7 +54,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenR import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; -import org.thingsboard.server.transport.mqtt.session.GatewaySessionCtx; +import org.thingsboard.server.transport.mqtt.session.GatewaySessionHandler; import org.thingsboard.server.transport.mqtt.util.SslUtil; import javax.net.ssl.SSLPeerUnverifiedException; @@ -98,7 +98,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private volatile SessionInfoProto sessionInfo; private volatile InetSocketAddress address; private volatile DeviceSessionCtx deviceSessionCtx; - private volatile GatewaySessionCtx gatewaySessionCtx; + private volatile GatewaySessionHandler gatewaySessionHandler; MqttTransportHandler(MqttTransportContext context) { this.sessionId = UUID.randomUUID(); @@ -175,7 +175,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId); if (topicName.startsWith(MqttTopics.BASE_GATEWAY_API_TOPIC)) { - if (gatewaySessionCtx != null) { + if (gatewaySessionHandler != null) { handleGatewayPublishMsg(topicName, msgId, mqttMsg); } } else { @@ -187,22 +187,22 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement try { switch (topicName) { case MqttTopics.GATEWAY_TELEMETRY_TOPIC: - gatewaySessionCtx.onDeviceTelemetry(mqttMsg); + gatewaySessionHandler.onDeviceTelemetry(mqttMsg); break; case MqttTopics.GATEWAY_ATTRIBUTES_TOPIC: - gatewaySessionCtx.onDeviceAttributes(mqttMsg); + gatewaySessionHandler.onDeviceAttributes(mqttMsg); break; case MqttTopics.GATEWAY_ATTRIBUTES_REQUEST_TOPIC: - gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); + gatewaySessionHandler.onDeviceAttributesRequest(mqttMsg); break; case MqttTopics.GATEWAY_RPC_TOPIC: - gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); + gatewaySessionHandler.onDeviceRpcResponse(mqttMsg); break; case MqttTopics.GATEWAY_CONNECT_TOPIC: - gatewaySessionCtx.onDeviceConnect(mqttMsg); + gatewaySessionHandler.onDeviceConnect(mqttMsg); break; case MqttTopics.GATEWAY_DISCONNECT_TOPIC: - gatewaySessionCtx.onDeviceDisconnect(mqttMsg); + gatewaySessionHandler.onDeviceDisconnect(mqttMsg); break; } } catch (RuntimeException | AdaptorException e) { @@ -405,8 +405,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement if (deviceSessionCtx.isConnected()) { transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.CLOSED), null); transportService.deregisterSession(sessionInfo); - if (gatewaySessionCtx != null) { - gatewaySessionCtx.onGatewayDisconnect(); + if (gatewaySessionHandler != null) { + gatewaySessionHandler.onGatewayDisconnect(); } } } @@ -467,7 +467,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement if (infoNode != null) { JsonNode gatewayNode = infoNode.get("gateway"); if (gatewayNode != null && gatewayNode.asBoolean()) { - gatewaySessionCtx = new GatewaySessionCtx(context, deviceSessionCtx, sessionId); + gatewaySessionHandler = new GatewaySessionHandler(context, deviceSessionCtx, sessionId); } } } catch (IOException e) { diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java index 8560b35acf..1e2c2ca71d 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -30,10 +30,10 @@ import java.util.concurrent.ConcurrentMap; @Slf4j public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext implements SessionMsgListener { - private final GatewaySessionCtx parent; + private final GatewaySessionHandler parent; private final SessionInfoProto sessionInfo; - public GatewayDeviceSessionCtx(GatewaySessionCtx parent, DeviceInfoProto deviceInfo, ConcurrentMap mqttQoSMap) { + public GatewayDeviceSessionCtx(GatewaySessionHandler parent, DeviceInfoProto deviceInfo, ConcurrentMap mqttQoSMap) { super(UUID.randomUUID(), mqttQoSMap); this.parent = parent; this.sessionInfo = SessionInfoProto.newBuilder() diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java similarity index 96% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java rename to transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java index fd5eeb7876..b1504b1246 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -52,13 +52,12 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicInteger; /** * Created by ashvayka on 19.01.17. */ @Slf4j -public class GatewaySessionCtx { +public class GatewaySessionHandler { private static final String DEFAULT_DEVICE_TYPE = "default"; private static final String CAN_T_PARSE_VALUE = "Can't parse value: "; @@ -73,7 +72,7 @@ public class GatewaySessionCtx { private final ChannelHandlerContext channel; private final DeviceSessionCtx deviceSessionCtx; - public GatewaySessionCtx(MqttTransportContext context, DeviceSessionCtx deviceSessionCtx, UUID sessionId) { + public GatewaySessionHandler(MqttTransportContext context, DeviceSessionCtx deviceSessionCtx, UUID sessionId) { this.context = context; this.transportService = context.getTransportService(); this.deviceSessionCtx = deviceSessionCtx; @@ -114,10 +113,12 @@ public class GatewaySessionCtx { new TransportServiceCallback() { @Override public void onSuccess(GetOrCreateDeviceFromGatewayResponseMsg msg) { - GatewayDeviceSessionCtx deviceSessionCtx = new GatewayDeviceSessionCtx(GatewaySessionCtx.this, msg.getDeviceInfo(), mqttQoSMap); + GatewayDeviceSessionCtx deviceSessionCtx = new GatewayDeviceSessionCtx(GatewaySessionHandler.this, msg.getDeviceInfo(), mqttQoSMap); if (devices.putIfAbsent(deviceName, deviceSessionCtx) == null) { SessionInfoProto deviceSessionInfo = deviceSessionCtx.getSessionInfo(); transportService.process(deviceSessionInfo, MqttTransportHandler.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); + transportService.process(deviceSessionInfo, TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), null); + transportService.process(deviceSessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), null); transportService.registerSession(deviceSessionInfo, deviceSessionCtx); } future.set(devices.get(deviceName)); @@ -203,7 +204,7 @@ public class GatewaySessionCtx { @Override public void onFailure(Throwable t) { - log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + log.debug("[{}] Failed to process device attributes command: {}", sessionId, deviceName, t); } }, context.getExecutor()); } @@ -264,8 +265,8 @@ public class GatewaySessionCtx { } else { result.addAllSharedAttributeNames(keys); } - int msgId = msg.variableHeader().packetId(); TransportProtos.GetAttributeRequestMsg requestMsg = result.build(); + int msgId = msg.variableHeader().packetId(); Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback() { @Override @@ -275,7 +276,7 @@ public class GatewaySessionCtx { @Override public void onFailure(Throwable t) { - log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + log.debug("[{}] Failed to process device attributes request command: {}", sessionId, deviceName, t); } }, context.getExecutor()); ack(msg); diff --git a/transport/pom.xml b/transport/pom.xml index 671e3c1d63..79159885d2 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -35,7 +35,7 @@ - + http mqtt-common mqtt-transport From 2590f04ac0fd9882ecbefa98111ce57dd9264ab1 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 12 Oct 2018 15:41:44 +0300 Subject: [PATCH 207/307] Local Transport Service implementation --- .../transport/LocalTransportApiService.java | 173 ++++++++++++ .../transport/LocalTransportService.java | 249 ++++++++++++++++++ .../RemoteRuleEngineTransportService.java | 5 +- .../transport/RemoteTransportApiService.java | 121 ++------- .../src/main/resources/thingsboard.yml | 2 +- .../server/kafka/TbKafkaHandler.java | 2 +- .../transport/mqtt/MqttTransportContext.java | 1 - .../transport/mqtt/MqttTransportHandler.java | 2 +- .../mqtt/session/GatewaySessionHandler.java | 2 + .../src/main/resources/logback.xml | 34 +++ 10 files changed, 479 insertions(+), 112 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java create mode 100644 transport/mqtt-transport/src/main/resources/logback.xml diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java new file mode 100644 index 0000000000..5c39f8af4d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java @@ -0,0 +1,173 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaResponseTemplate; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.service.cluster.discovery.DiscoveryService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.state.DeviceStateService; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Created by ashvayka on 05.10.18. + */ +@Slf4j +@Service +public class LocalTransportApiService implements TransportApiService { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private DeviceService deviceService; + + @Autowired + private RelationService relationService; + + @Autowired + private DeviceCredentialsService deviceCredentialsService; + + @Autowired + private DeviceStateService deviceStateService; + + @Autowired + private DbCallbackExecutorService dbCallbackExecutorService; + + private ReentrantLock deviceCreationLock = new ReentrantLock(); + + @Override + public ListenableFuture handle(TransportApiRequestMsg transportApiRequestMsg) { + if (transportApiRequestMsg.hasValidateTokenRequestMsg()) { + ValidateDeviceTokenRequestMsg msg = transportApiRequestMsg.getValidateTokenRequestMsg(); + return validateCredentials(msg.getToken(), DeviceCredentialsType.ACCESS_TOKEN); + } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { + ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); + return validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { + return handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); + } + return getEmptyTransportApiResponseFuture(); + } + + private ListenableFuture validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) { + //TODO: Make async and enable caching + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId); + if (credentials != null && credentials.getCredentialsType() == credentialsType) { + return getDeviceInfo(credentials.getDeviceId()); + } else { + return getEmptyTransportApiResponseFuture(); + } + } + + private ListenableFuture handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { + DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); + ListenableFuture gatewayFuture = deviceService.findDeviceByIdAsync(gatewayId); + return Futures.transform(gatewayFuture, gateway -> { + deviceCreationLock.lock(); + try { + Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); + if (device == null) { + device = new Device(); + device.setTenantId(gateway.getTenantId()); + device.setName(requestMsg.getDeviceName()); + device.setType(requestMsg.getDeviceType()); + device.setCustomerId(gateway.getCustomerId()); + device = deviceService.saveDevice(device); + relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); + deviceStateService.onDeviceAdded(device); + } + return TransportApiResponseMsg.newBuilder() + .setGetOrCreateDeviceResponseMsg(GetOrCreateDeviceFromGatewayResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to lookup device by gateway id and name", gatewayId, requestMsg.getDeviceName(), e); + throw new RuntimeException(e); + } finally { + deviceCreationLock.unlock(); + } + }, dbCallbackExecutorService); + } + + + private ListenableFuture getDeviceInfo(DeviceId deviceId) { + return Futures.transform(deviceService.findDeviceByIdAsync(deviceId), device -> { + if (device == null) { + log.trace("[{}] Failed to lookup device by id", deviceId); + return getEmptyTransportApiResponse(); + } + try { + return TransportApiResponseMsg.newBuilder() + .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to lookup device by id", deviceId, e); + return getEmptyTransportApiResponse(); + } + }); + } + + private DeviceInfoProto getDeviceInfoProto(Device device) throws JsonProcessingException { + return DeviceInfoProto.newBuilder() + .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(device.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(device.getId().getId().getMostSignificantBits()) + .setDeviceIdLSB(device.getId().getId().getLeastSignificantBits()) + .setDeviceName(device.getName()) + .setDeviceType(device.getType()) + .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())) + .build(); + } + + private ListenableFuture getEmptyTransportApiResponseFuture() { + return Futures.immediateFuture(getEmptyTransportApiResponse()); + } + + private TransportApiResponseMsg getEmptyTransportApiResponse() { + return TransportApiResponseMsg.newBuilder() + .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.getDefaultInstance()).build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java new file mode 100644 index 0000000000..ba5cc074d4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -0,0 +1,249 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import akka.actor.ActorRef; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.util.DonAsynchron; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; +import org.thingsboard.server.service.cluster.rpc.ClusterRpcService; +import org.thingsboard.server.service.encoding.DataDecodingEncodingService; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 12.10.18. + */ +@Slf4j +@Service +@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "local") +public class LocalTransportService implements TransportService, RuleEngineTransportService { + + private ConcurrentMap sessions = new ConcurrentHashMap<>(); + + private ExecutorService transportCallbackExecutor; + + @Autowired + private TransportApiService transportApiService; + + @Autowired + private ActorSystemContext actorContext; + + //TODO: completely replace this routing with the Kafka routing by partition ids. + @Autowired + private ClusterRoutingService routingService; + @Autowired + private ClusterRpcService rpcService; + @Autowired + private DataDecodingEncodingService encodingService; + + @PostConstruct + public void init() { + this.transportCallbackExecutor = new ThreadPoolExecutor(0, 100, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + } + + @PreDestroy + public void destroy() { + if (transportCallbackExecutor != null) { + transportCallbackExecutor.shutdownNow(); + } + } + + @Override + public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback callback) { + DonAsynchron.withCallback( + transportApiService.handle(TransportApiRequestMsg.newBuilder().setValidateTokenRequestMsg(msg).build()), + transportApiResponseMsg -> { + if (callback != null) { + callback.onSuccess(transportApiResponseMsg.getValidateTokenResponseMsg()); + } + }, + getThrowableConsumer(callback), transportCallbackExecutor); + } + + @Override + public void process(ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + DonAsynchron.withCallback( + transportApiService.handle(TransportApiRequestMsg.newBuilder().setValidateX509CertRequestMsg(msg).build()), + transportApiResponseMsg -> { + if (callback != null) { + callback.onSuccess(transportApiResponseMsg.getValidateTokenResponseMsg()); + } + }, + getThrowableConsumer(callback), transportCallbackExecutor); + } + + @Override + public void process(GetOrCreateDeviceFromGatewayRequestMsg msg, TransportServiceCallback callback) { + DonAsynchron.withCallback( + transportApiService.handle(TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(msg).build()), + transportApiResponseMsg -> { + if (callback != null) { + callback.onSuccess(transportApiResponseMsg.getGetOrCreateDeviceResponseMsg()); + } + }, + getThrowableConsumer(callback), transportCallbackExecutor); + } + + @Override + public void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSessionEvent(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostTelemetry(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostAttributes(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setGetAttributes(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToAttributes(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToRPC(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToDeviceRPCCallResponse(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback); + } + + @Override + public void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener) { + sessions.putIfAbsent(toId(sessionInfo), listener); + //TODO: monitor sessions periodically: PING REQ/RESP, etc. + } + + @Override + public void deregisterSession(SessionInfoProto sessionInfo) { + sessions.remove(toId(sessionInfo)); + } + + private UUID toId(SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg) { + process(nodeId, msg, null, null); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { + UUID sessionId = new UUID(msg.getSessionIdMSB(), msg.getSessionIdLSB()); + SessionMsgListener listener = sessions.get(sessionId); + if (listener != null) { + transportCallbackExecutor.submit(() -> { + if (msg.hasGetAttributesResponse()) { + listener.onGetAttributesResponse(msg.getGetAttributesResponse()); + } + if (msg.hasAttributeUpdateNotification()) { + listener.onAttributeUpdate(msg.getAttributeUpdateNotification()); + } + if (msg.hasSessionCloseNotification()) { + listener.onRemoteSessionCloseCommand(msg.getSessionCloseNotification()); + } + if (msg.hasToDeviceRequest()) { + listener.onToDeviceRpcRequest(msg.getToDeviceRequest()); + } + if (msg.hasToServerResponse()) { + listener.onToServerRpcResponse(msg.getToServerResponse()); + } + }); + } else { + //TODO: should we notify the device actor about missed session? + log.debug("[{}] Missing session.", sessionId); + } + if (onSuccess != null) { + onSuccess.run(); + } + } + + private void forwardToDeviceActor(TransportToDeviceActorMsg toDeviceActorMsg, TransportServiceCallback callback) { + TransportToDeviceActorMsgWrapper wrapper = new TransportToDeviceActorMsgWrapper(toDeviceActorMsg); + Optional address = routingService.resolveById(wrapper.getDeviceId()); + if (address.isPresent()) { + rpcService.tell(encodingService.convertToProtoDataMessage(address.get(), wrapper)); + } else { + actorContext.getAppActor().tell(wrapper, ActorRef.noSender()); + } + if (callback != null) { + callback.onSuccess(null); + } + } + + private Consumer getThrowableConsumer(TransportServiceCallback callback) { + return e -> { + if (callback != null) { + callback.onError(e); + } + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index aa36b9b2cb..a0791adddf 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -55,7 +55,7 @@ import java.util.function.Consumer; */ @Slf4j @Service -@ConditionalOnProperty(prefix = "transport.remote", value = "enabled", havingValue = "true") +@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote") public class RemoteRuleEngineTransportService implements RuleEngineTransportService { private static final ObjectMapper mapper = new ObjectMapper(); @@ -78,9 +78,6 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ @Autowired private ActorSystemContext actorContext; - @Autowired - private ActorService actorService; - //TODO: completely replace this routing with the Kafka routing by partition ids. @Autowired private ClusterRoutingService routingService; diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java index 66a8226a98..ebd35a414e 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; @@ -48,20 +49,22 @@ import org.thingsboard.server.service.cluster.discovery.DiscoveryService; import org.thingsboard.server.service.state.DeviceStateService; import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; /** * Created by ashvayka on 05.10.18. */ @Slf4j -@Service -@ConditionalOnProperty(prefix = "transport.remote", value = "enabled", havingValue = "true") -public class RemoteTransportApiService implements TransportApiService { - - private static final ObjectMapper mapper = new ObjectMapper(); +@Component +@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote") +public class RemoteTransportApiService { @Value("${transport.remote.transport_api.requests_topic}") private String transportApiRequestsTopic; @@ -83,26 +86,15 @@ public class RemoteTransportApiService implements TransportApiService { private DiscoveryService discoveryService; @Autowired - private DeviceService deviceService; - - @Autowired - private RelationService relationService; - - @Autowired - private DeviceCredentialsService deviceCredentialsService; - - @Autowired - private DeviceStateService deviceStateService; + private TransportApiService transportApiService; private ExecutorService transportCallbackExecutor; private TbKafkaResponseTemplate transportApiTemplate; - private ReentrantLock deviceCreationLock = new ReentrantLock(); - @PostConstruct public void init() { - this.transportCallbackExecutor = Executors.newCachedThreadPool(); + this.transportCallbackExecutor = new ThreadPoolExecutor(0, 100, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder responseBuilder = TBKafkaProducerTemplate.builder(); responseBuilder.settings(kafkaSettings); @@ -126,98 +118,19 @@ public class RemoteTransportApiService implements TransportApiService { builder.requestTimeout(requestTimeout); builder.pollInterval(responsePollDuration); builder.executor(transportCallbackExecutor); - builder.handler(this); + builder.handler(transportApiService); transportApiTemplate = builder.build(); transportApiTemplate.init(); } - @Override - public ListenableFuture handle(TransportApiRequestMsg transportApiRequestMsg) throws Exception { - if (transportApiRequestMsg.hasValidateTokenRequestMsg()) { - ValidateDeviceTokenRequestMsg msg = transportApiRequestMsg.getValidateTokenRequestMsg(); - return validateCredentials(msg.getToken(), DeviceCredentialsType.ACCESS_TOKEN); - } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { - ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); - return validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); - } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { - return handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); + @PreDestroy + public void destroy() { + if (transportApiTemplate != null) { + transportApiTemplate.stop(); } - return getEmptyTransportApiResponseFuture(); - } - - private ListenableFuture validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) { - //TODO: Make async and enable caching - DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId); - if (credentials != null && credentials.getCredentialsType() == credentialsType) { - return getDeviceInfo(credentials.getDeviceId()); - } else { - return getEmptyTransportApiResponseFuture(); + if (transportCallbackExecutor != null) { + transportCallbackExecutor.shutdownNow(); } } - private ListenableFuture handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { - DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); - ListenableFuture gatewayFuture = deviceService.findDeviceByIdAsync(gatewayId); - return Futures.transform(gatewayFuture, gateway -> { - deviceCreationLock.lock(); - try { - Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); - if (device == null) { - device = new Device(); - device.setTenantId(gateway.getTenantId()); - device.setName(requestMsg.getDeviceName()); - device.setType(requestMsg.getDeviceType()); - device.setCustomerId(gateway.getCustomerId()); - device = deviceService.saveDevice(device); - relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); - deviceStateService.onDeviceAdded(device); - } - return TransportApiResponseMsg.newBuilder() - .setGetOrCreateDeviceResponseMsg(GetOrCreateDeviceFromGatewayResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); - } catch (JsonProcessingException e) { - log.warn("[{}] Failed to lookup device by gateway id and name", gatewayId, requestMsg.getDeviceName(), e); - throw new RuntimeException(e); - } finally { - deviceCreationLock.unlock(); - } - }, transportCallbackExecutor); - } - - - private ListenableFuture getDeviceInfo(DeviceId deviceId) { - return Futures.transform(deviceService.findDeviceByIdAsync(deviceId), device -> { - if (device == null) { - log.trace("[{}] Failed to lookup device by id", deviceId); - return getEmptyTransportApiResponse(); - } - try { - return TransportApiResponseMsg.newBuilder() - .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); - } catch (JsonProcessingException e) { - log.warn("[{}] Failed to lookup device by id", deviceId, e); - return getEmptyTransportApiResponse(); - } - }); - } - - private DeviceInfoProto getDeviceInfoProto(Device device) throws JsonProcessingException { - return DeviceInfoProto.newBuilder() - .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) - .setTenantIdLSB(device.getTenantId().getId().getLeastSignificantBits()) - .setDeviceIdMSB(device.getId().getId().getMostSignificantBits()) - .setDeviceIdLSB(device.getId().getId().getLeastSignificantBits()) - .setDeviceName(device.getName()) - .setDeviceType(device.getType()) - .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())) - .build(); - } - - private ListenableFuture getEmptyTransportApiResponseFuture() { - return Futures.immediateFuture(getEmptyTransportApiResponse()); - } - - private TransportApiResponseMsg getEmptyTransportApiResponse() { - return TransportApiResponseMsg.newBuilder() - .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.getDefaultInstance()).build(); - } } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 923976c302..529ab25040 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -452,8 +452,8 @@ js: max_errors: "${REMOTE_JS_SANDBOX_MAX_ERRORS:3}" transport: + type: "${TRANSPORT_TYPE:remote}" # local or remote remote: - enabled: "${REMOTE_TRANSPORT_ENABLED:true}" transport_api: requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java index a64d28e173..9f86155611 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java @@ -22,6 +22,6 @@ import com.google.common.util.concurrent.ListenableFuture; */ public interface TbKafkaHandler { - ListenableFuture handle(Request request) throws Exception; + ListenableFuture handle(Request request); } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 4742b3b520..173fe8aef1 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -48,7 +48,6 @@ public class MqttTransportContext { private final ObjectMapper mapper = new ObjectMapper(); @Autowired - @Lazy private TransportService transportService; @Autowired(required = false) diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index b1b176d032..f9427a846e 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -494,7 +494,6 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); ctx.close(); } else { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); sessionInfo = SessionInfoProto.newBuilder() .setNodeId(context.getNodeId()) @@ -508,6 +507,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.OPEN), null); transportService.registerSession(sessionInfo, this); checkGatewaySession(); + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java index b1504b1246..7ef832f705 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java +++ b/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -87,10 +87,12 @@ public class GatewaySessionHandler { JsonElement json = getJson(msg); String deviceName = checkDeviceName(getDeviceName(json)); String deviceType = getDeviceType(json); + log.trace("[{}] onDeviceConnect: {}", sessionId, deviceName); Futures.addCallback(onDeviceConnect(deviceName, deviceType), new FutureCallback() { @Override public void onSuccess(@Nullable GatewayDeviceSessionCtx result) { ack(msg); + log.trace("[{}] onDeviceConnectOk: {}", sessionId, deviceName); } @Override diff --git a/transport/mqtt-transport/src/main/resources/logback.xml b/transport/mqtt-transport/src/main/resources/logback.xml new file mode 100644 index 0000000000..18864a9fab --- /dev/null +++ b/transport/mqtt-transport/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file From 67431a044a55c241ac99d7a9fa4d0c21102489a1 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Mon, 15 Oct 2018 15:53:06 +0300 Subject: [PATCH 208/307] Refactoring of packages --- application/pom.xml | 4 +- .../server/actors/ActorSystemContext.java | 1 - .../src/main/resources/thingsboard.yml | 7 +- common/message/pom.xml | 4 + .../server/common/msg}/EncryptionUtil.java | 2 +- common/pom.xml | 1 - common/queue/pom.xml | 6 +- .../server/kafka/TbNodeIdProvider.java | 51 ++++++++ .../transport/mqtt}/pom.xml | 22 +++- .../mqtt/MqttSslHandlerProvider.java | 56 ++++++--- .../server/transport/mqtt/MqttTopics.java | 0 .../transport/mqtt/MqttTransportContext.java | 22 ++-- .../transport/mqtt/MqttTransportHandler.java | 2 +- .../mqtt/MqttTransportServerInitializer.java | 7 -- .../transport/mqtt/MqttTransportService.java | 10 -- .../mqtt/adaptors/JsonMqttAdaptor.java | 0 .../mqtt/adaptors/MqttTransportAdaptor.java | 1 - .../mqtt/session/DeviceSessionCtx.java | 3 - .../mqtt/session/GatewayDeviceSessionCtx.java | 0 .../mqtt/session/GatewaySessionHandler.java | 0 .../MqttDeviceAwareSessionContext.java | 0 .../mqtt/session/MqttTopicMatcher.java | 0 .../server/transport/mqtt/util/SslUtil.java | 2 +- common/transport/pom.xml | 83 ++----------- common/transport/transport-api/pom.xml | 113 ++++++++++++++++++ .../common/transport/SessionMsgListener.java | 0 .../common/transport/SessionMsgProcessor.java | 0 .../common/transport/TransportAdaptor.java | 0 .../common/transport/TransportService.java | 0 .../transport/TransportServiceCallback.java | 0 .../transport/adaptor/AdaptorException.java | 0 .../transport/adaptor/JsonConverter.java | 0 .../transport/auth/DeviceAuthResult.java | 0 .../transport/auth/DeviceAuthService.java | 0 .../transport/quota/AbstractQuotaService.java | 0 .../server/common/transport/quota/Clock.java | 0 .../common/transport/quota/QuotaService.java | 0 .../transport/quota/RequestLimitPolicy.java | 0 .../host/HostIntervalRegistryCleaner.java | 0 .../host/HostIntervalRegistryLogger.java | 0 .../host/HostRequestIntervalRegistry.java | 0 .../quota/host/HostRequestLimitPolicy.java | 0 .../quota/host/HostRequestsQuotaService.java | 0 .../quota/inmemory/IntervalCount.java | 0 .../inmemory/IntervalRegistryCleaner.java | 0 .../inmemory/IntervalRegistryLogger.java | 0 .../inmemory/KeyBasedIntervalRegistry.java | 0 .../tenant/TenantIntervalRegistryCleaner.java | 0 .../tenant/TenantIntervalRegistryLogger.java | 0 .../tenant/TenantMsgsIntervalRegistry.java | 0 .../quota/tenant/TenantQuotaService.java | 0 .../tenant/TenantRequestLimitPolicy.java | 0 .../service/RemoteTransportService.java | 18 +-- .../service/ToRuleEngineMsgEncoder.java | 2 +- .../ToTransportMsgResponseDecoder.java | 2 +- .../service/TransportApiRequestEncoder.java | 2 +- .../service/TransportApiResponseDecoder.java | 2 +- .../session/DeviceAwareSessionContext.java | 0 .../src/main/proto/transport.proto | 1 + .../common/transport/quota/ClockTest.java | 0 .../quota/HostRequestLimitPolicyTest.java | 0 .../quota/HostRequestsQuotaServiceTest.java | 0 .../HostRequestIntervalRegistryTest.java | 0 .../quota/inmemory/IntervalCountTest.java | 0 .../inmemory/IntervalRegistryLoggerTest.java | 0 .../device/DeviceCredentialsServiceImpl.java | 2 +- pom.xml | 13 +- rule-engine/rule-engine-components/pom.xml | 4 + .../TbCopyAttributesToEntityViewNode.java | 1 - .../transport/http/DeviceApiController.java | 1 - .../http/session/HttpSessionCtx.java | 12 -- transport/mqtt-transport/pom.xml | 8 +- transport/pom.xml | 2 - 73 files changed, 287 insertions(+), 180 deletions(-) rename {dao/src/main/java/org/thingsboard/server/dao => common/message/src/main/java/org/thingsboard/server/common/msg}/EncryptionUtil.java (97%) create mode 100644 common/queue/src/main/java/org/thingsboard/server/kafka/TbNodeIdProvider.java rename {transport/mqtt-common => common/transport/mqtt}/pom.xml (80%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java (70%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java (100%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java (80%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java (99%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java (80%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java (83%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java (100%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java (98%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java (88%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java (100%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java (100%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java (100%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java (100%) rename {transport/mqtt-common => common/transport/mqtt}/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java (96%) create mode 100644 common/transport/transport-api/pom.xml rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/TransportService.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java (100%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java (100%) rename transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java => common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java (96%) rename {transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt => common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport}/service/ToRuleEngineMsgEncoder.java (94%) rename {transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt => common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport}/service/ToTransportMsgResponseDecoder.java (94%) rename {transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt => common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport}/service/TransportApiRequestEncoder.java (94%) rename {transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt => common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport}/service/TransportApiResponseDecoder.java (94%) rename common/transport/{ => transport-api}/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java (100%) rename common/transport/{ => transport-api}/src/main/proto/transport.proto (99%) rename common/transport/{ => transport-api}/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java (100%) rename common/transport/{ => transport-api}/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java (100%) rename common/transport/{ => transport-api}/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java (100%) rename common/transport/{ => transport-api}/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java (100%) rename common/transport/{ => transport-api}/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java (100%) rename common/transport/{ => transport-api}/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java (100%) diff --git a/application/pom.xml b/application/pom.xml index dd1bade336..915a4ab5e1 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -69,8 +69,8 @@ - org.thingsboard.transport - mqtt-common + org.thingsboard.common.transport + mqtt org.thingsboard diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 1081ab2aec..5e19d690c4 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -64,7 +64,6 @@ import org.thingsboard.server.service.encoding.DataDecodingEncodingService; import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.executors.ExternalCallExecutorService; import org.thingsboard.server.service.mail.MailExecutorService; -import org.thingsboard.server.service.queue.MsgQueueService; import org.thingsboard.server.service.rpc.DeviceRpcService; import org.thingsboard.server.service.script.JsExecutorService; import org.thingsboard.server.service.script.JsInvokeService; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 0693d5ddd2..ca509443bf 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -417,7 +417,7 @@ js: max_errors: "${REMOTE_JS_SANDBOX_MAX_ERRORS:3}" transport: - type: "${TRANSPORT_TYPE:remote}" # local or remote + type: "${TRANSPORT_TYPE:local}" # local or remote remote: transport_api: requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" @@ -434,11 +434,12 @@ transport: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" # Local HTTP transport parameters http: + enabled: "${MQTT_ENABLED:true}" request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" # Local MQTT transport parameters mqtt: # Enable/disable mqtt transport protocol. - enabled: "${MQTT_ENABLED:false}" + enabled: "${MQTT_ENABLED:true}" bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" bind_port: "${MQTT_BIND_PORT:1883}" adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" @@ -465,7 +466,7 @@ transport: # Local CoAP transport parameters coap: # Enable/disable coap transport protocol. - enabled: "${COAP_ENABLED:false}" + enabled: "${COAP_ENABLED:true}" bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" bind_port: "${COAP_BIND_PORT:5683}" adaptor: "${COAP_ADAPTOR_NAME:JsonCoapAdaptor}" diff --git a/common/message/pom.xml b/common/message/pom.xml index f4f20b08c6..d914d4f21d 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -40,6 +40,10 @@ org.thingsboard.common data + + org.bouncycastle + bcprov-jdk15on + org.slf4j slf4j-api diff --git a/dao/src/main/java/org/thingsboard/server/dao/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java similarity index 97% rename from dao/src/main/java/org/thingsboard/server/dao/EncryptionUtil.java rename to common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index a44ad57e19..41a608763a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao; +package org.thingsboard.server.common.msg; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.crypto.digests.SHA3Digest; diff --git a/common/pom.xml b/common/pom.xml index e3cdabff98..84481334e9 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -23,7 +23,6 @@ 2.2.0-SNAPSHOT thingsboard - org.thingsboard common pom diff --git a/common/queue/pom.xml b/common/queue/pom.xml index 11bb13981f..765960e6a7 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -16,7 +16,7 @@ --> + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.thingsboard @@ -64,6 +64,10 @@ com.google.code.gson gson + + org.apache.commons + commons-lang3 + org.slf4j slf4j-api diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbNodeIdProvider.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbNodeIdProvider.java new file mode 100644 index 0000000000..47dd725682 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbNodeIdProvider.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.annotation.PostConstruct; +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Created by ashvayka on 12.10.18. + */ +@Slf4j +@Component +public class TbNodeIdProvider { + + @Getter + @Value("${cluster.node_id:#{null}}") + private String nodeId; + + @PostConstruct + public void init() { + if (StringUtils.isEmpty(nodeId)) { + try { + nodeId = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + nodeId = org.apache.commons.lang3.RandomStringUtils.randomAlphabetic(10); + } + } + log.info("Current NodeId: {}", nodeId); + } + +} diff --git a/transport/mqtt-common/pom.xml b/common/transport/mqtt/pom.xml similarity index 80% rename from transport/mqtt-common/pom.xml rename to common/transport/mqtt/pom.xml index 6be5235a8a..91f30a6f3d 100644 --- a/transport/mqtt-common/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -19,12 +19,12 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.thingsboard + org.thingsboard.common 2.2.0-SNAPSHOT transport - org.thingsboard.transport - mqtt-common + org.thingsboard.common.transport + mqtt jar Thingsboard MQTT Transport Common @@ -32,18 +32,22 @@ UTF-8 - ${basedir}/../.. + ${basedir}/../../.. - org.thingsboard.common - transport + org.thingsboard.common.transport + transport-api io.netty netty-all + + org.springframework + spring-context-support + org.springframework spring-context @@ -68,6 +72,12 @@ com.google.guava guava + + com.google.code.findbugs + jsr305 + 3.0.1 + true + org.springframework.boot spring-boot-starter-test diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java similarity index 70% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 70ed5013e4..ee28738a1e 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -23,12 +23,19 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.security.DeviceCredentials; -import org.thingsboard.server.dao.EncryptionUtil; -import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.util.SslUtil; -import javax.net.ssl.*; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -37,6 +44,8 @@ import java.net.URL; import java.security.KeyStore; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; /** * Created by valerii.sosliuk on 11/6/16. @@ -58,7 +67,7 @@ public class MqttSslHandlerProvider { private String keyStoreType; @Autowired - private DeviceCredentialsService deviceCredentialsService; + private TransportService transportService; public SslHandler getSslHandler() { try { @@ -111,17 +120,17 @@ public class MqttSslHandlerProvider { break; } } - return new ThingsboardMqttX509TrustManager(x509Tm, deviceCredentialsService); + return new ThingsboardMqttX509TrustManager(x509Tm, transportService); } static class ThingsboardMqttX509TrustManager implements X509TrustManager { private final X509TrustManager trustManager; - private DeviceCredentialsService deviceCredentialsService; + private TransportService transportService; - ThingsboardMqttX509TrustManager(X509TrustManager trustManager, DeviceCredentialsService deviceCredentialsService) { + ThingsboardMqttX509TrustManager(X509TrustManager trustManager, TransportService transportService) { this.trustManager = trustManager; - this.deviceCredentialsService = deviceCredentialsService; + this.transportService = transportService; } @Override @@ -138,20 +147,39 @@ public class MqttSslHandlerProvider { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - DeviceCredentials deviceCredentials = null; + String credentialsBody = null; for (X509Certificate cert : chain) { try { String strCert = SslUtil.getX509CertificateString(cert); String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - deviceCredentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(sha3Hash); - if (deviceCredentials != null && strCert.equals(deviceCredentials.getCredentialsValue())) { + final String[] credentialsBodyHolder = new String[1]; + CountDownLatch latch = new CountDownLatch(1); + transportService.process(TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(TransportProtos.ValidateDeviceCredentialsResponseMsg msg) { + if (!StringUtils.isEmpty(msg.getCredentialsBody())) { + credentialsBodyHolder[0] = msg.getCredentialsBody(); + } + latch.countDown(); + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (strCert.equals(credentialsBodyHolder[0])) { + credentialsBody = credentialsBodyHolder[0]; break; } - } catch (IOException e) { + } catch (InterruptedException | IOException e) { log.error(e.getMessage(), e); } } - if (deviceCredentials == null) { + if (credentialsBody == null) { throw new CertificateException("Invalid Device Certificate"); } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java similarity index 100% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java similarity index 80% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 9d3d45f92b..9ae42e8ca3 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -20,21 +20,17 @@ import io.netty.handler.ssl.SslHandler; import lombok.Data; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; +import org.thingsboard.server.kafka.TbNodeIdProvider; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -61,11 +57,12 @@ public class MqttTransportContext { @Autowired private MqttTransportAdaptor adaptor; + @Autowired + private TbNodeIdProvider nodeIdProvider; + @Value("${transport.mqtt.netty.max_payload_size}") private Integer maxPayloadSize; - @Value("${cluster.node_id:#{null}}") - private String nodeId; private SslHandler sslHandler; @@ -74,14 +71,6 @@ public class MqttTransportContext { @PostConstruct public void init() { - if (StringUtils.isEmpty(nodeId)) { - try { - nodeId = InetAddress.getLocalHost().getHostName(); - } catch (UnknownHostException e) { - nodeId = RandomStringUtils.randomAlphabetic(10); - } - } - log.info("Current NodeId: {}", nodeId); executor = Executors.newCachedThreadPool(); } @@ -92,4 +81,7 @@ public class MqttTransportContext { } } + public String getNodeId() { + return nodeIdProvider.getNodeId(); + } } diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java similarity index 99% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index c09b9193e4..6a5597bb11 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -43,7 +43,7 @@ import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.dao.EncryptionUtil; +import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java similarity index 80% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java index 102a0d9f07..550837842f 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java @@ -21,13 +21,6 @@ import io.netty.channel.socket.SocketChannel; import io.netty.handler.codec.mqtt.MqttDecoder; import io.netty.handler.codec.mqtt.MqttEncoder; import io.netty.handler.ssl.SslHandler; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.TransportService; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; /** * @author Andrew Shvayka diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java similarity index 83% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index 41026784df..199b567430 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -23,19 +23,9 @@ import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.ResourceLeakDetector; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.TransportService; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java similarity index 100% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java similarity index 98% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index a653e4c93e..7af0e66e34 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -18,7 +18,6 @@ package org.thingsboard.server.transport.mqtt.adaptors; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java similarity index 88% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index dc0e9a44c2..fb05e0a624 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -16,11 +16,8 @@ package org.thingsboard.server.transport.mqtt.session; import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.mqtt.MqttMessage; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; import java.util.UUID; import java.util.concurrent.ConcurrentMap; diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java similarity index 100% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java similarity index 100% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java similarity index 100% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java similarity index 100% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java diff --git a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java similarity index 96% rename from transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java index 96cdf142a6..d98d1b91bc 100644 --- a/transport/mqtt-common/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java @@ -17,7 +17,7 @@ package org.thingsboard.server.transport.mqtt.util; import lombok.extern.slf4j.Slf4j; import org.springframework.util.Base64Utils; -import org.thingsboard.server.dao.EncryptionUtil; +import org.thingsboard.server.common.msg.EncryptionUtil; import java.io.IOException; import java.security.cert.CertificateEncodingException; diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 46efbf4dcc..69f64e6307 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -16,7 +16,7 @@ --> + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.thingsboard @@ -25,85 +25,20 @@ org.thingsboard.common transport - jar + pom - Thingsboard Server Common Transport components + Thingsboard Server Commons https://thingsboard.io UTF-8 ${basedir}/../.. - - - - org.thingsboard.common - data - - - org.thingsboard.common - message - - - com.google.code.gson - gson - - - org.slf4j - slf4j-api - - - org.slf4j - log4j-over-slf4j - - - ch.qos.logback - logback-core - - - ch.qos.logback - logback-classic - - - junit - junit - test - - - org.mockito - mockito-all - test - - - org.springframework - spring-context - - - org.springframework.boot - spring-boot-starter-web - provided - - - com.google.guava - guava - - - org.apache.commons - commons-lang3 - - - com.google.protobuf - protobuf-java - - - - - - - org.xolstice.maven.plugins - protobuf-maven-plugin - - - + + transport-api + mqtt + + + diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml new file mode 100644 index 0000000000..3538e468d4 --- /dev/null +++ b/common/transport/transport-api/pom.xml @@ -0,0 +1,113 @@ + + + 4.0.0 + + org.thingsboard.common + 2.2.0-SNAPSHOT + transport + + org.thingsboard.common.transport + transport-api + jar + + Thingsboard Server Common Transport components + https://thingsboard.io + + + UTF-8 + ${basedir}/../../.. + + + + + org.thingsboard.common + data + + + org.thingsboard.common + message + + + org.thingsboard.common + queue + + + com.google.code.gson + gson + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + junit + junit + test + + + org.mockito + mockito-all + test + + + org.springframework + spring-context + + + org.springframework.boot + spring-boot-starter-web + provided + + + com.google.guava + guava + + + org.apache.commons + commons-lang3 + + + com.google.protobuf + protobuf-java + + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + + diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/TransportService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java similarity index 96% rename from transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index 4ce6b081aa..ab52d41366 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/MqttTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.mqtt.service; +package org.thingsboard.server.common.transport.service; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecords; @@ -21,6 +21,7 @@ import org.apache.kafka.clients.producer.Callback; import org.apache.kafka.clients.producer.RecordMetadata; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; @@ -44,7 +45,7 @@ import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; import org.thingsboard.server.kafka.TBKafkaProducerTemplate; import org.thingsboard.server.kafka.TbKafkaRequestTemplate; import org.thingsboard.server.kafka.TbKafkaSettings; -import org.thingsboard.server.transport.mqtt.MqttTransportContext; +import org.thingsboard.server.kafka.TbNodeIdProvider; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; @@ -58,9 +59,10 @@ import java.util.concurrent.Executors; /** * Created by ashvayka on 05.10.18. */ +@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote", matchIfMissing = true) @Service @Slf4j -public class MqttTransportService implements TransportService { +public class RemoteTransportService implements TransportService { @Value("${kafka.rule_engine.topic}") private String ruleEngineTopic; @@ -89,7 +91,7 @@ public class MqttTransportService implements TransportService { private TbKafkaSettings kafkaSettings; //We use this to get the node id. We should replace this with a component that provides the node id. @Autowired - private MqttTransportContext transportContext; + private TbNodeIdProvider nodeIdProvider; private ExecutorService transportCallbackExecutor; @@ -112,8 +114,8 @@ public class MqttTransportService implements TransportService { TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); responseBuilder.settings(kafkaSettings); - responseBuilder.topic(transportApiResponsesTopic + "." + transportContext.getNodeId()); - responseBuilder.clientId("transport-api-client-" + transportContext.getNodeId()); + responseBuilder.topic(transportApiResponsesTopic + "." + nodeIdProvider.getNodeId()); + responseBuilder.clientId("transport-api-client-" + nodeIdProvider.getNodeId()); responseBuilder.groupId("transport-api-client"); responseBuilder.autoCommit(true); responseBuilder.autoCommitIntervalMs(autoCommitInterval); @@ -138,8 +140,8 @@ public class MqttTransportService implements TransportService { TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder mainConsumerBuilder = TBKafkaConsumerTemplate.builder(); mainConsumerBuilder.settings(kafkaSettings); - mainConsumerBuilder.topic(notificationsTopic + "." + transportContext.getNodeId()); - mainConsumerBuilder.clientId("transport-" + transportContext.getNodeId()); + mainConsumerBuilder.topic(notificationsTopic + "." + nodeIdProvider.getNodeId()); + mainConsumerBuilder.clientId("transport-" + nodeIdProvider.getNodeId()); mainConsumerBuilder.groupId("transport"); mainConsumerBuilder.autoCommit(true); mainConsumerBuilder.autoCommitIntervalMs(notificationsAutoCommitInterval); diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java similarity index 94% rename from transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java index 179a2a13cf..2842d7ef66 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToRuleEngineMsgEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.mqtt.service; +package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.kafka.TbKafkaEncoder; diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToTransportMsgResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java similarity index 94% rename from transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToTransportMsgResponseDecoder.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java index b8d42049de..a905f76ace 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/ToTransportMsgResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.mqtt.service; +package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; import org.thingsboard.server.kafka.TbKafkaDecoder; diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java similarity index 94% rename from transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java index 8ae4d1711f..a8f27aad8f 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiRequestEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.mqtt.service; +package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.kafka.TbKafkaEncoder; diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java similarity index 94% rename from transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java index 66e64b56e6..3b5c9ce579 100644 --- a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/service/TransportApiResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.mqtt.service; +package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; import org.thingsboard.server.kafka.TbKafkaDecoder; diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java diff --git a/common/transport/src/main/proto/transport.proto b/common/transport/transport-api/src/main/proto/transport.proto similarity index 99% rename from common/transport/src/main/proto/transport.proto rename to common/transport/transport-api/src/main/proto/transport.proto index 3099c5c7f6..0e36dabbf3 100644 --- a/common/transport/src/main/proto/transport.proto +++ b/common/transport/transport-api/src/main/proto/transport.proto @@ -123,6 +123,7 @@ message ValidateDeviceX509CertRequestMsg { message ValidateDeviceCredentialsResponseMsg { DeviceInfoProto deviceInfo = 1; + string credentialsBody = 2; } message GetOrCreateDeviceFromGatewayRequestMsg { diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java similarity index 100% rename from common/transport/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java rename to common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java similarity index 100% rename from common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java rename to common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java similarity index 100% rename from common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java rename to common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java similarity index 100% rename from common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java rename to common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java similarity index 100% rename from common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java rename to common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java similarity index 100% rename from common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java rename to common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index 4219b068f1..4b25f2b38a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; -import org.thingsboard.server.dao.EncryptionUtil; +import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; diff --git a/pom.xml b/pom.xml index 62fda36056..e999a4c3f8 100755 --- a/pom.xml +++ b/pom.xml @@ -355,18 +355,23 @@ ${project.version} - org.thingsboard.transport - http + org.thingsboard.common.transport + transport-api + ${project.version} + + + org.thingsboard.common.transport + mqtt ${project.version} org.thingsboard.transport - coap + http ${project.version} org.thingsboard.transport - mqtt-common + coap ${project.version} diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 6390929911..0dc64f0245 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -71,6 +71,10 @@ com.google.guava guava + + com.google.code.gson + gson + org.springframework spring-web diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 8b4411eb6a..1aa9e67384 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -25,7 +25,6 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.TbRelationTypes; import org.thingsboard.rule.engine.api.util.DonAsynchron; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.DataConstants; diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index aedab28dcb..cd6d89998c 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -23,7 +23,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.server.common.msg.core.*; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java index 0e087d4966..27cd4f1dd4 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java +++ b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java @@ -15,22 +15,10 @@ */ package org.thingsboard.server.transport.http.session; -import com.google.gson.JsonObject; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; -import java.util.Optional; import java.util.UUID; -import java.util.function.Consumer; /** * @author Andrew Shvayka diff --git a/transport/mqtt-transport/pom.xml b/transport/mqtt-transport/pom.xml index 012aabd4e9..56814a68be 100644 --- a/transport/mqtt-transport/pom.xml +++ b/transport/mqtt-transport/pom.xml @@ -37,12 +37,8 @@ - org.thingsboard.common - transport - - - org.thingsboard.transport - mqtt-common + org.thingsboard.common.transport + mqtt org.thingsboard.common diff --git a/transport/pom.xml b/transport/pom.xml index 79159885d2..9a7c2e4690 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -23,7 +23,6 @@ 2.2.0-SNAPSHOT thingsboard - org.thingsboard transport pom @@ -37,7 +36,6 @@ http - mqtt-common mqtt-transport From 6678891c5b839a800e71d1f601faa10144f2b077 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 15 Oct 2018 16:42:08 +0300 Subject: [PATCH 209/307] MQTT Transport packaging --- application/pom.xml | 4 +- rule-engine/rule-engine-components/pom.xml | 4 +- transport/mqtt-transport/pom.xml | 64 ---- transport/mqtt/build.gradle | 140 ++++++++ transport/mqtt/pom.xml | 337 ++++++++++++++++++ transport/mqtt/src/main/assembly/windows.xml | 71 ++++ transport/mqtt/src/main/conf/logback.xml | 43 +++ .../mqtt/src/main/conf/tb-mqtt-transport.conf | 23 ++ .../mqtt/src/main/filters/unix.properties | 1 + .../mqtt/src/main/filters/windows.properties | 2 + .../ThingsboardMqttTransportApplication.java | 0 .../src/main/resources/logback.xml | 0 .../src/main/resources/tb-mqtt-transport.yml | 0 .../src/main/scripts/control/deb/postinst | 6 + .../mqtt/src/main/scripts/control/deb/postrm | 3 + .../mqtt/src/main/scripts/control/deb/preinst | 18 + .../mqtt/src/main/scripts/control/deb/prerm | 5 + .../src/main/scripts/control/rpm/postinst | 9 + .../mqtt/src/main/scripts/control/rpm/postrm | 6 + .../mqtt/src/main/scripts/control/rpm/preinst | 6 + .../mqtt/src/main/scripts/control/rpm/prerm | 6 + .../scripts/control/tb-mqtt-transport.service | 11 + .../mqtt/src/main/scripts/windows/install.bat | 87 +++++ .../mqtt/src/main/scripts/windows/service.xml | 36 ++ .../src/main/scripts/windows/uninstall.bat | 9 + transport/pom.xml | 4 +- 26 files changed, 825 insertions(+), 70 deletions(-) delete mode 100644 transport/mqtt-transport/pom.xml create mode 100644 transport/mqtt/build.gradle create mode 100644 transport/mqtt/pom.xml create mode 100644 transport/mqtt/src/main/assembly/windows.xml create mode 100644 transport/mqtt/src/main/conf/logback.xml create mode 100644 transport/mqtt/src/main/conf/tb-mqtt-transport.conf create mode 100644 transport/mqtt/src/main/filters/unix.properties create mode 100644 transport/mqtt/src/main/filters/windows.properties rename transport/{mqtt-transport => mqtt}/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java (100%) rename transport/{mqtt-transport => mqtt}/src/main/resources/logback.xml (100%) rename transport/{mqtt-transport => mqtt}/src/main/resources/tb-mqtt-transport.yml (100%) create mode 100644 transport/mqtt/src/main/scripts/control/deb/postinst create mode 100644 transport/mqtt/src/main/scripts/control/deb/postrm create mode 100644 transport/mqtt/src/main/scripts/control/deb/preinst create mode 100644 transport/mqtt/src/main/scripts/control/deb/prerm create mode 100644 transport/mqtt/src/main/scripts/control/rpm/postinst create mode 100644 transport/mqtt/src/main/scripts/control/rpm/postrm create mode 100644 transport/mqtt/src/main/scripts/control/rpm/preinst create mode 100644 transport/mqtt/src/main/scripts/control/rpm/prerm create mode 100644 transport/mqtt/src/main/scripts/control/tb-mqtt-transport.service create mode 100644 transport/mqtt/src/main/scripts/windows/install.bat create mode 100644 transport/mqtt/src/main/scripts/windows/service.xml create mode 100644 transport/mqtt/src/main/scripts/windows/uninstall.bat diff --git a/application/pom.xml b/application/pom.xml index 915a4ab5e1..45318b3bb2 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -57,8 +57,8 @@ rule-engine-components - org.thingsboard.common - transport + org.thingsboard.common.transport + transport-api diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 0dc64f0245..c22ce784fa 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -45,8 +45,8 @@ provided - org.thingsboard.common - transport + org.thingsboard.common.transport + transport-api provided diff --git a/transport/mqtt-transport/pom.xml b/transport/mqtt-transport/pom.xml deleted file mode 100644 index 56814a68be..0000000000 --- a/transport/mqtt-transport/pom.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - 4.0.0 - - org.thingsboard - 2.2.0-SNAPSHOT - transport - - org.thingsboard.transport - mqtt-transport - jar - - Thingsboard MQTT Transport Service - https://thingsboard.io - - - UTF-8 - ${basedir}/../.. - - - - - org.thingsboard.common.transport - mqtt - - - org.thingsboard.common - queue - - - org.springframework.boot - spring-boot-starter-test - test - - - junit - junit - test - - - org.mockito - mockito-all - test - - - - diff --git a/transport/mqtt/build.gradle b/transport/mqtt/build.gradle new file mode 100644 index 0000000000..6d54cb4789 --- /dev/null +++ b/transport/mqtt/build.gradle @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgName + permissionGroup pkgName + + // Copy the actual .jar file + from(mainJar) { + // Strip the version from the jar filename + rename { String fileName -> + "${pkgName}.jar" + } + fileMode 0500 + into "bin" + } + + // Copy the config files + from("target/conf") { + exclude "${pkgName}.conf" + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = NOARCH + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + requires("java-1.8.0") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgName + permissionGroup pkgName + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "all" + + archiveName = "${pkgName}.deb" + + requires("openjdk-8-jre").or("java8-runtime").or("oracle-java8-installer").or("openjdk-8-jre-headless") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/${pkgName}.yml") + configurationFile("${pkgInstallFolder}/conf/logback.xml") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgName + permissionGroup pkgName + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/bin/${pkgName}.jar") + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml new file mode 100644 index 0000000000..2750e1e511 --- /dev/null +++ b/transport/mqtt/pom.xml @@ -0,0 +1,337 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + transport + + org.thingsboard.transport + mqtt + jar + + Thingsboard MQTT Transport Service + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + tb-mqtt-transport + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/windows + + + + + org.thingsboard.common.transport + mqtt + + + org.thingsboard.common + queue + + + com.sun.winsw + winsw + bin + exe + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/resources + + logback.xml + + false + + + + + + copy-service-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/conf + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + src/main/resources + + logback.xml + + false + + + src/main/conf + + tb-mqtt-transport.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + **/logback.xml + + + + ThingsBoard MQTT Transport Service + ${project.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.mqtt.ThingsboardMqttTransportApplication + boot + ZIP + true + true + + ${pkg.installFolder}/conf + ${pkg.unixLogFolder} + ${pkg.name}.out + ${pkg.name} + + + + + + repackage + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/transport/mqtt/src/main/assembly/windows.xml b/transport/mqtt/src/main/assembly/windows.xml new file mode 100644 index 0000000000..82da34ed13 --- /dev/null +++ b/transport/mqtt/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + lib + ${pkg.name}.jar + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/transport/mqtt/src/main/conf/logback.xml b/transport/mqtt/src/main/conf/logback.xml new file mode 100644 index 0000000000..f36469d5f2 --- /dev/null +++ b/transport/mqtt/src/main/conf/logback.xml @@ -0,0 +1,43 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/transport/mqtt/src/main/conf/tb-mqtt-transport.conf b/transport/mqtt/src/main/conf/tb-mqtt-transport.conf new file mode 100644 index 0000000000..0afa91c18e --- /dev/null +++ b/transport/mqtt/src/main/conf/tb-mqtt-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/transport/mqtt/src/main/filters/unix.properties b/transport/mqtt/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/transport/mqtt/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/transport/mqtt/src/main/filters/windows.properties b/transport/mqtt/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/transport/mqtt/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java b/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java similarity index 100% rename from transport/mqtt-transport/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java rename to transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java diff --git a/transport/mqtt-transport/src/main/resources/logback.xml b/transport/mqtt/src/main/resources/logback.xml similarity index 100% rename from transport/mqtt-transport/src/main/resources/logback.xml rename to transport/mqtt/src/main/resources/logback.xml diff --git a/transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml similarity index 100% rename from transport/mqtt-transport/src/main/resources/tb-mqtt-transport.yml rename to transport/mqtt/src/main/resources/tb-mqtt-transport.yml diff --git a/transport/mqtt/src/main/scripts/control/deb/postinst b/transport/mqtt/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..d4066c027b --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/transport/mqtt/src/main/scripts/control/deb/postrm b/transport/mqtt/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/transport/mqtt/src/main/scripts/control/deb/preinst b/transport/mqtt/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..6be5959285 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.name} >/dev/null; then + addgroup --system ${pkg.name} +fi + +if ! getent passwd ${pkg.name} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.name} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.name} +fi diff --git a/transport/mqtt/src/main/scripts/control/deb/prerm b/transport/mqtt/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/transport/mqtt/src/main/scripts/control/rpm/postinst b/transport/mqtt/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..8a7a88f7e0 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/mqtt/src/main/scripts/control/rpm/postrm b/transport/mqtt/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/mqtt/src/main/scripts/control/rpm/preinst b/transport/mqtt/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..e19fc884c8 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.name} >/dev/null || groupadd -r ${pkg.name} +getent passwd ${pkg.name} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.name} -M -r ${pkg.name} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/transport/mqtt/src/main/scripts/control/rpm/prerm b/transport/mqtt/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/transport/mqtt/src/main/scripts/control/tb-mqtt-transport.service b/transport/mqtt/src/main/scripts/control/tb-mqtt-transport.service new file mode 100644 index 0000000000..d456fc03c0 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/tb-mqtt-transport.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.name} +ExecStart=${pkg.installFolder}/bin/${pkg.name}.jar +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/transport/mqtt/src/main/scripts/windows/install.bat b/transport/mqtt/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..dba773663c --- /dev/null +++ b/transport/mqtt/src/main/scripts/windows/install.bat @@ -0,0 +1,87 @@ +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Detecting Java version installed. +:CHECK_JAVA_64 +@ECHO Detecting if it is 64 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\Wow6432Node\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF NOT "%JRE_PATH2%" == "" GOTO JAVA_INSTALLED + +:CHECK_JAVA_32 +@ECHO Detecting if it is 32 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF "%JRE_PATH2%" == "" GOTO JAVA_NOT_INSTALLED + +:JAVA_INSTALLED + +@ECHO Java 1.8 found! +@ECHO Installing ${pkg.name} ... + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:JAVA_NOT_INSTALLED +@ECHO Java 1.8 or above is not installed +@ECHO Please go to https://java.com/ and install Java. Then retry installation. +PAUSE +GOTO END + +:END + + diff --git a/transport/mqtt/src/main/scripts/windows/service.xml b/transport/mqtt/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..f7b9d307c8 --- /dev/null +++ b/transport/mqtt/src/main/scripts/windows/service.xml @@ -0,0 +1,36 @@ + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\conf + ${pkg.winWrapperLogFolder} + rotate + + java + -Xloggc:%BASE%\logs\gc.log + -XX:+HeapDumpOnOutOfMemoryError + -XX:+PrintGCDetails + -XX:+PrintGCDateStamps + -XX:+PrintHeapAtGC + -XX:+PrintTenuringDistribution + -XX:+PrintGCApplicationStoppedTime + -XX:+UseGCLogFileRotation + -XX:NumberOfGCLogFiles=10 + -XX:GCLogFileSize=10M + -XX:-UseBiasedLocking + -XX:+UseTLAB + -XX:+ResizeTLAB + -XX:+PerfDisableSharedMem + -XX:+UseCondCardMark + -XX:CMSWaitDuration=10000 + -XX:+UseParNewGC + -XX:+UseConcMarkSweepGC + -XX:+CMSParallelRemarkEnabled + -XX:+CMSParallelInitialMarkEnabled + -XX:+CMSEdenChunksRecordAlways + -XX:CMSInitiatingOccupancyFraction=75 + -XX:+UseCMSInitiatingOccupancyOnly + -jar + %BASE%\lib\${pkg.name}.jar + + diff --git a/transport/mqtt/src/main/scripts/windows/uninstall.bat b/transport/mqtt/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..921e4c8395 --- /dev/null +++ b/transport/mqtt/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,9 @@ +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/transport/pom.xml b/transport/pom.xml index 9a7c2e4690..b0790262b1 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -34,9 +34,9 @@ - http + - mqtt-transport + mqtt From 6fa86eeacabde2b2edaae31336daf0aa404e0cee Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 15 Oct 2018 18:24:51 +0300 Subject: [PATCH 210/307] Add MQTT transport Docker image. --- msa/docker/.env | 3 +- msa/docker/docker-compose.yml | 27 +++- msa/docker/haproxy/config/haproxy.cfg | 13 ++ msa/docker/tb-mqtt-transport.env | 6 + msa/docker/tb-node.env | 6 +- msa/pom.xml | 1 + msa/transport/mqtt/docker/Dockerfile | 31 ++++ msa/transport/mqtt/docker/logback.xml | 50 +++++++ .../mqtt/docker/start-tb-mqtt-transport.sh | 29 ++++ .../mqtt/docker/tb-mqtt-transport.conf | 23 +++ msa/transport/mqtt/pom.xml | 137 ++++++++++++++++++ msa/transport/pom.xml | 53 +++++++ 12 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 msa/docker/tb-mqtt-transport.env create mode 100644 msa/transport/mqtt/docker/Dockerfile create mode 100644 msa/transport/mqtt/docker/logback.xml create mode 100755 msa/transport/mqtt/docker/start-tb-mqtt-transport.sh create mode 100644 msa/transport/mqtt/docker/tb-mqtt-transport.conf create mode 100644 msa/transport/mqtt/pom.xml create mode 100644 msa/transport/pom.xml diff --git a/msa/docker/.env b/msa/docker/.env index 111ff1e2b4..029503ac8e 100644 --- a/msa/docker/.env +++ b/msa/docker/.env @@ -4,7 +4,8 @@ DOCKER_REPO=local-maven-build JS_EXECUTOR_DOCKER_NAME=tb-js-executor TB_NODE_DOCKER_NAME=tb-node WEB_UI_DOCKER_NAME=tb-web-ui +MQTT_TRANSPORT_DOCKER_NAME=tb-mqtt-transport TB_VERSION=2.2.0-SNAPSHOT -KAFKA_TOPICS=js.eval.requests:100:1 +KAFKA_TOPICS=js.eval.requests:100:1,tb.transport.api.requests:30:1,tb.rule-engine:30:1 diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 93c2ab08cd..f2ca2abd35 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -53,7 +53,6 @@ services: image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" ports: - "8080" - - "1883:1883" - "5683:5683/udp" logging: driver: "json-file" @@ -72,6 +71,28 @@ services: - ./tb-node/log:/var/log/thingsboard depends_on: - kafka + tb-mqtt-transport1: + restart: always + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "1883" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-mqtt-transport.env + depends_on: + - kafka + tb-mqtt-transport2: + restart: always + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "1883" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-mqtt-transport.env + depends_on: + - kafka tb-web-ui1: restart: always image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" @@ -103,12 +124,16 @@ services: ports: - "80:80" - "443:443" + - "1883:1883" - "9999:9999" cap_add: - NET_ADMIN environment: HTTP_PORT: 80 HTTPS_PORT: 443 + MQTT_PORT: 1883 links: - tb-web-ui1 - tb-web-ui2 + - tb-mqtt-transport1 + - tb-mqtt-transport2 diff --git a/msa/docker/haproxy/config/haproxy.cfg b/msa/docker/haproxy/config/haproxy.cfg index 5f174653f9..d334c9272b 100644 --- a/msa/docker/haproxy/config/haproxy.cfg +++ b/msa/docker/haproxy/config/haproxy.cfg @@ -49,6 +49,12 @@ frontend https_in default_backend tb-web-backend +frontend mqtt-in + mode tcp + bind *:${MQTT_PORT} + + default_backend tb-mqtt-backend + backend letsencrypt_http server letsencrypt_http_srv 127.0.0.1:8080 @@ -59,3 +65,10 @@ backend tb-web-backend server tbWeb1 tb-web-ui1:8080 check server tbWeb2 tb-web-ui2:8080 check http-request set-header X-Forwarded-Port %[dst_port] + +backend tb-mqtt-backend + balance leastconn + option tcp-check + option log-health-checks + server tbMqtt1 tb-mqtt-transport1:1883 check + server tbMqtt2 tb-mqtt-transport2:1883 check diff --git a/msa/docker/tb-mqtt-transport.env b/msa/docker/tb-mqtt-transport.env new file mode 100644 index 0000000000..5ada3f786a --- /dev/null +++ b/msa/docker/tb-mqtt-transport.env @@ -0,0 +1,6 @@ + +MQTT_BIND_ADDRESS=0.0.0.0 +MQTT_BIND_PORT=1883 +MQTT_TIMEOUT=10000 + +TB_KAFKA_SERVERS=localhost:9092 \ No newline at end of file diff --git a/msa/docker/tb-node.env b/msa/docker/tb-node.env index 874bf75d55..abeccb12eb 100644 --- a/msa/docker/tb-node.env +++ b/msa/docker/tb-node.env @@ -1,8 +1,6 @@ # ThingsBoard server configuration -MQTT_BIND_ADDRESS=0.0.0.0 -MQTT_BIND_PORT=1883 -COAP_BIND_ADDRESS=0.0.0.0 -COAP_BIND_PORT=5683 + +TRANSPORT_TYPE=remote # type of database to use: sql[DEFAULT] or cassandra DATABASE_TS_TYPE=sql diff --git a/msa/pom.xml b/msa/pom.xml index 21ddb4dafe..59f954a1ed 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -40,6 +40,7 @@ js-executor web-ui tb-node + transport diff --git a/msa/transport/mqtt/docker/Dockerfile b/msa/transport/mqtt/docker/Dockerfile new file mode 100644 index 0000000000..5827c65229 --- /dev/null +++ b/msa/transport/mqtt/docker/Dockerfile @@ -0,0 +1,31 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jre + +COPY logback.xml ${pkg.name}.conf start-tb-mqtt-transport.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-mqtt-transport.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +CMD ["start-tb-mqtt-transport.sh"] diff --git a/msa/transport/mqtt/docker/logback.xml b/msa/transport/mqtt/docker/logback.xml new file mode 100644 index 0000000000..e9d86928bd --- /dev/null +++ b/msa/transport/mqtt/docker/logback.xml @@ -0,0 +1,50 @@ + + + + + + + /var/log/${pkg.name}/${pkg.name}.log + + /var/log/${pkg.name}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/msa/transport/mqtt/docker/start-tb-mqtt-transport.sh b/msa/transport/mqtt/docker/start-tb-mqtt-transport.sh new file mode 100755 index 0000000000..8fe06d272e --- /dev/null +++ b/msa/transport/mqtt/docker/start-tb-mqtt-transport.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.mqtt.ThingsboardMqttTransportApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher diff --git a/msa/transport/mqtt/docker/tb-mqtt-transport.conf b/msa/transport/mqtt/docker/tb-mqtt-transport.conf new file mode 100644 index 0000000000..6d4c54aad1 --- /dev/null +++ b/msa/transport/mqtt/docker/tb-mqtt-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml new file mode 100644 index 0000000000..975f9fcdd0 --- /dev/null +++ b/msa/transport/mqtt/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + org.thingsboard.msa + 2.2.0-SNAPSHOT + transport + + org.thingsboard.msa.transport + mqtt + pom + + ThingsBoard MQTT Transport Microservice + https://thingsboard.io + ThingsBoard MQTT Transport Microservice + + + UTF-8 + ${basedir}/../../.. + tb-mqtt-transport + tb-mqtt-transport + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard.transport + mqtt + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-mqtt-transport-deb + package + + copy + + + + + org.thingsboard.transport + mqtt + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + true + false + ${project.build.directory} + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml new file mode 100644 index 0000000000..7fc7b34c0d --- /dev/null +++ b/msa/transport/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + transport + pom + + ThingsBoard Transport Microservices + https://thingsboard.io + + + ${basedir}/../.. + + + + mqtt + + + + + + + com.spotify + dockerfile-maven-plugin + 1.4.5 + + + + + + From 6bd8f1f9590e289a6d179a5a8820795ee02488ac Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 15 Oct 2018 21:56:30 +0300 Subject: [PATCH 211/307] Entity view updates --- application/pom.xml | 2 +- .../src/main/resources/thingsboard.yml | 4 +- common/data/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/transport/pom.xml | 2 +- dao/pom.xml | 2 +- .../entityview/CassandraEntityViewDao.java | 3 +- .../dao/entityview/EntityViewServiceImpl.java | 2 +- .../dao/timeseries/BaseTimeseriesService.java | 20 ++++++- .../server/dao/SqlDaoServiceTestSuite.java | 2 +- .../dao/service/AbstractServiceTest.java | 4 ++ .../timeseries/BaseTimeseriesServiceTest.java | 51 +++++++++++++++- netty-mqtt/pom.xml | 4 +- pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- ui/pom.xml | 2 +- .../entity-view/entity-view-fieldset.tpl.html | 58 +++++++++++++++++-- .../app/entity-view/entity-view.directive.js | 21 ++++++- ui/src/app/locale/locale.constant-en_US.json | 6 +- ui/src/app/locale/locale.constant-es_ES.json | 2 +- 28 files changed, 172 insertions(+), 37 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 72e7e7d7c2..c6503ef248 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard application diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 5eef1bdda0..b889143122 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -165,9 +165,9 @@ quota: database: entities: - type: "${DATABASE_ENTITIES_TYPE:sql}" # cassandra OR sql + type: "${DATABASE_ENTITIES_TYPE:cassandra}" # cassandra OR sql ts: - type: "${DATABASE_TS_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) + type: "${DATABASE_TS_TYPE:cassandra}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) # Cassandra driver configuration parameters diff --git a/common/data/pom.xml b/common/data/pom.xml index aa68b1adf6..f25e78de0e 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 4da27e28f6..67648e9c4d 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index 4ee5937e7a..aba6d7f3a9 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard org.thingsboard diff --git a/common/transport/pom.xml b/common/transport/pom.xml index efd63b5240..583fcd28f7 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 8cc83e9393..e7d6783911 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard dao diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java index fe1a8d1f8d..6ddc18d463 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -84,8 +84,7 @@ public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao>> futures = new ArrayList<>(); - if (savedEntityView.getKeys() != null) { + if (savedEntityView.getKeys() != null && savedEntityView.getKeys().getAttributes() != null) { futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs())); futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs())); futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh())); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 4a09bb7a23..4658cbe490 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -79,12 +79,16 @@ public class BaseTimeseriesService implements TimeseriesService { if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); List filteredKeys = new ArrayList<>(keys); - if (!entityView.getKeys().getTimeseries().isEmpty()) { + if (entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null && + !entityView.getKeys().getTimeseries().isEmpty()) { filteredKeys.retainAll(entityView.getKeys().getTimeseries()); } List queries = filteredKeys.stream() - .map(key -> new BaseReadTsKvQuery(key, entityView.getStartTimeMs(), entityView.getEndTimeMs(), 1, "ASC")) + .map(key -> { + long endTs = entityView.getEndTimeMs() != 0 ? entityView.getEndTimeMs() : Long.MAX_VALUE; + return new BaseReadTsKvQuery(key, entityView.getStartTimeMs(), endTs, 1, "DESC"); + }) .collect(Collectors.toList()); if (queries.size() > 0) { @@ -100,7 +104,17 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> findAllLatest(EntityId entityId) { validate(entityId); - return timeseriesDao.findAllLatest(entityId); + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); + if (entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null && + !entityView.getKeys().getTimeseries().isEmpty()) { + return findLatest(entityId, entityView.getKeys().getTimeseries()); + } else { + return Futures.immediateFuture(new ArrayList<>()); + } + } else { + return timeseriesDao.findAllLatest(entityId); + } } @Override diff --git a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java index 9e56d64aba..0eaebfbbdc 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClassnameFilters({ - "org.thingsboard.server.dao.service.*ServiceSqlTest" + "org.thingsboard.server.dao.service.*.TimeseriesServiceSqlTest" }) public class SqlDaoServiceTestSuite { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index 1e1f15daff..f49c357caa 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -45,6 +45,7 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; @@ -88,6 +89,9 @@ public abstract class AbstractServiceTest { @Autowired protected AssetService assetService; + @Autowired + protected EntityViewService entityViewService; + @Autowired protected DeviceCredentialsService deviceCredentialsService; diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 4528d9afb1..88f4d84eb6 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -17,9 +17,15 @@ package org.thingsboard.server.dao.service.timeseries; import com.datastax.driver.core.utils.UUIDs; import lombok.extern.slf4j.Slf4j; +import org.junit.After; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; @@ -30,6 +36,7 @@ import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.service.AbstractServiceTest; import java.util.ArrayList; @@ -61,6 +68,22 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { KvEntry doubleKvEntry = new DoubleDataEntry(DOUBLE_KEY, Double.MAX_VALUE); KvEntry booleanKvEntry = new BooleanDataEntry(BOOLEAN_KEY, Boolean.TRUE); + private TenantId tenantId; + + @Before + public void before() { + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + Tenant savedTenant = tenantService.saveTenant(tenant); + Assert.assertNotNull(savedTenant); + tenantId = savedTenant.getId(); + } + + @After + public void after() { + tenantService.deleteTenant(tenantId); + } + @Test public void testFindAllLatest() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); @@ -69,7 +92,15 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { saveEntries(deviceId, TS - 1); saveEntries(deviceId, TS); - List tsList = tsService.findAllLatest(deviceId).get(); + testLatestTsAndVerify(deviceId); + + EntityView entityView = saveAndCreateEntityView(deviceId, Arrays.asList(STRING_KEY, DOUBLE_KEY, LONG_KEY, BOOLEAN_KEY)); + + testLatestTsAndVerify(entityView.getId()); + } + + private void testLatestTsAndVerify(EntityId entityId) throws ExecutionException, InterruptedException { + List tsList = tsService.findAllLatest(entityId).get(); assertNotNull(tsList); assertEquals(4, tsList.size()); @@ -89,6 +120,18 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { assertEquals(expected, tsList); } + private EntityView saveAndCreateEntityView(DeviceId deviceId, List timeseries) { + EntityView entityView = new EntityView(); + entityView.setName("entity_view_name"); + entityView.setType("default"); + entityView.setTenantId(tenantId); + TelemetryEntityView keys = new TelemetryEntityView(); + keys.setTimeseries(timeseries); + entityView.setKeys(keys); + entityView.setEntityId(deviceId); + return entityViewService.saveEntityView(entityView); + } + @Test public void testFindLatest() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); @@ -100,6 +143,12 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { List entries = tsService.findLatest(deviceId, Collections.singleton(STRING_KEY)).get(); Assert.assertEquals(1, entries.size()); Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); + + EntityView entityView = saveAndCreateEntityView(deviceId, Arrays.asList(STRING_KEY)); + + entries = tsService.findLatest(entityView.getId(), Collections.singleton(STRING_KEY)).get(); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); } @Test diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index f7e32b53e7..0b1ea1e35e 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,12 +19,12 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard org.thingsboard netty-mqtt - 2.1.2 + 2.1.3-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 981482b35a..05ab0d6f39 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 2.1.2 + 2.1.3-SNAPSHOT pom Thingsboard diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 5c5fe56075..3847120030 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 4b4f33624f..4e79b11056 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index ebab903190..6e7cfdc6a7 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index 8a9481d0b9..af4c53b4a1 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard org.thingsboard diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 537a9bee06..bcb45236c1 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 24e4ff94af..d73692f260 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index f5c1104b6a..e89bba42b1 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index 7858383d82..22ec9be255 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard org.thingsboard diff --git a/ui/pom.xml b/ui/pom.xml index a2eadb5445..c219a22e6b 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.2 + 2.1.3-SNAPSHOT thingsboard org.thingsboard diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index 66dc116cc8..74d1e3397c 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -78,31 +78,79 @@ ng-required="false" readonly="!isEdit" ng-model="entityView.keys.attributes.cs" - placeholder="{{'entity-view.client-attributes' | translate}}" + placeholder="{{'entity-view.client-attributes-placeholder' | translate}}" md-separator-keys="separatorKeys"> + + {{item}} + + + {{item}} + + + {{item}} + - + + + {{item}} +

diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index e1ae82fa6a..d183053f47 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -20,8 +20,8 @@ import entityViewFieldsetTemplate from './entity-view-fieldset.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function EntityViewDirective($compile, $templateCache, $filter, toast, $translate, $mdConstant, - types, clipboardService, entityViewService, customerService) { +export default function EntityViewDirective($q, $compile, $templateCache, $filter, toast, $translate, $mdConstant, + types, clipboardService, entityViewService, customerService, entityService) { var linker = function (scope, element) { var template = $templateCache.get(entityViewFieldsetTemplate); element.html(template); @@ -53,9 +53,13 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t } if (scope.entityView.startTimeMs > 0) { scope.startTimeMs = new Date(scope.entityView.startTimeMs); + } else { + scope.startTimeMs = null; } if (scope.entityView.endTimeMs > 0) { scope.endTimeMs = new Date(scope.entityView.endTimeMs); + } else { + scope.endTimeMs = null; } if (!scope.entityView.keys) { scope.entityView.keys = {}; @@ -68,6 +72,19 @@ export default function EntityViewDirective($compile, $templateCache, $filter, t } }); + scope.dataKeysSearch = function (searchText, type) { + var deferred = $q.defer(); + entityService.getEntityKeys(scope.entityView.entityId.entityType, scope.entityView.entityId.id, searchText, type, {ignoreLoading: true}).then( + function success(keys) { + deferred.resolve(keys); + }, + function fail() { + deferred.resolve([]); + } + ); + return deferred.promise; + + }; scope.$watch('startTimeMs', function (newDate) { if (newDate) { diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 6c6ea438af..ccfcf659ac 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -844,7 +844,11 @@ "client-attributes": "Client attributes", "shared-attributes": "Shared attributes", "server-attributes": "Server attributes", - "latest-timeseries": "Latest timeseries", + "timeseries": "Timeseries", + "client-attributes-placeholder": "Client attributes", + "shared-attributes-placeholder": "Shared attributes", + "server-attributes-placeholder": "Server attributes", + "timeseries-placeholder": "Timeseries", "related-entity": "Related entity" }, "event": { diff --git a/ui/src/app/locale/locale.constant-es_ES.json b/ui/src/app/locale/locale.constant-es_ES.json index 285c589bd6..1b0a16939b 100644 --- a/ui/src/app/locale/locale.constant-es_ES.json +++ b/ui/src/app/locale/locale.constant-es_ES.json @@ -839,7 +839,7 @@ "client-attributes": "Client attributes", "shared-attributes": "Shared attributes", "server-attributes": "Server attributes", - "latest-timeseries": "Latest timeseries" + "timeseries": "Timeseries" }, "event": { "event-type": "Tipo de evento", From 59d249fc3cd29af0ba1b7cea48aec19e859928dd Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 15 Oct 2018 21:58:45 +0300 Subject: [PATCH 212/307] Entity view updates --- application/src/main/resources/thingsboard.yml | 4 ++-- .../org/thingsboard/server/dao/SqlDaoServiceTestSuite.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index b889143122..5eef1bdda0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -165,9 +165,9 @@ quota: database: entities: - type: "${DATABASE_ENTITIES_TYPE:cassandra}" # cassandra OR sql + type: "${DATABASE_ENTITIES_TYPE:sql}" # cassandra OR sql ts: - type: "${DATABASE_TS_TYPE:cassandra}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) + type: "${DATABASE_TS_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) # Cassandra driver configuration parameters diff --git a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java index 0eaebfbbdc..9e56d64aba 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java @@ -24,7 +24,7 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClassnameFilters({ - "org.thingsboard.server.dao.service.*.TimeseriesServiceSqlTest" + "org.thingsboard.server.dao.service.*ServiceSqlTest" }) public class SqlDaoServiceTestSuite { From 14be925c73420dd1a249e2a6a2bdfc434642d242 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 16 Oct 2018 13:20:18 +0300 Subject: [PATCH 213/307] Minor fixes --- .../DefaultTelemetrySubscriptionService.java | 12 ++++++++---- ui/package.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index fb9a160a2c..9906c57e6d 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -169,9 +169,13 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio Map keyStates; if (sub.getType().equals(TelemetryFeature.TIMESERIES) && !entityView.getKeys().getTimeseries().isEmpty()) { allKeys = false; - keyStates = sub.getKeyStates().entrySet() - .stream().filter(entry -> entityView.getKeys().getTimeseries().contains(entry.getKey())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + if(sub.isAllKeys()) { + keyStates = entityView.getKeys().getTimeseries().stream().collect(Collectors.toMap(k -> k, k -> 0L)); + } else { + keyStates = sub.getKeyStates().entrySet() + .stream().filter(entry -> entityView.getKeys().getTimeseries().contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } } else if (sub.getType().equals(TelemetryFeature.ATTRIBUTES)) { if (sub.getScope().equals(DataConstants.CLIENT_SCOPE) && !entityView.getKeys().getAttributes().getCs().isEmpty()) { allKeys = false; @@ -179,7 +183,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio } else if (sub.getScope().equals(DataConstants.SERVER_SCOPE) && !entityView.getKeys().getAttributes().getSs().isEmpty()) { allKeys = false; keyStates = filterMap(sub, entityView.getKeys().getAttributes().getSs()); - } else if (sub.getScope().equals(DataConstants.SERVER_SCOPE) && !entityView.getKeys().getAttributes().getSh().isEmpty()) { + } else if (sub.getScope().equals(DataConstants.SHARED_SCOPE) && !entityView.getKeys().getAttributes().getSh().isEmpty()) { allKeys = false; keyStates = filterMap(sub, entityView.getKeys().getAttributes().getSh()); } else { diff --git a/ui/package.json b/ui/package.json index 8c347c5ccb..ec473a84ab 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard", "private": true, - "version": "2.1.2", + "version": "2.1.3", "description": "Thingsboard UI", "licenses": [ { From 68eabb9ec20c9e0abae1398e5998b6ccd7e88a40 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 16 Oct 2018 13:20:40 +0300 Subject: [PATCH 214/307] TMP commit for HTTP transport --- application/pom.xml | 15 +- .../transport/LocalTransportService.java | 3 +- {transport => common/transport}/http/pom.xml | 10 +- .../transport/http/DeviceApiController.java | 174 ++++++++++++++---- .../transport/http/HttpTransportContext.java | 37 ++++ .../http/session/HttpSessionCtx.java | 0 .../transport/mqtt/MqttTransportContext.java | 38 +--- .../transport/mqtt/MqttTransportHandler.java | 2 +- .../mqtt/session/GatewaySessionHandler.java | 2 +- common/transport/pom.xml | 2 +- .../common/transport/TransportContext.java | 53 ++++++ .../common/transport/TransportService.java | 2 +- .../service/RemoteTransportService.java | 22 ++- .../transport/service/SessionMetaData.java | 17 ++ pom.xml | 2 +- transport/pom.xml | 1 - 16 files changed, 281 insertions(+), 99 deletions(-) rename {transport => common/transport}/http/pom.xml (90%) rename {transport => common/transport}/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java (60%) create mode 100644 common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java rename {transport => common/transport}/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java (100%) create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java diff --git a/application/pom.xml b/application/pom.xml index 915a4ab5e1..f8c3219559 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -61,17 +61,17 @@ transport - - - - - - + + org.thingsboard.common.transport mqtt + + org.thingsboard.common.transport + http + org.thingsboard dao @@ -542,7 +542,8 @@ -PprojectBuildDir=${project.build.directory} -PprojectVersion=${project.version} - -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} -PpkgInstallFolder=${pkg.installFolder} -PpkgLogFolder=${pkg.unixLogFolder} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java index ba5cc074d4..a7590c9610 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; @@ -175,7 +176,7 @@ public class LocalTransportService implements TransportService, RuleEngineTransp } @Override - public void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener) { + public void registerSession(SessionInfoProto sessionInfo, TransportProtos.SessionType sessionType, SessionMsgListener listener) { sessions.putIfAbsent(toId(sessionInfo), listener); //TODO: monitor sessions periodically: PING REQ/RESP, etc. } diff --git a/transport/http/pom.xml b/common/transport/http/pom.xml similarity index 90% rename from transport/http/pom.xml rename to common/transport/http/pom.xml index 996470832c..2ee11426a9 100644 --- a/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -19,15 +19,15 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.thingsboard + org.thingsboard.common 2.2.0-SNAPSHOT transport - org.thingsboard.transport + org.thingsboard.common.transport http jar - Thingsboard HTTP Transport + Thingsboard HTTP Transport Common https://thingsboard.io @@ -37,8 +37,8 @@ - org.thingsboard.common - transport + org.thingsboard.common.transport + transport-api org.springframework.boot diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java similarity index 60% rename from transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java rename to common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index cd6d89998c..bca05c0459 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,64 +17,68 @@ package org.thingsboard.server.transport.http; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportContext; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.*; import org.thingsboard.server.transport.http.session.HttpSessionCtx; import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; /** * @author Andrew Shvayka */ @RestController -@ConditionalOnProperty(prefix = "transport.http", value = "enabled", havingValue = "true") +@ConditionalOnProperty(prefix = "transport.http", value = "enabled", havingValue = "true", matchIfMissing = true) @RequestMapping("/api/v1") @Slf4j public class DeviceApiController { - @Value("${http.request_timeout}") - private long defaultTimeout; - - @Autowired(required = false) - private SessionMsgProcessor processor; - - @Autowired(required = false) - private DeviceAuthService authService; - - @Autowired(required = false) - private HostRequestsQuotaService quotaService; + @Autowired + private HttpTransportContext transportContext; @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.GET, produces = "application/json") public DeferredResult getDeviceAttributes(@PathVariable("deviceToken") String deviceToken, @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys, @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys, HttpServletRequest httpRequest) { - DeferredResult responseWriter = new DeferredResult(); + DeferredResult responseWriter = new DeferredResult<>(); if (quotaExceeded(httpRequest, responseWriter)) { return responseWriter; } -// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); -// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { -// GetAttributesRequest request; -// if (StringUtils.isEmpty(clientKeys) && StringUtils.isEmpty(sharedKeys)) { -// request = new BasicGetAttributesRequest(0); -// } else { -// Set clientKeySet = !StringUtils.isEmpty(clientKeys) ? new HashSet<>(Arrays.asList(clientKeys.split(","))) : null; -// Set sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? new HashSet<>(Arrays.asList(sharedKeys.split(","))) : null; -// request = new BasicGetAttributesRequest(0, clientKeySet, sharedKeySet); -// } -// process(ctx, request); -// } else { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); -// } - + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + GetAttributeRequestMsg.Builder request = GetAttributeRequestMsg.newBuilder().setRequestId(0); + List clientKeySet = !StringUtils.isEmpty(clientKeys) ? Arrays.asList(clientKeys.split(",")) : null; + List sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? Arrays.asList(sharedKeys.split(",")) : null; + if (clientKeySet != null) { + request.addAllClientAttributeNames(clientKeySet); + } + if (sharedKeySet != null) { + request.addAllSharedAttributeNames(sharedKeySet); + } + TransportService transportService = transportContext.getTransportService(); + transportService.registerSession(sessionInfo, TransportProtos.SessionType.SYNC, new HttpSessionListener(transportContext, responseWriter)); + transportService.process(sessionInfo, request.build(), new SessionCloseOnErrorCallback(transportService, sessionInfo)); + })); return responseWriter; } @@ -199,7 +203,7 @@ public class DeviceApiController { // } private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter) { - return getHttpSessionCtx(responseWriter, defaultTimeout); + return getHttpSessionCtx(responseWriter, transportContext.getDefaultTimeout()); } private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter, long timeout) { @@ -213,7 +217,7 @@ public class DeviceApiController { // } private boolean quotaExceeded(HttpServletRequest request, DeferredResult responseWriter) { - if (quotaService.isQuotaExceeded(request.getRemoteAddr())) { + if (transportContext.getQuotaService().isQuotaExceeded(request.getRemoteAddr())) { log.warn("REST Quota exceeded for [{}] . Disconnect", request.getRemoteAddr()); responseWriter.setResult(new ResponseEntity<>(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)); return true; @@ -221,4 +225,96 @@ public class DeviceApiController { return false; } + private static class DeviceAuthCallback implements TransportServiceCallback { + private final TransportContext transportContext; + private final DeferredResult responseWriter; + private final Consumer onSuccess; + + DeviceAuthCallback(TransportContext transportContext, DeferredResult responseWriter, Consumer onSuccess) { + this.transportContext = transportContext; + this.responseWriter = responseWriter; + this.onSuccess = onSuccess; + } + + @Override + public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { + if (msg.hasDeviceInfo()) { + UUID sessionId = UUID.randomUUID(); + DeviceInfoProto deviceInfoProto = msg.getDeviceInfo(); + SessionInfoProto sessionInfo = SessionInfoProto.newBuilder() + .setNodeId(transportContext.getNodeId()) + .setTenantIdMSB(deviceInfoProto.getTenantIdMSB()) + .setTenantIdLSB(deviceInfoProto.getTenantIdLSB()) + .setDeviceIdMSB(deviceInfoProto.getDeviceIdMSB()) + .setDeviceIdLSB(deviceInfoProto.getDeviceIdLSB()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .build(); + onSuccess.accept(sessionInfo); + } else { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); + } + } + + @Override + public void onError(Throwable e) { + log.warn("Failed to process request", e); + responseWriter.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + } + + private static class SessionCloseOnErrorCallback implements TransportServiceCallback { + private final TransportService transportService; + private final SessionInfoProto sessionInfo; + + SessionCloseOnErrorCallback(TransportService transportService, SessionInfoProto sessionInfo) { + this.transportService = transportService; + this.sessionInfo = sessionInfo; + } + + @Override + public void onSuccess(Void msg) { + + } + + @Override + public void onError(Throwable e) { + transportService.deregisterSession(sessionInfo); + } + } + + private static class HttpSessionListener implements SessionMsgListener { + + private final TransportContext transportContext; + private final DeferredResult responseWriter; + + HttpSessionListener(TransportContext transportContext, DeferredResult responseWriter) { + this.transportContext = transportContext; + this.responseWriter = responseWriter; + } + + @Override + public void onGetAttributesResponse(GetAttributeResponseMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); + } + + @Override + public void onAttributeUpdate(AttributeUpdateNotificationMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); + } + + @Override + public void onRemoteSessionCloseCommand(SessionCloseNotificationProto sessionCloseNotification) { + } + + @Override + public void onToDeviceRpcRequest(ToDeviceRpcRequestMsg toDeviceRequest) { + + } + + @Override + public void onToServerRpcResponse(ToServerRpcResponseMsg toServerResponse) { + + } + } } diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java new file mode 100644 index 0000000000..87db06e66b --- /dev/null +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.http; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.transport.TransportContext; + +/** + * Created by ashvayka on 04.10.18. + */ +@Slf4j +@ConditionalOnProperty(prefix = "transport.mqtt", value = "enabled", havingValue = "true", matchIfMissing = true) +@Component +public class HttpTransportContext extends TransportContext { + + @Getter + @Value("${http.request_timeout}") + private long defaultTimeout; + +} diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java similarity index 100% rename from transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java rename to common/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 9ae42e8ca3..16f8981d57 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.transport.TransportContext; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.kafka.TbNodeIdProvider; @@ -40,48 +41,21 @@ import java.util.concurrent.Executors; @Slf4j @ConditionalOnProperty(prefix = "transport.mqtt", value = "enabled", havingValue = "true", matchIfMissing = true) @Component -@Data -public class MqttTransportContext { - - private final ObjectMapper mapper = new ObjectMapper(); - - @Autowired - private TransportService transportService; +public class MqttTransportContext extends TransportContext { + @Getter @Autowired(required = false) private MqttSslHandlerProvider sslHandlerProvider; - @Autowired(required = false) - private HostRequestsQuotaService quotaService; - + @Getter @Autowired private MqttTransportAdaptor adaptor; - @Autowired - private TbNodeIdProvider nodeIdProvider; - + @Getter @Value("${transport.mqtt.netty.max_payload_size}") private Integer maxPayloadSize; - - private SslHandler sslHandler; - @Getter - private ExecutorService executor; - - @PostConstruct - public void init() { - executor = Executors.newCachedThreadPool(); - } - - @PreDestroy - public void stop() { - if (executor != null) { - executor.shutdownNow(); - } - } + private SslHandler sslHandler; - public String getNodeId() { - return nodeIdProvider.getNodeId(); - } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 6a5597bb11..30cc9c75a2 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -506,7 +506,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement .setTenantIdLSB(msg.getDeviceInfo().getTenantIdLSB()) .build(); transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.OPEN), null); - transportService.registerSession(sessionInfo, this); + transportService.registerSession(sessionInfo, TransportProtos.SessionType.ASYNC, this); checkGatewaySession(); ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java index f233695fb1..43f997830f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -121,7 +121,7 @@ public class GatewaySessionHandler { transportService.process(deviceSessionInfo, MqttTransportHandler.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), null); - transportService.registerSession(deviceSessionInfo, deviceSessionCtx); + transportService.registerSession(deviceSessionInfo, TransportProtos.SessionType.ASYNC, deviceSessionCtx); } future.set(devices.get(deviceName)); } diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 69f64e6307..c9b47e876f 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -37,7 +37,7 @@ transport-api mqtt - + http diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java new file mode 100644 index 0000000000..f846cf0c38 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -0,0 +1,53 @@ +package org.thingsboard.server.common.transport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; +import org.thingsboard.server.kafka.TbNodeIdProvider; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by ashvayka on 15.10.18. + */ +@Slf4j +@Data +public class TransportContext { + + protected final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private TransportService transportService; + + @Autowired + private TbNodeIdProvider nodeIdProvider; + + @Autowired(required = false) + private HostRequestsQuotaService quotaService; + + @Getter + private ExecutorService executor; + + @PostConstruct + public void init() { + executor = Executors.newCachedThreadPool(); + } + + @PreDestroy + public void stop() { + if (executor != null) { + executor.shutdownNow(); + } + } + + public String getNodeId() { + return nodeIdProvider.getNodeId(); + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index c9a04c4daf..9bbc40c1b6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -59,7 +59,7 @@ public interface TransportService { void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback); - void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener); + void registerSession(SessionInfoProto sessionInfo, TransportProtos.SessionType sessionType, SessionMsgListener listener); void deregisterSession(SessionInfoProto sessionInfo); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index ab52d41366..eb6dbc5cb2 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -85,7 +85,7 @@ public class RemoteTransportService implements TransportService { @Value("${kafka.transport_api.response_auto_commit_interval}") private int autoCommitInterval; - private ConcurrentMap sessions = new ConcurrentHashMap<>(); + private ConcurrentMap sessions = new ConcurrentHashMap<>(); @Autowired private TbKafkaSettings kafkaSettings; @@ -159,8 +159,9 @@ public class RemoteTransportService implements TransportService { if (toTransportMsg.hasToDeviceSessionMsg()) { DeviceActorToTransportMsg toSessionMsg = toTransportMsg.getToDeviceSessionMsg(); UUID sessionId = new UUID(toSessionMsg.getSessionIdMSB(), toSessionMsg.getSessionIdLSB()); - SessionMsgListener listener = sessions.get(sessionId); - if (listener != null) { + SessionMetaData md = sessions.get(sessionId); + if (md != null) { + SessionMsgListener listener = md.getListener(); transportCallbackExecutor.submit(() -> { if (toSessionMsg.hasGetAttributesResponse()) { listener.onGetAttributesResponse(toSessionMsg.getGetAttributesResponse()); @@ -178,6 +179,9 @@ public class RemoteTransportService implements TransportService { listener.onToServerRpcResponse(toSessionMsg.getToServerResponse()); } }); + if (md.getSessionType() == SessionType.SYNC) { + deregisterSession(md.getSessionInfo()); + } } else { //TODO: should we notify the device actor about missed session? log.debug("[{}] Missing session.", sessionId); @@ -311,8 +315,8 @@ public class RemoteTransportService implements TransportService { } @Override - public void registerSession(SessionInfoProto sessionInfo, SessionMsgListener listener) { - sessions.putIfAbsent(toId(sessionInfo), listener); + public void registerSession(SessionInfoProto sessionInfo, SessionType sessionType, SessionMsgListener listener) { + sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, sessionType, listener)); //TODO: monitor sessions periodically: PING REQ/RESP, etc. } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java new file mode 100644 index 0000000000..dd52f77ca6 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -0,0 +1,17 @@ +package org.thingsboard.server.common.transport.service; + +import lombok.Data; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.gen.transport.TransportProtos; + +/** + * Created by ashvayka on 15.10.18. + */ +@Data +public class SessionMetaData { + + private final TransportProtos.SessionInfoProto sessionInfo; + private final TransportProtos.SessionType sessionType; + private final SessionMsgListener listener; + +} diff --git a/pom.xml b/pom.xml index e999a4c3f8..f4c2957b08 100755 --- a/pom.xml +++ b/pom.xml @@ -365,7 +365,7 @@ ${project.version} - org.thingsboard.transport + org.thingsboard.common.transport http ${project.version} diff --git a/transport/pom.xml b/transport/pom.xml index 9a7c2e4690..96a5a08304 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -34,7 +34,6 @@ - http mqtt-transport From acb94868355ac11b1dc6fd06e21b21561052e9b2 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 16 Oct 2018 13:30:38 +0300 Subject: [PATCH 215/307] Fix for WS subscriptions --- .../DefaultTelemetrySubscriptionService.java | 43 ++++--------------- 1 file changed, 8 insertions(+), 35 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 9906c57e6d..7bd64544d8 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -143,7 +143,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio public void addLocalWsSubscription(String sessionId, EntityId entityId, SubscriptionState sub) { long startTime = 0L; long endTime = 0L; - if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW) && TelemetryFeature.TIMESERIES.equals(sub.getType())) { EntityView entityView = entityViewService.findEntityViewById(new EntityViewId(entityId.getId())); entityId = entityView.getEntityId(); startTime = entityView.getStartTimeMs(); @@ -165,42 +165,15 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio } private SubscriptionState getUpdatedSubscriptionState(EntityId entityId, SubscriptionState sub, EntityView entityView) { - boolean allKeys; Map keyStates; - if (sub.getType().equals(TelemetryFeature.TIMESERIES) && !entityView.getKeys().getTimeseries().isEmpty()) { - allKeys = false; - if(sub.isAllKeys()) { - keyStates = entityView.getKeys().getTimeseries().stream().collect(Collectors.toMap(k -> k, k -> 0L)); - } else { - keyStates = sub.getKeyStates().entrySet() - .stream().filter(entry -> entityView.getKeys().getTimeseries().contains(entry.getKey())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - } else if (sub.getType().equals(TelemetryFeature.ATTRIBUTES)) { - if (sub.getScope().equals(DataConstants.CLIENT_SCOPE) && !entityView.getKeys().getAttributes().getCs().isEmpty()) { - allKeys = false; - keyStates = filterMap(sub, entityView.getKeys().getAttributes().getCs()); - } else if (sub.getScope().equals(DataConstants.SERVER_SCOPE) && !entityView.getKeys().getAttributes().getSs().isEmpty()) { - allKeys = false; - keyStates = filterMap(sub, entityView.getKeys().getAttributes().getSs()); - } else if (sub.getScope().equals(DataConstants.SHARED_SCOPE) && !entityView.getKeys().getAttributes().getSh().isEmpty()) { - allKeys = false; - keyStates = filterMap(sub, entityView.getKeys().getAttributes().getSh()); - } else { - allKeys = sub.isAllKeys(); - keyStates = sub.getKeyStates(); - } + if(sub.isAllKeys()) { + keyStates = entityView.getKeys().getTimeseries().stream().collect(Collectors.toMap(k -> k, k -> 0L)); } else { - allKeys = sub.isAllKeys(); - keyStates = sub.getKeyStates(); + keyStates = sub.getKeyStates().entrySet() + .stream().filter(entry -> entityView.getKeys().getTimeseries().contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } - return new SubscriptionState(sub.getWsSessionId(), sub.getSubscriptionId(), entityId, sub.getType(), allKeys, keyStates, sub.getScope()); - } - - private Map filterMap(SubscriptionState sub, List allowedKeys) { - return sub.getKeyStates().entrySet() - .stream().filter(entry -> allowedKeys.contains(entry.getKey())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + return new SubscriptionState(sub.getWsSessionId(), sub.getSubscriptionId(), entityId, sub.getType(), false, keyStates, sub.getScope()); } @Override @@ -471,7 +444,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio onLocalSubUpdate(entityId, s -> TelemetryFeature.ATTRIBUTES == s.getType() && (StringUtils.isEmpty(s.getScope()) || scope.equals(s.getScope())), s -> { List subscriptionUpdate = null; for (AttributeKvEntry kv : attributes) { - if (isInTimeRange(s, kv.getLastUpdateTs()) && (s.isAllKeys() || s.getKeyStates().containsKey(kv.getKey()))) { + if (s.isAllKeys() || s.getKeyStates().containsKey(kv.getKey())) { if (subscriptionUpdate == null) { subscriptionUpdate = new ArrayList<>(); } From 4b0554b274714657f70a03938b36e4f9551fce8a Mon Sep 17 00:00:00 2001 From: Viacheslav Kukhtyn Date: Tue, 16 Oct 2018 14:05:52 +0300 Subject: [PATCH 216/307] Integration-tests subproject --- msa/integration-tests/README.md | 18 +++ msa/integration-tests/pom.xml | 98 +++++++++++++++ .../server/msa/AbstractContainerTest.java | 112 ++++++++++++++++++ .../server/msa/ContainerTestSuite.java | 37 ++++++ .../org/thingsboard/server/msa/WsClient.java | 57 +++++++++ .../server/msa/WsTelemetryResponse.java | 40 +++++++ .../msa/connectivity/HttpClientTest.java | 57 +++++++++ .../msa/connectivity/MqttClientTest.java | 111 +++++++++++++++++ msa/pom.xml | 1 + 9 files changed, 531 insertions(+) create mode 100644 msa/integration-tests/README.md create mode 100644 msa/integration-tests/pom.xml create mode 100644 msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java create mode 100644 msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java create mode 100644 msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java create mode 100644 msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsTelemetryResponse.java create mode 100644 msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java create mode 100644 msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java diff --git a/msa/integration-tests/README.md b/msa/integration-tests/README.md new file mode 100644 index 0000000000..5ae6354740 --- /dev/null +++ b/msa/integration-tests/README.md @@ -0,0 +1,18 @@ + +## Integration tests execution +To run the integration tests with using Docker, the local Docker images of Thingsboard's microservices should be built.
+- Build the local Docker images in the directory with the Thingsboard's main [pom.xml](./../../pom.xml): + + mvn clean install -Ddockerfile.skip=false +- Verify that the new local images were built: + + docker image ls +As result, in REPOSITORY column, next images should be present: + + local-maven-build/tb-node + local-maven-build/tb-web-ui + local-maven-build/tb-web-ui + +- Run the integration tests in the [msa/integration-tests](../integration-tests) directory: + + mvn clean install -Dintegrationtests.skip=false \ No newline at end of file diff --git a/msa/integration-tests/pom.xml b/msa/integration-tests/pom.xml new file mode 100644 index 0000000000..a1c24f39c6 --- /dev/null +++ b/msa/integration-tests/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + integration-tests + + ThingsBoard Integration Tests + https://thingsboard.io + Project for ThingsBoard integration tests with using Docker + + + UTF-8 + ${basedir}/../.. + true + 1.9.1 + 1.3.9 + + + + + org.testcontainers + testcontainers + ${testcontainers.version} + + + org.java-websocket + Java-WebSocket + ${java-websocket.version} + + + io.takari.junit + takari-cpsuite + + + ch.qos.logback + logback-classic + + + com.google.code.gson + gson + + + org.apache.commons + commons-lang3 + + + com.google.guava + guava + + + org.thingsboard + netty-mqtt + + + org.thingsboard + tools + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*TestSuite.java + + ${integrationtests.skip} + + + + + + diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java new file mode 100644 index 0000000000..ab7f101f98 --- /dev/null +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java @@ -0,0 +1,112 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.*; +import org.thingsboard.client.tools.RestClient; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DeviceId; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +@Slf4j +public abstract class AbstractContainerTest { + protected static String httpUrl; + protected static String wsUrl; + protected static RestClient restClient; + protected ObjectMapper mapper = new ObjectMapper(); + + @BeforeClass + public static void before() { + httpUrl = "http://localhost:" + ContainerTestSuite.composeContainer.getServicePort("tb-web-ui1", ContainerTestSuite.EXPOSED_PORT); + wsUrl = "ws://localhost:" + ContainerTestSuite.composeContainer.getServicePort("tb-web-ui1", ContainerTestSuite.EXPOSED_PORT); + restClient = new RestClient(httpUrl); + } + + protected Device createDevice(String name) { + return restClient.createDevice(name + RandomStringUtils.randomAlphanumeric(7), "DEFAULT"); + } + + protected WsClient subscribeToTelemetryWebSocket(DeviceId deviceId) throws URISyntaxException, InterruptedException { + WsClient mWs = new WsClient(new URI(wsUrl + "/api/ws/plugins/telemetry?token=" + restClient.getToken())); + mWs.connectBlocking(1, TimeUnit.SECONDS); + + JsonObject tsSubCmd = new JsonObject(); + tsSubCmd.addProperty("entityType", EntityType.DEVICE.name()); + tsSubCmd.addProperty("entityId", deviceId.toString()); + tsSubCmd.addProperty("scope", "LATEST_TELEMETRY"); + tsSubCmd.addProperty("cmdId", new Random().nextInt(100)); + tsSubCmd.addProperty("unsubscribe", false); + JsonArray wsTsSubCmds = new JsonArray(); + wsTsSubCmds.add(tsSubCmd); + JsonObject wsRequest = new JsonObject(); + wsRequest.add("tsSubCmds", wsTsSubCmds); + wsRequest.add("historyCmds", new JsonArray()); + wsRequest.add("attrSubCmds", new JsonArray()); + mWs.send(wsRequest.toString()); + return mWs; + } + + protected Map getExpectedLatestValues(long ts) { + return ImmutableMap.builder() + .put("booleanKey", ts) + .put("stringKey", ts) + .put("doubleKey", ts) + .put("longKey", ts) + .build(); + } + + protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, Long expectedTs, String expectedValue) { + List list = wsTelemetryResponse.getDataValuesByKey(key); + return expectedTs.equals(list.get(0)) && expectedValue.equals(list.get(1)); + } + + protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, String expectedValue) { + List list = wsTelemetryResponse.getDataValuesByKey(key); + return expectedValue.equals(list.get(1)); + } + + protected JsonObject createPayload(long ts) { + JsonObject values = createPayload(); + JsonObject payload = new JsonObject(); + payload.addProperty("ts", ts); + payload.add("values", values); + return payload; + } + + protected JsonObject createPayload() { + JsonObject values = new JsonObject(); + values.addProperty("stringKey", "value1"); + values.addProperty("booleanKey", true); + values.addProperty("doubleKey", 42.0); + values.addProperty("longKey", 73L); + + return values; + } + +} diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java new file mode 100644 index 0000000000..fd2de229d0 --- /dev/null +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import org.junit.ClassRule; +import org.junit.extensions.cpsuite.ClasspathSuite; +import org.junit.runner.RunWith; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.File; + +@RunWith(ClasspathSuite.class) +@ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*"}) +public class ContainerTestSuite { + static final int EXPOSED_PORT = 8080; + + @ClassRule + public static DockerComposeContainer composeContainer = new DockerComposeContainer(new File("./../docker/docker-compose.yml")) + .withPull(false) + .withLocalCompose(true) + .withTailChildContainers(true) + .withExposedService("tb-web-ui1", EXPOSED_PORT, Wait.forHttp("/login")); +} diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java new file mode 100644 index 0000000000..2f05eee4ce --- /dev/null +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; + +import java.net.URI; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +public class WsClient extends WebSocketClient { + private final BlockingQueue events; + private String message; + + public WsClient(URI serverUri) { + super(serverUri); + events = new ArrayBlockingQueue<>(100); + } + + @Override + public void onOpen(ServerHandshake serverHandshake) { + } + + @Override + public void onMessage(String message) { + events.add(message); + this.message = message; + } + + @Override + public void onClose(int code, String reason, boolean remote) { + events.clear(); + } + + @Override + public void onError(Exception ex) { + ex.printStackTrace(); + } + + public String getLastMessage() { + return this.message; + } +} \ No newline at end of file diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsTelemetryResponse.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsTelemetryResponse.java new file mode 100644 index 0000000000..834c5d1449 --- /dev/null +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsTelemetryResponse.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Data +public class WsTelemetryResponse implements Serializable { + private int subscriptionId; + private int errorCode; + private String errorMsg; + private Map>> data; + private Map latestValues; + + public List getDataValuesByKey(String key) { + return data.entrySet().stream() + .filter(e -> e.getKey().equals(key)) + .flatMap(e -> e.getValue().stream().flatMap(Collection::stream)) + .collect(Collectors.toList()); + } +} diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java new file mode 100644 index 0000000000..7cc0a0f6e3 --- /dev/null +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.connectivity; + +import org.junit.Assert; +import org.junit.Test; +import org.springframework.http.ResponseEntity; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.WsClient; +import org.thingsboard.server.msa.WsTelemetryResponse; + +import java.util.concurrent.TimeUnit; + +public class HttpClientTest extends AbstractContainerTest { + + @Test + public void telemetryUpdate() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + + Device device = createDevice("http_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient mWs = subscribeToTelemetryWebSocket(device.getId()); + ResponseEntity deviceTelemetryResponse = restClient.getRestTemplate() + .postForEntity(httpUrl + "/api/v1/{credentialsId}/telemetry", + mapper.readTree(createPayload().toString()), + ResponseEntity.class, + deviceCredentials.getCredentialsId()); + Assert.assertTrue(deviceTelemetryResponse.getStatusCode().is2xxSuccessful()); + TimeUnit.SECONDS.sleep(1); + WsTelemetryResponse actualLatestTelemetry = mapper.readValue(mWs.getLastMessage(), WsTelemetryResponse.class); + + Assert.assertEquals(getExpectedLatestValues(123456789L).keySet(), actualLatestTelemetry.getLatestValues().keySet()); + + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73))); + + restClient.getRestTemplate().delete(httpUrl + "/api/device/" + device.getId()); + } +} diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java new file mode 100644 index 0000000000..4ad638ed15 --- /dev/null +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -0,0 +1,111 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.connectivity; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import lombok.Data; +import org.junit.*; +import org.thingsboard.mqtt.MqttClient; +import org.thingsboard.mqtt.MqttClientConfig; +import org.thingsboard.mqtt.MqttHandler; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.WsClient; +import org.thingsboard.server.msa.WsTelemetryResponse; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.*; + +public class MqttClientTest extends AbstractContainerTest { + + @Test + public void telemetryUpload() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient mWs = subscribeToTelemetryWebSocket(device.getId()); + MqttClient mqttClient = getMqttClient(deviceCredentials); + mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload().toString().getBytes())); + TimeUnit.SECONDS.sleep(1); + WsTelemetryResponse actualLatestTelemetry = mapper.readValue(mWs.getLastMessage(), WsTelemetryResponse.class); + + Assert.assertEquals(getExpectedLatestValues(123456789L).keySet(), actualLatestTelemetry.getLatestValues().keySet()); + + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73))); + + restClient.getRestTemplate().delete(httpUrl + "/api/device/" + device.getId()); + } + + @Test + public void telemetryUploadWithTs() throws Exception { + long ts = 1451649600512L; + + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient mWs = subscribeToTelemetryWebSocket(device.getId()); + MqttClient mqttClient = getMqttClient(deviceCredentials); + mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload(ts).toString().getBytes())); + TimeUnit.SECONDS.sleep(1); + WsTelemetryResponse actualLatestTelemetry = mapper.readValue(mWs.getLastMessage(), WsTelemetryResponse.class); + + Assert.assertEquals(getExpectedLatestValues(ts), actualLatestTelemetry.getLatestValues()); + + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", ts, Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", ts, "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", ts, Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", ts, Long.toString(73))); + + restClient.getRestTemplate().delete(httpUrl + "/api/device/" + device.getId()); + } + + private MqttClient getMqttClient(DeviceCredentials deviceCredentials) throws InterruptedException { + MqttMessageListener queue = new MqttMessageListener(); + MqttClientConfig clientConfig = new MqttClientConfig(); + clientConfig.setClientId("MQTT client from test"); + clientConfig.setUsername(deviceCredentials.getCredentialsId()); + MqttClient mqttClient = MqttClient.create(clientConfig, queue); + mqttClient.connect("localhost", 1883).sync(); + return mqttClient; + } + + @Data + private class MqttMessageListener implements MqttHandler { + private final BlockingQueue events; + + private MqttMessageListener() { + events = new ArrayBlockingQueue<>(100); + } + + @Override + public void onMessage(String topic, ByteBuf message) { + events.add(new MqttEvent(topic, message.toString(StandardCharsets.UTF_8))); + } + } + + @Data + private class MqttEvent { + private final String topic; + private final String message; + } +} diff --git a/msa/pom.xml b/msa/pom.xml index 21ddb4dafe..dd4c3653ba 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -40,6 +40,7 @@ js-executor web-ui tb-node + integration-tests From cc1ad23c4e9244fa43d2217c245f6a4d4e39e8f8 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 16 Oct 2018 14:46:34 +0300 Subject: [PATCH 217/307] Improve Entity View UI --- .../app/entity-view/add-entity-view.tpl.html | 2 +- .../entity-view/entity-view-fieldset.tpl.html | 239 ++++++++++-------- .../app/entity-view/entity-view.directive.js | 9 +- ui/src/app/entity-view/entity-view.scss | 47 ++++ ui/src/app/locale/locale.constant-en_US.json | 8 +- 5 files changed, 200 insertions(+), 105 deletions(-) create mode 100644 ui/src/app/entity-view/entity-view.scss diff --git a/ui/src/app/entity-view/add-entity-view.tpl.html b/ui/src/app/entity-view/add-entity-view.tpl.html index 48a1788ed0..ebf1f1d341 100644 --- a/ui/src/app/entity-view/add-entity-view.tpl.html +++ b/ui/src/app/entity-view/add-entity-view.tpl.html @@ -15,7 +15,7 @@ limitations under the License. --> - +
diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index 74d1e3397c..140c199688 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -60,7 +60,7 @@ entity-type="types.entityType.entityView">
- +
+ + + +
{{ 'entity-view.attributes-propagation' | translate }}
+ + +
+ + +
{{ 'entity-view.attributes-propagation' | translate }}
+ + +
+ +
entity-view.attributes-propagation-hint
+ + + + {{item}} + + + + + + {{item}} + + + + + + {{item}} + + +
+
+
+ + +
{{ 'entity-view.timeseries-data' | translate }}
+ + +
+ + +
{{ 'entity-view.timeseries-data' | translate }}
+ + +
+ +
entity-view.timeseries-data-hint
+ + + + {{item}} + + +
+
+
+
+
+ + +
+
+ + +
-
- - - - {{item}} - - - - - - {{item}} - - - - - - {{item}} - - - - - - {{item}} - - -
-
-
- - -
-
- - -
-
diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index d183053f47..25377f4cb4 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import './entity-view.scss'; + /* eslint-disable import/no-unresolved, import/default */ import entityViewFieldsetTemplate from './entity-view-fieldset.tpl.html'; @@ -20,12 +23,16 @@ import entityViewFieldsetTemplate from './entity-view-fieldset.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function EntityViewDirective($q, $compile, $templateCache, $filter, toast, $translate, $mdConstant, +export default function EntityViewDirective($q, $compile, $templateCache, $filter, toast, $translate, $mdConstant, $mdExpansionPanel, types, clipboardService, entityViewService, customerService, entityService) { var linker = function (scope, element) { var template = $templateCache.get(entityViewFieldsetTemplate); element.html(template); + scope.attributesPanelId = (Math.random()*1000).toFixed(0); + scope.timeseriesPanelId = (Math.random()*1000).toFixed(0); + scope.$mdExpansionPanel = $mdExpansionPanel; + scope.types = types; scope.isAssignedToCustomer = false; scope.isPublic = false; diff --git a/ui/src/app/entity-view/entity-view.scss b/ui/src/app/entity-view/entity-view.scss new file mode 100644 index 0000000000..7b16b4a85d --- /dev/null +++ b/ui/src/app/entity-view/entity-view.scss @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../scss/constants"; + +.tb-entity-view-panel-group { + .tb-panel-title { + min-width: 90px; + user-select: none; + + @media (min-width: $layout-breakpoint-sm) { + min-width: 180px; + } + } + + .tb-panel-prompt { + overflow: hidden; + font-size: 14px; + color: rgba(0, 0, 0, .87); + text-overflow: ellipsis; + white-space: nowrap; + } + + &.disabled { + .tb-panel-title, + .tb-panel-prompt { + color: rgba(0, 0, 0, .38); + } + } + + md-icon.md-expansion-panel-icon { + margin-right: 0; + } +} diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index ccfcf659ac..48e578ea13 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -838,7 +838,9 @@ "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", "select-entity-view": "Select entity view", "make-public": "Make entity view public", + "start-date": "Start date", "start-ts": "Start time", + "end-date": "End date", "end-ts": "End time", "date-limits": "Date limits", "client-attributes": "Client attributes", @@ -849,7 +851,11 @@ "shared-attributes-placeholder": "Shared attributes", "server-attributes-placeholder": "Server attributes", "timeseries-placeholder": "Timeseries", - "related-entity": "Related entity" + "target-entity": "Target entity", + "attributes-propagation": "Attributes propagation", + "attributes-propagation-hint": "Entity View will automatically copy specified attributes from Target Entity each time you save or update this entity view. For performance reasons target entity attributes are not propagated to entity view on each attribute change. You can enable automatic propagation by configuring \"copy to view\" rule node in your rule chain and linking \"Post attributes\" and \"Attributes Updated\" messages to the new rule node.", + "timeseries-data": "Timeseries data", + "timeseries-data-hint": "Configure timeseries data keys of the target entity that will be accessible to the entity view. This timeseries data is read-only." }, "event": { "event-type": "Event type", From 438ee3bb640d66d0f33253407b76385024076456 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 16 Oct 2018 15:26:00 +0300 Subject: [PATCH 218/307] Added DELETE attributes case handle --- .../TbCopyAttributesToEntityViewNode.java | 137 +++++++++++------- 1 file changed, 82 insertions(+), 55 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 51386dc942..5d29a438f2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -17,7 +17,9 @@ package org.thingsboard.rule.engine.action; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; +import com.google.gson.JsonElement; import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; @@ -25,7 +27,6 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.TbRelationTypes; import org.thingsboard.rule.engine.api.util.DonAsynchron; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.DataConstants; @@ -37,12 +38,11 @@ import org.thingsboard.server.common.msg.session.SessionMsgType; import org.thingsboard.server.common.transport.adaptor.JsonConverter; import javax.annotation.Nullable; +import java.util.ArrayList; import java.util.List; import java.util.Set; -import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; -import static org.thingsboard.rule.engine.api.TbRelationTypes.FAILURE; import static org.thingsboard.rule.engine.api.TbRelationTypes.SUCCESS; @Slf4j @@ -68,66 +68,93 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { } @Override - public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { - if (!msg.getMetaData().getData().isEmpty()) { - long now = System.currentTimeMillis(); - String scope = msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()) ? - DataConstants.CLIENT_SCOPE : msg.getMetaData().getValue("scope"); + public void onMsg(TbContext ctx, TbMsg msg) { + if (DataConstants.ATTRIBUTES_UPDATED.equals(msg.getType()) || + DataConstants.ATTRIBUTES_DELETED.equals(msg.getType()) || + SessionMsgType.POST_ATTRIBUTES_REQUEST.name().equals(msg.getType())) { + if (!msg.getMetaData().getData().isEmpty()) { + long now = System.currentTimeMillis(); + String scope = msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()) ? + DataConstants.CLIENT_SCOPE : msg.getMetaData().getValue("scope"); - ListenableFuture> entityViewsFuture = - ctx.getEntityViewService().findEntityViewsByTenantIdAndEntityIdAsync(ctx.getTenantId(), msg.getOriginator()); + ListenableFuture> entityViewsFuture = + ctx.getEntityViewService().findEntityViewsByTenantIdAndEntityIdAsync(ctx.getTenantId(), msg.getOriginator()); - DonAsynchron.withCallback(entityViewsFuture, - entityViews -> { - for (EntityView entityView : entityViews) { - long startTime = entityView.getStartTimeMs(); - long endTime = entityView.getEndTimeMs(); - if ((endTime != 0 && endTime > now && startTime < now) || (endTime == 0 && startTime < now)) { - Set attributes = - JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())).getAttributes(); - List filteredAttributes = - attributes.stream() - .filter(attr -> { - switch (scope) { - case DataConstants.CLIENT_SCOPE: - if (entityView.getKeys().getAttributes().getCs().isEmpty()) { - return true; - } - return entityView.getKeys().getAttributes().getCs().contains(attr.getKey()); - case DataConstants.SERVER_SCOPE: - if (entityView.getKeys().getAttributes().getSs().isEmpty()) { - return true; - } - return entityView.getKeys().getAttributes().getSs().contains(attr.getKey()); - case DataConstants.SHARED_SCOPE: - if (entityView.getKeys().getAttributes().getSh().isEmpty()) { - return true; - } - return entityView.getKeys().getAttributes().getSh().contains(attr.getKey()); + DonAsynchron.withCallback(entityViewsFuture, + entityViews -> { + for (EntityView entityView : entityViews) { + long startTime = entityView.getStartTimeMs(); + long endTime = entityView.getEndTimeMs(); + if ((endTime != 0 && endTime > now && startTime < now) || (endTime == 0 && startTime < now)) { + if (DataConstants.ATTRIBUTES_UPDATED.equals(msg.getType()) || + SessionMsgType.POST_ATTRIBUTES_REQUEST.name().equals(msg.getType())) { + Set attributes = + JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())).getAttributes(); + List filteredAttributes = + attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr.getKey(), entityView)).collect(Collectors.toList()); + ctx.getTelemetryService().saveAndNotify(entityView.getId(), scope, filteredAttributes, + new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + transformAndTellNext(ctx, msg, entityView); } - return false; - }).collect(Collectors.toList()); - ctx.getTelemetryService().saveAndNotify(entityView.getId(), scope, filteredAttributes, - new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - TbMsg updMsg = ctx.transformMsg(msg, msg.getType(), entityView.getId(), msg.getMetaData(), msg.getData()); - ctx.tellNext(updMsg, SUCCESS); - } - - @Override - public void onFailure(Throwable t) { - ctx.tellFailure(msg, t); + @Override + public void onFailure(Throwable t) { + ctx.tellFailure(msg, t); + } + }); + } else if (DataConstants.ATTRIBUTES_DELETED.equals(msg.getType())) { + List attributes = new ArrayList<>(); + for (JsonElement element : new JsonParser().parse(msg.getData()).getAsJsonObject().get("attributes").getAsJsonArray()) { + if (element.isJsonPrimitive()) { + JsonPrimitive value = element.getAsJsonPrimitive(); + if (value.isString()) { + attributes.add(value.getAsString()); + } } - }); + List filteredAttributes = + attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr, entityView)).collect(Collectors.toList()); + ctx.getAttributesService().removeAll(entityView.getId(), scope, filteredAttributes); + } + transformAndTellNext(ctx, msg, entityView); + } + } } - } - }, - t -> ctx.tellFailure(msg, t)); + }, + t -> ctx.tellFailure(msg, t)); + } else { + ctx.tellFailure(msg, new IllegalArgumentException("Message metadata is empty")); + } } else { - ctx.tellNext(msg, FAILURE); + ctx.tellFailure(msg, new IllegalArgumentException("Unsupported msg type [" + msg.getType() + "]")); + } + } + + private void transformAndTellNext(TbContext ctx, TbMsg msg, EntityView entityView) { + TbMsg updMsg = ctx.transformMsg(msg, msg.getType(), entityView.getId(), msg.getMetaData(), msg.getData()); + ctx.tellNext(updMsg, SUCCESS); + } + + private boolean attributeContainsInEntityView(String scope, String attrKey, EntityView entityView) { + switch (scope) { + case DataConstants.CLIENT_SCOPE: + if (entityView.getKeys().getAttributes().getCs().isEmpty()) { + return true; + } + return entityView.getKeys().getAttributes().getCs().contains(attrKey); + case DataConstants.SERVER_SCOPE: + if (entityView.getKeys().getAttributes().getSs().isEmpty()) { + return true; + } + return entityView.getKeys().getAttributes().getSs().contains(attrKey); + case DataConstants.SHARED_SCOPE: + if (entityView.getKeys().getAttributes().getSh().isEmpty()) { + return true; + } + return entityView.getKeys().getAttributes().getSh().contains(attrKey); } + return false; } @Override From 88a171d7df00e13ff75c2e55236a878622be28af Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 16 Oct 2018 15:27:10 +0300 Subject: [PATCH 219/307] Added DELETE attributes case handle --- .../engine/action/TbCopyAttributesToEntityViewNode.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 5d29a438f2..1ab76059f7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -113,10 +113,10 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { attributes.add(value.getAsString()); } } - List filteredAttributes = - attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr, entityView)).collect(Collectors.toList()); - ctx.getAttributesService().removeAll(entityView.getId(), scope, filteredAttributes); } + List filteredAttributes = + attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr, entityView)).collect(Collectors.toList()); + ctx.getAttributesService().removeAll(entityView.getId(), scope, filteredAttributes); transformAndTellNext(ctx, msg, entityView); } } From 8b1a9351f34717de9fc8f799fdee691a39ce9081 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 16 Oct 2018 16:26:01 +0300 Subject: [PATCH 220/307] Fixed for entity view afte testing --- .../server/controller/BaseController.java | 8 ++ .../controller/EntityViewController.java | 85 +++++++++++++++++++ .../controller/TelemetryController.java | 6 -- .../server/dao/entity/BaseEntityService.java | 7 ++ .../dao/entityview/EntityViewServiceImpl.java | 45 ---------- .../TbCopyAttributesToEntityViewNode.java | 16 +--- ui/src/app/api/entity-view.service.js | 27 ------ ui/src/app/api/entity.service.js | 4 + 8 files changed, 108 insertions(+), 90 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 889630b81f..154ed3adb2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -51,6 +51,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; @@ -70,6 +71,7 @@ import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import javax.mail.MessagingException; import javax.servlet.http.HttpServletRequest; @@ -143,6 +145,12 @@ public abstract class BaseController { @Autowired protected EntityViewService entityViewService; + @Autowired + protected TelemetrySubscriptionService tsSubService; + + @Autowired + protected AttributesService attributesService; + @ExceptionHandler(ThingsboardException.class) public void handleThingsboardException(ThingsboardException ex, HttpServletResponse response) { errorResponseHandler.handle(ex, response); diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index a1ba1c63f8..69e28db65a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -15,7 +15,10 @@ */ package org.thingsboard.server.controller; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -26,7 +29,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; @@ -34,15 +39,24 @@ import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.service.security.model.SecurityUser; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.controller.CustomerController.CUSTOMER_ID; @@ -52,6 +66,7 @@ import static org.thingsboard.server.controller.CustomerController.CUSTOMER_ID; */ @RestController @RequestMapping("/api") +@Slf4j public class EntityViewController extends BaseController { public static final String ENTITY_VIEW_ID = "entityViewId"; @@ -75,6 +90,20 @@ public class EntityViewController extends BaseController { try { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); + List>> futures = new ArrayList<>(); + if (savedEntityView.getKeys() != null && savedEntityView.getKeys().getAttributes() != null) { + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs(), getCurrentUser())); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs(), getCurrentUser())); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh(), getCurrentUser())); + } + for (ListenableFuture> future : futures) { + try { + future.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException("Failed to copy attributes to entity view", e); + } + } + logEntityAction(savedEntityView.getId(), savedEntityView, null, entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); return savedEntityView; @@ -85,6 +114,62 @@ public class EntityViewController extends BaseController { } } + private ListenableFuture> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys, SecurityUser user) throws ThingsboardException { + EntityViewId entityId = entityView.getId(); + if (keys != null && !keys.isEmpty()) { + ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); + return Futures.transform(getAttrFuture, attributeKvEntries -> { + List attributes; + if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { + attributes = + attributeKvEntries.stream() + .filter(attributeKvEntry -> { + long startTime = entityView.getStartTimeMs(); + long endTime = entityView.getEndTimeMs(); + long lastUpdateTs = attributeKvEntry.getLastUpdateTs(); + return startTime == 0 && endTime == 0 || + (endTime == 0 && startTime < lastUpdateTs) || + (startTime == 0 && endTime > lastUpdateTs) + ? true : startTime < lastUpdateTs && endTime > lastUpdateTs; + }).collect(Collectors.toList()); + tsSubService.saveAndNotify(entityId, scope, attributes, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + try { + logAttributesUpdated(user, entityId, scope, attributes, null); + } catch (ThingsboardException e) { + log.error("Failed to log attribute updates", e); + } + if (entityId.getEntityType() == EntityType.ENTITY_VIEW) { + DeviceId deviceId = new DeviceId(entityId.getId()); + DeviceAttributesEventNotificationMsg notificationMsg = DeviceAttributesEventNotificationMsg.onUpdate( + user.getTenantId(), deviceId, scope, attributes); + actorService.onMsg(new SendToClusterMsg(deviceId, notificationMsg)); + } + } + + @Override + public void onFailure(Throwable t) { + try { + logAttributesUpdated(user, entityId, scope, attributes, t); + } catch (ThingsboardException e) { + log.error("Failed to log attribute updates", e); + } + } + }); + } + return null; + }); + } else { + return Futures.immediateFuture(null); + } + } + + private void logAttributesUpdated(SecurityUser user, EntityId entityId, String scope, List attributes, Throwable e) throws ThingsboardException { + logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.ATTRIBUTES_UPDATED, toException(e), + scope, attributes); + } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.DELETE) @ResponseStatus(value = HttpStatus.OK) diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index 7062ca97f9..9f92019550 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -93,15 +93,9 @@ import java.util.stream.Collectors; @Slf4j public class TelemetryController extends BaseController { - @Autowired - private AttributesService attributesService; - @Autowired private TimeseriesService tsService; - @Autowired - private TelemetrySubscriptionService tsSubService; - @Autowired private AccessValidator accessValidator; diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index 1b67ca0486..33a26997ca 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.user.UserService; @@ -46,6 +47,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe @Autowired private DeviceService deviceService; + @Autowired + private EntityViewService entityViewService; + @Autowired private TenantService tenantService; @@ -81,6 +85,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe case DEVICE: hasName = deviceService.findDeviceByIdAsync(new DeviceId(entityId.getId())); break; + case ENTITY_VIEW: + hasName = entityViewService.findEntityViewByIdAsync(new EntityViewId(entityId.getId())); + break; case TENANT: hasName = tenantService.findTenantByIdAsync(new TenantId(entityId.getId())); break; diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index a44a4349a0..0cc6008ffb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -105,21 +105,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti log.trace("Executing save entity view [{}]", entityView); entityViewValidator.validate(entityView); EntityView savedEntityView = entityViewDao.save(entityView); - - List>> futures = new ArrayList<>(); - if (savedEntityView.getKeys() != null && savedEntityView.getKeys().getAttributes() != null) { - futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs())); - futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs())); - futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh())); - } - for (ListenableFuture> future : futures) { - try { - future.get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Failed to copy attributes to entity view", e); - throw new RuntimeException("Failed to copy attributes to entity view", e); - } - } return savedEntityView; } @@ -294,36 +279,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti }); } - private ListenableFuture> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys) { - if (keys != null && !keys.isEmpty()) { - ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); - return Futures.transform(getAttrFuture, attributeKvEntries -> { - List filteredAttributes = new ArrayList<>(); - if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { - filteredAttributes = - attributeKvEntries.stream() - .filter(attributeKvEntry -> { - long startTime = entityView.getStartTimeMs(); - long endTime = entityView.getEndTimeMs(); - long lastUpdateTs = attributeKvEntry.getLastUpdateTs(); - return startTime == 0 && endTime == 0 || - (endTime == 0 && startTime < lastUpdateTs) || - (startTime == 0 && endTime > lastUpdateTs) - ? true : startTime < lastUpdateTs && endTime > lastUpdateTs; - }).collect(Collectors.toList()); - } - try { - return attributesService.save(entityView.getId(), scope, filteredAttributes).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Failed to copy attributes to entity view", e); - throw new RuntimeException("Failed to copy attributes to entity view", e); - } - }); - } else { - return Futures.immediateFuture(null); - } - } - private DataValidator entityViewValidator = new DataValidator() { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 1ab76059f7..fc0ff28749 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -116,8 +116,10 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { } List filteredAttributes = attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr, entityView)).collect(Collectors.toList()); - ctx.getAttributesService().removeAll(entityView.getId(), scope, filteredAttributes); - transformAndTellNext(ctx, msg, entityView); + if (filteredAttributes != null && !filteredAttributes.isEmpty()) { + ctx.getAttributesService().removeAll(entityView.getId(), scope, filteredAttributes); + transformAndTellNext(ctx, msg, entityView); + } } } } @@ -139,19 +141,10 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { private boolean attributeContainsInEntityView(String scope, String attrKey, EntityView entityView) { switch (scope) { case DataConstants.CLIENT_SCOPE: - if (entityView.getKeys().getAttributes().getCs().isEmpty()) { - return true; - } return entityView.getKeys().getAttributes().getCs().contains(attrKey); case DataConstants.SERVER_SCOPE: - if (entityView.getKeys().getAttributes().getSs().isEmpty()) { - return true; - } return entityView.getKeys().getAttributes().getSs().contains(attrKey); case DataConstants.SHARED_SCOPE: - if (entityView.getKeys().getAttributes().getSh().isEmpty()) { - return true; - } return entityView.getKeys().getAttributes().getSh().contains(attrKey); } return false; @@ -159,6 +152,5 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { @Override public void destroy() { - } } diff --git a/ui/src/app/api/entity-view.service.js b/ui/src/app/api/entity-view.service.js index e34d3a4aee..23fefbc05b 100644 --- a/ui/src/app/api/entity-view.service.js +++ b/ui/src/app/api/entity-view.service.js @@ -27,7 +27,6 @@ function EntityViewService($http, $q, $window, userService, attributeService, cu deleteEntityView: deleteEntityView, getCustomerEntityViews: getCustomerEntityViews, getEntityView: getEntityView, - getEntityViews: getEntityViews, getTenantEntityViews: getTenantEntityViews, saveEntityView: saveEntityView, unassignEntityViewFromCustomer: unassignEntityViewFromCustomer, @@ -126,32 +125,6 @@ function EntityViewService($http, $q, $window, userService, attributeService, cu return deferred.promise; } - function getEntityViews(entityViewIds, config) { - var deferred = $q.defer(); - var ids = ''; - for (var i=0;i0) { - ids += ','; - } - ids += entityViewIds[i]; - } - var url = '/api/entityViews?entityViewIds=' + ids; - $http.get(url, config).then(function success(response) { - var entityViews = response.data; - entityViews.sort(function (entityView1, entityView2) { - var id1 = entityView1.id.id; - var id2 = entityView2.id.id; - var index1 = entityViewIds.indexOf(id1); - var index2 = entityViewIds.indexOf(id2); - return index1 - index2; - }); - deferred.resolve(entityViews); - }, function fail(response) { - deferred.reject(response.data); - }); - return deferred.promise; - } - function saveEntityView(entityView) { var deferred = $q.defer(); var url = '/api/entityView'; diff --git a/ui/src/app/api/entity.service.js b/ui/src/app/api/entity.service.js index 205abc9e4e..00c7ecf990 100644 --- a/ui/src/app/api/entity.service.js +++ b/ui/src/app/api/entity.service.js @@ -135,6 +135,10 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case types.entityType.asset: promise = assetService.getAssets(entityIds, config); break; + case types.entityType.entityView: + promise = getEntitiesByIdsPromise( + (id) => entityViewService.getEntityView(id, config), entityIds); + break; case types.entityType.tenant: promise = getEntitiesByIdsPromise( (id) => tenantService.getTenant(id, config), entityIds); From 626d2ce194f5ca0beea5d78d32e89bcfc906210a Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Tue, 16 Oct 2018 17:11:09 +0300 Subject: [PATCH 221/307] caffeine cache max size should include value collection size --- .../dao/cache/CaffeineCacheConfiguration.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java b/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java index 688dfb3f9f..fc2868e805 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.RemovalCause; import com.github.benmanes.caffeine.cache.Ticker; +import com.github.benmanes.caffeine.cache.Weigher; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -31,6 +32,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -64,8 +66,9 @@ public class CaffeineCacheConfiguration { private CaffeineCache buildCache(String name, CacheSpecs cacheSpec) { final Caffeine caffeineBuilder = Caffeine.newBuilder() + .weigher(collectionSafeWeigher()) + .maximumWeight(cacheSpec.getMaxSize()) .expireAfterWrite(cacheSpec.getTimeToLiveInMinutes(), TimeUnit.MINUTES) - .maximumSize(cacheSpec.getMaxSize()) .ticker(ticker()); return new CaffeineCache(name, caffeineBuilder.build()); } @@ -80,4 +83,12 @@ public class CaffeineCacheConfiguration { return new PreviousDeviceCredentialsIdKeyGenerator(); } + private Weigher collectionSafeWeigher() { + return (Weigher) (key, value) -> { + if(value instanceof Collection) { + return ((Collection) value).size(); + } + return 1; + }; + } } From 840c04282779141ceba25dc8d755f45084be3aff Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Tue, 16 Oct 2018 17:25:23 +0300 Subject: [PATCH 222/307] log tenantID/ruleEntityId when RuleChain is not active --- .../server/actors/shared/ComponentMsgProcessor.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java index 1cf83397fc..c35b255af6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -82,7 +82,8 @@ public abstract class ComponentMsgProcessor extends Abstract protected void checkActive() { if (state != ComponentLifecycleState.ACTIVE) { - throw new IllegalStateException("Rule chain is not active!"); + logger.warning("Rule chain is not active. Current state [{}] for processor [{}] tenant [{}]", state, tenantId, entityId); + throw new IllegalStateException("Rule chain is not active! " + entityId + " - " + tenantId); } } From ef9c432aec49205c9a403801f688859864c38f9a Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 16 Oct 2018 17:26:03 +0300 Subject: [PATCH 223/307] Removed unnecessary code --- .../thingsboard/server/controller/EntityViewController.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 69e28db65a..9b7ffd2d11 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -140,12 +140,6 @@ public class EntityViewController extends BaseController { } catch (ThingsboardException e) { log.error("Failed to log attribute updates", e); } - if (entityId.getEntityType() == EntityType.ENTITY_VIEW) { - DeviceId deviceId = new DeviceId(entityId.getId()); - DeviceAttributesEventNotificationMsg notificationMsg = DeviceAttributesEventNotificationMsg.onUpdate( - user.getTenantId(), deviceId, scope, attributes); - actorService.onMsg(new SendToClusterMsg(deviceId, notificationMsg)); - } } @Override From 591bc88c2ebf96671f963999de38c91d41700fce Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 16 Oct 2018 17:34:06 +0300 Subject: [PATCH 224/307] Docker: Improve kafka configuration. --- msa/docker/.env | 2 +- msa/docker/docker-compose.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/msa/docker/.env b/msa/docker/.env index 111ff1e2b4..018d4ac7b7 100644 --- a/msa/docker/.env +++ b/msa/docker/.env @@ -7,4 +7,4 @@ WEB_UI_DOCKER_NAME=tb-web-ui TB_VERSION=2.2.0-SNAPSHOT -KAFKA_TOPICS=js.eval.requests:100:1 +KAFKA_TOPICS=js.eval.requests:100:1:delete --config=retention.ms=60000 --config=retention.bytes=1073741824 diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 93c2ab08cd..91e6286d69 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -36,6 +36,9 @@ services: KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE KAFKA_CREATE_TOPICS: "${KAFKA_TOPICS}" KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false' + KAFKA_LOG_RETENTION_BYTES: 1073741824 + KAFKA_LOG_RETENTION_MS: 300000 + KAFKA_LOG_CLEANUP_POLICY: delete depends_on: - zookeeper tb-js-executor: From 3aa3a56bab880111cde412e094f8692b2cbe0bbf Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 16 Oct 2018 18:01:46 +0300 Subject: [PATCH 225/307] Version set to 2.1.3 --- application/pom.xml | 2 +- common/data/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/transport/pom.xml | 2 +- dao/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- ui/pom.xml | 2 +- 17 files changed, 18 insertions(+), 18 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index c6503ef248..2945ddfcd7 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard application diff --git a/common/data/pom.xml b/common/data/pom.xml index f25e78de0e..b27b93c0f5 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 67648e9c4d..18aa049307 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index aba6d7f3a9..f75fd89834 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard org.thingsboard diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 583fcd28f7..cc83bc0c03 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index e7d6783911..b6b6aa183b 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard dao diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 0b1ea1e35e..d97013290a 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,12 +19,12 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard org.thingsboard netty-mqtt - 2.1.3-SNAPSHOT + 2.1.3 jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 05ab0d6f39..301b0e77ab 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 2.1.3-SNAPSHOT + 2.1.3 pom Thingsboard diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 3847120030..60f7901b48 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 4e79b11056..1dbf906d7d 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 6e7cfdc6a7..d5bb024277 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index af4c53b4a1..c6115a7680 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard org.thingsboard diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index bcb45236c1..adceea8a43 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index d73692f260..0bcad5ed31 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index e89bba42b1..6df5738ff2 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index 22ec9be255..3a98387e85 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard org.thingsboard diff --git a/ui/pom.xml b/ui/pom.xml index c219a22e6b..7fe0bbad5e 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.3-SNAPSHOT + 2.1.3 thingsboard org.thingsboard From e729494e82c35e2fa452300d5fecfe7d98bde043 Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Wed, 17 Oct 2018 10:54:00 +0300 Subject: [PATCH 226/307] add alarm ack/clear event to the Rule Engine --- .../org/thingsboard/server/controller/BaseController.java | 6 ++++++ .../org/thingsboard/server/common/data/DataConstants.java | 2 ++ .../thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java | 6 +++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 889630b81f..951ee760ca 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -605,6 +605,12 @@ public abstract class BaseController { case ATTRIBUTES_DELETED: msgType = DataConstants.ATTRIBUTES_DELETED; break; + case ALARM_ACK: + msgType = DataConstants.ALARM_ACK; + break; + case ALARM_CLEAR: + msgType = DataConstants.ALARM_CLEAR; + break; } if (!StringUtils.isEmpty(msgType)) { try { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java index 34c14deb8c..307c59bdb9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java @@ -57,6 +57,8 @@ public class DataConstants { public static final String ENTITY_UNASSIGNED = "ENTITY_UNASSIGNED"; public static final String ATTRIBUTES_UPDATED = "ATTRIBUTES_UPDATED"; public static final String ATTRIBUTES_DELETED = "ATTRIBUTES_DELETED"; + public static final String ALARM_ACK = "ALARM_ACK"; + public static final String ALARM_CLEAR = "ALARM_CLEAR"; public static final String RPC_CALL_FROM_SERVER_TO_DEVICE = "RPC_CALL_FROM_SERVER_TO_DEVICE"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java index f78b186b96..035a17ddc8 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java @@ -30,7 +30,7 @@ import org.thingsboard.server.common.msg.session.SessionMsgType; configClazz = EmptyNodeConfiguration.class, relationTypes = {"Post attributes", "Post telemetry", "RPC Request from Device", "RPC Request to Device", "Activity Event", "Inactivity Event", "Connect Event", "Disconnect Event", "Entity Created", "Entity Updated", "Entity Deleted", "Entity Assigned", - "Entity Unassigned", "Attributes Updated", "Attributes Deleted", "Other"}, + "Entity Unassigned", "Attributes Updated", "Attributes Deleted", "Alarm Acknowledged", "Alarm Cleared", "Other"}, nodeDescription = "Route incoming messages by Message Type", nodeDetails = "Sends messages with message types \"Post attributes\", \"Post telemetry\", \"RPC Request\" etc. via corresponding chain, otherwise Other chain is used.", uiResources = {"static/rulenode/rulenode-core-config.js"}, @@ -75,6 +75,10 @@ public class TbMsgTypeSwitchNode implements TbNode { relationType = "Attributes Updated"; } else if (msg.getType().equals(DataConstants.ATTRIBUTES_DELETED)) { relationType = "Attributes Deleted"; + } else if (msg.getType().equals(DataConstants.ALARM_ACK)) { + relationType = "Alarm Acknowledged"; + } else if (msg.getType().equals(DataConstants.ALARM_CLEAR)) { + relationType = "Alarm Cleared"; } else if (msg.getType().equals(DataConstants.RPC_CALL_FROM_SERVER_TO_DEVICE)) { relationType = "RPC Request to Device"; } else { From 823b6aa7a722b6a992dfcca9d63bd78f38c9b4d9 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 17 Oct 2018 18:59:52 +0300 Subject: [PATCH 227/307] HTTP transport implementation --- .../transport/LocalTransportService.java | 63 +----- .../src/main/resources/thingsboard.yml | 2 +- .../transport/http/DeviceApiController.java | 193 +++++++++--------- .../transport/http/HttpTransportContext.java | 4 +- .../http/session/HttpSessionCtx.java | 161 --------------- .../transport/mqtt/MqttTransportContext.java | 2 + .../transport/mqtt/MqttTransportHandler.java | 10 +- .../mqtt/session/GatewaySessionHandler.java | 2 +- .../common/transport/TransportContext.java | 15 ++ .../common/transport/TransportService.java | 12 +- .../service/AbstractTransportService.java | 101 +++++++++ .../service/RemoteTransportService.java | 71 +------ .../transport/service/SessionMetaData.java | 15 ++ 13 files changed, 268 insertions(+), 383 deletions(-) delete mode 100644 common/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java index a7590c9610..b8001725d7 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -26,6 +26,7 @@ import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.service.AbstractTransportService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; @@ -56,6 +57,8 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -67,11 +70,7 @@ import java.util.function.Consumer; @Slf4j @Service @ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "local") -public class LocalTransportService implements TransportService, RuleEngineTransportService { - - private ConcurrentMap sessions = new ConcurrentHashMap<>(); - - private ExecutorService transportCallbackExecutor; +public class LocalTransportService extends AbstractTransportService implements RuleEngineTransportService { @Autowired private TransportApiService transportApiService; @@ -89,14 +88,12 @@ public class LocalTransportService implements TransportService, RuleEngineTransp @PostConstruct public void init() { - this.transportCallbackExecutor = new ThreadPoolExecutor(0, 100, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + super.init(); } @PreDestroy public void destroy() { - if (transportCallbackExecutor != null) { - transportCallbackExecutor.shutdownNow(); - } + super.destroy(); } @Override @@ -175,21 +172,6 @@ public class LocalTransportService implements TransportService, RuleEngineTransp forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback); } - @Override - public void registerSession(SessionInfoProto sessionInfo, TransportProtos.SessionType sessionType, SessionMsgListener listener) { - sessions.putIfAbsent(toId(sessionInfo), listener); - //TODO: monitor sessions periodically: PING REQ/RESP, etc. - } - - @Override - public void deregisterSession(SessionInfoProto sessionInfo) { - sessions.remove(toId(sessionInfo)); - } - - private UUID toId(SessionInfoProto sessionInfo) { - return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); - } - @Override public void process(String nodeId, DeviceActorToTransportMsg msg) { process(nodeId, msg, null, null); @@ -197,30 +179,7 @@ public class LocalTransportService implements TransportService, RuleEngineTransp @Override public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { - UUID sessionId = new UUID(msg.getSessionIdMSB(), msg.getSessionIdLSB()); - SessionMsgListener listener = sessions.get(sessionId); - if (listener != null) { - transportCallbackExecutor.submit(() -> { - if (msg.hasGetAttributesResponse()) { - listener.onGetAttributesResponse(msg.getGetAttributesResponse()); - } - if (msg.hasAttributeUpdateNotification()) { - listener.onAttributeUpdate(msg.getAttributeUpdateNotification()); - } - if (msg.hasSessionCloseNotification()) { - listener.onRemoteSessionCloseCommand(msg.getSessionCloseNotification()); - } - if (msg.hasToDeviceRequest()) { - listener.onToDeviceRpcRequest(msg.getToDeviceRequest()); - } - if (msg.hasToServerResponse()) { - listener.onToServerRpcResponse(msg.getToServerResponse()); - } - }); - } else { - //TODO: should we notify the device actor about missed session? - log.debug("[{}] Missing session.", sessionId); - } + processToTransportMsg(msg); if (onSuccess != null) { onSuccess.run(); } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ca509443bf..0eb624b181 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -439,7 +439,7 @@ transport: # Local MQTT transport parameters mqtt: # Enable/disable mqtt transport protocol. - enabled: "${MQTT_ENABLED:true}" + enabled: "${MQTT_ENABLED:false}" bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" bind_port: "${MQTT_BIND_PORT:1883}" adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index bca05c0459..8d579d4eb2 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.transport.http; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -33,9 +35,20 @@ import org.thingsboard.server.common.transport.TransportContext; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.gen.transport.TransportProtos; -import org.thingsboard.server.gen.transport.TransportProtos.*; -import org.thingsboard.server.transport.http.session.HttpSessionCtx; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import javax.servlet.http.HttpServletRequest; import java.util.Arrays; @@ -76,7 +89,7 @@ public class DeviceApiController { request.addAllSharedAttributeNames(sharedKeySet); } TransportService transportService = transportContext.getTransportService(); - transportService.registerSession(sessionInfo, TransportProtos.SessionType.SYNC, new HttpSessionListener(transportContext, responseWriter)); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), transportContext.getDefaultTimeout()); transportService.process(sessionInfo, request.build(), new SessionCloseOnErrorCallback(transportService, sessionInfo)); })); return responseWriter; @@ -85,20 +98,16 @@ public class DeviceApiController { @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.POST) public DeferredResult postDeviceAttributes(@PathVariable("deviceToken") String deviceToken, @RequestBody String json, HttpServletRequest request) { - DeferredResult responseWriter = new DeferredResult(); + DeferredResult responseWriter = new DeferredResult<>(); if (quotaExceeded(request, responseWriter)) { return responseWriter; } -// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); -// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { -// try { -// process(ctx, JsonConverter.convertToAttributes(new JsonParser().parse(json))); -// } catch (IllegalStateException | JsonSyntaxException ex) { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); -// } -// } else { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); -// } + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.process(sessionInfo, JsonConverter.convertToAttributesProto(new JsonParser().parse(json)), + new HttpOkCallback(responseWriter)); + })); return responseWriter; } @@ -109,26 +118,33 @@ public class DeviceApiController { if (quotaExceeded(request, responseWriter)) { return responseWriter; } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); -// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { -// try { -// process(ctx, JsonConverter.convertToTelemetry(new JsonParser().parse(json))); -// } catch (IllegalStateException | JsonSyntaxException ex) { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); -// } -// } else { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); -// } + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.process(sessionInfo, JsonConverter.convertToTelemetryProto(new JsonParser().parse(json)), + new HttpOkCallback(responseWriter)); + })); return responseWriter; } @RequestMapping(value = "/{deviceToken}/rpc", method = RequestMethod.GET, produces = "application/json") public DeferredResult subscribeToCommands(@PathVariable("deviceToken") String deviceToken, @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, - HttpServletRequest request) { + HttpServletRequest httpRequest) { + DeferredResult responseWriter = new DeferredResult<>(); + if (quotaExceeded(httpRequest, responseWriter)) { + return responseWriter; + } + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), + timeout == 0 ? transportContext.getDefaultTimeout() : timeout); + transportService.process(sessionInfo, SubscribeToRPCMsg.getDefaultInstance(), + new SessionCloseOnErrorCallback(transportService, sessionInfo)); -// return subscribe(deviceToken, timeout, new RpcSubscribeMsg(), request); - return null; + })); + return responseWriter; } @RequestMapping(value = "/{deviceToken}/rpc/{requestId}", method = RequestMethod.POST) @@ -139,17 +155,11 @@ public class DeviceApiController { if (quotaExceeded(request, responseWriter)) { return responseWriter; } -// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); -// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { -// try { -// JsonObject response = new JsonParser().parse(json).getAsJsonObject(); -// process(ctx, new ToDeviceRpcResponseMsg(requestId, response.toString())); -// } catch (IllegalStateException | JsonSyntaxException ex) { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); -// } -// } else { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); -// } + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.process(sessionInfo, ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId).setPayload(json).build(), new HttpOkCallback(responseWriter)); + })); return responseWriter; } @@ -160,19 +170,16 @@ public class DeviceApiController { if (quotaExceeded(httpRequest, responseWriter)) { return responseWriter; } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); -// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { -// try { -// JsonObject request = new JsonParser().parse(json).getAsJsonObject(); -// process(ctx, new ToServerRpcRequestMsg(0, -// request.get("method").getAsString(), -// request.get("params").toString())); -// } catch (IllegalStateException | JsonSyntaxException ex) { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); -// } -// } else { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); -// } + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + JsonObject request = new JsonParser().parse(json).getAsJsonObject(); + TransportService transportService = transportContext.getTransportService(); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), transportContext.getDefaultTimeout()); + transportService.process(sessionInfo, ToServerRpcRequestMsg.newBuilder().setRequestId(0) + .setMethodName(request.get("method").getAsString()) + .setParams(request.get("params").toString()).build(), + new SessionCloseOnErrorCallback(transportService, sessionInfo)); + })); return responseWriter; } @@ -180,49 +187,30 @@ public class DeviceApiController { public DeferredResult subscribeToAttributes(@PathVariable("deviceToken") String deviceToken, @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, HttpServletRequest httpRequest) { - return null; -// return subscribe(deviceToken, timeout, new AttributesSubscribeMsg(), httpRequest); - } - -// private DeferredResult subscribe(String deviceToken, long timeout, FromDeviceMsg msg, HttpServletRequest httpRequest) { -// DeferredResult responseWriter = new DeferredResult(); -// if (quotaExceeded(httpRequest, responseWriter)) { -// return responseWriter; -// } -// HttpSessionCtx ctx = getHttpSessionCtx(responseWriter, timeout); -// if (ctx.login(new DeviceTokenCredentials(deviceToken))) { -// try { -// process(ctx, msg); -// } catch (IllegalStateException | JsonSyntaxException ex) { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); -// } -// } else { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); -// } -// return responseWriter; -// } - - private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter) { - return getHttpSessionCtx(responseWriter, transportContext.getDefaultTimeout()); - } + DeferredResult responseWriter = new DeferredResult<>(); + if (quotaExceeded(httpRequest, responseWriter)) { + return responseWriter; + } + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), + timeout == 0 ? transportContext.getDefaultTimeout() : timeout); + transportService.process(sessionInfo, SubscribeToAttributeUpdatesMsg.getDefaultInstance(), + new SessionCloseOnErrorCallback(transportService, sessionInfo)); - private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter, long timeout) { - return null; -// return new HttpSessionCtx(processor, authService, responseWriter, timeout != 0 ? timeout : defaultTimeout); + })); + return responseWriter; } -// private void process(HttpSessionCtx ctx, FromDeviceMsg request) { -// AdaptorToSessionActorMsg msg = new BasicAdaptorToSessionActorMsg(ctx, request); -//// processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); -// } - private boolean quotaExceeded(HttpServletRequest request, DeferredResult responseWriter) { if (transportContext.getQuotaService().isQuotaExceeded(request.getRemoteAddr())) { log.warn("REST Quota exceeded for [{}] . Disconnect", request.getRemoteAddr()); responseWriter.setResult(new ResponseEntity<>(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)); return true; + } else { + return false; } - return false; } private static class DeviceAuthCallback implements TransportServiceCallback { @@ -274,7 +262,6 @@ public class DeviceApiController { @Override public void onSuccess(Void msg) { - } @Override @@ -283,13 +270,30 @@ public class DeviceApiController { } } + private static class HttpOkCallback implements TransportServiceCallback { + private final DeferredResult responseWriter; + + public HttpOkCallback(DeferredResult responseWriter) { + this.responseWriter = responseWriter; + } + + @Override + public void onSuccess(Void msg) { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + + @Override + public void onError(Throwable e) { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + } + + private static class HttpSessionListener implements SessionMsgListener { - private final TransportContext transportContext; private final DeferredResult responseWriter; - HttpSessionListener(TransportContext transportContext, DeferredResult responseWriter) { - this.transportContext = transportContext; + HttpSessionListener(DeferredResult responseWriter) { this.responseWriter = responseWriter; } @@ -305,16 +309,17 @@ public class DeviceApiController { @Override public void onRemoteSessionCloseCommand(SessionCloseNotificationProto sessionCloseNotification) { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.REQUEST_TIMEOUT)); } @Override - public void onToDeviceRpcRequest(ToDeviceRpcRequestMsg toDeviceRequest) { - + public void onToDeviceRpcRequest(ToDeviceRpcRequestMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg, true).toString(), HttpStatus.OK)); } @Override - public void onToServerRpcResponse(ToServerRpcResponseMsg toServerResponse) { - + public void onToServerRpcResponse(ToServerRpcResponseMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); } } } diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 87db06e66b..66654c1bed 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -26,12 +26,12 @@ import org.thingsboard.server.common.transport.TransportContext; * Created by ashvayka on 04.10.18. */ @Slf4j -@ConditionalOnProperty(prefix = "transport.mqtt", value = "enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnProperty(prefix = "transport.http", value = "enabled", havingValue = "true", matchIfMissing = true) @Component public class HttpTransportContext extends TransportContext { @Getter - @Value("${http.request_timeout}") + @Value("${transport.http.request_timeout}") private long defaultTimeout; } diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java deleted file mode 100644 index 27cd4f1dd4..0000000000 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.http.session; - -import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; - -import java.util.UUID; - -/** - * @author Andrew Shvayka - */ -@Slf4j -public class HttpSessionCtx extends DeviceAwareSessionContext { - - public HttpSessionCtx(UUID sessionId) { - super(sessionId); - } - - @Override - public int nextMsgId() { - return 0; - } - - // private final SessionId sessionId; -// private final long timeout; -// private final DeferredResult responseWriter; -// -// public HttpSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, DeferredResult responseWriter, long timeout) { -// super(); -// this.sessionId = new HttpSessionId(); -// this.responseWriter = responseWriter; -// this.timeout = timeout; -// } -// -// @Override -// public SessionType getSessionType() { -// return SessionType.SYNC; -// } -// -// @Override -// public void onMsg(SessionActorToAdaptorMsg source) throws SessionException { -// ToDeviceMsg msg = source.getMsg(); -// switch (msg.getSessionMsgType()) { -// case GET_ATTRIBUTES_RESPONSE: -// reply((GetAttributesResponse) msg); -// return; -// case STATUS_CODE_RESPONSE: -// reply((StatusCodeResponse) msg); -// return; -// case ATTRIBUTES_UPDATE_NOTIFICATION: -// reply((AttributesUpdateNotification) msg); -// return; -// case TO_DEVICE_RPC_REQUEST: -// reply((ToDeviceRpcRequestMsg) msg); -// return; -// case TO_SERVER_RPC_RESPONSE: -// reply((ToServerRpcResponseMsg) msg); -// return; -// case RULE_ENGINE_ERROR: -// reply((RuleEngineErrorMsg) msg); -// return; -// default: -// break; -// } -// } -// -// private void reply(RuleEngineErrorMsg msg) { -// HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; -// switch (msg.getError()) { -// case QUEUE_PUT_TIMEOUT: -// status = HttpStatus.REQUEST_TIMEOUT; -// break; -// default: -// if (msg.getInSessionMsgType() == SessionMsgType.TO_SERVER_RPC_REQUEST) { -// status = HttpStatus.BAD_REQUEST; -// } -// break; -// } -// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toErrorJson(msg.getErrorMsg()).toString(), status)); -// } -// -// private void reply(ResponseMsg msg, Consumer f) { -// Optional msgError = msg.getError(); -// if (!msgError.isPresent()) { -// Optional msgData = msg.getData(); -// if (msgData.isPresent()) { -// f.accept(msgData.get()); -// } -// } else { -// Exception e = msgError.get(); -// responseWriter.setResult(new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR)); -// } -// } -// -// private void reply(ToDeviceRpcRequestMsg msg) { -// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg, true).toString(), HttpStatus.OK)); -// } -// -// private void reply(ToServerRpcResponseMsg msg) { -//// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); -// } -// -// private void reply(AttributesUpdateNotification msg) { -// responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg.getData(), false).toString(), HttpStatus.OK)); -// } -// -// private void reply(GetAttributesResponse msg) { -// reply(msg, payload -> { -// if (payload.getClientAttributes().isEmpty() && payload.getSharedAttributes().isEmpty()) { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.NOT_FOUND)); -// } else { -// JsonObject result = JsonConverter.toJson(payload, false); -// responseWriter.setResult(new ResponseEntity<>(result.toString(), HttpStatus.OK)); -// } -// }); -// } -// -// private void reply(StatusCodeResponse msg) { -// reply(msg, payload -> { -// if (payload == 0) { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); -// } else { -// responseWriter.setResult(new ResponseEntity<>(HttpStatus.valueOf(payload))); -// } -// }); -// } -// -// @Override -// public void onMsg(SessionCtrlMsg msg) throws SessionException { -// //Do nothing -// } -// -// @Override -// public boolean isClosed() { -// return false; -// } -// -// @Override -// public long getTimeout() { -// return timeout; -// } -// -// @Override -// public SessionId getSessionId() { -// return sessionId; -// } -} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 16f8981d57..91580bff7d 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.netty.handler.ssl.SslHandler; import lombok.Data; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -56,6 +57,7 @@ public class MqttTransportContext extends TransportContext { private Integer maxPayloadSize; @Getter + @Setter private SslHandler sslHandler; } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 30cc9c75a2..d739824aa5 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -506,7 +506,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement .setTenantIdLSB(msg.getDeviceInfo().getTenantIdLSB()) .build(); transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.OPEN), null); - transportService.registerSession(sessionInfo, TransportProtos.SessionType.ASYNC, this); + transportService.registerAsyncSession(sessionInfo, this); checkGatewaySession(); ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java index 43f997830f..d6000591a8 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -121,7 +121,7 @@ public class GatewaySessionHandler { transportService.process(deviceSessionInfo, MqttTransportHandler.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), null); - transportService.registerSession(deviceSessionInfo, TransportProtos.SessionType.ASYNC, deviceSessionCtx); + transportService.registerAsyncSession(deviceSessionInfo, deviceSessionCtx); } future.set(devices.get(deviceName)); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java index f846cf0c38..ab429829e4 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.transport; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 9bbc40c1b6..5d0c14d78c 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -59,7 +59,9 @@ public interface TransportService { void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback); - void registerSession(SessionInfoProto sessionInfo, TransportProtos.SessionType sessionType, SessionMsgListener listener); + void registerAsyncSession(SessionInfoProto sessionInfo, SessionMsgListener listener); + + void registerSyncSession(SessionInfoProto sessionInfo, SessionMsgListener listener, long timeout); void deregisterSession(SessionInfoProto sessionInfo); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java new file mode 100644 index 0000000000..1bf4dd1cbf --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java @@ -0,0 +1,101 @@ +package org.thingsboard.server.common.transport.service; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Created by ashvayka on 17.10.18. + */ +@Slf4j +public abstract class AbstractTransportService implements TransportService { + + protected ScheduledExecutorService schedulerExecutor; + protected ExecutorService transportCallbackExecutor; + protected ConcurrentMap sessions = new ConcurrentHashMap<>(); + + @Override + public void registerAsyncSession(TransportProtos.SessionInfoProto sessionInfo, SessionMsgListener listener) { + sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, TransportProtos.SessionType.ASYNC, listener)); + //TODO: monitor sessions periodically: PING REQ/RESP, etc. + } + + @Override + public void registerSyncSession(TransportProtos.SessionInfoProto sessionInfo, SessionMsgListener listener, long timeout) { + sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, TransportProtos.SessionType.SYNC, listener)); + schedulerExecutor.schedule(() -> { + listener.onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto.getDefaultInstance()); + deregisterSession(sessionInfo); + }, timeout, TimeUnit.MILLISECONDS); + + } + + @Override + public void deregisterSession(TransportProtos.SessionInfoProto sessionInfo) { + sessions.remove(toId(sessionInfo)); + } + + protected void processToTransportMsg(TransportProtos.DeviceActorToTransportMsg toSessionMsg) { + UUID sessionId = new UUID(toSessionMsg.getSessionIdMSB(), toSessionMsg.getSessionIdLSB()); + SessionMetaData md = sessions.get(sessionId); + if (md != null) { + SessionMsgListener listener = md.getListener(); + transportCallbackExecutor.submit(() -> { + if (toSessionMsg.hasGetAttributesResponse()) { + listener.onGetAttributesResponse(toSessionMsg.getGetAttributesResponse()); + } + if (toSessionMsg.hasAttributeUpdateNotification()) { + listener.onAttributeUpdate(toSessionMsg.getAttributeUpdateNotification()); + } + if (toSessionMsg.hasSessionCloseNotification()) { + listener.onRemoteSessionCloseCommand(toSessionMsg.getSessionCloseNotification()); + } + if (toSessionMsg.hasToDeviceRequest()) { + listener.onToDeviceRpcRequest(toSessionMsg.getToDeviceRequest()); + } + if (toSessionMsg.hasToServerResponse()) { + listener.onToServerRpcResponse(toSessionMsg.getToServerResponse()); + } + }); + if (md.getSessionType() == TransportProtos.SessionType.SYNC) { + deregisterSession(md.getSessionInfo()); + } + } else { + //TODO: should we notify the device actor about missed session? + log.debug("[{}] Missing session.", sessionId); + } + } + + protected UUID toId(TransportProtos.SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + } + + String getRoutingKey(TransportProtos.SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB()).toString(); + } + + public void init() { + this.schedulerExecutor = Executors.newSingleThreadScheduledExecutor(); + this.transportCallbackExecutor = new ThreadPoolExecutor(0, 20, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + } + + public void destroy() { + if (schedulerExecutor != null) { + schedulerExecutor.shutdownNow(); + } + if (transportCallbackExecutor != null) { + transportCallbackExecutor.shutdownNow(); + } + } +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index eb6dbc5cb2..1442cc5f21 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -26,6 +26,7 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.*; import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; @@ -62,7 +63,7 @@ import java.util.concurrent.Executors; @ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote", matchIfMissing = true) @Service @Slf4j -public class RemoteTransportService implements TransportService { +public class RemoteTransportService extends AbstractTransportService { @Value("${kafka.rule_engine.topic}") private String ruleEngineTopic; @@ -85,16 +86,12 @@ public class RemoteTransportService implements TransportService { @Value("${kafka.transport_api.response_auto_commit_interval}") private int autoCommitInterval; - private ConcurrentMap sessions = new ConcurrentHashMap<>(); - @Autowired private TbKafkaSettings kafkaSettings; //We use this to get the node id. We should replace this with a component that provides the node id. @Autowired private TbNodeIdProvider nodeIdProvider; - private ExecutorService transportCallbackExecutor; - private TbKafkaRequestTemplate transportApiTemplate; private TBKafkaProducerTemplate ruleEngineProducer; private TBKafkaConsumerTemplate mainConsumer; @@ -105,7 +102,7 @@ public class RemoteTransportService implements TransportService { @PostConstruct public void init() { - this.transportCallbackExecutor = Executors.newCachedThreadPool(); + super.init(); TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder requestBuilder = TBKafkaProducerTemplate.builder(); requestBuilder.settings(kafkaSettings); @@ -157,36 +154,7 @@ public class RemoteTransportService implements TransportService { try { ToTransportMsg toTransportMsg = mainConsumer.decode(record); if (toTransportMsg.hasToDeviceSessionMsg()) { - DeviceActorToTransportMsg toSessionMsg = toTransportMsg.getToDeviceSessionMsg(); - UUID sessionId = new UUID(toSessionMsg.getSessionIdMSB(), toSessionMsg.getSessionIdLSB()); - SessionMetaData md = sessions.get(sessionId); - if (md != null) { - SessionMsgListener listener = md.getListener(); - transportCallbackExecutor.submit(() -> { - if (toSessionMsg.hasGetAttributesResponse()) { - listener.onGetAttributesResponse(toSessionMsg.getGetAttributesResponse()); - } - if (toSessionMsg.hasAttributeUpdateNotification()) { - listener.onAttributeUpdate(toSessionMsg.getAttributeUpdateNotification()); - } - if (toSessionMsg.hasSessionCloseNotification()) { - listener.onRemoteSessionCloseCommand(toSessionMsg.getSessionCloseNotification()); - } - if (toSessionMsg.hasToDeviceRequest()) { - listener.onToDeviceRpcRequest(toSessionMsg.getToDeviceRequest()); - } - if (toSessionMsg.hasToServerResponse()) { - listener.onToServerRpcResponse(toSessionMsg.getToServerResponse()); - } - }); - if (md.getSessionType() == SessionType.SYNC) { - deregisterSession(md.getSessionInfo()); - } - } else { - //TODO: should we notify the device actor about missed session? - log.debug("[{}] Missing session.", sessionId); - } - + processToTransportMsg(toTransportMsg.getToDeviceSessionMsg()); } } catch (Throwable e) { log.warn("Failed to process the notification.", e); @@ -206,13 +174,11 @@ public class RemoteTransportService implements TransportService { @PreDestroy public void destroy() { + super.destroy(); stopped = true; if (transportApiTemplate != null) { transportApiTemplate.stop(); } - if (transportCallbackExecutor != null) { - transportCallbackExecutor.shutdownNow(); - } if (mainConsumer != null) { mainConsumer.unsubscribe(); } @@ -314,25 +280,6 @@ public class RemoteTransportService implements TransportService { send(sessionInfo, toRuleEngineMsg, callback); } - @Override - public void registerSession(SessionInfoProto sessionInfo, SessionType sessionType, SessionMsgListener listener) { - sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, sessionType, listener)); - //TODO: monitor sessions periodically: PING REQ/RESP, etc. - } - - @Override - public void deregisterSession(SessionInfoProto sessionInfo) { - sessions.remove(toId(sessionInfo)); - } - - private UUID toId(SessionInfoProto sessionInfo) { - return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); - } - - private String getRoutingKey(SessionInfoProto sessionInfo) { - return new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB()).toString(); - } - private static class TransportCallbackAdaptor implements Callback { private final TransportServiceCallback callback; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java index dd52f77ca6..1de57114e4 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.transport.service; import lombok.Data; From d7c4ff6bb023850b83ad428941d20dace4da9c93 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 17 Oct 2018 20:49:35 +0300 Subject: [PATCH 228/307] HTTP transport implementation --- .../transport/LocalTransportService.java | 8 +- .../RemoteRuleEngineTransportService.java | 2 - .../src/main/resources/thingsboard.yml | 17 +- common/pom.xml | 2 +- common/transport/http/pom.xml | 2 +- .../transport/http/DeviceApiController.java | 11 +- .../transport/http/HttpTransportContext.java | 13 +- .../mqtt/MqttSslHandlerProvider.java | 2 + .../transport/mqtt/MqttTransportContext.java | 3 +- .../transport/mqtt/MqttTransportHandler.java | 8 +- .../transport/mqtt/MqttTransportService.java | 3 +- .../common/transport/TransportService.java | 8 +- .../service/AbstractTransportService.java | 15 + .../service/RemoteTransportService.java | 3 +- transport/http/build.gradle | 140 +++++++ transport/http/pom.xml | 341 ++++++++++++++++++ transport/http/src/main/assembly/windows.xml | 71 ++++ transport/http/src/main/conf/logback.xml | 43 +++ .../http/src/main/conf/tb-http-transport.conf | 23 ++ .../http/src/main/filters/unix.properties | 1 + .../http/src/main/filters/windows.properties | 2 + .../ThingsboardHttpTransportApplication.java | 48 +++ transport/http/src/main/resources/logback.xml | 34 ++ .../src/main/resources/tb-http-transport.yml | 69 ++++ .../src/main/scripts/control/deb/postinst | 6 + .../http/src/main/scripts/control/deb/postrm | 3 + .../http/src/main/scripts/control/deb/preinst | 18 + .../http/src/main/scripts/control/deb/prerm | 5 + .../src/main/scripts/control/rpm/postinst | 9 + .../http/src/main/scripts/control/rpm/postrm | 6 + .../http/src/main/scripts/control/rpm/preinst | 6 + .../http/src/main/scripts/control/rpm/prerm | 6 + .../scripts/control/tb-http-transport.service | 11 + .../http/src/main/scripts/windows/install.bat | 87 +++++ .../http/src/main/scripts/windows/service.xml | 36 ++ .../src/main/scripts/windows/uninstall.bat | 9 + transport/pom.xml | 17 +- 37 files changed, 1041 insertions(+), 47 deletions(-) create mode 100644 transport/http/build.gradle create mode 100644 transport/http/pom.xml create mode 100644 transport/http/src/main/assembly/windows.xml create mode 100644 transport/http/src/main/conf/logback.xml create mode 100644 transport/http/src/main/conf/tb-http-transport.conf create mode 100644 transport/http/src/main/filters/unix.properties create mode 100644 transport/http/src/main/filters/windows.properties create mode 100644 transport/http/src/main/java/org/thingsboard/server/mqtt/ThingsboardHttpTransportApplication.java create mode 100644 transport/http/src/main/resources/logback.xml create mode 100644 transport/http/src/main/resources/tb-http-transport.yml create mode 100644 transport/http/src/main/scripts/control/deb/postinst create mode 100644 transport/http/src/main/scripts/control/deb/postrm create mode 100644 transport/http/src/main/scripts/control/deb/preinst create mode 100644 transport/http/src/main/scripts/control/deb/prerm create mode 100644 transport/http/src/main/scripts/control/rpm/postinst create mode 100644 transport/http/src/main/scripts/control/rpm/postrm create mode 100644 transport/http/src/main/scripts/control/rpm/preinst create mode 100644 transport/http/src/main/scripts/control/rpm/prerm create mode 100644 transport/http/src/main/scripts/control/tb-http-transport.service create mode 100644 transport/http/src/main/scripts/windows/install.bat create mode 100644 transport/http/src/main/scripts/windows/service.xml create mode 100644 transport/http/src/main/scripts/windows/uninstall.bat diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java index b8001725d7..d670db3c19 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index a0791adddf..4f8860daf7 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -58,8 +58,6 @@ import java.util.function.Consumer; @ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote") public class RemoteRuleEngineTransportService implements RuleEngineTransportService { - private static final ObjectMapper mapper = new ObjectMapper(); - @Value("${transport.remote.rule_engine.topic}") private String ruleEngineTopic; @Value("${transport.remote.notifications.topic}") diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 0eb624b181..6238a3e232 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -386,6 +386,19 @@ kafka: batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" linger.ms: "${TB_KAFKA_LINGER_MS:1}" buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + request_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + request_poll_interval: "${TB_TRANSPORT_REQUEST_POLL_INTERVAL_MS:25}" + request_auto_commit_interval: "${TB_TRANSPORT_REQUEST_AUTO_COMMIT_INTERVAL_MS:100}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + poll_interval: "${TB_RULE_ENGINE_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_RULE_ENGINE_AUTO_COMMIT_INTERVAL_MS:100}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" js: evaluator: "${JS_EVALUATOR:local}" # local/remote @@ -434,12 +447,12 @@ transport: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" # Local HTTP transport parameters http: - enabled: "${MQTT_ENABLED:true}" + enabled: "${HTTP_ENABLED:true}" request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" # Local MQTT transport parameters mqtt: # Enable/disable mqtt transport protocol. - enabled: "${MQTT_ENABLED:false}" + enabled: "${MQTT_ENABLED:true}" bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" bind_port: "${MQTT_BIND_PORT:1883}" adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" diff --git a/common/pom.xml b/common/pom.xml index 84481334e9..e55d71fcd3 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -36,8 +36,8 @@ data message - transport queue + transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index 2ee11426a9..5d735c9def 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -32,7 +32,7 @@ UTF-8 - ${basedir}/../.. + ${basedir}/../../.. diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index 8d579d4eb2..b40fbc194f 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -19,6 +19,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -60,7 +61,7 @@ import java.util.function.Consumer; * @author Andrew Shvayka */ @RestController -@ConditionalOnProperty(prefix = "transport.http", value = "enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.http.enabled}'=='true')") @RequestMapping("/api/v1") @Slf4j public class DeviceApiController { diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 66654c1bed..601c9ee8e8 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -18,15 +18,18 @@ package org.thingsboard.server.transport.http; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; +import javax.annotation.PostConstruct; + /** * Created by ashvayka on 04.10.18. */ @Slf4j -@ConditionalOnProperty(prefix = "transport.http", value = "enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.http.enabled}'=='true')") @Component public class HttpTransportContext extends TransportContext { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index ee28738a1e..aa643ebc9b 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -20,6 +20,7 @@ import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -52,6 +53,7 @@ import java.util.concurrent.TimeUnit; */ @Slf4j @Component("MqttSslHandlerProvider") +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.http.enabled}'=='true')") @ConditionalOnProperty(prefix = "mqtt.ssl", value = "enabled", havingValue = "true", matchIfMissing = false) public class MqttSslHandlerProvider { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 91580bff7d..538daa1f9c 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -23,6 +23,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; @@ -40,7 +41,7 @@ import java.util.concurrent.Executors; * Created by ashvayka on 04.10.18. */ @Slf4j -@ConditionalOnProperty(prefix = "transport.mqtt", value = "enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.mqtt.enabled}'=='true')") @Component public class MqttTransportContext extends TransportContext { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index d739824aa5..89ecdd583e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index 199b567430..c1639ced7d 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -24,6 +24,7 @@ import io.netty.util.ResourceLeakDetector; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @@ -34,7 +35,7 @@ import javax.annotation.PreDestroy; * @author Andrew Shvayka */ @Service("MqttTransportService") -@ConditionalOnProperty(prefix = "transport.mqtt", value = "enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.mqtt.enabled}'=='true')") @Slf4j public class MqttTransportService { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 5d0c14d78c..caf178a641 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java index 1bf4dd1cbf..e299a027e8 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.transport.service; import lombok.extern.slf4j.Slf4j; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index 1442cc5f21..582fe49b06 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -21,6 +21,7 @@ import org.apache.kafka.clients.producer.Callback; import org.apache.kafka.clients.producer.RecordMetadata; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.SessionMsgListener; @@ -60,7 +61,7 @@ import java.util.concurrent.Executors; /** * Created by ashvayka on 05.10.18. */ -@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote", matchIfMissing = true) +@ConditionalOnExpression("'${transport.type:null}'=='null'") @Service @Slf4j public class RemoteTransportService extends AbstractTransportService { diff --git a/transport/http/build.gradle b/transport/http/build.gradle new file mode 100644 index 0000000000..6d54cb4789 --- /dev/null +++ b/transport/http/build.gradle @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgName + permissionGroup pkgName + + // Copy the actual .jar file + from(mainJar) { + // Strip the version from the jar filename + rename { String fileName -> + "${pkgName}.jar" + } + fileMode 0500 + into "bin" + } + + // Copy the config files + from("target/conf") { + exclude "${pkgName}.conf" + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = NOARCH + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + requires("java-1.8.0") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgName + permissionGroup pkgName + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "all" + + archiveName = "${pkgName}.deb" + + requires("openjdk-8-jre").or("java8-runtime").or("oracle-java8-installer").or("openjdk-8-jre-headless") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/${pkgName}.yml") + configurationFile("${pkgInstallFolder}/conf/logback.xml") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgName + permissionGroup pkgName + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/bin/${pkgName}.jar") + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/transport/http/pom.xml b/transport/http/pom.xml new file mode 100644 index 0000000000..225f064f17 --- /dev/null +++ b/transport/http/pom.xml @@ -0,0 +1,341 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + transport + + org.thingsboard.transport + http + jar + + Thingsboard HTTP Transport Service + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + tb-http-transport + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/windows + + + + + org.thingsboard.common.transport + http + + + org.thingsboard.common + queue + + + com.sun.winsw + winsw + bin + exe + provided + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/resources + + logback.xml + + false + + + + + + copy-service-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/conf + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + src/main/resources + + logback.xml + + false + + + src/main/conf + + tb-mqtt-transport.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + **/logback.xml + + + + ThingsBoard MQTT Transport Service + ${project.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.mqtt.ThingsboardHttpTransportApplication + boot + ZIP + true + true + + ${pkg.installFolder}/conf + ${pkg.unixLogFolder} + ${pkg.name}.out + ${pkg.name} + + + + + + repackage + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/transport/http/src/main/assembly/windows.xml b/transport/http/src/main/assembly/windows.xml new file mode 100644 index 0000000000..82da34ed13 --- /dev/null +++ b/transport/http/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + lib + ${pkg.name}.jar + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/transport/http/src/main/conf/logback.xml b/transport/http/src/main/conf/logback.xml new file mode 100644 index 0000000000..f36469d5f2 --- /dev/null +++ b/transport/http/src/main/conf/logback.xml @@ -0,0 +1,43 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/transport/http/src/main/conf/tb-http-transport.conf b/transport/http/src/main/conf/tb-http-transport.conf new file mode 100644 index 0000000000..0afa91c18e --- /dev/null +++ b/transport/http/src/main/conf/tb-http-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/transport/http/src/main/filters/unix.properties b/transport/http/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/transport/http/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/transport/http/src/main/filters/windows.properties b/transport/http/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/transport/http/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/transport/http/src/main/java/org/thingsboard/server/mqtt/ThingsboardHttpTransportApplication.java b/transport/http/src/main/java/org/thingsboard/server/mqtt/ThingsboardHttpTransportApplication.java new file mode 100644 index 0000000000..29fae1bc6d --- /dev/null +++ b/transport/http/src/main/java/org/thingsboard/server/mqtt/ThingsboardHttpTransportApplication.java @@ -0,0 +1,48 @@ +package org.thingsboard.server.mqtt; /** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.Arrays; + +@SpringBootApplication +@EnableAsync +@ComponentScan({"org.thingsboard.server.http", "org.thingsboard.server.common", "org.thingsboard.server.transport.http", "org.thingsboard.server.kafka"}) +public class ThingsboardHttpTransportApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "tb-http-transport"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardHttpTransportApplication.class, updateArguments(args)); + } + + private static String[] updateArguments(String[] args) { + if (Arrays.stream(args).noneMatch(arg -> arg.startsWith(SPRING_CONFIG_NAME_KEY))) { + String[] modifiedArgs = new String[args.length + 1]; + System.arraycopy(args, 0, modifiedArgs, 0, args.length); + modifiedArgs[args.length] = DEFAULT_SPRING_CONFIG_PARAM; + return modifiedArgs; + } + return args; + } +} diff --git a/transport/http/src/main/resources/logback.xml b/transport/http/src/main/resources/logback.xml new file mode 100644 index 0000000000..18864a9fab --- /dev/null +++ b/transport/http/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml new file mode 100644 index 0000000000..65a1ad93a0 --- /dev/null +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -0,0 +1,69 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +server: + # Server bind address + address: "${HTTP_BIND_ADDRESS:0.0.0.0}" + # Server bind port + port: "${HTTP_BIND_PORT:8081}" + +# HTTP server parameters +transport: + http: + request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" + +#Quota parameters +quota: + host: + # Max allowed number of API requests in interval for single host + limit: "${QUOTA_HOST_LIMIT:10000}" + # Interval duration + intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" + # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs + ttlMs: "${QUOTA_HOST_TTL_MS:60000}" + # Interval for scheduled task that cleans expired records. TTL is used for expiring + cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" + # Enable Host API Limits + enabled: "${QUOTA_HOST_ENABLED:true}" + # Array of whitelist hosts + whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" + # Array of blacklist hosts + blacklist: "${QUOTA_HOST_BLACKLIST:}" + log: + topSize: 10 + intervalMin: 2 + +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + max_requests_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + response_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" diff --git a/transport/http/src/main/scripts/control/deb/postinst b/transport/http/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..d4066c027b --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/transport/http/src/main/scripts/control/deb/postrm b/transport/http/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/transport/http/src/main/scripts/control/deb/preinst b/transport/http/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..6be5959285 --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.name} >/dev/null; then + addgroup --system ${pkg.name} +fi + +if ! getent passwd ${pkg.name} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.name} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.name} +fi diff --git a/transport/http/src/main/scripts/control/deb/prerm b/transport/http/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/transport/http/src/main/scripts/control/rpm/postinst b/transport/http/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..8a7a88f7e0 --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/http/src/main/scripts/control/rpm/postrm b/transport/http/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/http/src/main/scripts/control/rpm/preinst b/transport/http/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..e19fc884c8 --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.name} >/dev/null || groupadd -r ${pkg.name} +getent passwd ${pkg.name} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.name} -M -r ${pkg.name} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/transport/http/src/main/scripts/control/rpm/prerm b/transport/http/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/transport/http/src/main/scripts/control/tb-http-transport.service b/transport/http/src/main/scripts/control/tb-http-transport.service new file mode 100644 index 0000000000..d456fc03c0 --- /dev/null +++ b/transport/http/src/main/scripts/control/tb-http-transport.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.name} +ExecStart=${pkg.installFolder}/bin/${pkg.name}.jar +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/transport/http/src/main/scripts/windows/install.bat b/transport/http/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..dba773663c --- /dev/null +++ b/transport/http/src/main/scripts/windows/install.bat @@ -0,0 +1,87 @@ +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Detecting Java version installed. +:CHECK_JAVA_64 +@ECHO Detecting if it is 64 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\Wow6432Node\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF NOT "%JRE_PATH2%" == "" GOTO JAVA_INSTALLED + +:CHECK_JAVA_32 +@ECHO Detecting if it is 32 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF "%JRE_PATH2%" == "" GOTO JAVA_NOT_INSTALLED + +:JAVA_INSTALLED + +@ECHO Java 1.8 found! +@ECHO Installing ${pkg.name} ... + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:JAVA_NOT_INSTALLED +@ECHO Java 1.8 or above is not installed +@ECHO Please go to https://java.com/ and install Java. Then retry installation. +PAUSE +GOTO END + +:END + + diff --git a/transport/http/src/main/scripts/windows/service.xml b/transport/http/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..f7b9d307c8 --- /dev/null +++ b/transport/http/src/main/scripts/windows/service.xml @@ -0,0 +1,36 @@ + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\conf + ${pkg.winWrapperLogFolder} + rotate + + java + -Xloggc:%BASE%\logs\gc.log + -XX:+HeapDumpOnOutOfMemoryError + -XX:+PrintGCDetails + -XX:+PrintGCDateStamps + -XX:+PrintHeapAtGC + -XX:+PrintTenuringDistribution + -XX:+PrintGCApplicationStoppedTime + -XX:+UseGCLogFileRotation + -XX:NumberOfGCLogFiles=10 + -XX:GCLogFileSize=10M + -XX:-UseBiasedLocking + -XX:+UseTLAB + -XX:+ResizeTLAB + -XX:+PerfDisableSharedMem + -XX:+UseCondCardMark + -XX:CMSWaitDuration=10000 + -XX:+UseParNewGC + -XX:+UseConcMarkSweepGC + -XX:+CMSParallelRemarkEnabled + -XX:+CMSParallelInitialMarkEnabled + -XX:+CMSEdenChunksRecordAlways + -XX:CMSInitiatingOccupancyFraction=75 + -XX:+UseCMSInitiatingOccupancyOnly + -jar + %BASE%\lib\${pkg.name}.jar + + diff --git a/transport/http/src/main/scripts/windows/uninstall.bat b/transport/http/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..921e4c8395 --- /dev/null +++ b/transport/http/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,9 @@ +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/transport/pom.xml b/transport/pom.xml index a10188bd56..281f511550 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -34,23 +34,8 @@ - + http mqtt - - - org.thingsboard - dao - - - org.springframework.boot - spring-boot-autoconfigure - - - org.bouncycastle - bcprov-jdk15on - - - From 5242864ccccdee77f95d08a80a6340b5f767eb9f Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 18 Oct 2018 13:47:42 +0300 Subject: [PATCH 229/307] CoAP transport implementation --- application/pom.xml | 8 +- .../src/main/resources/thingsboard.yml | 2 - common/transport/coap/pom.xml | 88 ++++ .../transport/coap/CoapTransportContext.java | 51 ++ .../transport/coap/CoapTransportResource.java | 445 ++++++++++++++++++ .../transport/coap/CoapTransportService.java | 38 +- .../coap/adaptors/CoapTransportAdaptor.java | 47 ++ .../coap/adaptors/JsonCoapAdaptor.java | 155 ++++++ .../transport/coap/client/DeviceEmulator.java | 0 .../transport/mqtt/MqttTransportService.java | 2 - common/transport/pom.xml | 2 +- pom.xml | 2 +- transport/coap/build.gradle | 140 ++++++ transport/coap/pom.xml | 299 +++++++++++- transport/coap/src/main/assembly/windows.xml | 71 +++ transport/coap/src/main/conf/logback.xml | 43 ++ .../coap/src/main/conf/tb-coap-transport.conf | 23 + .../coap/src/main/filters/unix.properties | 1 + .../coap/src/main/filters/windows.properties | 2 + .../ThingsboardCoapTransportApplication.java | 48 ++ .../transport/coap/CoapTransportResource.java | 242 ---------- .../coap/adaptors/CoapTransportAdaptor.java | 25 - .../coap/adaptors/JsonCoapAdaptor.java | 260 ---------- .../session/CoapExchangeObserverProxy.java | 38 -- .../coap/session/CoapSessionCtx.java | 127 ----- .../transport/coap/session/CoapSessionId.java | 75 --- transport/coap/src/main/resources/logback.xml | 34 ++ .../src/main/resources/tb-coap-transport.yml | 68 +++ .../src/main/scripts/control/deb/postinst | 6 + .../coap/src/main/scripts/control/deb/postrm | 3 + .../coap/src/main/scripts/control/deb/preinst | 18 + .../coap/src/main/scripts/control/deb/prerm | 5 + .../src/main/scripts/control/rpm/postinst | 9 + .../coap/src/main/scripts/control/rpm/postrm | 6 + .../coap/src/main/scripts/control/rpm/preinst | 6 + .../coap/src/main/scripts/control/rpm/prerm | 6 + .../scripts/control/tb-coap-transport.service | 11 + .../coap/src/main/scripts/windows/install.bat | 87 ++++ .../coap/src/main/scripts/windows/service.xml | 36 ++ .../src/main/scripts/windows/uninstall.bat | 9 + .../server/transport/coap/CoapServerTest.java | 197 -------- .../coap/CoapServerTestConfiguration.java | 35 -- .../resources/coap-transport-test.properties | 4 - transport/http/pom.xml | 6 +- transport/mqtt/pom.xml | 4 + .../src/main/resources/tb-mqtt-transport.yml | 1 + transport/pom.xml | 1 + 47 files changed, 1719 insertions(+), 1067 deletions(-) create mode 100644 common/transport/coap/pom.xml create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java rename {transport => common/transport}/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java (72%) create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java rename {transport => common/transport}/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java (100%) create mode 100644 transport/coap/build.gradle create mode 100644 transport/coap/src/main/assembly/windows.xml create mode 100644 transport/coap/src/main/conf/logback.xml create mode 100644 transport/coap/src/main/conf/tb-coap-transport.conf create mode 100644 transport/coap/src/main/filters/unix.properties create mode 100644 transport/coap/src/main/filters/windows.properties create mode 100644 transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java delete mode 100644 transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java delete mode 100644 transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java delete mode 100644 transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java delete mode 100644 transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapExchangeObserverProxy.java delete mode 100644 transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java delete mode 100644 transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java create mode 100644 transport/coap/src/main/resources/logback.xml create mode 100644 transport/coap/src/main/resources/tb-coap-transport.yml create mode 100644 transport/coap/src/main/scripts/control/deb/postinst create mode 100644 transport/coap/src/main/scripts/control/deb/postrm create mode 100644 transport/coap/src/main/scripts/control/deb/preinst create mode 100644 transport/coap/src/main/scripts/control/deb/prerm create mode 100644 transport/coap/src/main/scripts/control/rpm/postinst create mode 100644 transport/coap/src/main/scripts/control/rpm/postrm create mode 100644 transport/coap/src/main/scripts/control/rpm/preinst create mode 100644 transport/coap/src/main/scripts/control/rpm/prerm create mode 100644 transport/coap/src/main/scripts/control/tb-coap-transport.service create mode 100644 transport/coap/src/main/scripts/windows/install.bat create mode 100644 transport/coap/src/main/scripts/windows/service.xml create mode 100644 transport/coap/src/main/scripts/windows/uninstall.bat delete mode 100644 transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java delete mode 100644 transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTestConfiguration.java delete mode 100644 transport/coap/src/test/resources/coap-transport-test.properties diff --git a/application/pom.xml b/application/pom.xml index 309f7ded5b..d99f60773e 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -60,10 +60,6 @@ org.thingsboard.common.transport transport-api - - - - org.thingsboard.common.transport mqtt @@ -72,6 +68,10 @@ org.thingsboard.common.transport http + + org.thingsboard.common.transport + coap + org.thingsboard dao diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 6238a3e232..90d15003bc 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -455,7 +455,6 @@ transport: enabled: "${MQTT_ENABLED:true}" bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" bind_port: "${MQTT_BIND_PORT:1883}" - adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" timeout: "${MQTT_TIMEOUT:10000}" netty: leak_detector_level: "${NETTY_LEASK_DETECTOR_LVL:DISABLED}" @@ -482,5 +481,4 @@ transport: enabled: "${COAP_ENABLED:true}" bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" bind_port: "${COAP_BIND_PORT:5683}" - adaptor: "${COAP_ADAPTOR_NAME:JsonCoapAdaptor}" timeout: "${COAP_TIMEOUT:10000}" diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml new file mode 100644 index 0000000000..d68a6db39f --- /dev/null +++ b/common/transport/coap/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + org.thingsboard.common + 2.2.0-SNAPSHOT + transport + + org.thingsboard.common.transport + coap + jar + + Thingsboard CoAP Transport Common + https://thingsboard.io + + + UTF-8 + ${basedir}/../../.. + + + + + org.thingsboard.common.transport + transport-api + + + org.eclipse.californium + californium-core + + + org.springframework + spring-context-support + + + org.springframework + spring-context + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java new file mode 100644 index 0000000000..13662cf1be --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.transport.TransportContext; +import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; + +/** + * Created by ashvayka on 18.10.18. + */ +@Slf4j +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.coap.enabled}'=='true')") +@Component +public class CoapTransportContext extends TransportContext { + + @Getter + @Value("${transport.coap.bind_address}") + private String host; + + @Getter + @Value("${transport.coap.bind_port}") + private Integer port; + + @Getter + @Value("${transport.coap.timeout}") + private Long timeout; + + @Getter + @Autowired + private CoapTransportAdaptor adaptor; + +} diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java new file mode 100644 index 0000000000..0e2a794a45 --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -0,0 +1,445 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.coap.CoAP.ResponseCode; +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.core.coap.Response; +import org.eclipse.californium.core.network.Exchange; +import org.eclipse.californium.core.network.ExchangeObserver; +import org.eclipse.californium.core.server.resources.CoapExchange; +import org.eclipse.californium.core.server.resources.Resource; +import org.springframework.util.ReflectionUtils; +import org.thingsboard.server.common.data.security.DeviceTokenCredentials; +import org.thingsboard.server.common.msg.session.FeatureType; +import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportContext; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +@Slf4j +public class CoapTransportResource extends CoapResource { + // coap://localhost:port/api/v1/DEVICE_TOKEN/[attributes|telemetry|rpc[/requestId]] + private static final int ACCESS_TOKEN_POSITION = 3; + private static final int FEATURE_TYPE_POSITION = 4; + private static final int REQUEST_ID_POSITION = 5; + + private final CoapTransportContext transportContext; + private final TransportService transportService; + private final Field observerField; + private final long timeout; + private final ConcurrentMap tokenToSessionIdMap = new ConcurrentHashMap<>(); + + public CoapTransportResource(CoapTransportContext context, String name) { + super(name); + this.transportContext = context; + this.transportService = context.getTransportService(); + this.timeout = context.getTimeout(); + // This is important to turn off existing observable logic in + // CoapResource. We will have our own observe monitoring due to 1:1 + // observe relationship. + this.setObservable(false); + observerField = ReflectionUtils.findField(Exchange.class, "observer"); + observerField.setAccessible(true); + } + + @Override + public void handleGET(CoapExchange exchange) { + if (transportContext.getQuotaService().isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { + log.warn("CoAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); + exchange.respond(ResponseCode.BAD_REQUEST); + return; + } + + Optional featureType = getFeatureType(exchange.advanced().getRequest()); + if (!featureType.isPresent()) { + log.trace("Missing feature type parameter"); + exchange.respond(ResponseCode.BAD_REQUEST); + } else if (featureType.get() == FeatureType.TELEMETRY) { + log.trace("Can't fetch/subscribe to timeseries updates"); + exchange.respond(ResponseCode.BAD_REQUEST); + } else if (exchange.getRequestOptions().hasObserve()) { + processExchangeGetRequest(exchange, featureType.get()); + } else if (featureType.get() == FeatureType.ATTRIBUTES) { + processRequest(exchange, SessionMsgType.GET_ATTRIBUTES_REQUEST); + } else { + log.trace("Invalid feature type parameter"); + exchange.respond(ResponseCode.BAD_REQUEST); + } + } + + private void processExchangeGetRequest(CoapExchange exchange, FeatureType featureType) { + boolean unsubscribe = exchange.getRequestOptions().getObserve() == 1; + SessionMsgType sessionMsgType; + if (featureType == FeatureType.RPC) { + sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST : SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; + } else { + sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST : SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; + } + processRequest(exchange, sessionMsgType); + } + + @Override + public void handlePOST(CoapExchange exchange) { + if (transportContext.getQuotaService().isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { + log.warn("CoAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); + exchange.respond(ResponseCode.BAD_REQUEST); + return; + } + + Optional featureType = getFeatureType(exchange.advanced().getRequest()); + if (!featureType.isPresent()) { + log.trace("Missing feature type parameter"); + exchange.respond(ResponseCode.BAD_REQUEST); + } else { + switch (featureType.get()) { + case ATTRIBUTES: + processRequest(exchange, SessionMsgType.POST_ATTRIBUTES_REQUEST); + break; + case TELEMETRY: + processRequest(exchange, SessionMsgType.POST_TELEMETRY_REQUEST); + break; + case RPC: + Optional requestId = getRequestId(exchange.advanced().getRequest()); + if (requestId.isPresent()) { + processRequest(exchange, SessionMsgType.TO_DEVICE_RPC_RESPONSE); + } else { + processRequest(exchange, SessionMsgType.TO_SERVER_RPC_REQUEST); + } + break; + } + } + } + + private void processRequest(CoapExchange exchange, SessionMsgType type) { + log.trace("Processing {}", exchange.advanced().getRequest()); + exchange.accept(); + Exchange advanced = exchange.advanced(); + Request request = advanced.getRequest(); + + Optional credentials = decodeCredentials(request); + if (!credentials.isPresent()) { + exchange.respond(ResponseCode.BAD_REQUEST); + return; + } + + transportService.process(TransportProtos.ValidateDeviceTokenRequestMsg.newBuilder().setToken(credentials.get().getCredentialsId()).build(), + new DeviceAuthCallback(transportContext, exchange, sessionInfo -> { + UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + try { + switch (type) { + case POST_ATTRIBUTES_REQUEST: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToPostAttributes(sessionId, request), + new CoapOkCallback(exchange)); + break; + case POST_TELEMETRY_REQUEST: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToPostTelemetry(sessionId, request), + new CoapOkCallback(exchange)); + break; + case SUBSCRIBE_ATTRIBUTES_REQUEST: + advanced.setObserver(new CoapExchangeObserverProxy((ExchangeObserver) observerField.get(advanced), + registerAsyncCoapSession(exchange, request, sessionInfo, sessionId))); + transportService.process(sessionInfo, + TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), + new CoapNoOpCallback(exchange)); + break; + case UNSUBSCRIBE_ATTRIBUTES_REQUEST: + TransportProtos.SessionInfoProto attrSession = lookupAsyncSessionInfo(request); + if (attrSession != null) { + transportService.process(attrSession, + TransportProtos.SubscribeToAttributeUpdatesMsg.newBuilder().setUnsubscribe(true).build(), + new CoapOkCallback(exchange)); + closeAndDeregister(sessionInfo); + } + break; + case SUBSCRIBE_RPC_COMMANDS_REQUEST: + advanced.setObserver(new CoapExchangeObserverProxy((ExchangeObserver) observerField.get(advanced), + registerAsyncCoapSession(exchange, request, sessionInfo, sessionId))); + transportService.process(sessionInfo, + TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), + new CoapNoOpCallback(exchange)); + break; + case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: + TransportProtos.SessionInfoProto rpcSession = lookupAsyncSessionInfo(request); + if (rpcSession != null) { + transportService.process(rpcSession, + TransportProtos.SubscribeToRPCMsg.newBuilder().setUnsubscribe(true).build(), + new CoapOkCallback(exchange)); + transportService.process(sessionInfo, getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + transportService.deregisterSession(rpcSession); + } + break; + case TO_DEVICE_RPC_RESPONSE: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToDeviceRpcResponse(sessionId, request), + new CoapOkCallback(exchange)); + break; + case TO_SERVER_RPC_REQUEST: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToServerRpcRequest(sessionId, request), + new CoapNoOpCallback(exchange)); + break; + case GET_ATTRIBUTES_REQUEST: + transportService.registerSyncSession(sessionInfo, new CoapSessionListener(sessionId, exchange), transportContext.getTimeout()); + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToGetAttributes(sessionId, request), + new CoapNoOpCallback(exchange)); + break; + } + } catch (AdaptorException e) { + log.trace("[{}] Failed to decode message: ", sessionId, e); + exchange.respond(ResponseCode.BAD_REQUEST); + } catch (IllegalAccessException e) { + log.trace("[{}] Failed to process message: ", sessionId, e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + })); + } + + private TransportProtos.SessionInfoProto lookupAsyncSessionInfo(Request request) { + String token = request.getSource().getHostAddress() + ":" + request.getSourcePort() + ":" + request.getTokenString(); + return tokenToSessionIdMap.remove(token); + } + + private String registerAsyncCoapSession(CoapExchange exchange, Request request, TransportProtos.SessionInfoProto sessionInfo, UUID sessionId) { + String token = request.getSource().getHostAddress() + ":" + request.getSourcePort() + ":" + request.getTokenString(); + tokenToSessionIdMap.putIfAbsent(token, sessionInfo); + CoapSessionListener attrListener = new CoapSessionListener(sessionId, exchange); + transportService.registerAsyncSession(sessionInfo, attrListener); + transportService.process(sessionInfo, getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); + return token; + } + + private static TransportProtos.SessionEventMsg getSessionEventMsg(TransportProtos.SessionEvent event) { + return TransportProtos.SessionEventMsg.newBuilder() + .setSessionType(TransportProtos.SessionType.ASYNC) + .setEvent(event).build(); + } + + private Optional decodeCredentials(Request request) { + List uriPath = request.getOptions().getUriPath(); + if (uriPath.size() >= ACCESS_TOKEN_POSITION) { + return Optional.of(new DeviceTokenCredentials(uriPath.get(ACCESS_TOKEN_POSITION - 1))); + } else { + return Optional.empty(); + } + } + + private Optional getFeatureType(Request request) { + List uriPath = request.getOptions().getUriPath(); + try { + if (uriPath.size() >= FEATURE_TYPE_POSITION) { + return Optional.of(FeatureType.valueOf(uriPath.get(FEATURE_TYPE_POSITION - 1).toUpperCase())); + } + } catch (RuntimeException e) { + log.warn("Failed to decode feature type: {}", uriPath); + } + return Optional.empty(); + } + + public static Optional getRequestId(Request request) { + List uriPath = request.getOptions().getUriPath(); + try { + if (uriPath.size() >= REQUEST_ID_POSITION) { + return Optional.of(Integer.valueOf(uriPath.get(REQUEST_ID_POSITION - 1))); + } + } catch (RuntimeException e) { + log.warn("Failed to decode feature type: {}", uriPath); + } + return Optional.empty(); + } + + @Override + public Resource getChild(String name) { + return this; + } + + private static class DeviceAuthCallback implements TransportServiceCallback { + private final TransportContext transportContext; + private final CoapExchange exchange; + private final Consumer onSuccess; + + DeviceAuthCallback(TransportContext transportContext, CoapExchange exchange, Consumer onSuccess) { + this.transportContext = transportContext; + this.exchange = exchange; + this.onSuccess = onSuccess; + } + + @Override + public void onSuccess(TransportProtos.ValidateDeviceCredentialsResponseMsg msg) { + if (msg.hasDeviceInfo()) { + UUID sessionId = UUID.randomUUID(); + TransportProtos.DeviceInfoProto deviceInfoProto = msg.getDeviceInfo(); + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setNodeId(transportContext.getNodeId()) + .setTenantIdMSB(deviceInfoProto.getTenantIdMSB()) + .setTenantIdLSB(deviceInfoProto.getTenantIdLSB()) + .setDeviceIdMSB(deviceInfoProto.getDeviceIdMSB()) + .setDeviceIdLSB(deviceInfoProto.getDeviceIdLSB()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .build(); + onSuccess.accept(sessionInfo); + } else { + exchange.respond(ResponseCode.UNAUTHORIZED); + } + } + + @Override + public void onError(Throwable e) { + log.warn("Failed to process request", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + private static class CoapOkCallback implements TransportServiceCallback { + private final CoapExchange exchange; + + CoapOkCallback(CoapExchange exchange) { + this.exchange = exchange; + } + + @Override + public void onSuccess(Void msg) { + exchange.respond(ResponseCode.VALID); + } + + @Override + public void onError(Throwable e) { + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + private static class CoapNoOpCallback implements TransportServiceCallback { + private final CoapExchange exchange; + + CoapNoOpCallback(CoapExchange exchange) { + this.exchange = exchange; + } + + @Override + public void onSuccess(Void msg) { + + } + + @Override + public void onError(Throwable e) { + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + public class CoapSessionListener implements SessionMsgListener { + + private final CoapExchange exchange; + private final AtomicInteger seqNumber = new AtomicInteger(2); + + CoapSessionListener(UUID sessionId, CoapExchange exchange) { + this.exchange = exchange; + } + + @Override + public void onGetAttributesResponse(TransportProtos.GetAttributeResponseMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void onAttributeUpdate(TransportProtos.AttributeUpdateNotificationMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto sessionCloseNotification) { + exchange.respond(ResponseCode.SERVICE_UNAVAILABLE); + } + + @Override + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + public int getNextSeqNumber() { + return seqNumber.getAndIncrement(); + } + } + + public class CoapExchangeObserverProxy implements ExchangeObserver { + + private final ExchangeObserver proxy; + private final String token; + + CoapExchangeObserverProxy(ExchangeObserver proxy, String token) { + super(); + this.proxy = proxy; + this.token = token; + } + + @Override + public void completed(Exchange exchange) { + proxy.completed(exchange); + TransportProtos.SessionInfoProto session = tokenToSessionIdMap.remove(token); + if (session != null) { + closeAndDeregister(session); + } + } + } + + private void closeAndDeregister(TransportProtos.SessionInfoProto session) { + transportService.process(session, getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + transportService.deregisterSession(session); + } + +} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java similarity index 72% rename from transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java rename to common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java index ece0bb7e10..278ca6d8c5 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java @@ -21,6 +21,7 @@ import org.eclipse.californium.core.CoapServer; import org.eclipse.californium.core.network.CoapEndpoint; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; @@ -37,49 +38,26 @@ import java.net.InetSocketAddress; import java.net.UnknownHostException; @Service("CoapTransportService") -@ConditionalOnProperty(prefix = "transport.coap", value = "enabled", havingValue = "true") +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.coap.enabled}'=='true')") @Slf4j public class CoapTransportService { private static final String V1 = "v1"; private static final String API = "api"; - private CoapServer server; - - @Autowired(required = false) - private ApplicationContext appContext; - - @Autowired(required = false) - private SessionMsgProcessor processor; - - @Autowired(required = false) - private DeviceAuthService authService; + @Autowired + private CoapTransportContext coapTransportContext; - @Autowired(required = false) - private HostRequestsQuotaService quotaService; - - - @Value("${coap.bind_address}") - private String host; - @Value("${coap.bind_port}") - private Integer port; - @Value("${coap.adaptor}") - private String adaptorName; - @Value("${coap.timeout}") - private Long timeout; - - private CoapTransportAdaptor adaptor; + private CoapServer server; @PostConstruct public void init() throws UnknownHostException { log.info("Starting CoAP transport..."); - log.info("Lookup CoAP transport adaptor {}", adaptorName); - this.adaptor = (CoapTransportAdaptor) appContext.getBean(adaptorName); log.info("Starting CoAP transport server"); this.server = new CoapServer(); createResources(); - InetAddress addr = InetAddress.getByName(host); - InetSocketAddress sockAddr = new InetSocketAddress(addr, port); + InetAddress addr = InetAddress.getByName(coapTransportContext.getHost()); + InetSocketAddress sockAddr = new InetSocketAddress(addr, coapTransportContext.getPort()); server.addEndpoint(new CoapEndpoint(sockAddr)); server.start(); log.info("CoAP transport started!"); @@ -87,7 +65,7 @@ public class CoapTransportService { private void createResources() { CoapResource api = new CoapResource(API); - api.add(new CoapTransportResource(processor, authService, adaptor, V1, timeout, quotaService)); + api.add(new CoapTransportResource(coapTransportContext, V1)); server.add(api); } diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java new file mode 100644 index 0000000000..9e48cd3030 --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap.adaptors; + +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.core.coap.Response; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.transport.coap.CoapTransportResource; + +import java.util.UUID; +import java.util.Optional; + +public interface CoapTransportAdaptor { + + TransportProtos.PostTelemetryMsg convertToPostTelemetry(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.PostAttributeMsg convertToPostAttributes(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.GetAttributeRequestMsg convertToGetAttributes(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(UUID sessionId, Request inbound) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener coapSessionListener, TransportProtos.ToServerRpcResponseMsg msg) throws AdaptorException; + +} diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java new file mode 100644 index 0000000000..f5bfb49c08 --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java @@ -0,0 +1,155 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap.adaptors; + +import java.util.*; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.core.coap.Response; +import org.springframework.util.StringUtils; +import org.thingsboard.server.common.msg.kv.AttributesKVMsg; +import org.thingsboard.server.common.msg.session.SessionContext; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.springframework.stereotype.Component; + +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.transport.coap.CoapTransportResource; + +@Component("JsonCoapAdaptor") +@Slf4j +public class JsonCoapAdaptor implements CoapTransportAdaptor { + + @Override + public TransportProtos.PostTelemetryMsg convertToPostTelemetry(UUID sessionId, Request inbound) throws AdaptorException { + String payload = validatePayload(sessionId, inbound); + try { + return JsonConverter.convertToTelemetryProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.PostAttributeMsg convertToPostAttributes(UUID sessionId, Request inbound) throws AdaptorException { + String payload = validatePayload(sessionId, inbound); + try { + return JsonConverter.convertToAttributesProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(UUID sessionId, Request inbound) throws AdaptorException { + List queryElements = inbound.getOptions().getUriQuery(); + TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); + if (queryElements != null && queryElements.size() > 0) { + Set clientKeys = toKeys(queryElements, "clientKeys"); + Set sharedKeys = toKeys(queryElements, "sharedKeys"); + if (clientKeys != null) { + result.addAllClientAttributeNames(clientKeys); + } + if (sharedKeys != null) { + result.addAllSharedAttributeNames(sharedKeys); + } + } + return result.build(); + } + + @Override + public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound) throws AdaptorException { + Optional requestId = CoapTransportResource.getRequestId(inbound); + String payload = validatePayload(sessionId, inbound); + JsonObject response = new JsonParser().parse(payload).getAsJsonObject(); + return TransportProtos.ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId.orElseThrow(() -> new AdaptorException("Request id is missing!"))) + .setPayload(response.toString()).build(); + } + + @Override + public TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(UUID sessionId, Request inbound) throws AdaptorException { + String payload = validatePayload(sessionId, inbound); + return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), 0); + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.AttributeUpdateNotificationMsg msg) throws AdaptorException { + return getObserveNotification(session.getNextSeqNumber(), JsonConverter.toJson(msg)); + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.ToDeviceRpcRequestMsg msg) throws AdaptorException { + return getObserveNotification(session.getNextSeqNumber(), JsonConverter.toJson(msg, true)); + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener coapSessionListener, TransportProtos.ToServerRpcResponseMsg msg) throws AdaptorException { + Response response = new Response(CoAP.ResponseCode.CONTENT); + JsonElement result = JsonConverter.toJson(msg); + response.setPayload(result.toString()); + return response; + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.GetAttributeResponseMsg msg) throws AdaptorException { + if (msg.getClientAttributeListCount() == 0 && msg.getSharedAttributeListCount() == 0 && msg.getDeletedAttributeKeysCount() == 0) { + return new Response(CoAP.ResponseCode.NOT_FOUND); + } else { + Response response = new Response(CoAP.ResponseCode.CONTENT); + JsonObject result = JsonConverter.toJson(msg); + response.setPayload(result.toString()); + return response; + } + } + + private Response getObserveNotification(int seqNumber, JsonElement json) { + Response response = new Response(CoAP.ResponseCode.CONTENT); + response.getOptions().setObserve(seqNumber); + response.setPayload(json.toString()); + return response; + } + + private String validatePayload(UUID sessionId, Request inbound) throws AdaptorException { + String payload = inbound.getPayloadString(); + if (payload == null) { + log.warn("[{}] Payload is empty!", sessionId); + throw new AdaptorException(new IllegalArgumentException("Payload is empty!")); + } + return payload; + } + + private Set toKeys(List queryElements, String attributeName) throws AdaptorException { + String keys = null; + for (String queryElement : queryElements) { + String[] queryItem = queryElement.split("="); + if (queryItem.length == 2 && queryItem[0].equals(attributeName)) { + keys = queryItem[1]; + } + } + if (keys != null && !StringUtils.isEmpty(keys)) { + return new HashSet<>(Arrays.asList(keys.split(","))); + } else { + return null; + } + } + +} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java similarity index 100% rename from transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java rename to common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index c1639ced7d..55df31a735 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -43,8 +43,6 @@ public class MqttTransportService { private String host; @Value("${transport.mqtt.bind_port}") private Integer port; - @Value("${transport.mqtt.adaptor}") - private String adaptorName; @Value("${transport.mqtt.netty.leak_detector_level}") private String leakDetectorLevel; diff --git a/common/transport/pom.xml b/common/transport/pom.xml index c9b47e876f..83e7ba806a 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -38,7 +38,7 @@ transport-api mqtt http - + coap diff --git a/pom.xml b/pom.xml index f4c2957b08..684c3d02a4 100755 --- a/pom.xml +++ b/pom.xml @@ -370,7 +370,7 @@ ${project.version} - org.thingsboard.transport + org.thingsboard.common.transport coap ${project.version} diff --git a/transport/coap/build.gradle b/transport/coap/build.gradle new file mode 100644 index 0000000000..6d54cb4789 --- /dev/null +++ b/transport/coap/build.gradle @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgName + permissionGroup pkgName + + // Copy the actual .jar file + from(mainJar) { + // Strip the version from the jar filename + rename { String fileName -> + "${pkgName}.jar" + } + fileMode 0500 + into "bin" + } + + // Copy the config files + from("target/conf") { + exclude "${pkgName}.conf" + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = NOARCH + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + requires("java-1.8.0") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgName + permissionGroup pkgName + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "all" + + archiveName = "${pkgName}.deb" + + requires("openjdk-8-jre").or("java8-runtime").or("oracle-java8-installer").or("openjdk-8-jre-headless") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/${pkgName}.yml") + configurationFile("${pkgInstallFolder}/conf/logback.xml") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgName + permissionGroup pkgName + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/bin/${pkgName}.jar") + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index a7015f0a35..a5e8107ab6 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -27,42 +27,37 @@ coap jar - Thingsboard COAP Transport + Thingsboard CoAP Transport Service https://thingsboard.io UTF-8 ${basedir}/../.. + tb-coap-transport + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/windows - org.thingsboard.common - transport - - - org.eclipse.californium - californium-core - - - org.springframework - spring-context + org.thingsboard.common.transport + coap - org.slf4j - slf4j-api - - - org.slf4j - log4j-over-slf4j + org.thingsboard.common + queue - ch.qos.logback - logback-core + org.springframework.boot + spring-boot-starter-web - ch.qos.logback - logback-classic + com.sun.winsw + winsw + bin + exe + provided org.springframework.boot @@ -81,4 +76,266 @@ + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/resources + + logback.xml + + false + + + + + + copy-service-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/conf + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + src/main/resources + + logback.xml + + false + + + src/main/conf + + tb-coap-transport.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + **/logback.xml + + + + ThingsBoard CoAP Transport Service + ${project.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.coap.ThingsboardCoapTransportApplication + boot + ZIP + true + true + + ${pkg.installFolder}/conf + ${pkg.unixLogFolder} + ${pkg.name}.out + ${pkg.name} + + + + + + repackage + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + diff --git a/transport/coap/src/main/assembly/windows.xml b/transport/coap/src/main/assembly/windows.xml new file mode 100644 index 0000000000..82da34ed13 --- /dev/null +++ b/transport/coap/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + lib + ${pkg.name}.jar + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/transport/coap/src/main/conf/logback.xml b/transport/coap/src/main/conf/logback.xml new file mode 100644 index 0000000000..f36469d5f2 --- /dev/null +++ b/transport/coap/src/main/conf/logback.xml @@ -0,0 +1,43 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/transport/coap/src/main/conf/tb-coap-transport.conf b/transport/coap/src/main/conf/tb-coap-transport.conf new file mode 100644 index 0000000000..0afa91c18e --- /dev/null +++ b/transport/coap/src/main/conf/tb-coap-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/transport/coap/src/main/filters/unix.properties b/transport/coap/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/transport/coap/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/transport/coap/src/main/filters/windows.properties b/transport/coap/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/transport/coap/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java b/transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java new file mode 100644 index 0000000000..1ad86dd166 --- /dev/null +++ b/transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coap; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.Arrays; + +@SpringBootConfiguration +@EnableAsync +@EnableScheduling +@ComponentScan({"org.thingsboard.server.coap", "org.thingsboard.server.common", "org.thingsboard.server.transport.coap", "org.thingsboard.server.kafka"}) +public class ThingsboardCoapTransportApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "tb-coap-transport"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardCoapTransportApplication.class, updateArguments(args)); + } + + private static String[] updateArguments(String[] args) { + if (Arrays.stream(args).noneMatch(arg -> arg.startsWith(SPRING_CONFIG_NAME_KEY))) { + String[] modifiedArgs = new String[args.length + 1]; + System.arraycopy(args, 0, modifiedArgs, 0, args.length); + modifiedArgs[args.length] = DEFAULT_SPRING_CONFIG_PARAM; + return modifiedArgs; + } + return args; + } +} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java deleted file mode 100644 index 88fb69ecd0..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap; - -import java.lang.reflect.Field; -import java.util.List; -import java.util.Optional; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.CoapResource; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.network.Exchange; -import org.eclipse.californium.core.network.ExchangeObserver; -import org.eclipse.californium.core.server.resources.CoapExchange; -import org.eclipse.californium.core.server.resources.Resource; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; -import org.thingsboard.server.transport.coap.session.CoapExchangeObserverProxy; -import org.thingsboard.server.transport.coap.session.CoapSessionCtx; -import org.springframework.util.ReflectionUtils; - -@Slf4j -public class CoapTransportResource extends CoapResource { - // coap://localhost:port/api/v1/DEVICE_TOKEN/[attributes|telemetry|rpc[/requestId]] - private static final int ACCESS_TOKEN_POSITION = 3; - private static final int FEATURE_TYPE_POSITION = 4; - private static final int REQUEST_ID_POSITION = 5; - - private final CoapTransportAdaptor adaptor; - private final SessionMsgProcessor processor; - private final DeviceAuthService authService; - private final QuotaService quotaService; - private final Field observerField; - private final long timeout; - - public CoapTransportResource(SessionMsgProcessor processor, DeviceAuthService authService, CoapTransportAdaptor adaptor, String name, - long timeout, QuotaService quotaService) { - super(name); - this.processor = processor; - this.authService = authService; - this.quotaService = quotaService; - this.adaptor = adaptor; - this.timeout = timeout; - // This is important to turn off existing observable logic in - // CoapResource. We will have our own observe monitoring due to 1:1 - // observe relationship. - this.setObservable(false); - observerField = ReflectionUtils.findField(Exchange.class, "observer"); - observerField.setAccessible(true); - } - - @Override - public void handleGET(CoapExchange exchange) { - if(quotaService.isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { - log.warn("COAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); - exchange.respond(ResponseCode.BAD_REQUEST); - return; - } - - Optional featureType = getFeatureType(exchange.advanced().getRequest()); - if (!featureType.isPresent()) { - log.trace("Missing feature type parameter"); - exchange.respond(ResponseCode.BAD_REQUEST); - } else if (featureType.get() == FeatureType.TELEMETRY) { - log.trace("Can't fetch/subscribe to timeseries updates"); - exchange.respond(ResponseCode.BAD_REQUEST); - } else if (exchange.getRequestOptions().hasObserve()) { - processExchangeGetRequest(exchange, featureType.get()); - } else if (featureType.get() == FeatureType.ATTRIBUTES) { - processRequest(exchange, SessionMsgType.GET_ATTRIBUTES_REQUEST); - } else { - log.trace("Invalid feature type parameter"); - exchange.respond(ResponseCode.BAD_REQUEST); - } - } - - private void processExchangeGetRequest(CoapExchange exchange, FeatureType featureType) { - boolean unsubscribe = exchange.getRequestOptions().getObserve() == 1; - SessionMsgType sessionMsgType; - if (featureType == FeatureType.RPC) { - sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST : SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; - } else { - sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST : SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; - } - Optional sessionId = processRequest(exchange, sessionMsgType); - if (sessionId.isPresent()) { - if (exchange.getRequestOptions().getObserve() == 1) { - exchange.respond(ResponseCode.VALID); - } - } - } - - @Override - public void handlePOST(CoapExchange exchange) { - if(quotaService.isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { - log.warn("COAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); - exchange.respond(ResponseCode.BAD_REQUEST); - return; - } - - Optional featureType = getFeatureType(exchange.advanced().getRequest()); - if (!featureType.isPresent()) { - log.trace("Missing feature type parameter"); - exchange.respond(ResponseCode.BAD_REQUEST); - } else { - switch (featureType.get()) { - case ATTRIBUTES: - processRequest(exchange, SessionMsgType.POST_ATTRIBUTES_REQUEST); - break; - case TELEMETRY: - processRequest(exchange, SessionMsgType.POST_TELEMETRY_REQUEST); - break; - case RPC: - Optional requestId = getRequestId(exchange.advanced().getRequest()); - if (requestId.isPresent()) { - processRequest(exchange, SessionMsgType.TO_DEVICE_RPC_RESPONSE); - } else { - processRequest(exchange, SessionMsgType.TO_SERVER_RPC_REQUEST); - } - break; - } - } - } - - private Optional processRequest(CoapExchange exchange, SessionMsgType type) { - log.trace("Processing {}", exchange.advanced().getRequest()); - exchange.accept(); - Exchange advanced = exchange.advanced(); - Request request = advanced.getRequest(); - - Optional credentials = decodeCredentials(request); - if (!credentials.isPresent()) { - exchange.respond(ResponseCode.BAD_REQUEST); - return Optional.empty(); - } - - CoapSessionCtx ctx = new CoapSessionCtx(exchange, adaptor, processor, authService, timeout); - -// if (!ctx.login(credentials.get())) { -// exchange.respond(ResponseCode.UNAUTHORIZED); -// return Optional.empty(); -// } - - AdaptorToSessionActorMsg msg; - try { - switch (type) { - case GET_ATTRIBUTES_REQUEST: - case POST_ATTRIBUTES_REQUEST: - case POST_TELEMETRY_REQUEST: - case TO_DEVICE_RPC_RESPONSE: - case TO_SERVER_RPC_REQUEST: - ctx.setSessionType(SessionType.SYNC); - msg = adaptor.convertToActorMsg(ctx, type, request); - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - ExchangeObserver systemObserver = (ExchangeObserver) observerField.get(advanced); - advanced.setObserver(new CoapExchangeObserverProxy(systemObserver, ctx)); - ctx.setSessionType(SessionType.ASYNC); - msg = adaptor.convertToActorMsg(ctx, type, request); - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - ctx.setSessionType(SessionType.ASYNC); - msg = adaptor.convertToActorMsg(ctx, type, request); - break; - default: - log.trace("[{}] Unsupported msg type: {}", ctx.getSessionId(), type); - throw new IllegalArgumentException("Unsupported msg type: " + type); - } - log.trace("Processing msg: {}", msg); -// processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); - } catch (AdaptorException e) { - log.debug("Failed to decode payload {}", e); - exchange.respond(ResponseCode.BAD_REQUEST, e.getMessage()); - return Optional.empty(); - } catch (IllegalArgumentException | IllegalAccessException e) { - log.debug("Failed to process payload {}", e); - exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR, e.getMessage()); - } - return Optional.of(ctx.getSessionId()); - } - - private Optional decodeCredentials(Request request) { - List uriPath = request.getOptions().getUriPath(); - DeviceCredentialsFilter credentials = null; - if (uriPath.size() >= ACCESS_TOKEN_POSITION) { - credentials = new DeviceTokenCredentials(uriPath.get(ACCESS_TOKEN_POSITION - 1)); - } - return Optional.ofNullable(credentials); - } - - private Optional getFeatureType(Request request) { - List uriPath = request.getOptions().getUriPath(); - try { - if (uriPath.size() >= FEATURE_TYPE_POSITION) { - return Optional.of(FeatureType.valueOf(uriPath.get(FEATURE_TYPE_POSITION - 1).toUpperCase())); - } - } catch (RuntimeException e) { - log.warn("Failed to decode feature type: {}", uriPath); - } - return Optional.empty(); - } - - public static Optional getRequestId(Request request) { - List uriPath = request.getOptions().getUriPath(); - try { - if (uriPath.size() >= REQUEST_ID_POSITION) { - return Optional.of(Integer.valueOf(uriPath.get(REQUEST_ID_POSITION - 1))); - } - } catch (RuntimeException e) { - log.warn("Failed to decode feature type: {}", uriPath); - } - return Optional.empty(); - } - - @Override - public Resource getChild(String name) { - return this; - } - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java deleted file mode 100644 index d9a04b163a..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.adaptors; - -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.coap.Response; -import org.thingsboard.server.common.transport.TransportAdaptor; -import org.thingsboard.server.transport.coap.session.CoapSessionCtx; - -public interface CoapTransportAdaptor extends TransportAdaptor { - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java deleted file mode 100644 index 1c587f0fae..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.adaptors; - -import java.util.*; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.coap.Response; -import org.springframework.util.StringUtils; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.msg.session.ex.ProcessingTimeoutException; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.springframework.stereotype.Component; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; -import org.thingsboard.server.transport.coap.CoapTransportResource; -import org.thingsboard.server.transport.coap.session.CoapSessionCtx; - -@Component("JsonCoapAdaptor") -@Slf4j -public class JsonCoapAdaptor implements CoapTransportAdaptor { - - @Override - public AdaptorToSessionActorMsg convertToActorMsg(CoapSessionCtx ctx, SessionMsgType type, Request inbound) throws AdaptorException { - FromDeviceMsg msg = null; - switch (type) { - case POST_TELEMETRY_REQUEST: - msg = convertToTelemetryUploadRequest(ctx, inbound); - break; - case POST_ATTRIBUTES_REQUEST: - msg = convertToUpdateAttributesRequest(ctx, inbound); - break; - case GET_ATTRIBUTES_REQUEST: - msg = convertToGetAttributesRequest(ctx, inbound); - break; - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcSubscribeMsg(); - break; - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcUnsubscribeMsg(); - break; - case TO_DEVICE_RPC_RESPONSE: - msg = convertToDeviceRpcResponse(ctx, inbound); - break; - case TO_SERVER_RPC_REQUEST: - msg = convertToServerRpcRequest(ctx, inbound); - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesSubscribeMsg(); - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesUnsubscribeMsg(); - break; - default: - log.warn("[{}] Unsupported msg type: {}!", ctx.getSessionId(), type); - throw new AdaptorException(new IllegalArgumentException("Unsupported msg type: " + type + "!")); - } - return new BasicAdaptorToSessionActorMsg(ctx, msg); - } - - private FromDeviceMsg convertToDeviceRpcResponse(CoapSessionCtx ctx, Request inbound) throws AdaptorException { - Optional requestId = CoapTransportResource.getRequestId(inbound); - String payload = validatePayload(ctx, inbound); - JsonObject response = new JsonParser().parse(payload).getAsJsonObject(); - return new ToDeviceRpcResponseMsg( - requestId.orElseThrow(() -> new AdaptorException("Request id is missing!")), - response.get("response").toString()); - } - - private FromDeviceMsg convertToServerRpcRequest(CoapSessionCtx ctx, Request inbound) throws AdaptorException { - - String payload = validatePayload(ctx, inbound); - -// return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), 0); - return null; - } - - @Override - public Optional convertToAdaptorMsg(CoapSessionCtx ctx, SessionActorToAdaptorMsg source) throws AdaptorException { - ToDeviceMsg msg = source.getMsg(); - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - case TO_DEVICE_RPC_RESPONSE_ACK: - return Optional.of(convertStatusCodeResponse((StatusCodeResponse) msg)); - case GET_ATTRIBUTES_RESPONSE: - return Optional.of(convertGetAttributesResponse((GetAttributesResponse) msg)); - case ATTRIBUTES_UPDATE_NOTIFICATION: - return Optional.of(convertNotificationResponse(ctx, (AttributesUpdateNotification) msg)); - case TO_DEVICE_RPC_REQUEST: - return Optional.of(convertToDeviceRpcRequest(ctx, (ToDeviceRpcRequestMsg) msg)); - case TO_SERVER_RPC_RESPONSE: - return Optional.of(convertToServerRpcResponse(ctx, (ToServerRpcResponseMsg) msg)); - case RULE_ENGINE_ERROR: - return Optional.of(convertToRuleEngineErrorResponse(ctx, (RuleEngineErrorMsg) msg)); - default: - log.warn("[{}] Unsupported msg type: {}!", source.getSessionId(), msg.getSessionMsgType()); - throw new AdaptorException(new IllegalArgumentException("Unsupported msg type: " + msg.getSessionMsgType() + "!")); - } - } - - private Response convertToRuleEngineErrorResponse(CoapSessionCtx ctx, RuleEngineErrorMsg msg) { - ResponseCode status = ResponseCode.INTERNAL_SERVER_ERROR; - switch (msg.getError()) { - case QUEUE_PUT_TIMEOUT: - status = ResponseCode.GATEWAY_TIMEOUT; - break; - default: - if (msg.getInSessionMsgType() == SessionMsgType.TO_SERVER_RPC_REQUEST) { - status = ResponseCode.BAD_REQUEST; - } - break; - } - Response response = new Response(status); - response.setPayload(JsonConverter.toErrorJson(msg.getErrorMsg()).toString()); - return response; - } - - private Response convertNotificationResponse(CoapSessionCtx ctx, AttributesUpdateNotification msg) { - return getObserveNotification(ctx, JsonConverter.toJson(msg.getData(), false)); - } - - private Response convertToDeviceRpcRequest(CoapSessionCtx ctx, ToDeviceRpcRequestMsg msg) { - return getObserveNotification(ctx, JsonConverter.toJson(msg, true)); - } - - private Response getObserveNotification(CoapSessionCtx ctx, JsonObject json) { - Response response = new Response(ResponseCode.CONTENT); - response.getOptions().setObserve(ctx.nextSeqNumber()); - response.setPayload(json.toString()); - return response; - } - - private AttributesUpdateRequest convertToUpdateAttributesRequest(SessionContext ctx, Request inbound) throws AdaptorException { - String payload = validatePayload(ctx, inbound); - try { - return JsonConverter.convertToAttributes(new JsonParser().parse(payload)); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private FromDeviceMsg convertToGetAttributesRequest(SessionContext ctx, Request inbound) throws AdaptorException { - List queryElements = inbound.getOptions().getUriQuery(); - if (queryElements != null && queryElements.size() > 0) { - Set clientKeys = toKeys(ctx, queryElements, "clientKeys"); - Set sharedKeys = toKeys(ctx, queryElements, "sharedKeys"); - return new BasicGetAttributesRequest(0, clientKeys, sharedKeys); - } else { - return new BasicGetAttributesRequest(0); - } - } - - private Set toKeys(SessionContext ctx, List queryElements, String attributeName) throws AdaptorException { - String keys = null; - for (String queryElement : queryElements) { - String[] queryItem = queryElement.split("="); - if (queryItem.length == 2 && queryItem[0].equals(attributeName)) { - keys = queryItem[1]; - } - } - if (keys != null && !StringUtils.isEmpty(keys)) { - return new HashSet<>(Arrays.asList(keys.split(","))); - } else { - return null; - } - } - - private TelemetryUploadRequest convertToTelemetryUploadRequest(SessionContext ctx, Request inbound) throws AdaptorException { - String payload = validatePayload(ctx, inbound); - try { - return JsonConverter.convertToTelemetry(new JsonParser().parse(payload)); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private Response convertStatusCodeResponse(StatusCodeResponse msg) { - if (msg.isSuccess()) { - Optional code = msg.getData(); - if (code.isPresent() && code.get() == 200) { - return new Response(ResponseCode.VALID); - } else { - return new Response(ResponseCode.CREATED); - } - } else { - return convertError(msg.getError()); - } - } - - private String validatePayload(SessionContext ctx, Request inbound) throws AdaptorException { - String payload = inbound.getPayloadString(); - if (payload == null) { - log.warn("[{}] Payload is empty!", ctx.getSessionId()); - throw new AdaptorException(new IllegalArgumentException("Payload is empty!")); - } - return payload; - } - - private Response convertToServerRpcResponse(SessionContext ctx, ToServerRpcResponseMsg msg) { - if (msg.isSuccess()) { - Response response = new Response(ResponseCode.CONTENT); -// JsonElement result = JsonConverter.toJson(msg); -// response.setPayload(result.toString()); - return response; - } else { - return convertError(Optional.of(new RuntimeException("Server RPC response is empty!"))); - } - } - - private Response convertGetAttributesResponse(GetAttributesResponse msg) { - if (msg.isSuccess()) { - Optional payload = msg.getData(); - if (!payload.isPresent() || (payload.get().getClientAttributes().isEmpty() && payload.get().getSharedAttributes().isEmpty())) { - return new Response(ResponseCode.NOT_FOUND); - } else { - Response response = new Response(ResponseCode.CONTENT); - JsonObject result = JsonConverter.toJson(payload.get(), false); - response.setPayload(result.toString()); - return response; - } - } else { - return convertError(msg.getError()); - } - } - - private Response convertError(Optional exception) { - if (exception.isPresent()) { - log.warn("Converting exception: {}", exception.get().getMessage(), exception.get()); - if (exception.get() instanceof ProcessingTimeoutException) { - return new Response(ResponseCode.SERVICE_UNAVAILABLE); - } else { - return new Response(ResponseCode.INTERNAL_SERVER_ERROR); - } - } else { - return new Response(ResponseCode.INTERNAL_SERVER_ERROR); - } - } - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapExchangeObserverProxy.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapExchangeObserverProxy.java deleted file mode 100644 index 0c264817d5..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapExchangeObserverProxy.java +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.session; - -import org.eclipse.californium.core.network.Exchange; -import org.eclipse.californium.core.network.ExchangeObserver; - -public class CoapExchangeObserverProxy implements ExchangeObserver { - - private final ExchangeObserver proxy; - private final CoapSessionCtx ctx; - - public CoapExchangeObserverProxy(ExchangeObserver proxy, CoapSessionCtx ctx) { - super(); - this.proxy = proxy; - this.ctx = ctx; - } - - @Override - public void completed(Exchange exchange) { - proxy.completed(exchange); - ctx.close(); - } - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java deleted file mode 100644 index 60d54087bf..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.session; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.coap.Response; -import org.eclipse.californium.core.server.resources.CoapExchange; -import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; -import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; - -import java.util.concurrent.atomic.AtomicInteger; - -@Slf4j -public class CoapSessionCtx extends DeviceAwareSessionContext { - - private final SessionId sessionId; - private final CoapExchange exchange; - private final CoapTransportAdaptor adaptor; - private final String token; - private final long timeout; - private SessionType sessionType; - private final AtomicInteger seqNumber = new AtomicInteger(2); - - public CoapSessionCtx(CoapExchange exchange, CoapTransportAdaptor adaptor, SessionMsgProcessor processor, DeviceAuthService authService, long timeout) { - super(); - Request request = exchange.advanced().getRequest(); - this.token = request.getTokenString(); - this.sessionId = new CoapSessionId(request.getSource().getHostAddress(), request.getSourcePort(), this.token); - this.exchange = exchange; - this.adaptor = adaptor; - this.timeout = timeout; - } - - - @Override - public void onMsg(SessionActorToAdaptorMsg msg) throws SessionException { - try { - adaptor.convertToAdaptorMsg(this, msg).ifPresent(this::pushToNetwork); - } catch (AdaptorException e) { - logAndWrap(e); - } - } - - private void pushToNetwork(Response response) { - exchange.respond(response); - } - - private void logAndWrap(AdaptorException e) throws SessionException { - log.warn("Failed to convert msg: {}", e.getMessage(), e); - throw new SessionException(e); - } - - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - log.debug("[{}] onCtrl: {}", sessionId, msg); - if (msg instanceof SessionCloseMsg) { - onSessionClose((SessionCloseMsg) msg); - } - } - - private void onSessionClose(SessionCloseMsg msg) { - if (msg.isTimeout()) { - exchange.respond(ResponseCode.SERVICE_UNAVAILABLE); - } else if (msg.isCredentialsRevoked()) { - exchange.respond(ResponseCode.UNAUTHORIZED); - } else { - exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); - } - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - @Override - public String toString() { - return "CoapSessionCtx [sessionId=" + sessionId + "]"; - } - - @Override - public boolean isClosed() { - return exchange.advanced().isComplete() || exchange.advanced().isTimedOut(); - } - - public void close() { - log.info("[{}] Closing processing context. Timeout: {}", sessionId, exchange.advanced().isTimedOut()); -// processor.process(exchange.advanced().isTimedOut() ? SessionCloseMsg.onTimeout(sessionId) : SessionCloseMsg.onError(sessionId)); - } - - @Override - public long getTimeout() { - return timeout; - } - - public void setSessionType(SessionType sessionType) { - this.sessionType = sessionType; - } - - @Override - public SessionType getSessionType() { - return sessionType; - } - - public int nextSeqNumber() { - return seqNumber.getAndIncrement(); - } -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java deleted file mode 100644 index 2bfcf1d2c6..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.session; - -public final class CoapSessionId implements SessionId { - - private final String clientAddress; - private final int clientPort; - private final String token; - - public CoapSessionId(String host, int port, String token) { - super(); - this.clientAddress = host; - this.clientPort = port; - this.token = token; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((clientAddress == null) ? 0 : clientAddress.hashCode()); - result = prime * result + clientPort; - result = prime * result + ((token == null) ? 0 : token.hashCode()); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - CoapSessionId other = (CoapSessionId) obj; - if (clientAddress == null) { - if (other.clientAddress != null) - return false; - } else if (!clientAddress.equals(other.clientAddress)) - return false; - if (clientPort != other.clientPort) - return false; - if (token == null) { - if (other.token != null) - return false; - } else if (!token.equals(other.token)) - return false; - return true; - } - - @Override - public String toString() { - return "CoapSessionId [clientAddress=" + clientAddress + ", clientPort=" + clientPort + ", token=" + token + "]"; - } - - @Override - public String toUidStr() { - return clientAddress + ":" + clientPort + ":" + token; - } - -} diff --git a/transport/coap/src/main/resources/logback.xml b/transport/coap/src/main/resources/logback.xml new file mode 100644 index 0000000000..18864a9fab --- /dev/null +++ b/transport/coap/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml new file mode 100644 index 0000000000..1790d822d2 --- /dev/null +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -0,0 +1,68 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +spring.main.web-environment: false +spring.main.web-application-type: none + +# MQTT server parameters +transport: + coap: + bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" + bind_port: "${COAP_BIND_PORT:5683}" + timeout: "${COAP_TIMEOUT:10000}" + +#Quota parameters +quota: + host: + # Max allowed number of API requests in interval for single host + limit: "${QUOTA_HOST_LIMIT:10000}" + # Interval duration + intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" + # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs + ttlMs: "${QUOTA_HOST_TTL_MS:60000}" + # Interval for scheduled task that cleans expired records. TTL is used for expiring + cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" + # Enable Host API Limits + enabled: "${QUOTA_HOST_ENABLED:true}" + # Array of whitelist hosts + whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" + # Array of blacklist hosts + blacklist: "${QUOTA_HOST_BLACKLIST:}" + log: + topSize: 10 + intervalMin: 2 + +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + max_requests_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + response_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" diff --git a/transport/coap/src/main/scripts/control/deb/postinst b/transport/coap/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..d4066c027b --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/transport/coap/src/main/scripts/control/deb/postrm b/transport/coap/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/transport/coap/src/main/scripts/control/deb/preinst b/transport/coap/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..6be5959285 --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.name} >/dev/null; then + addgroup --system ${pkg.name} +fi + +if ! getent passwd ${pkg.name} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.name} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.name} +fi diff --git a/transport/coap/src/main/scripts/control/deb/prerm b/transport/coap/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/transport/coap/src/main/scripts/control/rpm/postinst b/transport/coap/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..8a7a88f7e0 --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/coap/src/main/scripts/control/rpm/postrm b/transport/coap/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/coap/src/main/scripts/control/rpm/preinst b/transport/coap/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..e19fc884c8 --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.name} >/dev/null || groupadd -r ${pkg.name} +getent passwd ${pkg.name} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.name} -M -r ${pkg.name} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/transport/coap/src/main/scripts/control/rpm/prerm b/transport/coap/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/transport/coap/src/main/scripts/control/tb-coap-transport.service b/transport/coap/src/main/scripts/control/tb-coap-transport.service new file mode 100644 index 0000000000..d456fc03c0 --- /dev/null +++ b/transport/coap/src/main/scripts/control/tb-coap-transport.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.name} +ExecStart=${pkg.installFolder}/bin/${pkg.name}.jar +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/transport/coap/src/main/scripts/windows/install.bat b/transport/coap/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..dba773663c --- /dev/null +++ b/transport/coap/src/main/scripts/windows/install.bat @@ -0,0 +1,87 @@ +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Detecting Java version installed. +:CHECK_JAVA_64 +@ECHO Detecting if it is 64 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\Wow6432Node\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF NOT "%JRE_PATH2%" == "" GOTO JAVA_INSTALLED + +:CHECK_JAVA_32 +@ECHO Detecting if it is 32 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF "%JRE_PATH2%" == "" GOTO JAVA_NOT_INSTALLED + +:JAVA_INSTALLED + +@ECHO Java 1.8 found! +@ECHO Installing ${pkg.name} ... + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:JAVA_NOT_INSTALLED +@ECHO Java 1.8 or above is not installed +@ECHO Please go to https://java.com/ and install Java. Then retry installation. +PAUSE +GOTO END + +:END + + diff --git a/transport/coap/src/main/scripts/windows/service.xml b/transport/coap/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..f7b9d307c8 --- /dev/null +++ b/transport/coap/src/main/scripts/windows/service.xml @@ -0,0 +1,36 @@ + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\conf + ${pkg.winWrapperLogFolder} + rotate + + java + -Xloggc:%BASE%\logs\gc.log + -XX:+HeapDumpOnOutOfMemoryError + -XX:+PrintGCDetails + -XX:+PrintGCDateStamps + -XX:+PrintHeapAtGC + -XX:+PrintTenuringDistribution + -XX:+PrintGCApplicationStoppedTime + -XX:+UseGCLogFileRotation + -XX:NumberOfGCLogFiles=10 + -XX:GCLogFileSize=10M + -XX:-UseBiasedLocking + -XX:+UseTLAB + -XX:+ResizeTLAB + -XX:+PerfDisableSharedMem + -XX:+UseCondCardMark + -XX:CMSWaitDuration=10000 + -XX:+UseParNewGC + -XX:+UseConcMarkSweepGC + -XX:+CMSParallelRemarkEnabled + -XX:+CMSParallelInitialMarkEnabled + -XX:+CMSEdenChunksRecordAlways + -XX:CMSInitiatingOccupancyFraction=75 + -XX:+UseCMSInitiatingOccupancyOnly + -jar + %BASE%\lib\${pkg.name}.jar + + diff --git a/transport/coap/src/main/scripts/windows/uninstall.bat b/transport/coap/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..921e4c8395 --- /dev/null +++ b/transport/coap/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,9 @@ +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java deleted file mode 100644 index 84aeb848c8..0000000000 --- a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.CoapClient; -import org.eclipse.californium.core.CoapResponse; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.MediaTypeRegistry; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.annotation.DirtiesContext.ClassMode; -import org.springframework.test.context.junit4.SpringRunner; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.data.security.DeviceCredentialsType; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.auth.DeviceAuthResult; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; - -import java.util.Optional; -import java.util.UUID; - -@RunWith(SpringRunner.class) -@DirtiesContext(classMode = ClassMode.BEFORE_CLASS) -@Slf4j -public class CoapServerTest { - - private static final int TEST_PORT = 5555; - private static final String TELEMETRY_POST_MESSAGE = "[{\"key1\":\"value1\"}]"; - private static final String TEST_ATTRIBUTES_RESPONSE = "{\"key1\":\"value1\",\"key2\":42}"; - private static final String DEVICE1_TOKEN = "Device1Token"; - - @Configuration - public static class EchoCoapServerITConfiguration extends CoapServerTestConfiguration { - - @Bean - public static DeviceAuthService authService() { - return new DeviceAuthService() { - - private final DeviceId devId = new DeviceId(UUID.randomUUID()); - - @Override - public DeviceAuthResult process(DeviceCredentialsFilter credentials) { - if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.ACCESS_TOKEN) { - DeviceTokenCredentials tokenCredentials = (DeviceTokenCredentials) credentials; - if (tokenCredentials.getCredentialsId().equals(DEVICE1_TOKEN)) { - return DeviceAuthResult.of(devId); - } - } - return DeviceAuthResult.of("Credentials are invalid!"); - } - - @Override - public Optional findDeviceById(DeviceId deviceId) { - if (deviceId.equals(devId)) { - Device dev = new Device(); - dev.setId(devId); - dev.setTenantId(new TenantId(UUID.randomUUID())); - dev.setCustomerId(new CustomerId(UUID.randomUUID())); - return Optional.of(dev); - } else { - return Optional.empty(); - } - } - }; - } - - @Bean - public static SessionMsgProcessor sessionMsgProcessor() { - return new SessionMsgProcessor() { - -// @Override -// public void process(SessionAwareMsg toActorMsg) { -// if (toActorMsg instanceof TransportToDeviceSessionActorMsg) { -// AdaptorToSessionActorMsg sessionMsg = ((TransportToDeviceSessionActorMsg) toActorMsg).getSessionMsg(); -// try { -// FromDeviceMsg deviceMsg = sessionMsg.getMsg(); -// ToDeviceMsg toDeviceMsg = null; -// if (deviceMsg.getMsgType() == SessionMsgType.POST_TELEMETRY_REQUEST) { -// toDeviceMsg = BasicStatusCodeResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID); -// } else if (deviceMsg.getMsgType() == SessionMsgType.GET_ATTRIBUTES_REQUEST) { -// List data = new ArrayList<>(); -// data.add(new BaseAttributeKvEntry(new StringDataEntry("key1", "value1"), System.currentTimeMillis())); -// data.add(new BaseAttributeKvEntry(new LongDataEntry("key2", 42L), System.currentTimeMillis())); -// BasicAttributeKVMsg kv = BasicAttributeKVMsg.fromClient(data); -// toDeviceMsg = BasicGetAttributesResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID, kv); -// } -// if (toDeviceMsg != null) { -// sessionMsg.getSessionContext().onMsg(new BasicSessionActorToAdaptorMsg(sessionMsg.getSessionContext(), toDeviceMsg)); -// } -// } catch (Exception e) { -// e.printStackTrace(); -// } -// } -// } - - @Override - public void onDeviceAdded(Device device) { - - } - - - }; - } - - @Bean - public static HostRequestsQuotaService quotaService() { - return new HostRequestsQuotaService(null, null, null, null, false); - } - } - - @Autowired - private CoapTransportService service; - - @Before - public void beforeTest() { - log.info("Service info: {}", service.toString()); - } - - @Test - public void testBadJsonTelemetryPostRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.TELEMETRY.name().toLowerCase()); - CoapResponse response = client.setTimeout(6000).post("test", MediaTypeRegistry.APPLICATION_JSON); - Assert.assertEquals(ResponseCode.BAD_REQUEST, response.getCode()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - @Test - public void testNoCredentialsPostRequest() { - CoapClient client = new CoapClient(getBaseTestUrl()); - CoapResponse response = client.setTimeout(6000).post(TELEMETRY_POST_MESSAGE, MediaTypeRegistry.APPLICATION_JSON); - Assert.assertEquals(ResponseCode.BAD_REQUEST, response.getCode()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - @Test - public void testValidJsonTelemetryPostRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.TELEMETRY.name().toLowerCase()); - CoapResponse response = client.setTimeout(6000).post(TELEMETRY_POST_MESSAGE, MediaTypeRegistry.APPLICATION_JSON); - Assert.assertEquals(ResponseCode.CREATED, response.getCode()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - @Test - public void testNoCredentialsAttributesGetRequest() { - CoapClient client = new CoapClient("coap://localhost:5555/api/v1?keys=key1,key2"); - CoapResponse response = client.setTimeout(6000).get(); - Assert.assertEquals(ResponseCode.BAD_REQUEST, response.getCode()); - } - - @Test - public void testNoKeysAttributesGetRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.ATTRIBUTES.name().toLowerCase() + "?data=key1,key2"); - CoapResponse response = client.setTimeout(6000).get(); - Assert.assertEquals(ResponseCode.CONTENT, response.getCode()); - } - - @Test - public void testValidAttributesGetRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.ATTRIBUTES.name().toLowerCase() + "?clientKeys=key1,key2"); - CoapResponse response = client.setTimeout(6000).get(); - Assert.assertEquals(ResponseCode.CONTENT, response.getCode()); - Assert.assertEquals(TEST_ATTRIBUTES_RESPONSE, response.getResponseText()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - private String getBaseTestUrl() { - return "coap://localhost:" + TEST_PORT + "/api/v1/"; - } - -} diff --git a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTestConfiguration.java b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTestConfiguration.java deleted file mode 100644 index f2cd080293..0000000000 --- a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTestConfiguration.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.PropertySource; -import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; -import org.springframework.test.context.TestPropertySource; - -@Configuration -@ComponentScan({ "org.thingsboard.server.transport.coap" }) -@PropertySource("classpath:coap-transport-test.properties") -public class CoapServerTestConfiguration { - - @Bean - public static PropertySourcesPlaceholderConfigurer propertyConfig() { - return new PropertySourcesPlaceholderConfigurer(); - } - -} diff --git a/transport/coap/src/test/resources/coap-transport-test.properties b/transport/coap/src/test/resources/coap-transport-test.properties deleted file mode 100644 index 469b0348b1..0000000000 --- a/transport/coap/src/test/resources/coap-transport-test.properties +++ /dev/null @@ -1,4 +0,0 @@ -coap.bind_address=0.0.0.0 -coap.bind_port=5555 -coap.adaptor=JsonCoapAdaptor -coap.timeout=10000 \ No newline at end of file diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 225f064f17..407d995381 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -145,7 +145,7 @@ src/main/conf - tb-mqtt-transport.conf + tb-http-transport.conf true @@ -229,7 +229,7 @@ - ThingsBoard MQTT Transport Service + ThingsBoard HTTP Transport Service ${project.version} @@ -239,7 +239,7 @@ org.springframework.boot spring-boot-maven-plugin - org.thingsboard.server.mqtt.ThingsboardHttpTransportApplication + org.thingsboard.server.http.ThingsboardHttpTransportApplication boot ZIP true diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 2750e1e511..6395b40365 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -48,6 +48,10 @@ org.thingsboard.common queue + + org.springframework.boot + spring-boot-starter-web + com.sun.winsw winsw diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index e46de8e9ab..1d425e4761 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -14,6 +14,7 @@ # limitations under the License. # +spring.main.web-environment: false spring.main.web-application-type: none # MQTT server parameters diff --git a/transport/pom.xml b/transport/pom.xml index 281f511550..0bb9300b27 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -36,6 +36,7 @@ http mqtt + coap From 144e3bf8cebfb8cfaeae72b5d2a4291a0e6d89c5 Mon Sep 17 00:00:00 2001 From: Sergey Tarnavskiy Date: Thu, 18 Oct 2018 15:28:21 +0300 Subject: [PATCH 230/307] added two additional parameters to post-processing function --- ui/src/app/api/datasource.service.js | 18 +++++++++++++++--- ui/src/app/components/datakey-config.tpl.html | 11 +++++++++-- ui/src/app/locale/locale.constant-en_US.json | 7 ++++++- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js index dd1a34f516..27921518b7 100644 --- a/ui/src/app/api/datasource.service.js +++ b/ui/src/app/api/datasource.service.js @@ -104,6 +104,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var listeners = []; var datasourceType = datasourceSubscription.datasourceType; var datasourceData = {}; + var dataSourceOrigData = {}; var dataKeys = {}; var subscribers = []; var history = datasourceSubscription.subscriptionTimewindow && @@ -140,7 +141,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } } else { if (dataKey.postFuncBody && !dataKey.postFunc) { - dataKey.postFunc = new Function("time", "value", "prevValue", dataKey.postFuncBody); + dataKey.postFunc = new Function("time", "value", "prevValue", "timePrev", "prevOrigValue", dataKey.postFuncBody); } } if (datasourceType === types.datasourceType.entity || datasourceSubscription.type === types.widgetType.timeseries.value) { @@ -165,6 +166,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic }; dataKeys[key] = dataKey; } + dataSourceOrigData = angular.copy(datasourceData); dataKey.key = key; } if (datasourceType === types.datasourceType.function) { @@ -678,27 +680,36 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var dataKey = dataKeyList[keyIndex]; var data = []; var prevSeries; + var prevOrigSeries; var datasourceKeyData; + var datasourceOrigKeyData; var update = false; if (realtime) { datasourceKeyData = []; + datasourceOrigKeyData = []; } else { datasourceKeyData = datasourceData[datasourceKey].data; + datasourceOrigKeyData = dataSourceOrigData[datasourceKey].data; } if (datasourceKeyData.length > 0) { prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; + prevOrigSeries = datasourceOrigKeyData[datasourceOrigKeyData.length -1]; } else { prevSeries = [0, 0]; + prevOrigSeries = [0, 0]; } + dataSourceOrigData[datasourceKey].data = []; if (datasourceSubscription.type === types.widgetType.timeseries.value) { var series, time, value; for (var i = 0; i < keyData.length; i++) { series = keyData[i]; time = series[0]; + dataSourceOrigData[datasourceKey].data.push(series); value = convertValue(series[1]); if (dataKey.postFunc) { - value = dataKey.postFunc(time, value, prevSeries[1]); + value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); } + prevOrigSeries = series; series = [time, value]; data.push(series); prevSeries = series; @@ -708,9 +719,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic if (keyData.length > 0) { series = keyData[0]; time = series[0]; + dataSourceOrigData[datasourceKey].data.push(series); value = convertValue(series[1]); if (dataKey.postFunc) { - value = dataKey.postFunc(time, value, prevSeries[1]); + value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); } series = [time, value]; data.push(series); diff --git a/ui/src/app/components/datakey-config.tpl.html b/ui/src/app/components/datakey-config.tpl.html index f9126e6f82..ff94f1a955 100644 --- a/ui/src/app/components/datakey-config.tpl.html +++ b/ui/src/app/components/datakey-config.tpl.html @@ -75,9 +75,16 @@ + \ 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 1fde4b6d47..9f326c7612 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -555,7 +555,12 @@ "alarm-fields-required": "Alarm fields are required.", "function-types": "Function types", "function-types-required": "Function types are required.", - "maximum-function-types": "Maximum { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }" + "maximum-function-types": "Maximum { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }", + "time-description": "timestamp of the current value;", + "value-description": "the current value;", + "prev-value-description": "result of the previous function call;", + "time-prev-description": "timestamp of the previous value;", + "prev-orig-value-description": "original previous value;" }, "datasource": { "type": "Datasource type", From 0ae28408c9feacb0e79862d10c73e07dd614ffc2 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 18 Oct 2018 16:21:50 +0300 Subject: [PATCH 231/307] Docker build for transports. --- .../service/RemoteTransportService.java | 21 ++- msa/docker/.env | 2 + msa/docker/docker-compose.yml | 57 +++++++- msa/docker/haproxy/config/haproxy.cfg | 8 + msa/docker/tb-coap-transport.env | 6 + msa/docker/tb-http-transport.env | 6 + msa/transport/coap/docker/Dockerfile | 31 ++++ msa/transport/coap/docker/logback.xml | 50 +++++++ .../coap/docker/start-tb-coap-transport.sh | 29 ++++ .../coap/docker/tb-coap-transport.conf | 23 +++ msa/transport/coap/pom.xml | 137 ++++++++++++++++++ msa/transport/http/docker/Dockerfile | 31 ++++ msa/transport/http/docker/logback.xml | 50 +++++++ .../http/docker/start-tb-http-transport.sh | 29 ++++ .../http/docker/tb-http-transport.conf | 23 +++ msa/transport/http/pom.xml | 137 ++++++++++++++++++ msa/transport/pom.xml | 2 + .../ThingsboardHttpTransportApplication.java | 7 +- .../ThingsboardMqttTransportApplication.java | 4 +- 19 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 msa/docker/tb-coap-transport.env create mode 100644 msa/docker/tb-http-transport.env create mode 100644 msa/transport/coap/docker/Dockerfile create mode 100644 msa/transport/coap/docker/logback.xml create mode 100755 msa/transport/coap/docker/start-tb-coap-transport.sh create mode 100644 msa/transport/coap/docker/tb-coap-transport.conf create mode 100644 msa/transport/coap/pom.xml create mode 100644 msa/transport/http/docker/Dockerfile create mode 100644 msa/transport/http/docker/logback.xml create mode 100755 msa/transport/http/docker/start-tb-http-transport.sh create mode 100644 msa/transport/http/docker/tb-http-transport.conf create mode 100644 msa/transport/http/pom.xml rename transport/http/src/main/java/org/thingsboard/server/{mqtt => http}/ThingsboardHttpTransportApplication.java (88%) diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index 582fe49b06..aa3b42b58c 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -16,6 +16,8 @@ package org.thingsboard.server.common.transport.service; import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.producer.Callback; import org.apache.kafka.clients.producer.RecordMetadata; @@ -42,12 +44,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; -import org.thingsboard.server.kafka.AsyncCallbackTemplate; -import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; -import org.thingsboard.server.kafka.TBKafkaProducerTemplate; -import org.thingsboard.server.kafka.TbKafkaRequestTemplate; -import org.thingsboard.server.kafka.TbKafkaSettings; -import org.thingsboard.server.kafka.TbNodeIdProvider; +import org.thingsboard.server.kafka.*; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; @@ -136,9 +133,19 @@ public class RemoteTransportService extends AbstractTransportService { ruleEngineProducer = ruleEngineProducerBuilder.build(); ruleEngineProducer.init(); + String notificationsTopicName = notificationsTopic + "." + nodeIdProvider.getNodeId(); + + try { + TBKafkaAdmin admin = new TBKafkaAdmin(kafkaSettings); + CreateTopicsResult result = admin.createTopic(new NewTopic(notificationsTopicName, 1, (short) 1)); + result.all().get(); + } catch (Exception e) { + log.trace("Failed to create topic: {}", e.getMessage(), e); + } + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder mainConsumerBuilder = TBKafkaConsumerTemplate.builder(); mainConsumerBuilder.settings(kafkaSettings); - mainConsumerBuilder.topic(notificationsTopic + "." + nodeIdProvider.getNodeId()); + mainConsumerBuilder.topic(notificationsTopicName); mainConsumerBuilder.clientId("transport-" + nodeIdProvider.getNodeId()); mainConsumerBuilder.groupId("transport"); mainConsumerBuilder.autoCommit(true); diff --git a/msa/docker/.env b/msa/docker/.env index 72eaef8695..3d8faeb355 100644 --- a/msa/docker/.env +++ b/msa/docker/.env @@ -5,6 +5,8 @@ JS_EXECUTOR_DOCKER_NAME=tb-js-executor TB_NODE_DOCKER_NAME=tb-node WEB_UI_DOCKER_NAME=tb-web-ui MQTT_TRANSPORT_DOCKER_NAME=tb-mqtt-transport +HTTP_TRANSPORT_DOCKER_NAME=tb-http-transport +COAP_TRANSPORT_DOCKER_NAME=tb-coap-transport TB_VERSION=2.2.0-SNAPSHOT diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 5b88a79c72..0735443fc2 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -56,7 +56,6 @@ services: image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" ports: - "8080" - - "5683:5683/udp" logging: driver: "json-file" options: @@ -96,6 +95,50 @@ services: - tb-mqtt-transport.env depends_on: - kafka + tb-http-transport1: + restart: always + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-http-transport.env + depends_on: + - kafka + tb-http-transport2: + restart: always + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-http-transport.env + depends_on: + - kafka + tb-coap-transport1: + restart: always + image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "5683" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-coap-transport.env + depends_on: + - kafka + tb-coap-transport2: + restart: always + image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "5683" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-coap-transport.env + depends_on: + - kafka tb-web-ui1: restart: always image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" @@ -116,6 +159,16 @@ services: TB_PORT: 8080 env_file: - tb-web-ui.env + coap-lb: + restart: always + container_name: coap-loadbalancer + image: instantlinux/udp-nginx-proxy + ports: + - "5683:5683/udp" + environment: + BACKENDS: tb-coap-transport1 tb-coap-transport2 + PORT_BACKEND: 5683 + PORT_LISTEN: 5683 web: restart: always container_name: haproxy-certbot @@ -140,3 +193,5 @@ services: - tb-web-ui2 - tb-mqtt-transport1 - tb-mqtt-transport2 + - tb-http-transport1 + - tb-http-transport2 diff --git a/msa/docker/haproxy/config/haproxy.cfg b/msa/docker/haproxy/config/haproxy.cfg index d334c9272b..b8c3ced4cf 100644 --- a/msa/docker/haproxy/config/haproxy.cfg +++ b/msa/docker/haproxy/config/haproxy.cfg @@ -39,6 +39,7 @@ frontend http-in acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ redirect scheme https if !letsencrypt_http_acl !transport_http_acl use_backend letsencrypt_http if letsencrypt_http_acl + use_backend tb-http-backend if transport_http_acl default_backend tb-web-backend @@ -72,3 +73,10 @@ backend tb-mqtt-backend option log-health-checks server tbMqtt1 tb-mqtt-transport1:1883 check server tbMqtt2 tb-mqtt-transport2:1883 check + +backend tb-http-backend + balance leastconn + option tcp-check + option log-health-checks + server tbHttp1 tb-http-transport1:8081 check + server tbHttp2 tb-http-transport2:8081 check diff --git a/msa/docker/tb-coap-transport.env b/msa/docker/tb-coap-transport.env new file mode 100644 index 0000000000..b3331fe849 --- /dev/null +++ b/msa/docker/tb-coap-transport.env @@ -0,0 +1,6 @@ + +COAP_BIND_ADDRESS=0.0.0.0 +COAP_BIND_PORT=5683 +COAP_TIMEOUT=10000 + +TB_KAFKA_SERVERS=localhost:9092 \ No newline at end of file diff --git a/msa/docker/tb-http-transport.env b/msa/docker/tb-http-transport.env new file mode 100644 index 0000000000..33c479104e --- /dev/null +++ b/msa/docker/tb-http-transport.env @@ -0,0 +1,6 @@ + +HTTP_BIND_ADDRESS=0.0.0.0 +HTTP_BIND_PORT=8081 +HTTP_REQUEST_TIMEOUT=60000 + +TB_KAFKA_SERVERS=localhost:9092 \ No newline at end of file diff --git a/msa/transport/coap/docker/Dockerfile b/msa/transport/coap/docker/Dockerfile new file mode 100644 index 0000000000..dcaef3a358 --- /dev/null +++ b/msa/transport/coap/docker/Dockerfile @@ -0,0 +1,31 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jre + +COPY logback.xml ${pkg.name}.conf start-tb-coap-transport.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-coap-transport.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +CMD ["start-tb-coap-transport.sh"] diff --git a/msa/transport/coap/docker/logback.xml b/msa/transport/coap/docker/logback.xml new file mode 100644 index 0000000000..e9d86928bd --- /dev/null +++ b/msa/transport/coap/docker/logback.xml @@ -0,0 +1,50 @@ + + + + + + + /var/log/${pkg.name}/${pkg.name}.log + + /var/log/${pkg.name}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/msa/transport/coap/docker/start-tb-coap-transport.sh b/msa/transport/coap/docker/start-tb-coap-transport.sh new file mode 100755 index 0000000000..43d4602230 --- /dev/null +++ b/msa/transport/coap/docker/start-tb-coap-transport.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.coap.ThingsboardCoapTransportApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher diff --git a/msa/transport/coap/docker/tb-coap-transport.conf b/msa/transport/coap/docker/tb-coap-transport.conf new file mode 100644 index 0000000000..6d4c54aad1 --- /dev/null +++ b/msa/transport/coap/docker/tb-coap-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml new file mode 100644 index 0000000000..07c4e63342 --- /dev/null +++ b/msa/transport/coap/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + org.thingsboard.msa + 2.2.0-SNAPSHOT + transport + + org.thingsboard.msa.transport + coap + pom + + ThingsBoard COAP Transport Microservice + https://thingsboard.io + ThingsBoard COAP Transport Microservice + + + UTF-8 + ${basedir}/../../.. + tb-coap-transport + tb-coap-transport + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard.transport + coap + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-coap-transport-deb + package + + copy + + + + + org.thingsboard.transport + coap + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + true + false + ${project.build.directory} + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/http/docker/Dockerfile b/msa/transport/http/docker/Dockerfile new file mode 100644 index 0000000000..212047fa42 --- /dev/null +++ b/msa/transport/http/docker/Dockerfile @@ -0,0 +1,31 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jre + +COPY logback.xml ${pkg.name}.conf start-tb-http-transport.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-http-transport.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +CMD ["start-tb-http-transport.sh"] diff --git a/msa/transport/http/docker/logback.xml b/msa/transport/http/docker/logback.xml new file mode 100644 index 0000000000..e9d86928bd --- /dev/null +++ b/msa/transport/http/docker/logback.xml @@ -0,0 +1,50 @@ + + + + + + + /var/log/${pkg.name}/${pkg.name}.log + + /var/log/${pkg.name}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/msa/transport/http/docker/start-tb-http-transport.sh b/msa/transport/http/docker/start-tb-http-transport.sh new file mode 100755 index 0000000000..667988fa42 --- /dev/null +++ b/msa/transport/http/docker/start-tb-http-transport.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.http.ThingsboardHttpTransportApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher diff --git a/msa/transport/http/docker/tb-http-transport.conf b/msa/transport/http/docker/tb-http-transport.conf new file mode 100644 index 0000000000..6d4c54aad1 --- /dev/null +++ b/msa/transport/http/docker/tb-http-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml new file mode 100644 index 0000000000..22eb299dd5 --- /dev/null +++ b/msa/transport/http/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + org.thingsboard.msa + 2.2.0-SNAPSHOT + transport + + org.thingsboard.msa.transport + http + pom + + ThingsBoard HTTP Transport Microservice + https://thingsboard.io + ThingsBoard HTTP Transport Microservice + + + UTF-8 + ${basedir}/../../.. + tb-http-transport + tb-http-transport + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard.transport + http + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-http-transport-deb + package + + copy + + + + + org.thingsboard.transport + http + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + true + false + ${project.build.directory} + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index 7fc7b34c0d..9f091e3d20 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -36,6 +36,8 @@ mqtt + http + coap diff --git a/transport/http/src/main/java/org/thingsboard/server/mqtt/ThingsboardHttpTransportApplication.java b/transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java similarity index 88% rename from transport/http/src/main/java/org/thingsboard/server/mqtt/ThingsboardHttpTransportApplication.java rename to transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java index 29fae1bc6d..517794c346 100644 --- a/transport/http/src/main/java/org/thingsboard/server/mqtt/ThingsboardHttpTransportApplication.java +++ b/transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java @@ -1,4 +1,4 @@ -package org.thingsboard.server.mqtt; /** +/** * Copyright © 2016-2018 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,13 +14,12 @@ package org.thingsboard.server.mqtt; /** * limitations under the License. */ +package org.thingsboard.server.http; + import org.springframework.boot.SpringApplication; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.ComponentScan; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.scheduling.annotation.EnableScheduling; import java.util.Arrays; diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java b/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java index 4740342692..2bae2ff855 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java @@ -1,4 +1,4 @@ -package org.thingsboard.server.mqtt; /** +/** * Copyright © 2016-2018 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,6 +14,8 @@ package org.thingsboard.server.mqtt; /** * limitations under the License. */ +package org.thingsboard.server.mqtt; + import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.ComponentScan; From 7a764778e013519edf8a4e90d23a4b1a4def4e23 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 18 Oct 2018 16:23:35 +0300 Subject: [PATCH 232/307] Fixed consumer id uniquness --- .../service/cluster/discovery/DiscoveryService.java | 2 -- .../service/cluster/discovery/DummyDiscoveryService.java | 8 -------- .../service/cluster/discovery/ZkDiscoveryService.java | 8 -------- .../server/service/script/RemoteJsInvokeService.java | 7 ++++--- .../transport/RemoteRuleEngineTransportService.java | 5 +++-- 5 files changed, 7 insertions(+), 23 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java index 04c4cedabd..f9caafa412 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java @@ -26,8 +26,6 @@ public interface DiscoveryService { void unpublishCurrentServer(); - String getNodeId(); - ServerInstance getCurrentServer(); List getOtherServers(); diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java index 859a7af4fc..358c8477d8 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java @@ -38,17 +38,9 @@ public class DummyDiscoveryService implements DiscoveryService { @Autowired private ServerInstanceService serverInstance; - private String nodeId; - @PostConstruct public void init() { log.info("Initializing..."); - this.nodeId = RandomStringUtils.randomAlphabetic(10); - } - - @Override - public String getNodeId() { - return nodeId; } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java index b4829f27aa..bb92642970 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java @@ -94,13 +94,10 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi private CuratorFramework client; private PathChildrenCache cache; private String nodePath; - //TODO: make persistent? - private String nodeId; @PostConstruct public void init() { log.info("Initializing..."); - this.nodeId = RandomStringUtils.randomAlphabetic(10); Assert.hasLength(zkUrl, MiscUtils.missingProperty("zk.url")); Assert.notNull(zkRetryInterval, MiscUtils.missingProperty("zk.retry_interval_ms")); Assert.notNull(zkConnectionTimeout, MiscUtils.missingProperty("zk.connection_timeout_ms")); @@ -183,11 +180,6 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } } - @Override - public String getNodeId() { - return nodeId; - } - @Override public ServerInstance getCurrentServer() { return serverInstance.getSelf(); diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index b334ff15ae..db31bda942 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -28,6 +28,7 @@ import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; import org.thingsboard.server.kafka.TBKafkaProducerTemplate; import org.thingsboard.server.kafka.TbKafkaRequestTemplate; import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.kafka.TbNodeIdProvider; import org.thingsboard.server.service.cluster.discovery.DiscoveryService; import javax.annotation.PostConstruct; @@ -42,7 +43,7 @@ import java.util.concurrent.ConcurrentHashMap; public class RemoteJsInvokeService extends AbstractJsInvokeService { @Autowired - private DiscoveryService discoveryService; + private TbNodeIdProvider nodeIdProvider; @Autowired private TbKafkaSettings kafkaSettings; @@ -97,8 +98,8 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); responseBuilder.settings(kafkaSettings); - responseBuilder.topic(responseTopicPrefix + "." + discoveryService.getNodeId()); - responseBuilder.clientId(discoveryService.getNodeId()); + responseBuilder.topic(responseTopicPrefix + "." + nodeIdProvider.getNodeId()); + responseBuilder.clientId("js-" + nodeIdProvider.getNodeId()); responseBuilder.groupId("rule-engine-node"); responseBuilder.autoCommit(true); responseBuilder.autoCommitIntervalMs(autoCommitInterval); diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index 4f8860daf7..38db1a415b 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -35,6 +35,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceAct import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; import org.thingsboard.server.kafka.TBKafkaProducerTemplate; import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.kafka.TbNodeIdProvider; import org.thingsboard.server.service.cluster.discovery.DiscoveryService; import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; import org.thingsboard.server.service.cluster.rpc.ClusterRpcService; @@ -71,7 +72,7 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ private TbKafkaSettings kafkaSettings; @Autowired - private DiscoveryService discoveryService; + private TbNodeIdProvider nodeIdProvider; @Autowired private ActorSystemContext actorContext; @@ -104,7 +105,7 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder ruleEngineConsumerBuilder = TBKafkaConsumerTemplate.builder(); ruleEngineConsumerBuilder.settings(kafkaSettings); ruleEngineConsumerBuilder.topic(ruleEngineTopic); - ruleEngineConsumerBuilder.clientId(discoveryService.getNodeId()); + ruleEngineConsumerBuilder.clientId("transport-" + nodeIdProvider.getNodeId()); ruleEngineConsumerBuilder.groupId("tb-node"); ruleEngineConsumerBuilder.autoCommit(true); ruleEngineConsumerBuilder.autoCommitIntervalMs(autoCommitInterval); From b2aed2f30736b36b6a5bfc61951c0f851871b8ab Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 18 Oct 2018 16:31:48 +0300 Subject: [PATCH 233/307] Fix compilation error --- .../transport/RemoteTransportApiService.java | 33 ++----------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java index ebd35a414e..6d422f4dd6 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -15,48 +15,21 @@ */ package org.thingsboard.server.service.transport; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import org.springframework.stereotype.Service; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.security.DeviceCredentials; -import org.thingsboard.server.common.data.security.DeviceCredentialsType; -import org.thingsboard.server.dao.device.DeviceCredentialsService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; -import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; -import org.thingsboard.server.kafka.TBKafkaProducerTemplate; -import org.thingsboard.server.kafka.TbKafkaResponseTemplate; -import org.thingsboard.server.kafka.TbKafkaSettings; -import org.thingsboard.server.service.cluster.discovery.DiscoveryService; -import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.kafka.*; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; -import java.util.UUID; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; /** * Created by ashvayka on 05.10.18. @@ -83,7 +56,7 @@ public class RemoteTransportApiService { private TbKafkaSettings kafkaSettings; @Autowired - private DiscoveryService discoveryService; + private TbNodeIdProvider nodeIdProvider; @Autowired private TransportApiService transportApiService; @@ -104,7 +77,7 @@ public class RemoteTransportApiService { TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder requestBuilder = TBKafkaConsumerTemplate.builder(); requestBuilder.settings(kafkaSettings); requestBuilder.topic(transportApiRequestsTopic); - requestBuilder.clientId(discoveryService.getNodeId()); + requestBuilder.clientId(nodeIdProvider.getNodeId()); requestBuilder.groupId("tb-node"); requestBuilder.autoCommit(true); requestBuilder.autoCommitIntervalMs(autoCommitInterval); From a1d7715b211ce58520d6cbc4b7d902c4517cc282 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 18 Oct 2018 18:28:19 +0300 Subject: [PATCH 234/307] Update Docker loadbalancer config. Use signle instance COAP transport. --- msa/docker/docker-compose.yml | 25 ++---------------- msa/docker/haproxy/config/haproxy.cfg | 38 ++++++++++++++++----------- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml index 0735443fc2..e3b56ae425 100644 --- a/msa/docker/docker-compose.yml +++ b/msa/docker/docker-compose.yml @@ -117,22 +117,11 @@ services: - tb-http-transport.env depends_on: - kafka - tb-coap-transport1: + tb-coap-transport: restart: always image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - - "5683" - environment: - TB_KAFKA_SERVERS: kafka:9092 - env_file: - - tb-coap-transport.env - depends_on: - - kafka - tb-coap-transport2: - restart: always - image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" - ports: - - "5683" + - "5683:5683/udp" environment: TB_KAFKA_SERVERS: kafka:9092 env_file: @@ -159,16 +148,6 @@ services: TB_PORT: 8080 env_file: - tb-web-ui.env - coap-lb: - restart: always - container_name: coap-loadbalancer - image: instantlinux/udp-nginx-proxy - ports: - - "5683:5683/udp" - environment: - BACKENDS: tb-coap-transport1 tb-coap-transport2 - PORT_BACKEND: 5683 - PORT_LISTEN: 5683 web: restart: always container_name: haproxy-certbot diff --git a/msa/docker/haproxy/config/haproxy.cfg b/msa/docker/haproxy/config/haproxy.cfg index b8c3ced4cf..cb70552a73 100644 --- a/msa/docker/haproxy/config/haproxy.cfg +++ b/msa/docker/haproxy/config/haproxy.cfg @@ -1,6 +1,9 @@ #HA Proxy Config global - maxconn 4096 + ulimit-n 500000 + maxconn 99999 + maxpipes 99999 + tune.maxaccept 500 log 127.0.0.1 local0 log 127.0.0.1 local1 notice @@ -13,8 +16,6 @@ global defaults - option forwardfor - log global mode http @@ -30,9 +31,22 @@ listen stats stats uri /stats stats auth admin:admin@123 +listen mqtt-in + bind *:${MQTT_PORT} + mode tcp + option clitcpka # For TCP keep-alive + timeout client 3h + timeout server 3h + option tcplog + balance leastconn + server tbMqtt1 tb-mqtt-transport1:1883 check + server tbMqtt2 tb-mqtt-transport2:1883 check + frontend http-in bind *:${HTTP_PORT} + option forwardfor + reqadd X-Forwarded-Proto:\ http acl transport_http_acl path_beg /api/v1/ @@ -46,15 +60,14 @@ frontend http-in frontend https_in bind *:${HTTPS_PORT} ssl crt /usr/local/etc/haproxy/default.pem crt /usr/local/etc/haproxy/certs.d ciphers ECDHE-RSA-AES256-SHA:RC4-SHA:RC4:HIGH:!MD5:!aNULL:!EDH:!AESGCM - reqadd X-Forwarded-Proto:\ https + option forwardfor - default_backend tb-web-backend + reqadd X-Forwarded-Proto:\ https -frontend mqtt-in - mode tcp - bind *:${MQTT_PORT} + acl transport_http_acl path_beg /api/v1/ + use_backend tb-http-backend if transport_http_acl - default_backend tb-mqtt-backend + default_backend tb-web-backend backend letsencrypt_http server letsencrypt_http_srv 127.0.0.1:8080 @@ -67,13 +80,6 @@ backend tb-web-backend server tbWeb2 tb-web-ui2:8080 check http-request set-header X-Forwarded-Port %[dst_port] -backend tb-mqtt-backend - balance leastconn - option tcp-check - option log-health-checks - server tbMqtt1 tb-mqtt-transport1:1883 check - server tbMqtt2 tb-mqtt-transport2:1883 check - backend tb-http-backend balance leastconn option tcp-check From f135b268affbd47054655937b700417ca5cbf48b Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 18 Oct 2018 18:54:22 +0300 Subject: [PATCH 235/307] Update jackson-databind version. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 684c3d02a4..0ac38cdf8b 100755 --- a/pom.xml +++ b/pom.xml @@ -50,7 +50,7 @@ 1.5.0 2.5 1.4 - 2.8.8.1 + 2.8.11.1 2.2.6 2.11 2.4.2 From 2638d1eaf7122927e04ec0bfd23e75a7c2563aa6 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 19 Oct 2018 13:39:46 +0300 Subject: [PATCH 236/307] Remove Msg queue. Fix tests. --- .../actors/shared/ComponentMsgProcessor.java | 1 - .../service/queue/DefaultMsgQueueService.java | 111 ------------- .../server/service/queue/MsgQueueService.java | 32 ---- .../src/main/resources/thingsboard.yml | 13 -- .../AbstractRuleEngineControllerTest.java | 5 - ...AbstractRuleEngineFlowIntegrationTest.java | 9 -- ...actRuleEngineLifecycleIntegrationTest.java | 69 -------- .../server/system/BaseHttpDeviceApiTest.java | 2 +- .../server/dao/queue/MsgQueue.java | 34 ---- .../server/dao/queue/QueueBenchmark.java | 152 ------------------ .../dao/queue/memory/InMemoryMsgKey.java | 29 ---- .../dao/queue/memory/InMemoryMsgQueue.java | 123 -------------- dao/src/test/resources/nosql-test.properties | 5 - 13 files changed, 1 insertion(+), 584 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java delete mode 100644 application/src/main/java/org/thingsboard/server/service/queue/MsgQueueService.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/queue/MsgQueue.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/queue/QueueBenchmark.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgKey.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgQueue.java diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java index 2c40be6fba..c9dc307451 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -26,7 +26,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentLifecycleState; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.service.queue.MsgQueueService; import javax.annotation.Nullable; import java.util.function.Consumer; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java deleted file mode 100644 index 416a4d6b0f..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.queue; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.transport.quota.tenant.TenantQuotaService; -import org.thingsboard.server.dao.queue.MsgQueue; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -@Service -@Slf4j -public class DefaultMsgQueueService implements MsgQueueService { - - @Value("${actors.rule.queue.max_size}") - private long queueMaxSize; - - @Value("${actors.rule.queue.cleanup_period}") - private long queueCleanUpPeriod; - - @Autowired - private MsgQueue msgQueue; - - @Autowired(required = false) - private TenantQuotaService quotaService; - - private ScheduledExecutorService cleanupExecutor; - - private Map pendingCountPerTenant = new ConcurrentHashMap<>(); - - @PostConstruct - public void init() { - if (queueCleanUpPeriod > 0) { - cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); - cleanupExecutor.scheduleAtFixedRate(() -> cleanup(), - queueCleanUpPeriod, queueCleanUpPeriod, TimeUnit.SECONDS); - } - } - - @PreDestroy - public void stop() { - if (cleanupExecutor != null) { - cleanupExecutor.shutdownNow(); - } - } - - @Override - public ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - if(quotaService != null && quotaService.isQuotaExceeded(tenantId.getId().toString())) { - log.warn("Tenant TbMsg Quota exceeded for [{}:{}] . Reject", tenantId.getId()); - return Futures.immediateFailedFuture(new RuntimeException("Tenant TbMsg Quota exceeded")); - } - - AtomicLong pendingMsgCount = pendingCountPerTenant.computeIfAbsent(tenantId, key -> new AtomicLong()); - if (pendingMsgCount.incrementAndGet() < queueMaxSize) { - return msgQueue.put(tenantId, msg, nodeId, clusterPartition); - } else { - pendingMsgCount.decrementAndGet(); - return Futures.immediateFailedFuture(new RuntimeException("Message queue is full!")); - } - } - - @Override - public ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - ListenableFuture result = msgQueue.ack(tenantId, msg, nodeId, clusterPartition); - AtomicLong pendingMsgCount = pendingCountPerTenant.computeIfAbsent(tenantId, key -> new AtomicLong()); - pendingMsgCount.decrementAndGet(); - return result; - } - - @Override - public Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition) { - return msgQueue.findUnprocessed(tenantId, nodeId, clusterPartition); - } - - private void cleanup() { - pendingCountPerTenant.forEach((tenantId, pendingMsgCount) -> { - pendingMsgCount.set(0); - msgQueue.cleanUp(tenantId); - }); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/MsgQueueService.java b/application/src/main/java/org/thingsboard/server/service/queue/MsgQueueService.java deleted file mode 100644 index 2cf001ba73..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/queue/MsgQueueService.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.queue; - -import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; - -import java.util.UUID; - -public interface MsgQueueService { - - ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition); - -} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 90d15003bc..984fa280d6 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -182,12 +182,6 @@ cassandra: permit_max_wait_time: "${PERMIT_MAX_WAIT_TIME:120000}" rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:30000}" - queue: - msg.ttl: 604800 # 7 days - ack.ttl: 604800 # 7 days - partitions.ttl: 604800 # 7 days - partitioning: "HOURS" - # SQL configuration parameters sql: # Specify executor service type used to perform timeseries insert tasks: SINGLE FIXED CACHED @@ -221,13 +215,6 @@ actors: node: # Errors for particular actor are persisted once per specified amount of milliseconds error_persist_frequency: "${ACTORS_RULE_NODE_ERROR_FREQUENCY:3000}" - queue: - # Message queue type - type: "${ACTORS_RULE_QUEUE_TYPE:memory}" - # Message queue maximum size (per tenant) - max_size: "${ACTORS_RULE_QUEUE_MAX_SIZE:100}" - # Message queue cleanup period in seconds - cleanup_period: "${ACTORS_RULE_QUEUE_CLEANUP_PERIOD:3600}" statistics: # Enable/disable actor statistics enabled: "${ACTORS_STATISTICS_ENABLED:true}" diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java index 1b042a821c..cb31a4b50c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java @@ -27,9 +27,7 @@ import org.thingsboard.server.common.data.page.TimePageData; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; -import org.thingsboard.server.dao.queue.MsgQueue; import org.thingsboard.server.dao.rule.RuleChainService; -import org.thingsboard.server.service.queue.MsgQueueService; import java.io.IOException; import java.util.function.Predicate; @@ -42,9 +40,6 @@ public class AbstractRuleEngineControllerTest extends AbstractControllerTest { @Autowired protected RuleChainService ruleChainService; - @Autowired - protected MsgQueueService msgQueueService; - protected RuleChain saveRuleChain(RuleChain ruleChain) throws Exception { return doPost("/api/ruleChain", ruleChain, RuleChain.class); } diff --git a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java index c86d49609e..f050f3b505 100644 --- a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java @@ -191,9 +191,6 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule Assert.assertEquals("serverAttributeValue1", getMetadata(outEvent).get("ss_serverAttributeKey1").asText()); Assert.assertEquals("serverAttributeValue2", getMetadata(outEvent).get("ss_serverAttributeKey2").asText()); - - List unAckMsgList = Lists.newArrayList(msgQueueService.findUnprocessed(savedTenant.getId(), ruleChain.getId().getId(), 0L)); - Assert.assertEquals(0, unAckMsgList.size()); } @Test @@ -311,12 +308,6 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule Assert.assertEquals("serverAttributeValue1", getMetadata(outEvent).get("ss_serverAttributeKey1").asText()); Assert.assertEquals("serverAttributeValue2", getMetadata(outEvent).get("ss_serverAttributeKey2").asText()); - - List unAckMsgList = Lists.newArrayList(msgQueueService.findUnprocessed(savedTenant.getId(), rootRuleChain.getId().getId(), 0L)); - Assert.assertEquals(0, unAckMsgList.size()); - - unAckMsgList = Lists.newArrayList(msgQueueService.findUnprocessed(savedTenant.getId(), secondaryRuleChain.getId().getId(), 0L)); - Assert.assertEquals(0, unAckMsgList.size()); } } diff --git a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java index 7ac0789383..24db4570bd 100644 --- a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java @@ -162,73 +162,4 @@ public abstract class AbstractRuleEngineLifecycleIntegrationTest extends Abstrac Assert.assertEquals("serverAttributeValue", getMetadata(outEvent).get("ss_serverAttributeKey").asText()); } - @Test - public void testRuleChainWithOneRuleAndMsgFromQueue() throws Exception { - // Creating Rule Chain - RuleChain ruleChain = new RuleChain(); - ruleChain.setName("Simple Rule Chain"); - ruleChain.setTenantId(savedTenant.getId()); - ruleChain.setRoot(true); - ruleChain.setDebugMode(true); - ruleChain = saveRuleChain(ruleChain); - Assert.assertNull(ruleChain.getFirstRuleNodeId()); - - // Saving the device - Device device = new Device(); - device.setName("My device"); - device.setType("default"); - device = doPost("/api/device", device, Device.class); - - attributesService.save(device.getId(), DataConstants.SERVER_SCOPE, - Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry("serverAttributeKey", "serverAttributeValue"), System.currentTimeMillis()))); - - // Pushing Message to the system - TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), - "CUSTOM", - device.getId(), - new TbMsgMetaData(), - "{}", - ruleChain.getId(), null, 0L); - msgQueueService.put(device.getTenantId(), tbMsg, ruleChain.getId().getId(), 0L); - - Thread.sleep(1000); - - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChain.getId()); - - RuleNode ruleNode = new RuleNode(); - ruleNode.setName("Simple Rule Node"); - ruleNode.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); - ruleNode.setDebugMode(true); - TbGetAttributesNodeConfiguration configuration = new TbGetAttributesNodeConfiguration(); - configuration.setServerAttributeNames(Collections.singletonList("serverAttributeKey")); - ruleNode.setConfiguration(mapper.valueToTree(configuration)); - - metaData.setNodes(Collections.singletonList(ruleNode)); - metaData.setFirstNodeIndex(0); - - metaData = saveRuleChainMetaData(metaData); - Assert.assertNotNull(metaData); - - ruleChain = getRuleChain(ruleChain.getId()); - Assert.assertNotNull(ruleChain.getFirstRuleNodeId()); - - Thread.sleep(3000); - - TimePageData eventsPage = getDebugEvents(savedTenant.getId(), ruleChain.getFirstRuleNodeId(), 1000); - List events = eventsPage.getData().stream().filter(filterByCustomEvent()).collect(Collectors.toList()); - - Assert.assertEquals(2, events.size()); - - Event inEvent = events.stream().filter(e -> e.getBody().get("type").asText().equals(DataConstants.IN)).findFirst().get(); - Assert.assertEquals(ruleChain.getFirstRuleNodeId(), inEvent.getEntityId()); - Assert.assertEquals(device.getId().getId().toString(), inEvent.getBody().get("entityId").asText()); - - Event outEvent = events.stream().filter(e -> e.getBody().get("type").asText().equals(DataConstants.OUT)).findFirst().get(); - Assert.assertEquals(ruleChain.getFirstRuleNodeId(), outEvent.getEntityId()); - Assert.assertEquals(device.getId().getId().toString(), outEvent.getBody().get("entityId").asText()); - - Assert.assertEquals("serverAttributeValue", getMetadata(outEvent).get("ss_serverAttributeKey").asText()); - } - } diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index 5e6896b2b7..1ebccadfea 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -57,7 +57,7 @@ public abstract class BaseHttpDeviceApiTest extends AbstractControllerTest { @Test public void testGetAttributes() throws Exception { doGetAsync("/api/v1/" + "WRONG_TOKEN" + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isUnauthorized()); - doGetAsync("/api/v1/" + deviceCredentials.getCredentialsId() + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isNotFound()); + doGetAsync("/api/v1/" + deviceCredentials.getCredentialsId() + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isOk()); Map attrMap = new HashMap<>(); attrMap.put("keyA", "valueA"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/MsgQueue.java b/dao/src/main/java/org/thingsboard/server/dao/queue/MsgQueue.java deleted file mode 100644 index 19021eb95c..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/MsgQueue.java +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.queue; - -import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; - -import java.util.UUID; - -public interface MsgQueue { - - ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition); - - ListenableFuture cleanUp(TenantId tenantId); - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/QueueBenchmark.java b/dao/src/main/java/org/thingsboard/server/dao/queue/QueueBenchmark.java deleted file mode 100644 index ca61a63a4c..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/QueueBenchmark.java +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.queue; - -import com.datastax.driver.core.Cluster; -import com.datastax.driver.core.HostDistance; -import com.datastax.driver.core.PoolingOptions; -import com.datastax.driver.core.Session; -import com.datastax.driver.core.utils.UUIDs; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.SpringApplication; -import org.springframework.context.annotation.Bean; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.RuleChainId; -import org.thingsboard.server.common.data.id.RuleNodeId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.TbMsgDataType; -import org.thingsboard.server.common.msg.TbMsgMetaData; - -import javax.annotation.Nullable; -import java.net.InetSocketAddress; -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -//@SpringBootApplication -//@EnableAutoConfiguration -//@ComponentScan({"org.thingsboard.rule.engine"}) -//@PropertySource("classpath:processing-pipeline.properties") -@Slf4j -public class QueueBenchmark implements CommandLineRunner { - - public static void main(String[] args) { - try { - SpringApplication.run(QueueBenchmark.class, args); - } catch (Throwable th) { - th.printStackTrace(); - System.exit(0); - } - } - - @Autowired - private MsgQueue msgQueue; - - @Override - public void run(String... strings) throws Exception { - System.out.println("It works + " + msgQueue); - - - long start = System.currentTimeMillis(); - int msgCount = 10000000; - AtomicLong count = new AtomicLong(0); - ExecutorService service = Executors.newFixedThreadPool(100); - - CountDownLatch latch = new CountDownLatch(msgCount); - for (int i = 0; i < msgCount; i++) { - service.submit(() -> { - boolean isFinished = false; - while (!isFinished) { - try { - TbMsg msg = randomMsg(); - UUID nodeId = UUIDs.timeBased(); - ListenableFuture put = msgQueue.put(new TenantId(EntityId.NULL_UUID), msg, nodeId, 100L); -// ListenableFuture put = msgQueue.ack(msg, nodeId, 100L); - Futures.addCallback(put, new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - latch.countDown(); - } - - @Override - public void onFailure(Throwable t) { -// t.printStackTrace(); - System.out.println("onFailure, because:" + t.getMessage()); - latch.countDown(); - } - }); - isFinished = true; - } catch (Throwable th) { -// th.printStackTrace(); - System.out.println("Repeat query, because:" + th.getMessage()); -// latch.countDown(); - } - } - }); - } - - long prev = 0L; - while (latch.getCount() != 0) { - TimeUnit.SECONDS.sleep(1); - long curr = latch.getCount(); - long rps = prev - curr; - prev = curr; - System.out.println("rps = " + rps); - } - - long end = System.currentTimeMillis(); - System.out.println("final rps = " + (msgCount / (end - start) * 1000)); - - System.out.println("Finished"); - - } - - private TbMsg randomMsg() { - TbMsgMetaData metaData = new TbMsgMetaData(); - metaData.putValue("key", "value"); - String dataStr = "someContent"; - return new TbMsg(UUIDs.timeBased(), "type", null, metaData, TbMsgDataType.JSON, dataStr, new RuleChainId(UUIDs.timeBased()), new RuleNodeId(UUIDs.timeBased()), 0L); - } - - @Bean - public Session session() { - Cluster thingsboard = Cluster.builder() - .addContactPointsWithPorts(new InetSocketAddress("127.0.0.1", 9042)) - .withClusterName("thingsboard") -// .withSocketOptions(socketOpts.getOpts()) - .withPoolingOptions(new PoolingOptions() - .setMaxRequestsPerConnection(HostDistance.LOCAL, 32768) - .setMaxRequestsPerConnection(HostDistance.REMOTE, 32768)).build(); - - Session session = thingsboard.connect("thingsboard"); - return session; - } - - @Bean - public int defaultTtl() { - return 6000; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgKey.java b/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgKey.java deleted file mode 100644 index bb44a2b192..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgKey.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.queue.memory; - -import lombok.Data; - -import java.util.UUID; - -/** - * Created by ashvayka on 30.04.18. - */ -@Data -public final class InMemoryMsgKey { - final UUID nodeId; - final long clusterPartition; -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgQueue.java b/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgQueue.java deleted file mode 100644 index 93057784f6..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgQueue.java +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.queue.memory; - -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.dao.queue.MsgQueue; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; - -/** - * Created by ashvayka on 27.04.18. - */ -@Component -@ConditionalOnProperty(prefix = "actors.rule.queue", value = "type", havingValue = "memory", matchIfMissing = true) -@Slf4j -public class InMemoryMsgQueue implements MsgQueue { - - private ListeningExecutorService queueExecutor; - private Map>> data = new HashMap<>(); - - @PostConstruct - public void init() { - // Should be always single threaded due to absence of locks. - queueExecutor = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); - } - - @PreDestroy - public void stop() { - if (queueExecutor != null) { - queueExecutor.shutdownNow(); - } - } - - @Override - public ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - return queueExecutor.submit(() -> { - data.computeIfAbsent(tenantId, key -> new HashMap<>()). - computeIfAbsent(new InMemoryMsgKey(nodeId, clusterPartition), key -> new HashMap<>()).put(msg.getId(), msg); - return null; - }); - } - - @Override - public ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - return queueExecutor.submit(() -> { - Map> tenantMap = data.get(tenantId); - if (tenantMap != null) { - InMemoryMsgKey key = new InMemoryMsgKey(nodeId, clusterPartition); - Map map = tenantMap.get(key); - if (map != null) { - map.remove(msg.getId()); - if (map.isEmpty()) { - tenantMap.remove(key); - } - } - if (tenantMap.isEmpty()) { - data.remove(tenantId); - } - } - return null; - }); - } - - @Override - public Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition) { - ListenableFuture> list = queueExecutor.submit(() -> { - Map> tenantMap = data.get(tenantId); - if (tenantMap != null) { - InMemoryMsgKey key = new InMemoryMsgKey(nodeId, clusterPartition); - Map map = tenantMap.get(key); - if (map != null) { - return new ArrayList<>(map.values()); - } else { - return Collections.emptyList(); - } - } else { - return Collections.emptyList(); - } - }); - try { - return list.get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - } - - @Override - public ListenableFuture cleanUp(TenantId tenantId) { - return queueExecutor.submit(() -> { - data.remove(tenantId); - return null; - }); - } -} diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index 06a92faa01..7c3ec51e8a 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -1,7 +1,2 @@ database.entities.type=cassandra database.ts.type=cassandra - -cassandra.queue.partitioning=HOURS -cassandra.queue.ack.ttl=3600 -cassandra.queue.msg.ttl=3600 -cassandra.queue.partitions.ttl=3600 \ No newline at end of file From a64d212335959d45fb4242cc4dac184dd941ea9a Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Fri, 19 Oct 2018 15:56:29 +0300 Subject: [PATCH 237/307] Fixed label --- ui/src/app/entity-view/entity-view-fieldset.tpl.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html index 140c199688..766f8c82a1 100644 --- a/ui/src/app/entity-view/entity-view-fieldset.tpl.html +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -121,7 +121,7 @@ md-items="item in dataKeysSearch(attributeDataKeySearchText, types.dataKeyType.attribute)" md-item-text="item.name" md-min-length="0" - placeholder="{{'entity-view.server-attributes-placeholder' | translate }}" + placeholder="{{'entity-view.shared-attributes-placeholder' | translate }}" md-menu-class="tb-attribute-datakey-autocomplete"> {{item}} From bc5f39956148fdf07ef865a22c111e8e4c8eb0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorukhan=20G=C3=BCl=C3=B6z?= Date: Fri, 19 Oct 2018 15:50:56 +0200 Subject: [PATCH 238/307] Turkish language characters were corrected. --- ui/src/app/locale/locale.constant-TR.json | 1910 ++++++++++----------- 1 file changed, 955 insertions(+), 955 deletions(-) diff --git a/ui/src/app/locale/locale.constant-TR.json b/ui/src/app/locale/locale.constant-TR.json index 04575fbc90..bb5a3222c0 100644 --- a/ui/src/app/locale/locale.constant-TR.json +++ b/ui/src/app/locale/locale.constant-TR.json @@ -1,40 +1,40 @@ { "access": { "unauthorized": "Yetkisiz", - "unauthorized-access": "Yetkisiz Eriþim", - "unauthorized-access-text": "Bu kaynaða eriþmek için giriþ yapmalýsýnýz!", - "access-forbidden": "Eriþim Yasaklandý", - "access-forbidden-text": "Bu yere eriþim haklarýna sahip deðilsiniz!
Bu konuma eriþmek istiyorsanýz yine de farklý kullanýcýlarla giriþ yapmayý deneyin.", + "unauthorized-access": "Yetkisiz Erişim", + "unauthorized-access-text": "Bu kaynağa erişmek için giriş yapmalısınız!", + "access-forbidden": "Erişim Yasaklandı", + "access-forbidden-text": "Bu yere erişim haklarına sahip değilsiniz!
Bu konuma erişmek istiyorsanız yine de farklı kullanıcılarla giriş yapmayı deneyin.", "refresh-token-expired": "Oturum süresi doldu", "refresh-token-failed": "Seans yenilenemiyor" }, "action": { "activate": "Aktive Et", - "suspend": "Askýya Alma", - "save": "Kayýt etmek", - "saveAs": "Farklý kaydet", - "cancel": "Ýptal etmek", + "suspend": "Askıya Alma", + "save": "Kayıt etmek", + "saveAs": "Farklı kaydet", + "cancel": "İptal etmek", "ok": "TAMAM", "delete": "Sil", "add": "Eklemek", "yes": "Evet", - "no": "Yok hayýr", - "update": "Güncelleþtirme", - "remove": "Kaldýr", + "no": "Yok hayır", + "update": "Güncelleştirme", + "remove": "Kaldır", "search": "Arama", - "clear-search": "Aramayý Temizle", + "clear-search": "Aramayı Temizle", "assign": "Atamak", - "unassign": "Atamayý kaldýrma", + "unassign": "Atamayı kaldırma", "share": "Pay", "make-private": "Özel yap", "apply": "Uygulamak", - "apply-changes": "Deðiþiklikleri uygula", + "apply-changes": "Değişiklikleri uygula", "edit-mode": "Düzenleme modu", "enter-edit-mode": "Düzenleme moduna girin", - "decline-changes": "Deðiþiklikleri reddet", + "decline-changes": "Değişiklikleri reddet", "close": "Kapat", "back": "Geri", - "run": "Koþmak", + "run": "Koşmak", "sign-in": "Oturum aç!", "edit": "Düzenle", "view": "Görünüm", @@ -43,53 +43,53 @@ "refresh": "Yenile", "undo": "Geri alma", "copy": "Kopyala", - "paste": "Yapýþtýrmak", - "copy-reference": "Referansý kopyala", - "paste-reference": "Referansý yapýþtýr", - "import": "Ýthalat", - "export": "Ýhracat", - "share-via": "{{Provider}} aracýlýðýyla paylaþ" + "paste": "Yapıştırmak", + "copy-reference": "Referansı kopyala", + "paste-reference": "Referansı yapıştır", + "import": "İthalat", + "export": "İhracat", + "share-via": "{{Provider}} aracılığıyla paylaş" }, "aggregation": { "aggregation": "Toplama", - "function": "Veri toplama iþlevi", - "limit": "Maksimum deðerler", - "group-interval": "Gruplama aralýðý", + "function": "Veri toplama işlevi", + "limit": "Maksimum değerler", + "group-interval": "Gruplama aralığı", "min": "Min", "max": "Max", "avg": "Ortalama", "sum": "Sum", - "count": "Sayý", + "count": "Sayı", "none": "Yok" }, "admin": { "general": "Genel", "general-settings": "Genel Ayarlar", "outgoing-mail": "Giden posta", - "outgoing-mail-settings": "Giden Posta Ayarlarý", - "system-settings": "Sistem ayarlarý", - "test-mail-sent": "Test postasý baþarýyla gönderildi!", + "outgoing-mail-settings": "Giden Posta Ayarları", + "system-settings": "Sistem ayarları", + "test-mail-sent": "Test postası başarıyla gönderildi!", "base-url": "Temel URL", "base-url-required": "Temel URL gerekli.", - "mail-from": "Mail þu kiþiden geldi", + "mail-from": "Mail şu kişiden geldi", "mail-from-required": "Mail From gereklidir.", "smtp-protocol": "SMTP protokolü", - "smtp-host": "SMTP ana bilgisayarý", - "smtp-host-required": "SMTP ana bilgisayarý gerekli.", + "smtp-host": "SMTP ana bilgisayarı", + "smtp-host-required": "SMTP ana bilgisayarı gerekli.", "smtp-port": "SMTP portu", - "smtp-port-required": "Bir smtp portu saðlamalýsýnýz.", + "smtp-port-required": "Bir smtp portu sağlamalısınız.", "smtp-port-invalid": "Bu geçerli bir smtp portuna benzemiyor.", - "timeout-msec": "Zaman aþýmý (msn)", - "timeout-required": "Zaman aþýmý gerekli.", - "timeout-invalid": "Bu geçerli bir zaman aþýmý gibi görünmüyor.", - "enable-tls": "TLS'yi etkinleþtir", - "send-test-mail": "Test postasý gönder" + "timeout-msec": "Zaman aşımı (msn)", + "timeout-required": "Zaman aşımı gerekli.", + "timeout-invalid": "Bu geçerli bir zaman aşımı gibi görünmüyor.", + "enable-tls": "TLS'yi etkinleştir", + "send-test-mail": "Test postası gönder" }, "alarm": { "alarm": "Alarm", "alarms": "Alarmlar", - "select-alarm": "Alarmý seç", - "no-alarms-matching": " '{{Entity}}' ile eþleþen hiçbir alarm bulunamadý. ", + "select-alarm": "Alarmı seç", + "no-alarms-matching": " '{{Entity}}' ile eşleşen hiçbir alarm bulunamadı. ", "alarm-required": "Alarm gerekli", "alarm-status": "Alarm durumu", "search-status": { @@ -100,217 +100,217 @@ "UNACK": "Unacknowledged" }, "display-status": { - "ACTIVE_UNACK": "Aktif Olmamýþ", + "ACTIVE_UNACK": "Aktif Olmamış", "ACTIVE_ACK": "Aktif Kabul", - "CLEARED_UNACK": "Onaylanmamýþ Onaylandý", - "CLEARED_ACK": "Onaylandý onaylandý" + "CLEARED_UNACK": "Onaylanmamış Onaylandı", + "CLEARED_ACK": "Onaylandı onaylandı" }, - "no-alarms-prompt": "Alarm bulunamadý", - "created-time": "Zaman yaratýldý", + "no-alarms-prompt": "Alarm bulunamadı", + "created-time": "Zaman yaratıldı", "type": "Tür", "severity": "Önem", "originator": "Originator", "originator-type": "Gönderen türü", - "details": "Ayrýntýlar", + "details": "Ayrıntılar", "status": "Durum", - "alarm-details": "Alarm detaylarý", - "start-time": "Baþlama zamaný", - "end-time": "Bitiþ zamaný", - "ack-time": "Onaylanmýþ zaman", - "clear-time": "Temizlenmiþ zaman", + "alarm-details": "Alarm detayları", + "start-time": "Başlama zamanı", + "end-time": "Bitiş zamanı", + "ack-time": "Onaylanmış zaman", + "clear-time": "Temizlenmiş zaman", "severity-critical": "Kritik", "severity-major": "Majör", "severity-minor": "Minör", - "severity-warning": "Uyarý", + "severity-warning": "Uyarı", "severity-indeterminate": "Belirsiz", "acknowledge": "Onay", - "clear": "Açýk", - "search": "Arama alarmlarý", - "selected-alarms": "{count, çoðul, 1 {1 alarm} diðer {# alarm}} seçildi", + "clear": "Açık", + "search": "Arama alarmları", + "selected-alarms": "{count, çoğul, 1 {1 alarm} diğer {# alarm}} seçildi", "no-data": "Gösterilecek bilgi yok", - "polling-interval": "Alarmlar sorgulama aralýðý (sn)", - "polling-interval-required": "Alarmlar sorgulama aralýðý gerekli.", - "min-polling-interval-message": "En az 1 san yoklama aralýðýna izin verilir.", + "polling-interval": "Alarmlar sorgulama aralığı (sn)", + "polling-interval-required": "Alarmlar sorgulama aralığı gerekli.", + "min-polling-interval-message": "En az 1 san yoklama aralığına izin verilir.", "aknowledge-alarms-title": "Kabul et { count, plural, 1 {1 alarm} other {# alarms}} selected ", - "aknowledge-alarms-text": "{Count, plural, 1 {1 alarm} dother {# alarm}} kimliðini kabul etmek istediðinizden emin misiniz?", + "aknowledge-alarms-text": "{Count, plural, 1 {1 alarm} dother {# alarm}} kimliğini kabul etmek istediğinizden emin misiniz?", "clear-alarms-title": "Temizle {count, plural, 1 {1 alarm} other {# alarm}}", - "clear-alarms-text": "count, plural, 1 {1 alarm} diðer {# alarms}} silmek istediðinizden emin misiniz?" + "clear-alarms-text": "count, plural, 1 {1 alarm} diğer {# alarms}} silmek istediğinizden emin misiniz?" }, "alias": { "add": "Takma ad ekle", - "edit": "Takma adý düzenle", + "edit": "Takma adı düzenle", "name": "Takma ad", "name-required": "Takma ad gerekli", - "duplicate-alias": "Ayný ada sahip diðer ad zaten var.", - "filter-type-single-entity": "Tek varlýk", - "filter-type-entity-list": "Varlýk listesi", - "filter-type-entity-name": "Varlýk adý", - "filter-type-state-entity": "Gösterge panosu durumundan varlýk", - "filter-type-state-entity-description": "Gösterge panosu durum parametrelerinden alýnan varlýk", - "filter-type-asset-type": "Varlýk türü", - "filter-type-asset-type-description": "Öðe varlýklarý {{assetType}} '", - "filter-type-asset-type-and-name-description": "{{AssetType}} türündeki varlýklar ve {{prefix}} ile baþlayan adla ", + "duplicate-alias": "Aynı ada sahip diğer ad zaten var.", + "filter-type-single-entity": "Tek varlık", + "filter-type-entity-list": "Varlık listesi", + "filter-type-entity-name": "Varlık adı", + "filter-type-state-entity": "Gösterge panosu durumundan varlık", + "filter-type-state-entity-description": "Gösterge panosu durum parametrelerinden alınan varlık", + "filter-type-asset-type": "Varlık türü", + "filter-type-asset-type-description": "Öğe varlıkları {{assetType}} '", + "filter-type-asset-type-and-name-description": "{{AssetType}} türündeki varlıklar ve {{prefix}} ile başlayan adla ", "filter-type-device-type": "Cihaz tipi", "filter-type-device-type-description": "Türlü cihazlar {{deviceType}} ", - "filter-type-device-type-and-name-description": "{{DeviceType}} türündeki cihazlar ve {{prefix}} ile baþlayan adla ", - "filter-type-relations-query": "Ýliþkiler sorgusu", - "filter-type-relations-query-description": "{{relationType}} iliþkisine sahip {{direction}} {{rootEntity}} olan {{entities}}", - "filter-type-asset-search-query": "Öðe arama sorgusu", - "filter-type-asset-search-query-description": "{{RelationType}} iliþkisine sahip {{direction}}} türlerine sahip öðeler {{direction}} {{rootEntity}} ", + "filter-type-device-type-and-name-description": "{{DeviceType}} türündeki cihazlar ve {{prefix}} ile başlayan adla ", + "filter-type-relations-query": "İlişkiler sorgusu", + "filter-type-relations-query-description": "{{relationType}} ilişkisine sahip {{direction}} {{rootEntity}} olan {{entities}}", + "filter-type-asset-search-query": "Öğe arama sorgusu", + "filter-type-asset-search-query-description": "{{RelationType}} ilişkisine sahip {{direction}}} türlerine sahip öğeler {{direction}} {{rootEntity}} ", "filter-type-device-search-query": "Cihaz arama sorgusu", - "filter-type-device-search-query-description": "{{RelationType}} iliþkisi olan {{direction}} {{rootEntity}} içeren {{deviceTypes}} türüne sahip cihazlar", - "entity-filter": "Varlýk filtresi", - "resolve-multiple": "Birden çok varlýk olarak çöz", + "filter-type-device-search-query-description": "{{RelationType}} ilişkisi olan {{direction}} {{rootEntity}} içeren {{deviceTypes}} türüne sahip cihazlar", + "entity-filter": "Varlık filtresi", + "resolve-multiple": "Birden çok varlık olarak çöz", "filter-type": "Filtre tipi", "filter-type-required": "Filtre türü gerekli.", - "entity-filter-no-entity-matched": "Belirtilen filtreyle eþleþen varlýk bulunamadý.", - "no-entity-filter-specified": "Hiçbir varlýk filtresi belirtilmemiþ", - "root-state-entity": "Gösterge panosu durum varlýðýný root olarak kullan", - "root-entity": "Kök varlýk", - "state-entity-parameter-name": "Durum öðesi parametre adý", - "default-state-entity": "Varsayýlan durum varlýðý", - "default-entity-parameter-name": "Varsayýlan olarak", - "max-relation-level": "Maksimum iliþki seviyesi", - "unlimited-level": "Sýnýrsýz seviye", + "entity-filter-no-entity-matched": "Belirtilen filtreyle eşleşen varlık bulunamadı.", + "no-entity-filter-specified": "Hiçbir varlık filtresi belirtilmemiş", + "root-state-entity": "Gösterge panosu durum varlığını root olarak kullan", + "root-entity": "Kök varlık", + "state-entity-parameter-name": "Durum öğesi parametre adı", + "default-state-entity": "Varsayılan durum varlığı", + "default-entity-parameter-name": "Varsayılan olarak", + "max-relation-level": "Maksimum ilişki seviyesi", + "unlimited-level": "Sınırsız seviye", "state-entity": "Gösterge panosu durumu", - "all-entities": "Tüm varlýklar", + "all-entities": "Tüm varlıklar", "any-relation": "Herhangi" }, "asset": { - "asset": "Varlýk", - "assets": "Varlýklar", - "management": "Varlýk Yönetimi", - "view-assets": "Varlýklarý Görüntüle", - "add": "Öðe Ekle", - "assign-to-customer": "Müþteriye atama", - "assign-asset-to-customer": "Müþteriye Varlýk (lar) Atama", - "assign-asset-to-customer-text": "Lütfen müþteriye atamak için varlýklarý seçin", - "no-assets-text": "Öðe bulunamadý", - "assign-to-customer-text": "Lütfen varlýk (lar) a atamak için müþteriyi seçin", - "public": "Halka açýk", - "assignedToCustomer": "Müþteriye atandý", - "make-public": "Varlýðý herkese açýk yap", - "make-private": "Varlýk özel yap", - "unassign-from-customer": "Müþteriden atama", - "delete": "Öðeyi sil", - "asset-public": "Varlýk halka açýk", - "asset-type": "Varlýk türü", - "asset-type-required": "Öðe türü gerekli.", - "select-asset-type": "Varlýk türünü seç", - "enter-asset-type": "Varlýk türünü girin", - "any-asset": "Herhangi bir varlýk", - "no-asset-types-matching": "{{EntitySubtype}} ile eþleþen öðe türü bulunamadý. ", - "asset-type-list-empty": "Hiçbir öðe türü seçilmedi.", - "asset-types": "Varlýk türleri", + "asset": "Varlık", + "assets": "Varlıklar", + "management": "Varlık Yönetimi", + "view-assets": "Varlıkları Görüntüle", + "add": "Öğe Ekle", + "assign-to-customer": "Müşteriye atama", + "assign-asset-to-customer": "Müşteriye Varlık (lar) Atama", + "assign-asset-to-customer-text": "Lütfen müşteriye atamak için varlıkları seçin", + "no-assets-text": "Öğe bulunamadı", + "assign-to-customer-text": "Lütfen varlık (lar) a atamak için müşteriyi seçin", + "public": "Halka açık", + "assignedToCustomer": "Müşteriye atandı", + "make-public": "Varlığı herkese açık yap", + "make-private": "Varlık özel yap", + "unassign-from-customer": "Müşteriden atama", + "delete": "Öğeyi sil", + "asset-public": "Varlık halka açık", + "asset-type": "Varlık türü", + "asset-type-required": "Öğe türü gerekli.", + "select-asset-type": "Varlık türünü seç", + "enter-asset-type": "Varlık türünü girin", + "any-asset": "Herhangi bir varlık", + "no-asset-types-matching": "{{EntitySubtype}} ile eşleşen öğe türü bulunamadı. ", + "asset-type-list-empty": "Hiçbir öğe türü seçilmedi.", + "asset-types": "Varlık türleri", "name": "Ad", - "name-required": "Ýsim gerekli.", - "description": "Açýklama", + "name-required": "İsim gerekli.", + "description": "Açıklama", "type": "Tür", "type-required": "Tür gerekli.", - "details": "Ayrýntýlar", + "details": "Ayrıntılar", "events": "Etkinlikler", - "add-asset-text": "Yeni varlýk ekle", - "asset-details": "Varlýk ayrýntýlarý", - "assign-assets": "Öðeleri atama", - "assign-assets-text": "Müþteriye {count, çoðul, 1 {1 asset}} diðer {# asset}} atayýn ", - "delete-assets": "Öðeleri sil", - "unassign-assets": "Varlýklarý atama", - "unassign-assets-action-title": "Müþteriden tekil, sayým, çoðul, 1 {1 öðe} diðer {# varlýk}} atama ", - "assign-new-asset": "Yeni varlýk atama", - "delete-asset-title": "Öðeyi {{assetName}} silmek istediðinizden emin misiniz?", - "delete-asset-text": "Dikkatli olun, onaylandýktan sonra varlýk ve ilgili tüm veriler kurtarýlamaz.", - "delete-assets-title": "{Count, çoðul, 1 {1 asset} diðer {# asset}} silmek istediðinizden emin misiniz?", - "delete-assets-action-title": "Sil {count, çoðul, 1 {1 asset} diðer {# asset}}", - "delete-assets-text": "Dikkatli olun, onaylandýktan sonra tüm seçilmiþ varlýklar kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", - "make-public-asset-title": "Varlýðý {{assetName}} herkese açýk yapmak istediðinizden emin misiniz? ", - "make-public-asset-text": "Onaydan sonra varlýk ve tüm verileri kamuya açýk ve baþkalarý tarafýndan eriþilebilir olacak.", - "make-private-asset-title": "Öðeyi {{assetName}} özel yapmak istediðinizden emin misiniz? ", - "make-private-asset-text": "Onaydan sonra varlýk ve tüm verileri gizli tutulacak ve baþkalarý tarafýndan eriþilemeyecektir.", - "unassign-asset-title": "Öðeyi {{assetName}} öðesinin atamasýný kaldýrmak istediðinizden emin misiniz?", - "unassign-asset-text": "Onaydan sonra varlýk atama yapýlmayacak ve müþteri tarafýndan eriþilemeyecektir.", - "unassign-asset": "Atama atamayý kaldýr", - "unassign-assets-title": "count, plural, 1 {1 öðe} other {# asset}} atama atamak istediðinizden emin misiniz?", - "unassign-assets-text": "Onaylandýktan sonra, seçilen tüm öðeler atamadan kaldýrýlacak ve müþteri tarafýndan eriþilemeyecektir.", - "copyId": "Öðe Kimliði Kopyala", - "idCopiedMessage": "Öðe Kimliði panoya kopyalandý", - "select-asset": "Varlýk seç", - "no-assets-matching": "{{Entity}} ile eþleþen hiçbir öðe bulunamadý. ", - "asset-required": "Varlýk gerekli", - "name-starts-with": "Öðe adý ile baþlar" + "add-asset-text": "Yeni varlık ekle", + "asset-details": "Varlık ayrıntıları", + "assign-assets": "Öğeleri atama", + "assign-assets-text": "Müşteriye {count, çoğul, 1 {1 asset}} diğer {# asset}} atayın ", + "delete-assets": "Öğeleri sil", + "unassign-assets": "Varlıkları atama", + "unassign-assets-action-title": "Müşteriden tekil, sayım, çoğul, 1 {1 öğe} diğer {# varlık}} atama ", + "assign-new-asset": "Yeni varlık atama", + "delete-asset-title": "Öğeyi {{assetName}} silmek istediğinizden emin misiniz?", + "delete-asset-text": "Dikkatli olun, onaylandıktan sonra varlık ve ilgili tüm veriler kurtarılamaz.", + "delete-assets-title": "{Count, çoğul, 1 {1 asset} diğer {# asset}} silmek istediğinizden emin misiniz?", + "delete-assets-action-title": "Sil {count, çoğul, 1 {1 asset} diğer {# asset}}", + "delete-assets-text": "Dikkatli olun, onaylandıktan sonra tüm seçilmiş varlıklar kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", + "make-public-asset-title": "Varlığı {{assetName}} herkese açık yapmak istediğinizden emin misiniz? ", + "make-public-asset-text": "Onaydan sonra varlık ve tüm verileri kamuya açık ve başkaları tarafından erişilebilir olacak.", + "make-private-asset-title": "Öğeyi {{assetName}} özel yapmak istediğinizden emin misiniz? ", + "make-private-asset-text": "Onaydan sonra varlık ve tüm verileri gizli tutulacak ve başkaları tarafından erişilemeyecektir.", + "unassign-asset-title": "Öğeyi {{assetName}} öğesinin atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-asset-text": "Onaydan sonra varlık atama yapılmayacak ve müşteri tarafından erişilemeyecektir.", + "unassign-asset": "Atama atamayı kaldır", + "unassign-assets-title": "count, plural, 1 {1 öğe} other {# asset}} atama atamak istediğinizden emin misiniz?", + "unassign-assets-text": "Onaylandıktan sonra, seçilen tüm öğeler atamadan kaldırılacak ve müşteri tarafından erişilemeyecektir.", + "copyId": "Öğe Kimliği Kopyala", + "idCopiedMessage": "Öğe Kimliği panoya kopyalandı", + "select-asset": "Varlık seç", + "no-assets-matching": "{{Entity}} ile eşleşen hiçbir öğe bulunamadı. ", + "asset-required": "Varlık gerekli", + "name-starts-with": "Öğe adı ile başlar" }, "attribute": { "attributes": "Öznitellikler", "latest-telemetry": "Son telemetri", - "attributes-scope": "Varlýk öznitelikleri kapsamý", + "attributes-scope": "Varlık öznitelikleri kapsamı", "scope-latest-telemetry": "Son telemetri", - "scope-client": "Müþteri özellikleri", + "scope-client": "Müşteri özellikleri", "scope-server": "Sunucu öznitelikleri", - "scope-shared": "Paylaþýlan özellikler", + "scope-shared": "Paylaşılan özellikler", "add": "Özellik ekle", "key": "Anahtar", - "last-update-time": "Son güncelleme zamaný", - "key-required": "Özellik anahtarý gerekli.", - "value": "Deðer", - "value-required": "Özellik deðeri gerekli.", - "delete-attributes-title": "{Count, çoðul, 1 {1 özellik} diðer {# attributes}} silmek istediðinizden emin misiniz?", - "delete-attributes-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen özellikler kaldýrýlacak.", + "last-update-time": "Son güncelleme zamanı", + "key-required": "Özellik anahtarı gerekli.", + "value": "Değer", + "value-required": "Özellik değeri gerekli.", + "delete-attributes-title": "{Count, çoğul, 1 {1 özellik} diğer {# attributes}} silmek istediğinizden emin misiniz?", + "delete-attributes-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen özellikler kaldırılacak.", "delete-attributes": "Nitelikleri sil", - "enter-attribute-value": "Özellik deðerini girin", - "show-on-widget": "Widget’ta göster", + "enter-attribute-value": "Özellik değerini girin", + "show-on-widget": "Widget'ta göster", "widget-mode": "Widget modu", - "next-widget": "Sonraki pencere öðesi", - "prev-widget": "Önceki pencere öðesi", + "next-widget": "Sonraki pencere öğesi", + "prev-widget": "Önceki pencere öğesi", "add-to-dashboard": "Gösterge tablosuna ekle", "add-widget-to-dashboard": "Gösterge tablosuna widget ekle", - "selected-attributes": "{count, çoðul, 1 {1 özellik} diðer {# attributes}} seçildi", - "selected-telemetry": "{count, çoðul, 1 {1 telemetri birimi} diðer {# telemetri birimleri}} seçildi" + "selected-attributes": "{count, çoğul, 1 {1 özellik} diğer {# attributes}} seçildi", + "selected-telemetry": "{count, çoğul, 1 {1 telemetri birimi} diğer {# telemetri birimleri}} seçildi" }, "audit-log": { "audit": "Denetim", - "audit-logs": "Denetim Kayýtlarý", - "timestamp": "Zaman Damgasý", - "entity-type": "Varlýk Türü", - "entity-name": "Varlýk adý", - "user": "Kullanýcý", + "audit-logs": "Denetim Kayıtları", + "timestamp": "Zaman Damgası", + "entity-type": "Varlık Türü", + "entity-name": "Varlık adı", + "user": "Kullanıcı", "type": "Tür", "status": "Durum", - "details": "Ayrýntýlar", + "details": "Ayrıntılar", "type-added": "Katma", "type-deleted": "Silindi", - "type-updated": "Güncellenmiþ", + "type-updated": "Güncellenmiş", "type-attributes-updated": "Öznitelikler güncellendi", "type-attributes-deleted": "Öznitelikler silindi", - "type-rpc-call": "RPC çaðrýsý", + "type-rpc-call": "RPC çağrısı", "type-credentials-updated": "Kimlik bilgileri güncellendi", - "type-assigned-to-customer": "Müþteriye Atanan", - "type-unassigned-from-customer": "Müþteriden Atanmamýþ", + "type-assigned-to-customer": "Müşteriye Atanan", + "type-unassigned-from-customer": "Müşteriden Atanmamış", "type-activated": "Aktif", - "type-suspended": "Askýya alýndý", + "type-suspended": "Askıya alındı", "type-credentials-read": "Kimlik bilgileri okundu", "type-attributes-read": "Nitelikler oku", - "type-relation-add-or-update": "Ýliþki güncellendi", - "type-relation-delete": "Ýliþki silindi", - "type-relations-delete": "Tüm iliþki silindi", + "type-relation-add-or-update": "İlişki güncellendi", + "type-relation-delete": "İlişki silindi", + "type-relations-delete": "Tüm ilişki silindi", "type-alarm-ack": "Kabul edilen", "type-alarm-clear": "Temizlendi", - "status-success": "Baþarý", - "status-failure": "Baþarýsýzlýk", - "audit-log-details": "Denetim kaydý ayrýntýlarý", - "no-audit-logs-prompt": "Kayýt bulunamadý", + "status-success": "Başarı", + "status-failure": "Başarısızlık", + "audit-log-details": "Denetim kaydı ayrıntıları", + "no-audit-logs-prompt": "Kayıt bulunamadı", "action-data": "Eylem verileri", - "failure-details": "Arýza detaylarý", + "failure-details": "Arıza detayları", "search": "Denetim günlüklerini ara", - "clear-search": "Aramayý Temizle" + "clear-search": "Aramayı Temizle" }, "confirm-on-exit": { - "message": "Kaydedilmemiþ deðiþiklikleriniz var. Bu sayfadan ayrýlmak istediðinizden emin misiniz?", - "html-message": "Kaydedilmemiþ deðiþiklikleriniz var.
Bu sayfadan ayrýlmak istediðinizden emin misiniz?", - "title": "Kaydedilmemiþ deðiþiklikler" + "message": "Kaydedilmemiş değişiklikleriniz var. Bu sayfadan ayrılmak istediğinizden emin misiniz?", + "html-message": "Kaydedilmemiş değişiklikleriniz var.
Bu sayfadan ayrılmak istediğinizden emin misiniz?", + "title": "Kaydedilmemiş değişiklikler" }, "contact": { "country": "Ülke", "city": "Kent", - "state": "Eyalet / Ýl", + "state": "Eyalet / İl", "postal-code": "Posta Kodu", "postal-code-invalid": "Geçersiz Posta Kodu / Posta Kodu biçimi.", "address": "Adres", @@ -320,64 +320,64 @@ "no-address": "Adres yok" }, "common": { - "username": "Kullanýcý adý", + "username": "Kullanıcı adı", "password": "Parola", - "enter-username": "Kullanýcý adý girin", - "enter-password": "Parolaný Gir", + "enter-username": "Kullanıcı adı girin", + "enter-password": "Parolanı Gir", "enter-search": "Arama girin" }, "content-type": { "json": "Json", "text": "Metin", - "binary": "Ýkili (Base64)" + "binary": "İkili (Base64)" }, "customer": { - "customer": "Müþteri", - "customers": "Müþteriler", - "management": "Müþteri yönetimi", - "dashboard": "Müþteri Kontrol Paneli", - "dashboards": "Müþteri Kontrol Panelleri", - "devices": "Müþteri Cihazlarý", - "entity-views": "Müþteri Varlýðý Görüntüleme Sayýsý", - "assets": "Müþteri Varlýklarý", + "customer": "Müşteri", + "customers": "Müşteriler", + "management": "Müşteri yönetimi", + "dashboard": "Müşteri Kontrol Paneli", + "dashboards": "Müşteri Kontrol Panelleri", + "devices": "Müşteri Cihazları", + "entity-views": "Müşteri Varlığı Görüntüleme Sayısı", + "assets": "Müşteri Varlıkları", "public-dashboards": "Genel Panolar", - "public-devices": "Kamu Aygýtlarý", - "public-assets": "Kamu Varlýklarý", - "public-entity-views": "Kamu Varlýk Görüntüleme Sayýsý", - "add": "Müþteri Ekle", - "delete": "Müþteriyi sil", - "manage-customer-users": "Müþteri kullanýcýlarýný yönet", - "manage-customer-devices": "Müþteri cihazlarýný yönet", - "manage-customer-dashboards": "Müþteri panolarýný yönet", - "manage-public-devices": "Genel cihazlarý yönet", - "manage-public-dashboards": "Genel panolarý yönet", - "manage-customer-assets": "Müþteri varlýklarýný yönet", - "manage-public-assets": "Kamu varlýklarýný yönet", - "add-customer-text": "Yeni müþteri ekle", - "no-customers-text": "Müþteri bulunamadý", - "customer-details": "Müþteri detaylarý", - "delete-customer-title": "Müþteriyi silmek istediðinizden emin misiniz? {{CustomerTitle}} ? ", - "delete-customer-text": "Dikkatli olun, onaylandýktan sonra müþteri ve ilgili tüm veriler kurtarýlamaz.", - "delete-customers-title": "{Count, çoðul, 1 {1 müþteri} diðer {# customers}} silmek istediðinizden emin misiniz?", - "delete-customers-action-title": "Sil {count, çoðul, 1 {1 müþteri} diðer {# customers}}", - "delete-customers-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen müþteriler kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", - "manage-users": "Kullanýcýlarý Yönet", - "manage-assets": "Varlýklarý yönet", - "manage-devices": "Cihazlarý yönet", - "manage-dashboards": "Gösterge tablolarýný yönet", - "title": "Baþlýk", - "title-required": "Baþlýk gerekli.", - "description": "Açýklama", - "details": "Ayrýntýlar", + "public-devices": "Kamu Aygıtları", + "public-assets": "Kamu Varlıkları", + "public-entity-views": "Kamu Varlık Görüntüleme Sayısı", + "add": "Müşteri Ekle", + "delete": "Müşteriyi sil", + "manage-customer-users": "Müşteri kullanıcılarını yönet", + "manage-customer-devices": "Müşteri cihazlarını yönet", + "manage-customer-dashboards": "Müşteri panolarını yönet", + "manage-public-devices": "Genel cihazları yönet", + "manage-public-dashboards": "Genel panoları yönet", + "manage-customer-assets": "Müşteri varlıklarını yönet", + "manage-public-assets": "Kamu varlıklarını yönet", + "add-customer-text": "Yeni müşteri ekle", + "no-customers-text": "Müşteri bulunamadı", + "customer-details": "Müşteri detayları", + "delete-customer-title": "Müşteriyi silmek istediğinizden emin misiniz? {{CustomerTitle}} ? ", + "delete-customer-text": "Dikkatli olun, onaylandıktan sonra müşteri ve ilgili tüm veriler kurtarılamaz.", + "delete-customers-title": "{Count, çoğul, 1 {1 müşteri} diğer {# customers}} silmek istediğinizden emin misiniz?", + "delete-customers-action-title": "Sil {count, çoğul, 1 {1 müşteri} diğer {# customers}}", + "delete-customers-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen müşteriler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", + "manage-users": "Kullanıcıları Yönet", + "manage-assets": "Varlıkları yönet", + "manage-devices": "Cihazları yönet", + "manage-dashboards": "Gösterge tablolarını yönet", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Ayrıntılar", "events": "Etkinlikler", - "copyId": "Müþteri kimliðini kopyala", - "idCopiedMessage": "Müþteri Kimliði panoya kopyalandý", - "select-customer": "Müþteriyi seç", - "no-customers-matching": "{{Entity}} ile eþleþen hiçbir müþteri bulunamadý. ", - "customer-required": "Müþteri gerekli", - "select-default-customer": "Varsayýlan müþteriyi seç", - "default-customer": "Varsayýlan müþteri", - "default-customer-required": "Kiracý düzeyinde gösterge tablosunda hata ayýklamak için varsayýlan müþteri gerekiyor" + "copyId": "Müşteri kimliğini kopyala", + "idCopiedMessage": "Müşteri Kimliği panoya kopyalandı", + "select-customer": "Müşteriyi seç", + "no-customers-matching": "{{Entity}} ile eşleşen hiçbir müşteri bulunamadı.", + "customer-required": "Müşteri gerekli", + "select-default-customer": "Varsayılan müşteriyi seç", + "default-customer": "Varsayılan müşteri", + "default-customer-required": "Kiracı düzeyinde gösterge tablosunda hata ayıklamak için varsayılan müşteri gerekiyor" }, "datetime": { "date-from": "Tarih", @@ -387,244 +387,244 @@ }, "dashboard": { "dashboard": "Pano", - "dashboards": "Gösterge tablolarý", + "dashboards": "Gösterge tabloları", "management": "Gösterge tablosu yönetimi", - "view-dashboards": "Gösterge Panolarý", + "view-dashboards": "Gösterge Panoları", "add": "Gösterge Tablosu Ekle", - "assign-dashboard-to-customer": "Gösterge Tablosunu / Müþterilerini Müþteriye Atama", - "assign-dashboard-to-customer-text": "Lütfen müþteriye atamak için kontrol panellerini seçin", - "assign-to-customer-text": "Gösterge panellerini atamak için lütfen müþteriyi seçiniz", - "assign-to-customer": "Müþteriye atama", - "unassign-from-customer": "Müþteriden atama", - "make-public": "Gösterge panosunu herkese açýk yap", + "assign-dashboard-to-customer": "Gösterge Tablosunu / Müşterilerini Müşteriye Atama", + "assign-dashboard-to-customer-text": "Lütfen müşteriye atamak için kontrol panellerini seçin", + "assign-to-customer-text": "Gösterge panellerini atamak için lütfen müşteriyi seçiniz", + "assign-to-customer": "Müşteriye atama", + "unassign-from-customer": "Müşteriden atama", + "make-public": "Gösterge panosunu herkese açık yap", "make-private": "Gösterge panosunu özel yap", - "manage-assigned-customers": "Atanan müþterileri yönet", - "assigned-customers": "Atanan müþteriler", - "assign-to-customers": "Gösterge Tablosunu / Müþterilerini Müþterilere Atama", - "assign-to-customers-text": "Lütfen gösterge panosunu atamak için müþterileri seçin", - "unassign-from-customers": "Müþterilerden Gösterge Tablosunu (Notlarýný) Atama", - "unassign-from-customers-text": "Lütfen gösterge tablosundan atamak için müþterileri seçin", - "no-dashboards-text": "Gösterge panelleri bulunamadý", - "no-widgets": "Hiçbir widget yapýlandýrýlmamýþ", - "add-widget": "Yeni pencere öðesi ekle", - "title": "Baþlýk", + "manage-assigned-customers": "Atanan müşterileri yönet", + "assigned-customers": "Atanan müşteriler", + "assign-to-customers": "Gösterge Tablosunu / Müşterilerini Müşterilere Atama", + "assign-to-customers-text": "Lütfen gösterge panosunu atamak için müşterileri seçin", + "unassign-from-customers": "Müşterilerden Gösterge Tablosunu (Notlarını) Atama", + "unassign-from-customers-text": "Lütfen gösterge tablosundan atamak için müşterileri seçin", + "no-dashboards-text": "Gösterge panelleri bulunamadı", + "no-widgets": "Hiçbir widget yapılandırılmamış", + "add-widget": "Yeni pencere öğesi ekle", + "title": "Başlık", "select-widget-title": "Widget seç", - "select-widget-subtitle": "Kullanýlabilir pencere öðesi türleri", + "select-widget-subtitle": "Kullanılabilir pencere öğesi türleri", "delete": "Gösterge tablosunu sil", - "title-required": "Baþlýk gerekli.", - "description": "Açýklama", - "details": "Ayrýntýlar", - "dashboard-details": "Gösterge tablosu ayrýntýlarý", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Ayrıntılar", + "dashboard-details": "Gösterge tablosu ayrıntıları", "add-dashboard-text": "Yeni gösterge tablosu ekle", - "assign-dashboards": "Gösterge tablolarý atama", + "assign-dashboards": "Gösterge tabloları atama", "assign-new-dashboard": "Yeni kontrol paneli atama", - "assign-dashboards-text": "Müþterilere {count, plural, 1 {1 gösterge tablosu} other {# dashboard}}} atayýn ", - "unassign-dashboards-action-text": "Müþterilerden atama sayýsý, çoðul, 1 {1 gösterge tablosu} diðer {# panolar}}", - "delete-dashboards": "Gösterge tablolarýný sil", - "unassign-dashboards": "Atanmamýþ gösterge panolarý", - "unassign-dashboards-action-title": "Müþteriden atama sayým, çoðul, 1 {1 gösterge tablosu} diðer {# pano}}", - "delete-dashboard-title": "'{{DashboardTitle}}' gösterge panosunu silmek istediðinizden emin misiniz?", - "delete-dashboard-text": "Doðrulamadan sonra, kontrol panelinden ve ilgili tüm veriler kurtarýlamayacak.", - "delete-dashboards-title": "{Count, çoðul, 1 {1 dashboard} diðer {# dashboards}} silmek istediðinizden emin misiniz?", - "delete-dashboards-action-title": "Sil {count, çoðul, 1 {1 gösterge tablosu} diðer {# dashboard}}", - "delete-dashboards-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen panolar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", - "unassign-dashboard-title": "{{DashboardTitle}} gösterge tablosunun atamasýný kaldýrmak istediðinizden emin misiniz? ", - "unassign-dashboard-text": "Onaydan sonra gösterge panosu atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", - "unassign-dashboard": "Atanmamýþ gösterge panosu", - "unassign-dashboards-title": "Sayým, çoðul, 1 {1 gösterge tablosu} diðer {# panolar}} atama atamak istediðinizden emin misiniz?", - "unassign-dashboards-text": "Onaydan sonra tüm seçilen gösterge tablolarý atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", - "public-dashboard-title": "Gösterge panosu artýk herkese açýk", - "public-dashboard-text": "Gösterge tablonuz {{dashboardTitle}} artýk herkese açýk ve bir sonraki herkese açýk link üzerinden eriþilebilir:", - "public-dashboard-notice": " Not: Verilerine eriþmek için ilgili cihazlarý herkese açýk yapmayý unutmayýn.", - "make-private-dashboard-title": "Gösterge panosunu {{dashboardTitle}} özel yapmak istediðinizden emin misiniz?", - "make-private-dashboard-text": "Onaydan sonra gösterge panosu özel hale getirilecek ve baþkalarý tarafýndan eriþilemeyecektir.", + "assign-dashboards-text": "Müşterilere {count, plural, 1 {1 gösterge tablosu} other {# dashboard}}} atayın ", + "unassign-dashboards-action-text": "Müşterilerden atama sayısı, çoğul, 1 {1 gösterge tablosu} diğer {# panolar}}", + "delete-dashboards": "Gösterge tablolarını sil", + "unassign-dashboards": "Atanmamış gösterge panoları", + "unassign-dashboards-action-title": "Müşteriden atama sayım, çoğul, 1 {1 gösterge tablosu} diğer {# pano}}", + "delete-dashboard-title": "'{{DashboardTitle}}' gösterge panosunu silmek istediğinizden emin misiniz?", + "delete-dashboard-text": "Doğrulamadan sonra, kontrol panelinden ve ilgili tüm veriler kurtarılamayacak.", + "delete-dashboards-title": "{Count, çoğul, 1 {1 dashboard} diğer {# dashboards}} silmek istediğinizden emin misiniz?", + "delete-dashboards-action-title": "Sil {count, çoğul, 1 {1 gösterge tablosu} diğer {# dashboard}}", + "delete-dashboards-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen panolar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", + "unassign-dashboard-title": "{{DashboardTitle}} gösterge tablosunun atamasını kaldırmak istediğinizden emin misiniz? ", + "unassign-dashboard-text": "Onaydan sonra gösterge panosu atanmamış olacak ve müşteri tarafından erişilemeyecektir.", + "unassign-dashboard": "Atanmamış gösterge panosu", + "unassign-dashboards-title": "Sayım, çoğul, 1 {1 gösterge tablosu} diğer {# panolar}} atama atamak istediğinizden emin misiniz?", + "unassign-dashboards-text": "Onaydan sonra tüm seçilen gösterge tabloları atanmamış olacak ve müşteri tarafından erişilemeyecektir.", + "public-dashboard-title": "Gösterge panosu artık herkese açık", + "public-dashboard-text": "Gösterge tablonuz {{dashboardTitle}} artık herkese açık ve bir sonraki herkese açık link üzerinden erişilebilir:", + "public-dashboard-notice": " Not: Verilerine erişmek için ilgili cihazları herkese açık yapmayı unutmayın.", + "make-private-dashboard-title": "Gösterge panosunu {{dashboardTitle}} özel yapmak istediğinizden emin misiniz?", + "make-private-dashboard-text": "Onaydan sonra gösterge panosu özel hale getirilecek ve başkaları tarafından erişilemeyecektir.", "make-private-dashboard": "Gösterge panosunu özel yap", - "socialshare-text": "'{{dashboardTitle}} ThingsBoard tarafýndan desteklenmektedir ", - "socialshare-title": "'{{dashboardTitle}} ThingsBoard tarafýndan desteklenmektedir ", + "socialshare-text": "'{{dashboardTitle}} ThingsBoard tarafından desteklenmektedir ", + "socialshare-title": "'{{dashboardTitle}} ThingsBoard tarafından desteklenmektedir ", "select-dashboard": "Gösterge tablosu seç", - "no-dashboards-matching": "{{Entity}} ile eþleþen hiçbir gösterge paneli bulunamadý. ", + "no-dashboards-matching": "{{Entity}} ile eşleşen hiçbir gösterge paneli bulunamadı. ", "dashboard-required": "Gösterge tablosu gerekli.", "select-existing": "Mevcut kontrol panelini seç", - "create-new": "Yeni gösterge tablosu oluþtur", - "new-dashboard-title": "Yeni kontrol paneli baþlýðý", - "open-dashboard": "Açýk kontrol paneli", - "set-background": "Arka planý ayarla", + "create-new": "Yeni gösterge tablosu oluştur", + "new-dashboard-title": "Yeni kontrol paneli başlığı", + "open-dashboard": "Açık kontrol paneli", + "set-background": "Arka planı ayarla", "background-color": "Arka plan rengi", "background-image": "Arka plan görüntüsü", "background-size-mode": "Arka plan boyut modu", "no-image": "Görüntü seçilmedi", - "drop-image": "Bir resim býrakýn veya yüklenecek bir dosya seçmek için týklayýn.", + "drop-image": "Bir resim bırakın veya yüklenecek bir dosya seçmek için tıklayın.", "settings": "Ayarlar", - "columns-count": "Sütun sayýsý", - "columns-count-required": "Sütun sayýsý gerekli.", - "min-columns-count-message": "Sadece 10 minimum sütun sayýsýna izin verilir.", - "max-columns-count-message": "Sadece maksimum 1000 sütun sayýsýna izin verilir.", - "widgets-margins": "Widget arasýnda marj", - "horizontal-margin": "Yatay kenar boþluðu", - "horizontal-margin-required": "Yatay kenar boþluðu deðeri gerekiyor.", - "min-horizontal-margin-message": "Minimum yatay kenar boþluðu deðeri olarak yalnýzca 0'a izin verilir.", - "max-horizontal-margin-message": "Maksimum yatay kenar boþluðu deðeri olarak yalnýzca 50'ye izin verilir.", - "vertical-margin": "Dikey kenar boþluðu", - "vertical-margin-required": "Dikey kenar boþluðu deðeri gereklidir.", - "min-vertical-margin-message": "En az dikey kenar boþluðu deðeri olarak yalnýzca 0'a izin verilir.", - "max-vertical-margin-message": "Maksimum dikey kenar boþluðu deðeri olarak yalnýzca 50'ye izin verilir.", - "autofill-height": "Otomatik dolgu düzeni yüksekliði", - "mobile-layout": "Mobil düzen ayarlarý", - "mobile-row-height": "Mobil satýr yüksekliði, px", - "mobile-row-height-required": "Mobil satýr yüksekliði deðeri gerekli.", - "min-mobile-row-height-message": "Minimum mobil satýr yüksekliði deðeri olarak yalnýzca 5 piksele izin verilir.", - "max-mobile-row-height-message": "Maksimum mobil satýr yüksekliði deðeri olarak yalnýzca 200 piksele izin verilir.", - "display-title": "Gösterge tablosu baþlýðý", - "toolbar-always-open": "Araç çubuðunu açýk tut", - "title-color": "Baþlýk rengi", - "display-dashboards-selection": "Gösterge panolarý seçimi", - "display-entities-selection": "Öðe varlýklarý seçimi", + "columns-count": "Sütun sayısı", + "columns-count-required": "Sütun sayısı gerekli.", + "min-columns-count-message": "Sadece 10 minimum sütun sayısına izin verilir.", + "max-columns-count-message": "Sadece maksimum 1000 sütun sayısına izin verilir.", + "widgets-margins": "Widget arasında marj", + "horizontal-margin": "Yatay kenar boşluğu", + "horizontal-margin-required": "Yatay kenar boşluğu değeri gerekiyor.", + "min-horizontal-margin-message": "Minimum yatay kenar boşluğu değeri olarak yalnızca 0'a izin verilir.", + "max-horizontal-margin-message": "Maksimum yatay kenar boşluğu değeri olarak yalnızca 50'ye izin verilir.", + "vertical-margin": "Dikey kenar boşluğu", + "vertical-margin-required": "Dikey kenar boşluğu değeri gereklidir.", + "min-vertical-margin-message": "En az dikey kenar boşluğu değeri olarak yalnızca 0'a izin verilir.", + "max-vertical-margin-message": "Maksimum dikey kenar boşluğu değeri olarak yalnızca 50'ye izin verilir.", + "autofill-height": "Otomatik dolgu düzeni yüksekliği", + "mobile-layout": "Mobil düzen ayarları", + "mobile-row-height": "Mobil satır yüksekliği, px", + "mobile-row-height-required": "Mobil satır yüksekliği değeri gerekli.", + "min-mobile-row-height-message": "Minimum mobil satır yüksekliği değeri olarak yalnızca 5 piksele izin verilir.", + "max-mobile-row-height-message": "Maksimum mobil satır yüksekliği değeri olarak yalnızca 200 piksele izin verilir.", + "display-title": "Gösterge tablosu başlığı", + "toolbar-always-open": "Araç çubuğunu açık tut", + "title-color": "Başlık rengi", + "display-dashboards-selection": "Gösterge panoları seçimi", + "display-entities-selection": "Öğe varlıkları seçimi", "display-dashboard-timewindow": "Zaman penceresi göster", - "display-dashboard-export": "Görüntülü dýþa aktarma", + "display-dashboard-export": "Görüntülü dışa aktarma", "import": "Gösterge paneli", - "export": "Dýþ gösterge panosu", + "export": "Dış gösterge panosu", "export-failed-error": "Gösterge tablosu gönderilemiyor: {{error}}", - "create-new-dashboard": "Yeni gösterge tablosu oluþtur", - "dashboard-file": "Gösterge tablosu dosyasý", - "invalid-dashboard-file-error": "Gösterge tablosu alýnamadý: Geçersiz pano veri yapýsý.", - "dashboard-import-missing-aliases-title": "Alýnan pano tarafýndan kullanýlan takma adlarý yapýlandýr", - "create-new-widget": "Yeni pencere öðesi oluþtur", - "import-widget": "Widget'ý içe aktar", - "widget-file": "Widget dosyasý", - "invalid-widget-file-error": "Widget içe aktarýlamýyor: Geçersiz pencere öðesi yapýsý.", - "widget-import-missing-aliases-title": "Ýçe aktarýlan pencere aracý tarafýndan kullanýlan takma adlarý yapýlandýr", - "open-toolbar": "Gösterge tablosu araç çubuðunu aç", - "close-toolbar": "Araç çubuðunu kapat", - "configuration-error": "Yapýlandýrma hatasý", - "alias-resolution-error-title": "Gösterge tablosu takma ad hatasý", - "invalid-aliases-config": "Diðer ad filtrelerinin biriyle eþleþen herhangi bir cihaz bulunamadý.
Lütfen bu sorunu çözmek için yöneticinize baþvurun.", + "create-new-dashboard": "Yeni gösterge tablosu oluştur", + "dashboard-file": "Gösterge tablosu dosyası", + "invalid-dashboard-file-error": "Gösterge tablosu alınamadı: Geçersiz pano veri yapısı.", + "dashboard-import-missing-aliases-title": "Alınan pano tarafından kullanılan takma adları yapılandır", + "create-new-widget": "Yeni pencere öğesi oluştur", + "import-widget": "Widget'ı içe aktar", + "widget-file": "Widget dosyası", + "invalid-widget-file-error": "Widget içe aktarılamıyor: Geçersiz pencere öğesi yapısı.", + "widget-import-missing-aliases-title": "İçe aktarılan pencere aracı tarafından kullanılan takma adları yapılandır", + "open-toolbar": "Gösterge tablosu araç çubuğunu aç", + "close-toolbar": "Araç çubuğunu kapat", + "configuration-error": "Yapılandırma hatası", + "alias-resolution-error-title": "Gösterge tablosu takma ad hatası", + "invalid-aliases-config": "Diğer ad filtrelerinin biriyle eşleşen herhangi bir cihaz bulunamadı.
Lütfen bu sorunu çözmek için yöneticinize başvurun.", "select-devices": "Cihaz seç", - "assignedToCustomer": "Müþteriye atandý", - "assignedToCustomers": "Müþterilere atandý", - "public": "Halka açýk", - "public-link": "Genel baðlantý", - "copy-public-link": "Genel baðlantýyý kopyala", - "public-link-copied-message": "Pano genel baðlantýsý panoya kopyalandý", - "manage-states": "Gösterge panosu durumlarýný yönet", + "assignedToCustomer": "Müşteriye atandı", + "assignedToCustomers": "Müşterilere atandı", + "public": "Halka açık", + "public-link": "Genel bağlantı", + "copy-public-link": "Genel bağlantıyı kopyala", + "public-link-copied-message": "Pano genel bağlantısı panoya kopyalandı", + "manage-states": "Gösterge panosu durumlarını yönet", "states": "Gösterge durumu", - "search-states": "Gösterge panosu durumlarý", - "selected-states": "{count, çoðul, 1 {1 gösterge tablosu durumu} diðer {# dashboard durumlarý}} seçildi", + "search-states": "Gösterge panosu durumları", + "selected-states": "{count, çoğul, 1 {1 gösterge tablosu durumu} diğer {# dashboard durumları}} seçildi", "edit-state": "Gösterge panosu durumunu düzenle", "delete-state": "Gösterge panosu durumunu sil", "add-state": "Gösterge paneli durumu", "state": "Gösterge paneli durumu", "state-name": "Ad", - "state-name-required": "Gösterge panosu durum adý gerekli.", - "state-id": "Durum Kimliði", - "state-id-required": "Gösterge durumu kimliði gerekiyor.", - "state-id-exists": "Ayný kimliðe sahip gösterge tablosu zaten var.", + "state-name-required": "Gösterge panosu durum adı gerekli.", + "state-id": "Durum Kimliği", + "state-id-required": "Gösterge durumu kimliği gerekiyor.", + "state-id-exists": "Aynı kimliğe sahip gösterge tablosu zaten var.", "is-root-state": "Kök devlet", "delete-state-title": "Gösterge panosu durumunu sil", - "delete-state-text": "Gösterge panosu durumunu '{{stateName}}' adýyla silmek istediðinizden emin misiniz?", - "show-details": "Detaylarý göster", - "hide-details": "Detaylarý gizle", + "delete-state-text": "Gösterge panosu durumunu '{{stateName}}' adıyla silmek istediğinizden emin misiniz?", + "show-details": "Detayları göster", + "hide-details": "Detayları gizle", "select-state": "Hedef durumu seç", "state-controller": "Durum kontrolörü" }, "datakey": { "settings": "Ayarlar", - "advanced": "Ýleri", + "advanced": "İleri", "label": "Etiket", "color": "Renk", - "units": "Deðerin yanýnda gösterilecek özel sembol", - "decimals": "Kayan noktadan sonraki basamak sayýsý", - "data-generation-func": "Veri oluþturma iþlevi", - "use-data-post-processing-func": "Veri iþleme sonrasý iþlevini kullan", - "configuration": "Veri anahtarý yapýlandýrmasý", + "units": "Değerin yanında gösterilecek özel sembol", + "decimals": "Kayan noktadan sonraki basamak sayısı", + "data-generation-func": "Veri oluşturma işlevi", + "use-data-post-processing-func": "Veri işleme sonrası işlevini kullan", + "configuration": "Veri anahtarı yapılandırması", "timeseries": "Zaman serisi", "attributes": "Öznitellikler", - "alarm": "Alarm alanlarý", - "timeseries-required": "Varlýk zamanlamalarý gerekli.", - "timeseries-or-attributes-required": "Varlýk zaman çizelgeleri / öznitelikler gereklidir.", - "maximum-timeseries-or-attributes": "Maksimum {count, çoðul, 1 {1 timeseries / attribute.} Diðer {# timeseries / attributes {} izin verilir}}", - "alarm-fields-required": "Alarm alanlarý gerekli.", - "function-types": "Ýþlev türleri", - "function-types-required": "Ýþlev tipleri gereklidir.", - "maximum-function-types": "Maksimum {sayým, çoðul, 1 {1 iþlev türüne izin verilir.} Diðer {# iþlev türlerine izin verilir}}" + "alarm": "Alarm alanları", + "timeseries-required": "Varlık zamanlamaları gerekli.", + "timeseries-or-attributes-required": "Varlık zaman çizelgeleri / öznitelikler gereklidir.", + "maximum-timeseries-or-attributes": "Maksimum {count, çoğul, 1 {1 timeseries / attribute.} Diğer {# timeseries / attributes {} izin verilir}}", + "alarm-fields-required": "Alarm alanları gerekli.", + "function-types": "İşlev türleri", + "function-types-required": "İşlev tipleri gereklidir.", + "maximum-function-types": "Maksimum {sayım, çoğul, 1 {1 işlev türüne izin verilir.} Diğer {# işlev türlerine izin verilir}}" }, "datasource": { - "type": "Veri kaynaðý türü", + "type": "Veri kaynağı türü", "name": "Ad", - "add-datasource-prompt": "Lütfen veri kaynaðý ekle" + "add-datasource-prompt": "Lütfen veri kaynağı ekle" }, "details": { "edit-mode": "Düzenleme modu", - "toggle-edit-mode": "Düzenleme modunu deðiþtir" + "toggle-edit-mode": "Düzenleme modunu değiştir" }, "device": { "device": "Cihaz", "device-required": "Cihaz gerekli.", "devices": "Cihazlar", "management": "Cihaz yönetimi", - "view-devices": "Cihazlarý Görüntüle", - "device-alias": "Cihaz takma adý", - "aliases": "Cihaz takma adlarý", - "no-alias-matching": "'{{alias}} bulunamadý. ", - "no-aliases-found": "Takma ad bulunamadý", - "no-key-matching": "'{{anahtar bulunamadý.", - "no-keys-found": "Anahtar bulunamadý.", - "create-new-alias": "Yeni bir tane oluþtur!", - "create-new-key": "Yeni bir tane oluþtur!", - "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}}..
Cihaz takma adlarý, kontrol panelinde benzersiz olmalýdýr. ", - "configure-alias": "Yapýlandýrma {{alias}} takma ad", - "no-devices-matching": "{{Entity}} ile eþleþen hiçbir cihaz bulunamadý. ", + "view-devices": "Cihazları Görüntüle", + "device-alias": "Cihaz takma adı", + "aliases": "Cihaz takma adları", + "no-alias-matching": "'{{alias}} bulunamadı. ", + "no-aliases-found": "Takma ad bulunamadı", + "no-key-matching": "'{{anahtar bulunamadı.", + "no-keys-found": "Anahtar bulunamadı.", + "create-new-alias": "Yeni bir tane oluştur!", + "create-new-key": "Yeni bir tane oluştur!", + "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}}..
Cihaz takma adları, kontrol panelinde benzersiz olmalıdır. ", + "configure-alias": "Yapılandırma {{alias}} takma ad", + "no-devices-matching": "{{Entity}} ile eşleşen hiçbir cihaz bulunamadı. ", "alias": "Alias", - "alias-required": "Cihaz takma adý gerekiyor.", - "remove-alias": "Cihaz takma adýný kaldýr", - "add-alias": "Cihaz takma adý ekle", - "name-starts-with": "Cihaz adý ile baþlýyor", - "device-list": "Aygýt listesi", + "alias-required": "Cihaz takma adı gerekiyor.", + "remove-alias": "Cihaz takma adını kaldır", + "add-alias": "Cihaz takma adı ekle", + "name-starts-with": "Cihaz adı ile başlıyor", + "device-list": "Aygıt listesi", "use-device-name-filter": "Filtre kullan", "device-list-empty": "Cihaz seçilmedi.", - "device-name-filter-required": "Cihaz adý filtresi gerekli.", - "device-name-filter-no-device-matched": "{{Device}} ile baþlayan hiçbir cihaz bulunamadý. ", + "device-name-filter-required": "Cihaz adı filtresi gerekli.", + "device-name-filter-no-device-matched": "{{Device}} ile başlayan hiçbir cihaz bulunamadı. ", "add": "Cihaz ekle", - "assign-to-customer": "Müþteriye atama", - "assign-device-to-customer": "Aygýtý / Aygýtlarý Müþteriye Atama", - "assign-device-to-customer-text": "Lütfen müþteriye atamak istediðiniz cihazlarý seçin", - "make-public": "Cihazý herkese açýk yap", - "make-private": "Cihazý özel yap", - "no-devices-text": "Hiçbir cihaz bulunamadý", - "assign-to-customer-text": "Lütfen cihazý atamak için müþteriyi seçin", - "device-details": "Cihaz detaylarý", + "assign-to-customer": "Müşteriye atama", + "assign-device-to-customer": "Aygıtı / Aygıtları Müşteriye Atama", + "assign-device-to-customer-text": "Lütfen müşteriye atamak istediğiniz cihazları seçin", + "make-public": "Cihazı herkese açık yap", + "make-private": "Cihazı özel yap", + "no-devices-text": "Hiçbir cihaz bulunamadı", + "assign-to-customer-text": "Lütfen cihazı atamak için müşteriyi seçin", + "device-details": "Cihaz detayları", "add-device-text": "Yeni cihaz ekle", "credentials": "Kimlik bilgileri", "manage-credentials": "Kimlik bilgilerini yönet", - "delete": "Cihazý sil", - "assign-devices": "Aygýtlarý atama", - "assign-devices-text": "Müþteriye {count, çoðul, 1 {1 cihaz} diðer {# devices}} atayýn ", - "delete-devices": "Cihazlarý sil", - "unassign-from-customer": "Müþteriden atama", - "unassign-devices": "Atanmamýþ cihazlarý", - "unassign-devices-action-title": "Müþteriden atayýn, çoðul, 1 {1 cihaz} diðer {# devices}} atama ", + "delete": "Cihazı sil", + "assign-devices": "Aygıtları atama", + "assign-devices-text": "Müşteriye {count, çoğul, 1 {1 cihaz} diğer {# devices}} atayın ", + "delete-devices": "Cihazları sil", + "unassign-from-customer": "Müşteriden atama", + "unassign-devices": "Atanmamış cihazları", + "unassign-devices-action-title": "Müşteriden atayın, çoğul, 1 {1 cihaz} diğer {# devices}} atama ", "assign-new-device": "Yeni cihaz atama", - "make-public-device-title": "Cihazý {{deviceName}} herkese açýk yapmak istediðinizden emin misiniz?", - "make-public-device-text": "Onaydan sonra cihaz ve tüm verileri kamuya açýk ve baþkalarý tarafýndan eriþilebilir olacak.", - "make-private-device-title": "Cihazý {{deviceName}} özel yapmak istediðinizden emin misiniz?", - "make-private-device-text": "Onaylandýktan sonra cihaz ve tüm verileri gizli tutulacak ve baþkalarý tarafýndan eriþilemeyecektir.", + "make-public-device-title": "Cihazı {{deviceName}} herkese açık yapmak istediğinizden emin misiniz?", + "make-public-device-text": "Onaydan sonra cihaz ve tüm verileri kamuya açık ve başkaları tarafından erişilebilir olacak.", + "make-private-device-title": "Cihazı {{deviceName}} özel yapmak istediğinizden emin misiniz?", + "make-private-device-text": "Onaylandıktan sonra cihaz ve tüm verileri gizli tutulacak ve başkaları tarafından erişilemeyecektir.", "view-credentials": "Kimlik bilgilerini görüntüle", - "delete-device-title": "Cihazý {{deviceName}} silmek istediðinizden emin misiniz? ", - "delete-device-text": "Dikkatli olun, onaylamadan sonra cihaz ve tüm ilgili veriler kurtarýlamaz.", - "delete-devices-title": "{Count, çoðul, 1 {1 cihaz} diðer {# devices}} silmek istediðinizden emin misiniz?", - "delete-devices-action-title": "Sil {count, çoðul, 1 {1 cihaz} diðer {# devices}}", - "delete-devices-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen cihazlar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", - "unassign-device-title": "Cihazýn atamasýný kaldýrmak istediðinizden emin misiniz? {{DeviceName}} ? ", - "unassign-device-text": "Onaydan sonra cihaz atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", - "unassign-device": "Atanmamýþ cihaz", - "unassign-devices-title": "Sayým, çoðul, 1 {1 cihaz} diðer {# devices}} atama atamak istediðinizden emin misiniz?", - "unassign-devices-text": "Onaylandýktan sonra tüm seçilen cihazlar atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", + "delete-device-title": "Cihazı {{deviceName}} silmek istediğinizden emin misiniz? ", + "delete-device-text": "Dikkatli olun, onaylamadan sonra cihaz ve tüm ilgili veriler kurtarılamaz.", + "delete-devices-title": "{Count, çoğul, 1 {1 cihaz} diğer {# devices}} silmek istediğinizden emin misiniz?", + "delete-devices-action-title": "Sil {count, çoğul, 1 {1 cihaz} diğer {# devices}}", + "delete-devices-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen cihazlar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", + "unassign-device-title": "Cihazın atamasını kaldırmak istediğinizden emin misiniz? {{DeviceName}} ? ", + "unassign-device-text": "Onaydan sonra cihaz atanmamış olacak ve müşteri tarafından erişilemeyecektir.", + "unassign-device": "Atanmamış cihaz", + "unassign-devices-title": "Sayım, çoğul, 1 {1 cihaz} diğer {# devices}} atama atamak istediğinizden emin misiniz?", + "unassign-devices-text": "Onaylandıktan sonra tüm seçilen cihazlar atanmamış olacak ve müşteri tarafından erişilemeyecektir.", "device-credentials": "Cihaz Kimlik Bilgileri", "credentials-type": "Kimlik bilgileri türü", - "access-token": "Eriþim belirteci", - "access-token-required": "Eriþim belirteci gerekli.", - "access-token-invalid": "Eriþim belirteci uzunluðu 1 ile 20 karakter arasýnda olmalýdýr.", - "rsa-key": "RSA ortak anahtarý", - "rsa-key-required": "RSA ortak anahtarý gerekli.", + "access-token": "Erişim belirteci", + "access-token-required": "Erişim belirteci gerekli.", + "access-token-invalid": "Erişim belirteci uzunluğu 1 ile 20 karakter arasında olmalıdır.", + "rsa-key": "RSA ortak anahtarı", + "rsa-key-required": "RSA ortak anahtarı gerekli.", "secret": "Gizli", "secret-required": "Gizli gerekli", "device-type": "Cihaz tipi", @@ -632,268 +632,268 @@ "select-device-type": "Cihaz türünü seç", "enter-device-type": "Cihaz türünü girin", "any-device": "Herhangi bir cihaz", - "no-device-types-matching": "{{EntitySubtype}} ile eþleþen cihaz türü bulunamadý. ", + "no-device-types-matching": "{{EntitySubtype}} ile eşleşen cihaz türü bulunamadı. ", "device-type-list-empty": "Hiçbir cihaz türü seçilmedi.", "device-types": "Cihaz türleri", "name": "Ad", - "name-required": "Ýsim gerekli.", - "description": "Açýklama", + "name-required": "İsim gerekli.", + "description": "Açıklama", "events": "Etkinlikler", - "details": "Ayrýntýlar", - "copyId": "Cihaz kimliðini kopyala", - "copyAccessToken": "Eriþim belirteci kopyala", - "idCopiedMessage": "Cihaz Kimliði panoya kopyalandý", - "accessTokenCopiedMessage": "Cihaz eriþim belirteci panoya kopyalandý", - "assignedToCustomer": "Müþteriye atandý", - "unable-delete-device-alias-title": "Cihaz takma adý silinemiyor", - "unable-delete-device-alias-text": "Cihaz takma adý {{deviceAlias}} ', þu widget (lar) tarafýndan kullanýldýðý þekliyle silinemiyor:
{{widgetsList}} ", - "is-gateway": "Að geçidi", - "public": "Halka açýk", - "device-public": "Cihaz herkese açýk", + "details": "Ayrıntılar", + "copyId": "Cihaz kimliğini kopyala", + "copyAccessToken": "Erişim belirteci kopyala", + "idCopiedMessage": "Cihaz Kimliği panoya kopyalandı", + "accessTokenCopiedMessage": "Cihaz erişim belirteci panoya kopyalandı", + "assignedToCustomer": "Müşteriye atandı", + "unable-delete-device-alias-title": "Cihaz takma adı silinemiyor", + "unable-delete-device-alias-text": "Cihaz takma adı {{deviceAlias}} ', şu widget (lar) tarafından kullanıldığı şekliyle silinemiyor:
{{widgetsList}} ", + "is-gateway": "Ağ geçidi", + "public": "Halka açık", + "device-public": "Cihaz herkese açık", "select-device": "Cihaz seç" }, "dialog": { - "close": "Ýletiþim kutusunu kapat" + "close": "İletişim kutusunu kapat" }, "error": { - "unable-to-connect": "Sunucuya baðlanýlamýyor! Lütfen Ýnternet baðlantýnýzý kontrol edin.", - "unhandled-error-code": "Ýþlenmemiþ hata kodu: {{errorCode}}", + "unable-to-connect": "Sunucuya bağlanılamıyor! Lütfen İnternet bağlantınızı kontrol edin.", + "unhandled-error-code": "İşlenmemiş hata kodu: {{errorCode}}", "unknown-error": "Bilinmeyen hata" }, "entity": { - "entity": "Varlýk", - "entities": "Varlýklarý", - "aliases": "Varlýk takma adlarý", - "entity-alias": "Varlýk takma adý", - "unable-delete-entity-alias-title": "Varlýk takma adý silinemiyor", - "unable-delete-entity-alias-text": "Varlýk takma adý {{entityAlias}} ', þu widget (lar) tarafýndan kullanýldýðý þekliyle silinemez:
{{widgetsList}} ", - "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '... Entity takma adlar, gösterge panosunda benzersiz olmalýdýr. ", - "missing-entity-filter-error": "Diðer adlar için filtre eksik {{alias}}. ", - "configure-alias": "Yapýlandýrma {{alias}} takma ad", + "entity": "Varlık", + "entities": "Varlıkları", + "aliases": "Varlık takma adları", + "entity-alias": "Varlık takma adı", + "unable-delete-entity-alias-title": "Varlık takma adı silinemiyor", + "unable-delete-entity-alias-text": "Varlık takma adı {{entityAlias}} ', şu widget (lar) tarafından kullanıldığı şekliyle silinemez:
{{widgetsList}} ", + "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '... Entity takma adlar, gösterge panosunda benzersiz olmalıdır. ", + "missing-entity-filter-error": "Diğer adlar için filtre eksik {{alias}}. ", + "configure-alias": "Yapılandırma {{alias}} takma ad", "alias": "Alias", - "alias-required": "Varlýk takma adý gerekiyor.", - "remove-alias": "Varlýk takma adýný kaldýrma", - "add-alias": "Varlýk takma adý ekle", - "entity-list": "Varlýk listesi", - "entity-type": "Varlýk türü", - "entity-types": "Varlýk türleri", - "entity-type-list": "Varlýk türü listesi", - "any-entity": "Herhangi bir varlýk", - "enter-entity-type": "Varlýk türü girin", - "no-entities-matching": "{{Entity}} ile eþleþen hiçbir varlýk bulunamadý. ", - "no-entity-types-matching": "{{EntityType}} ile eþleþen hiçbir varlýk türü bulunamadý. ", - "name-starts-with": "Ýsim ile baþlar", + "alias-required": "Varlık takma adı gerekiyor.", + "remove-alias": "Varlık takma adını kaldırma", + "add-alias": "Varlık takma adı ekle", + "entity-list": "Varlık listesi", + "entity-type": "Varlık türü", + "entity-types": "Varlık türleri", + "entity-type-list": "Varlık türü listesi", + "any-entity": "Herhangi bir varlık", + "enter-entity-type": "Varlık türü girin", + "no-entities-matching": "{{Entity}} ile eşleşen hiçbir varlık bulunamadı. ", + "no-entity-types-matching": "{{EntityType}} ile eşleşen hiçbir varlık türü bulunamadı. ", + "name-starts-with": "İsim ile başlar", "use-entity-name-filter": "Filtre kullan", - "entity-list-empty": "Hiçbir varlýk seçilmedi.", - "entity-type-list-empty": "Hiçbir varlýk türü seçilmedi.", - "entity-name-filter-required": "Varlýk adý filtresi gerekli.", - "entity-name-filter-no-entity-matched": "{{Entity}} ile baþlayan hiçbir varlýk bulunamadý. ", - "all-subtypes": "Herþey", - "select-entities": "Öðeleri seç", - "no-aliases-found": "Takma ad bulunamadý", - "no-alias-matching": "'{{alias}} bulunamadý. ", - "create-new-alias": "Yeni bir tane oluþtur!", + "entity-list-empty": "Hiçbir varlık seçilmedi.", + "entity-type-list-empty": "Hiçbir varlık türü seçilmedi.", + "entity-name-filter-required": "Varlık adı filtresi gerekli.", + "entity-name-filter-no-entity-matched": "{{Entity}} ile başlayan hiçbir varlık bulunamadı. ", + "all-subtypes": "Herşey", + "select-entities": "Öğeleri seç", + "no-aliases-found": "Takma ad bulunamadı", + "no-alias-matching": "'{{alias}} bulunamadı. ", + "create-new-alias": "Yeni bir tane oluştur!", "key": "Anahtar", - "key-name": "Anahtar adý", - "no-keys-found": "Anahtar bulunamadý.", - "no-key-matching": "'{{anahtar bulunamadý.", - "create-new-key": "Yeni bir tane oluþtur!", + "key-name": "Anahtar adı", + "no-keys-found": "Anahtar bulunamadı.", + "no-key-matching": "'{{anahtar bulunamadı.", + "create-new-key": "Yeni bir tane oluştur!", "type": "Tür", - "type-required": "Varlýk türü gerekli.", + "type-required": "Varlık türü gerekli.", "type-device": "Cihaz", "type-devices": "Cihazlar", - "list-of-devices": "{sayým, çoðul, 1 {Bir cihaz} diðer {# cihazlarýn listesi}}", - "device-name-starts-with": "Ýsimleri '{{prefix}} ile baþlayan cihazlar ", - "type-asset": "Varlýk", - "type-assets": "Varlýklar", - "list-of-assets": "{count, plural, 1 {One asset} diðer {# asset}}", - "asset-name-starts-with": "Adlarý {{prefix}} ile baþlayan varlýklar ", - "type-entity-view": "Varlýk Görünümü", - "type-entity-views": "Varlýk Görünümleri", - "list-of-entity-views": "{count, çoðul, 1 {Bir varlýk görünümü} diðer {# varlýk görüntüleme}} listesi", - "entity-view-name-starts-with": "Adý {{önek}} ile baþlayan varlýk görünümleri", + "list-of-devices": "{sayım, çoğul, 1 {Bir cihaz} diğer {# cihazların listesi}}", + "device-name-starts-with": "İsimleri '{{prefix}} ile başlayan cihazlar ", + "type-asset": "Varlık", + "type-assets": "Varlıklar", + "list-of-assets": "{count, plural, 1 {One asset} diğer {# asset}}", + "asset-name-starts-with": "Adları {{prefix}} ile başlayan varlıklar ", + "type-entity-view": "Varlık Görünümü", + "type-entity-views": "Varlık Görünümleri", + "list-of-entity-views": "{count, çoğul, 1 {Bir varlık görünümü} diğer {# varlık görüntüleme}} listesi", + "entity-view-name-starts-with": "Adı {{önek}} ile başlayan varlık görünümleri", "type-rule": "Kural", "type-rules": "Kurallar", - "list-of-rules": "{count, çoðul, 1 {Bir kural} diðer {# kurallarýn}} listesi", - "rule-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kurallar", + "list-of-rules": "{count, çoğul, 1 {Bir kural} diğer {# kuralların}} listesi", + "rule-name-starts-with": "İsimleri {{prefix}} ile başlayan kurallar", "type-plugin": "Eklenti", "type-plugins": "Eklentiler", - "list-of-plugins": "{count, çoðul, 1 {Bir eklenti} diðer {# eklenti listesi}}", - "plugin-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan eklentiler", - "type-tenant": "Kiracý", - "type-tenants": "Kiracýlar", - "list-of-tenants": "{count, çoðul, 1 {Bir kiracý} diðer {# kiracýlarýn listesi}}", - "tenant-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kiracýlar, ", - "type-customer": "Müþteri", - "type-customers": "Müþteriler", - "list-of-customers": "{count, çoðul, 1 {Bir müþteri} diðer {# müþteri}} listesi", - "customer-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan müþteriler,", - "type-user": "Kullanýcý", - "type-users": "Kullanýcýlar", - "list-of-users": "{count, çoðul, 1 {Bir kullanýcý} diðer {# user}} listesi", - "user-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kullanýcýlar", + "list-of-plugins": "{count, çoğul, 1 {Bir eklenti} diğer {# eklenti listesi}}", + "plugin-name-starts-with": "İsimleri {{prefix}} ile başlayan eklentiler", + "type-tenant": "Kiracı", + "type-tenants": "Kiracılar", + "list-of-tenants": "{count, çoğul, 1 {Bir kiracı} diğer {# kiracıların listesi}}", + "tenant-name-starts-with": "İsimleri {{prefix}} ile başlayan kiracılar, ", + "type-customer": "Müşteri", + "type-customers": "Müşteriler", + "list-of-customers": "{count, çoğul, 1 {Bir müşteri} diğer {# müşteri}} listesi", + "customer-name-starts-with": "İsimleri {{prefix}} ile başlayan müşteriler,", + "type-user": "Kullanıcı", + "type-users": "Kullanıcılar", + "list-of-users": "{count, çoğul, 1 {Bir kullanıcı} diğer {# user}} listesi", + "user-name-starts-with": "İsimleri {{prefix}} ile başlayan kullanıcılar", "type-dashboard": "Pano", - "type-dashboards": "Gösterge tablolarý", - "list-of-dashboards": "{count, çoðul, 1 {Bir pano} Diðer {# panolarýn}}} listesi", - "dashboard-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan panolar", + "type-dashboards": "Gösterge tabloları", + "list-of-dashboards": "{count, çoğul, 1 {Bir pano} Diğer {# panoların}}} listesi", + "dashboard-name-starts-with": "İsimleri {{prefix}} ile başlayan panolar", "type-alarm": "Alarm", "type-alarms": "Alarmlar", - "list-of-alarms": "{count, çoðul, 1 {Bir alarm} diðer {{# alarm}} listesi", - "alarm-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan alarmlar", + "list-of-alarms": "{count, çoğul, 1 {Bir alarm} diğer {{# alarm}} listesi", + "alarm-name-starts-with": "İsimleri {{prefix}} ile başlayan alarmlar", "type-rulechain": "Kural zinciri", "type-rulechains": "Kural zincirleri", - "list-of-rulechains": "{count, çoðul, 1 {Bir kural zinciri} diðer {# kural zincirinin listesi}}", - "rulechain-name-starts-with": "Ýsimleri {{prefix}} ile baþlayan kural zincirleri", - "type-rulenode": "Kural düðümü", - "type-rulenodes": "Kural düðümleri", - "list-of-rulenodes": "{count, çoðul, 1 {Bir kural node} diðer {# kural düðümünün listesi}}", - "rulenode-name-starts-with": "Ýsimleri '{{prefix}} ile baþlayan kural düðümleri", - "type-current-customer": "Mevcut Müþteri", - "search": "Varlýklar ara", - "selected-entities": "{count, çoðul, 1 {1 varlýk} diðer {# entities}} seçildi", - "entity-name": "Varlýk adý", - "details": "Varlýk ayrýntýlarý", - "no-entities-prompt": "Hiçbir varlýk bulunamadý", + "list-of-rulechains": "{count, çoğul, 1 {Bir kural zinciri} diğer {# kural zincirinin listesi}}", + "rulechain-name-starts-with": "İsimleri {{prefix}} ile başlayan kural zincirleri", + "type-rulenode": "Kural düğümü", + "type-rulenodes": "Kural düğümleri", + "list-of-rulenodes": "{count, çoğul, 1 {Bir kural node} diğer {# kural düğümünün listesi}}", + "rulenode-name-starts-with": "İsimleri '{{prefix}} ile başlayan kural düğümleri", + "type-current-customer": "Mevcut Müşteri", + "search": "Varlıklar ara", + "selected-entities": "{count, çoğul, 1 {1 varlık} diğer {# entities}} seçildi", + "entity-name": "Varlık adı", + "details": "Varlık ayrıntıları", + "no-entities-prompt": "Hiçbir varlık bulunamadı", "no-data": "Gösterilecek bilgi yok" }, "entity-view": { - "entity-view": "Varlýk Görünümü", - "entity-views": "Varlýk Görünümleri", - "management": "Varlýk Görünümü yönetimi", - "view-entity-views": "Varlýk Görünümlerini Görüntüle", - "entity-view-alias": "Varlýk Görünümü takma adý", - "aliases": "Varlýk Görünümü takma adlarý", - "no-alias-matching": "'{{alias}} bulunamadý. ", - "no-aliases-found": "Takma ad bulunamadý", - "no-key-matching": "'{{anahtar bulunamadý.", - "no-keys-found": "Anahtar bulunamadý.", - "create-new-alias": "Yeni bir tane oluþtur!", - "create-new-key": "Yeni bir tane oluþtur!", - "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '.. Entity View diðer adlar, gösterge panosunda benzersiz olmalýdýr. ", - "configure-alias": "Yapýlandýrma {{alias}} takma ad", - "no-entity-views-matching": "{{Entity}} ile eþleþen hiçbir varlýk yorumu bulunamadý. ", + "entity-view": "Varlık Görünümü", + "entity-views": "Varlık Görünümleri", + "management": "Varlık Görünümü yönetimi", + "view-entity-views": "Varlık Görünümlerini Görüntüle", + "entity-view-alias": "Varlık Görünümü takma adı", + "aliases": "Varlık Görünümü takma adları", + "no-alias-matching": "'{{alias}} bulunamadı. ", + "no-aliases-found": "Takma ad bulunamadı", + "no-key-matching": "'{{anahtar bulunamadı.", + "no-keys-found": "Anahtar bulunamadı.", + "create-new-alias": "Yeni bir tane oluştur!", + "create-new-key": "Yeni bir tane oluştur!", + "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '.. Entity View diğer adlar, gösterge panosunda benzersiz olmalıdır. ", + "configure-alias": "Yapılandırma {{alias}} takma ad", + "no-entity-views-matching": "{{Entity}} ile eşleşen hiçbir varlık yorumu bulunamadı. ", "alias": "Alias", - "alias-required": "Varlýk Görünümü takma adý gerekiyor.", - "remove-alias": "Varlýk görünümü takma adýný kaldýr", - "add-alias": "Varlýk görünümü takma adý ekle", - "name-starts-with": "Varlýk Görünümü adý ile baþlýyor", - "entity-view-list": "Varlýk Görünümü listesi", + "alias-required": "Varlık Görünümü takma adı gerekiyor.", + "remove-alias": "Varlık görünümü takma adını kaldır", + "add-alias": "Varlık görünümü takma adı ekle", + "name-starts-with": "Varlık Görünümü adı ile başlıyor", + "entity-view-list": "Varlık Görünümü listesi", "use-entity-view-name-filter": "Filtre kullan", - "entity-view-list-empty": "Hiçbir varlýk görüþü seçilmedi.", - "entity-view-name-filter-required": "Varlýk görünüm adý filtresi gerekli.", - "entity-view-name-filter-no-entity-view-matched": "{{EntityView}} ile baþlayan hiçbir varlýk sayýsý bulunamadý.", - "add": "Varlýk Görünümü Ekle", - "assign-to-customer": "Müþteriye atama", - "assign-entity-view-to-customer": "Varlýk Görünümlerini Müþteriye Atama", - "assign-entity-view-to-customer-text": "Lütfen müþteriye atamak için varlýk görünümlerini seçin", - "no-entity-views-text": "Varlýk görüþü bulunamadý", - "assign-to-customer-text": "Lütfen varlýk görünümlerini atamak için müþteriyi seçin", - "entity-view-details": "Varlýk görünümü ayrýntýlarý", - "add-entity-view-text": "Yeni varlýk görünümü ekle", - "delete": "Varlýk görünümünü sil", - "assign-entity-views": "Varlýk görünümleri atama", - "assign-entity-views-text": "Müþteriye {count, çoðul, 1 {1 entityView} diðer {# entityViews}} atayýn ", - "delete-entity-views": "Varlýk görünümlerini sil", - "unassign-from-customer": "Müþteriden atama", - "unassign-entity-views": "Varlýk görünümlerini atama", - "unassign-entity-views-action-title": "Müþteriden atama sayým, çoðul, 1 {1 entityView} diðer {# entityViews}}", - "assign-new-entity-view": "Yeni varlýk görünümü atama", - "delete-entity-view-title": "Varlýk görünümünü silmek istediðinizden emin misiniz?, {{EntityViewName}} '? ", - "delete-entity-view-text": "Dikkatli olun, onaylandýktan sonra varlýk görünümü ve ilgili tüm veriler kurtarýlamayacak.", - "delete-entity-views-title": "{Count, çoðul, 1 {1 entityView} diðer {# entityViews}} varlýk görünümüne sahip olmak istediðinizden emin misiniz?", - "delete-entity-views-action-title": "Sil {count, çoðul, 1 {1 entityView} diðer {# entityViews}}", - "delete-entity-views-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen görünümler kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", - "unassign-entity-view-title": "Varlýk görünümünün atamasýný kaldýrmak istediðinizden emin misiniz? {{EntityViewName}} '? ", - "unassign-entity-view-text": "Onaydan sonra varlýk görünümü atanmamýþ olacak ve müþteri tarafýndan eriþilemeyecektir.", - "unassign-entity-view": "Varlýk görünümünün atamasýný kaldýr", - "unassign-entity-views-title": "Sayým, çoðul, 1 {1 entityView} diðer {# entityViews}} hesabýnýn atamasýný kaldýrmak istediðinizden emin misiniz?", - "unassign-entity-views-text": "Onaylandýktan sonra, seçilen tüm öðe görünümleri atamadan kaldýrýlacak ve müþteri tarafýndan eriþilemeyecektir.", - "entity-view-type": "Varlýk Görünümü türü", - "entity-view-type-required": "Varlýk Görünümü türü gerekli.", - "select-entity-view-type": "Varlýk görüntüleme türünü seç", - "enter-entity-view-type": "Varlýk görüntüleme türünü girin", - "any-entity-view": "Herhangi bir varlýk görünümü", - "no-entity-view-types-matching": "{{EntitySubtype}} ile eþleþen hiçbir varlýk görüntüleme türü bulunamadý. ", - "entity-view-type-list-empty": "Hiçbir varlýk görünümü türü seçilmemiþ.", - "entity-view-types": "Varlýk Görünümü türleri", + "entity-view-list-empty": "Hiçbir varlık görüşü seçilmedi.", + "entity-view-name-filter-required": "Varlık görünüm adı filtresi gerekli.", + "entity-view-name-filter-no-entity-view-matched": "{{EntityView}} ile başlayan hiçbir varlık sayısı bulunamadı.", + "add": "Varlık Görünümü Ekle", + "assign-to-customer": "Müşteriye atama", + "assign-entity-view-to-customer": "Varlık Görünümlerini Müşteriye Atama", + "assign-entity-view-to-customer-text": "Lütfen müşteriye atamak için varlık görünümlerini seçin", + "no-entity-views-text": "Varlık görüşü bulunamadı", + "assign-to-customer-text": "Lütfen varlık görünümlerini atamak için müşteriyi seçin", + "entity-view-details": "Varlık görünümü ayrıntıları", + "add-entity-view-text": "Yeni varlık görünümü ekle", + "delete": "Varlık görünümünü sil", + "assign-entity-views": "Varlık görünümleri atama", + "assign-entity-views-text": "Müşteriye {count, çoğul, 1 {1 entityView} diğer {# entityViews}} atayın ", + "delete-entity-views": "Varlık görünümlerini sil", + "unassign-from-customer": "Müşteriden atama", + "unassign-entity-views": "Varlık görünümlerini atama", + "unassign-entity-views-action-title": "Müşteriden atama sayım, çoğul, 1 {1 entityView} diğer {# entityViews}}", + "assign-new-entity-view": "Yeni varlık görünümü atama", + "delete-entity-view-title": "Varlık görünümünü silmek istediğinizden emin misiniz?, {{EntityViewName}} '? ", + "delete-entity-view-text": "Dikkatli olun, onaylandıktan sonra varlık görünümü ve ilgili tüm veriler kurtarılamayacak.", + "delete-entity-views-title": "{Count, çoğul, 1 {1 entityView} diğer {# entityViews}} varlık görünümüne sahip olmak istediğinizden emin misiniz?", + "delete-entity-views-action-title": "Sil {count, çoğul, 1 {1 entityView} diğer {# entityViews}}", + "delete-entity-views-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen görünümler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", + "unassign-entity-view-title": "Varlık görünümünün atamasını kaldırmak istediğinizden emin misiniz? {{EntityViewName}} '? ", + "unassign-entity-view-text": "Onaydan sonra varlık görünümü atanmamış olacak ve müşteri tarafından erişilemeyecektir.", + "unassign-entity-view": "Varlık görünümünün atamasını kaldır", + "unassign-entity-views-title": "Sayım, çoğul, 1 {1 entityView} diğer {# entityViews}} hesabının atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-views-text": "Onaylandıktan sonra, seçilen tüm öğe görünümleri atamadan kaldırılacak ve müşteri tarafından erişilemeyecektir.", + "entity-view-type": "Varlık Görünümü türü", + "entity-view-type-required": "Varlık Görünümü türü gerekli.", + "select-entity-view-type": "Varlık görüntüleme türünü seç", + "enter-entity-view-type": "Varlık görüntüleme türünü girin", + "any-entity-view": "Herhangi bir varlık görünümü", + "no-entity-view-types-matching": "{{EntitySubtype}} ile eşleşen hiçbir varlık görüntüleme türü bulunamadı. ", + "entity-view-type-list-empty": "Hiçbir varlık görünümü türü seçilmemiş.", + "entity-view-types": "Varlık Görünümü türleri", "name": "Ad", - "name-required": "Ýsim gerekli.", - "description": "Açýklama", + "name-required": "İsim gerekli.", + "description": "Açıklama", "events": "Etkinlikler", - "details": "Ayrýntýlar", - "copyId": "Varlýk görüntüleme kimliðini kopyala", - "assignedToCustomer": "Müþteriye atandý", - "unable-entity-view-device-alias-title": "Varlýk görünümü takma adý silinemiyor", - "unable-entity-view-device-alias-text": "Cihaz takma adý {{entityViewAlias}} ', aþaðýdaki widget (lar) tarafýndan kullanýldýðý þekliyle silinemez:
{{widgetsList}} ", - "select-entity-view": "Varlýk görünümünü seç", - "make-public": "Varlýðý herkese görünür yap", + "details": "Ayrıntılar", + "copyId": "Varlık görüntüleme kimliğini kopyala", + "assignedToCustomer": "Müşteriye atandı", + "unable-entity-view-device-alias-title": "Varlık görünümü takma adı silinemiyor", + "unable-entity-view-device-alias-text": "Cihaz takma adı {{entityViewAlias}} ', aşağıdaki widget (lar) tarafından kullanıldığı şekliyle silinemez:
{{widgetsList}} ", + "select-entity-view": "Varlık görünümünü seç", + "make-public": "Varlığı herkese görünür yap", "start-ts": "Ts", "end-ts": "End ts" }, "event": { "event-type": "Etkinlik tipi", "type-error": "Hata", - "type-lc-event": "Yaþam döngüsü etkinliði", - "type-stats": "Ýstatistik", - "type-debug-rule-node": "Hata ayýklama", - "type-debug-rule-chain": "Hata ayýklama", - "no-events-prompt": "Etkinlik bulunamadý", + "type-lc-event": "Yaşam döngüsü etkinliği", + "type-stats": "İstatistik", + "type-debug-rule-node": "Hata ayıklama", + "type-debug-rule-chain": "Hata ayıklama", + "no-events-prompt": "Etkinlik bulunamadı", "error": "Hata", "alarm": "Alarm", - "event-time": "Etkinlik zamaný", + "event-time": "Etkinlik zamanı", "server": "Sunucu", "body": "Vücut", "method": "Yöntem", "type": "Tür", - "entity": "Varlýk", - "message-id": "Mesaj Kimliði", + "entity": "Varlık", + "message-id": "Mesaj Kimliği", "message-type": "Mesaj tipi", "data-type": "Veri tipi", - "relation-type": "Ýliþki Türü", + "relation-type": "İlişki Türü", "metadata": "Meta veri", "data": "Veri", "event": "Etkinlik", "status": "Durum", - "success": "Baþarý", - "failed": "Baþarýsýz oldu", - "messages-processed": "Mesajlar iþlendi", - "errors-occurred": "Hatalar oluþtu" + "success": "Başarı", + "failed": "Başarısız oldu", + "messages-processed": "Mesajlar işlendi", + "errors-occurred": "Hatalar oluştu" }, "extension": { - "extensions": "Uzantýlar", - "selected-extensions": "{count, çoðul, 1 {1 uzantý} diðer {# extensions}} seçildi", + "extensions": "Uzantılar", + "selected-extensions": "{count, çoğul, 1 {1 uzantı} diğer {# extensions}} seçildi", "type": "Tür", "key": "Anahtar", - "value": "Deðer", - "id": "ÝD", - "extension-id": "Uzantý kimliði", + "value": "Değer", + "id": "İD", + "extension-id": "Uzantı kimliği", "extension-type": "Uzatma tipi", "transformer-json": "JSON *", - "unique-id-required": "Mevcut uzantý kimliði zaten mevcut.", - "delete": "Uzantýyý sil", - "add": "Uzantý eklemek", - "edit": "Uzantýyý düzenle", - "delete-extension-title": "{{ExtensionId}} uzantýsýný silmek istediðinizden emin misiniz? ", - "delete-extension-text": "Dikkatli olun, onaylamadan sonra uzantý ve ilgili tüm veriler kurtarýlamaz.", - "delete-extensions-title": "{Count, çoðul, 1 {1 uzantý} diðer {# extensions}} silmek istediðinizden emin misiniz?", - "delete-extensions-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen uzantýlar kaldýrýlacak.", - "converters": "Dönüþtürücü", - "converter-id": "Dönüþtürücü kimliði", - "configuration": "Yapýlandýrma", - "converter-configurations": "Dönüþtürücü yapýlandýrmalarý", + "unique-id-required": "Mevcut uzantı kimliği zaten mevcut.", + "delete": "Uzantıyı sil", + "add": "Uzantı eklemek", + "edit": "Uzantıyı düzenle", + "delete-extension-title": "{{ExtensionId}} uzantısını silmek istediğinizden emin misiniz? ", + "delete-extension-text": "Dikkatli olun, onaylamadan sonra uzantı ve ilgili tüm veriler kurtarılamaz.", + "delete-extensions-title": "{Count, çoğul, 1 {1 uzantı} diğer {# extensions}} silmek istediğinizden emin misiniz?", + "delete-extensions-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen uzantılar kaldırılacak.", + "converters": "Dönüştürücü", + "converter-id": "Dönüştürücü kimliği", + "configuration": "Yapılandırma", + "converter-configurations": "Dönüştürücü yapılandırmaları", "token": "Güvenlik belirteci", - "add-converter": "Dönüþtürücü ekle", - "add-config": "Dönüþtürücü yapýlandýrmasý ekle", - "device-name-expression": "Cihaz adý ifadesi", + "add-converter": "Dönüştürücü ekle", + "add-config": "Dönüştürücü yapılandırması ekle", + "device-name-expression": "Cihaz adı ifadesi", "device-type-expression": "Cihaz tipi ifadesi", "custom": "Özel", "to-double": "Çifte", "transformer": "Transformer", "json-required": "Trafo jsonu gerekli.", - "json-parse": "Trafo json ayrýþtýrýlamýyor.", + "json-parse": "Trafo json ayrıştırılamıyor.", "attributes": "Öznitellikler", "add-attribute": "Özellik ekle", - "add-map": "Eþleme elemaný ekle", + "add-map": "Eşleme elemanı ekle", "timeseries": "Zaman serisi", "add-timeseries": "Zaman çizelgeleri ekle", "field-required": "Alan gereklidir", @@ -901,26 +901,26 @@ "add-broker": "Broker ekle", "host": "Host", "port": "Liman", - "port-range": "Liman 1'den 65535'e kadar olmalýdýr.", + "port-range": "Liman 1'den 65535'e kadar olmalıdır.", "ssl": "SSL", "credentials": "Kimlik bilgileri", - "username": "Kullanýcý adý", + "username": "Kullanıcı adı", "password": "Parola", - "retry-interval": "Milisaniye cinsinden tekrar deneme aralýðý", + "retry-interval": "Milisaniye cinsinden tekrar deneme aralığı", "anonymous": "Anonim", "basic": "Temel", "pem": "PEM", - "ca-cert": "CA sertifika dosyasý *", + "ca-cert": "CA sertifika dosyası *", "private-key": "Özel anahtar dosya *", - "cert": "Sertifika dosyasý *", + "cert": "Sertifika dosyası *", "no-file": "Dosya seçilmedi.", - "drop-file": "Bir dosya býrakýn veya yüklenecek bir dosya seçmek için týklayýn.", + "drop-file": "Bir dosya bırakın veya yüklenecek bir dosya seçmek için tıklayın.", "mapping": "Mapping", "topic-filter": "Konu filtresi", - "converter-type": "Dönüþtürücü tipi", + "converter-type": "Dönüştürücü tipi", "converter-json": "Json", - "json-name-expression": "Cihaz adý json ifadesi", - "topic-name-expression": "Cihaz adý konu ifadesi", + "json-name-expression": "Cihaz adı json ifadesi", + "topic-name-expression": "Cihaz adı konu ifadesi", "json-type-expression": "Cihaz tipi json ifadesi", "topic-type-expression": "Cihaz tipi konu ifadesi", "attribute-key-expression": "Öznitelik anahtar ifadesi", @@ -929,35 +929,35 @@ "request-id-expression": "Kimlik ifadesi iste", "request-id-json-expression": "Kimlik json ifadesi iste", "request-id-topic-expression": "Kimlik konu ifadesini isteyin", - "response-topic-expression": "Yanýt konusu ifadesi", - "value-expression": "Deðer ifadesi", + "response-topic-expression": "Yanıt konusu ifadesi", + "value-expression": "Değer ifadesi", "topic": "Konu", - "timeout": "Zaman aþýmý milisaniye cinsinden", - "converter-json-required": "Dönüþtürücü json gerekli.", - "converter-json-parse": "Dönüþtürücü json ayrýþtýrýlamýyor.", + "timeout": "Zaman aşımı milisaniye cinsinden", + "converter-json-required": "Dönüştürücü json gerekli.", + "converter-json-parse": "Dönüştürücü json ayrıştırılamıyor.", "filter-expression": "Filtre ifadesi", - "connect-requests": "Ýstekleri baðla", - "add-connect-request": "Baðlantý talebi ekle", - "disconnect-requests": "Ýstekleri kes", - "add-disconnect-request": "Baðlantýyý kes isteði ekle", + "connect-requests": "İstekleri bağla", + "add-connect-request": "Bağlantı talebi ekle", + "disconnect-requests": "İstekleri kes", + "add-disconnect-request": "Bağlantıyı kes isteği ekle", "attribute-requests": "Özellik istekleri", - "add-attribute-request": "Özellik isteði ekle", + "add-attribute-request": "Özellik isteği ekle", "attribute-updates": "Öznitelik güncellemeleri", "add-attribute-update": "Özellik güncellemesi ekle", - "server-side-rpc": "Sunucu tarafý RPC", - "add-server-side-rpc-request": "Sunucu tarafý RPC isteði ekle", - "device-name-filter": "Cihaz adý filtresi", + "server-side-rpc": "Sunucu tarafı RPC", + "add-server-side-rpc-request": "Sunucu tarafı RPC isteği ekle", + "device-name-filter": "Cihaz adı filtresi", "attribute-filter": "Özellik filtresi", "method-filter": "Yöntem filtresi", "request-topic-expression": "Konu ifadesi iste", - "response-timeout": "Milisaniye cinsinden yanýt zaman aþýmý", + "response-timeout": "Milisaniye cinsinden yanıt zaman aşımı", "topic-expression": "Konu ifadesi", - "client-scope": "Müþteri kapsamý", + "client-scope": "Müşteri kapsamı", "add-device": "Cihaz ekle", "opc-server": "Sunucular", "opc-add-server": "Sunucu ekle", "opc-add-server-prompt": "Lütfen sunucu ekle", - "opc-application-name": "Uygulama Adý", + "opc-application-name": "Uygulama Adı", "opc-application-uri": "Uygulama uri", "opc-scan-period-in-seconds": "Saniyeler içinde tarama süresi", "opc-security": "Güvenlik", @@ -968,120 +968,120 @@ "opc-keystore-location": "Yer *", "opc-keystore-password": "Parola", "opc-keystore-alias": "Alias", - "opc-keystore-key-password": "Anahtar þifre", - "opc-device-node-pattern": "Cihaz düðümü modeli", - "opc-device-name-pattern": "Cihaz adý deseni", + "opc-keystore-key-password": "Anahtar şifre", + "opc-device-node-pattern": "Cihaz düğümü modeli", + "opc-device-name-pattern": "Cihaz adı deseni", "modbus-server": "Sunucular / köle", "modbus-add-server": "Sunucu ekle / köle", "modbus-add-server-prompt": "Lütfen sunucu / slave ekle", - "modbus-transport": "Taþýma", - "modbus-port-name": "Seri port adý", + "modbus-transport": "Taşıma", + "modbus-port-name": "Seri port adı", "modbus-encoding": "Kodlama", "modbus-parity": "Parite", - "modbus-baudrate": "Baud hýzý", + "modbus-baudrate": "Baud hızı", "modbus-databits": "Veri bitleri", "modbus-stopbits": "Bitleri durdur", - "modbus-databits-range": "Veri bitleri 7 ila 8 arasýnda olmalýdýr", - "modbus-stopbits-range": "Durma bitleri 1'den 2'ye kadar olmalýdýr.", - "modbus-unit-id": "Birim Kimliði", - "modbus-unit-id-range": "Birim numarasý 1 ile 247 arasýnda olmalýdýr.", - "modbus-device-name": "Cihaz adý", + "modbus-databits-range": "Veri bitleri 7 ila 8 arasında olmalıdır", + "modbus-stopbits-range": "Durma bitleri 1'den 2'ye kadar olmalıdır.", + "modbus-unit-id": "Birim Kimliği", + "modbus-unit-id-range": "Birim numarası 1 ile 247 arasında olmalıdır.", + "modbus-device-name": "Cihaz adı", "modbus-poll-period": "Anket dönemi (ms)", "modbus-attributes-poll-period": "Nitelikler yoklama süresi (ms)", "modbus-timeseries-poll-period": "Timeseries anket süresi (ms)", - "modbus-poll-period-range": "Anket dönemi pozitif deðer olmalý", + "modbus-poll-period-range": "Anket dönemi pozitif değer olmalı", "modbus-tag": "Etiket", - "modbus-function": "Ýþlev", - "modbus-register-address": "Kayýt adresi", - "modbus-register-address-range": "Kayýt adresi 0 ile 65535 arasýnda olmalýdýr.", + "modbus-function": "İşlev", + "modbus-register-address": "Kayıt adresi", + "modbus-register-address-range": "Kayıt adresi 0 ile 65535 arasında olmalıdır.", "modbus-register-bit-index": "Bit endeksi", - "modbus-register-bit-index-range": "Bit endeksi 0 ile 15 arasýnda olmalýdýr", - "modbus-register-count": "Kayýt sayýsý", - "modbus-register-count-range": "Kayýt sayýsý pozitif bir deðer olmalýdýr.", - "modbus-byte-order": "Bayt sýrasý", + "modbus-register-bit-index-range": "Bit endeksi 0 ile 15 arasında olmalıdır", + "modbus-register-count": "Kayıt sayısı", + "modbus-register-count-range": "Kayıt sayısı pozitif bir değer olmalıdır.", + "modbus-byte-order": "Bayt sırası", "sync": { "status": "Durum", "sync": "Senkronizasyon", - "not-sync": "Eþitleme", - "last-sync-time": "Son senkronizasyon zamaný", - "not-available": "Müsait deðil" + "not-sync": "Eşitleme", + "last-sync-time": "Son senkronizasyon zamanı", + "not-available": "Müsait değil" }, - "export-extensions-configuration": "Ýhracat uzantýlarý yapýlandýrmasý", - "import-extensions-configuration": "Uzantýlarýný içe aktarma yapýlandýrmasý", - "import-extensions": "Uzantýlarý içe aktar", - "import-extension": "Uzantý içe aktar", - "export-extension": "Ýhracat uzantýsý", - "file": "Uzantýlar dosyasý", - "invalid-file-error": "Geçersiz uzantý dosyasý" + "export-extensions-configuration": "İhracat uzantıları yapılandırması", + "import-extensions-configuration": "Uzantılarını içe aktarma yapılandırması", + "import-extensions": "Uzantıları içe aktar", + "import-extension": "Uzantı içe aktar", + "export-extension": "İhracat uzantısı", + "file": "Uzantılar dosyası", + "invalid-file-error": "Geçersiz uzantı dosyası" }, "fullscreen": { - "expand": "Tam ekrana geniþlet", - "exit": "Tam ekrandan çýk", - "toggle": "Tam ekran modunu deðiþtir", + "expand": "Tam ekrana genişlet", + "exit": "Tam ekrandan çık", + "toggle": "Tam ekran modunu değiştir", "fullscreen": "Tam ekran" }, "function": { - "function": "Ýþlev" + "function": "İşlev" }, "grid": { - "delete-item-title": "Bu maddeyi silmek istediðinden emin misin?", - "delete-item-text": "Dikkatli olun, onaylandýktan sonra bu öðe ve ilgili tüm veriler kurtarýlamaz.", - "delete-items-title": "{Count, çoðul, 1 {1 item} diðer {# items}} silmek istediðinizden emin misiniz?", - "delete-items-action-title": "Sil {count, çoðul, 1 {1 öðe} diðer {# items}}", - "delete-items-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen öðeler silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", - "add-item-text": "Yeni öðe ekle", - "no-items-text": "Hiç bir öðe bulunamadý", - "item-details": "Ürün detaylarý", - "delete-item": "Öðeyi silmek", + "delete-item-title": "Bu maddeyi silmek istediğinden emin misin?", + "delete-item-text": "Dikkatli olun, onaylandıktan sonra bu öğe ve ilgili tüm veriler kurtarılamaz.", + "delete-items-title": "{Count, çoğul, 1 {1 item} diğer {# items}} silmek istediğinizden emin misiniz?", + "delete-items-action-title": "Sil {count, çoğul, 1 {1 öğe} diğer {# items}}", + "delete-items-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen öğeler silinecek ve ilgili tüm veriler kurtarılamayacaktır.", + "add-item-text": "Yeni öğe ekle", + "no-items-text": "Hiç bir öğe bulunamadı", + "item-details": "Ürün detayları", + "delete-item": "Öğeyi silmek", "delete-items": "Ürünleri sil", - "scroll-to-top": "Baþa kaydýr" + "scroll-to-top": "Başa kaydır" }, "help": { - "goto-help-page": "Yardým sayfasýna git" + "goto-help-page": "Yardım sayfasına git" }, "home": { "home": "Ev", "profile": "Profil", - "logout": "Çýkýþ Yap", + "logout": "Çıkış Yap", "menu": "Menü", "avatar": "Avatar", - "open-user-menu": "Kullanýcý menüsünü aç" + "open-user-menu": "Kullanıcı menüsünü aç" }, "import": { "no-file": "Dosya seçilmedi", - "drop-file": "Bir JSON dosyasýný indirin veya yüklemek için bir dosya seçmek için týklayýn." + "drop-file": "Bir JSON dosyasını indirin veya yüklemek için bir dosya seçmek için tıklayın." }, "item": { - "selected": "Seçilmiþ" + "selected": "Seçilmiş" }, "js-func": { - "no-return-error": "Ýþlev deðer döndürmeli!", - "return-type-mismatch": "Ýþlev '{{type}}' tipinin deðerini döndürmelidir!", + "no-return-error": "İşlev değer döndürmeli!", + "return-type-mismatch": "İşlev '{{type}}' tipinin değerini döndürmelidir!", "tidy": "Düzenli" }, "key-val": { "key": "Anahtar", - "value": "Deðer", - "remove-entry": "Giriþi kaldýr", - "add-entry": "Giriþ ekle", - "no-data": "Giriþ yok" + "value": "Değer", + "remove-entry": "Girişi kaldır", + "add-entry": "Giriş ekle", + "no-data": "Giriş yok" }, "layout": { "layout": "Düzen", "manage": "Düzenleri yönet", - "settings": "Yerleþim ayarlarý", + "settings": "Yerleşim ayarları", "color": "Renk", "main": "Ana", - "right": "Sað", - "select": "Hedef yerleþimini seç" + "right": "Sağ", + "select": "Hedef yerleşimini seç" }, "legend": { "position": "Efsane pozisyonu", - "show-max": "Maksimum deðeri göster", - "show-min": "Min deðerini göster", - "show-avg": "Ortalama deðeri göster", - "show-total": "Toplam deðeri göster", - "settings": "Açýklama ayarlarý", + "show-max": "Maksimum değeri göster", + "show-min": "Min değerini göster", + "show-avg": "Ortalama değeri göster", + "show-total": "Toplam değeri göster", + "settings": "Açıklama ayarları", "min": "min", "max": "max", "avg": "avg", @@ -1089,73 +1089,73 @@ }, "login": { "login": "Oturum aç", - "request-password-reset": "Þifre sýfýrlama isteði", - "reset-password": "Þifreyi yenile", - "create-password": "Þifre oluþtur", - "passwords-mismatch-error": "Girilen þifreler ayný olmalýdýr!", - "password-again": "Þifre Tekrar", - "sign-in": "Lütfen giriþ yapýn", - "username": "Kullanýcý adý (email)", - "remember-me": "Beni hatýrla", - "forgot-password": "Parolanýzý mý unuttunuz?", - "password-reset": "Parola sýfýrlama", - "new-password": "Yeni Þifre", - "new-password-again": "Yeni Þifre Tekrar", - "password-link-sent-message": "Þifre sýfýrlama baðlantýsý baþarýyla gönderildi!", + "request-password-reset": "şifre sıfırlama isteği", + "reset-password": "şifreyi yenile", + "create-password": "şifre oluştur", + "passwords-mismatch-error": "Girilen şifreler aynı olmalıdır!", + "password-again": "şifre Tekrar", + "sign-in": "Lütfen giriş yapın", + "username": "Kullanıcı adı (email)", + "remember-me": "Beni hatırla", + "forgot-password": "Parolanızı mı unuttunuz?", + "password-reset": "Parola sıfırlama", + "new-password": "Yeni şifre", + "new-password-again": "Yeni şifre Tekrar", + "password-link-sent-message": "şifre sıfırlama bağlantısı başarıyla gönderildi!", "email": "E-posta" }, "position": { "top": "Üst", "bottom": "Alt", - "left": "Ayrýldý", - "right": "Sað" + "left": "Ayrıldı", + "right": "Sağ" }, "profile": { "profile": "Profil", - "change-password": "Þifre deðiþtir", - "current-password": "Þimdiki Þifre" + "change-password": "şifre değiştir", + "current-password": "şimdiki şifre" }, "relation": { - "relations": "Ýliþkiler", + "relations": "İlişkiler", "direction": "Yön", "search-direction": { "FROM": "Kimden", - "TO": "Alýcý" + "TO": "Alıcı" }, "direction-type": { "FROM": "den", "TO": "to" }, - "from-relations": "Giden iliþkiler", - "to-relations": "Gelen iliþkiler", - "selected-relations": "{count, çoðul, 1 {1 iliþki} diðer {# relations}} seçildi", + "from-relations": "Giden ilişkiler", + "to-relations": "Gelen ilişkiler", + "selected-relations": "{count, çoğul, 1 {1 ilişki} diğer {# relations}} seçildi", "type": "Tür", - "to-entity-type": "Varlýk türüne", - "to-entity-name": "Varlýk adýna", - "from-entity-type": "Varlýk türünden", - "from-entity-name": "Varlýk adýndan", - "to-entity": "Varlýk", - "from-entity": "Varlýktan", - "delete": "Ýliþkisi sil", - "relation-type": "Ýliþki türü", - "relation-type-required": "Ýliþki türü gerekli.", + "to-entity-type": "Varlık türüne", + "to-entity-name": "Varlık adına", + "from-entity-type": "Varlık türünden", + "from-entity-name": "Varlık adından", + "to-entity": "Varlık", + "from-entity": "Varlıktan", + "delete": "İlişkisi sil", + "relation-type": "İlişki türü", + "relation-type-required": "İlişki türü gerekli.", "any-relation-type": "Her hangi bir tür", - "add": "Ýliþki ekle", - "edit": "Ýliþkisi düzenle", - "delete-to-relation-title": "{{EntityName}} varlýðýna iliþkin iliþkiyi silmek istediðinizden emin misiniz? ", - "delete-to-relation-text": "Doðrulamadan sonra, varlýðýn {{entityName}} öðesinin mevcut varlýktan alakasýz olacaðýndan emin olun. ", - "delete-to-relations-title": "{Count, çoðul, 1 {1 iliþki} diðer {# relations}} silmek istediðinizden emin misiniz?", - "delete-to-relations-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen iliþkiler kaldýrýlacak ve ilgili varlýklar mevcut varlýk ile ilgisiz olacaktýr.", - "delete-from-relation-title": "{{EntityName}} varlýðýndan iliþkiyi silmek istediðinizden emin misiniz? ", - "delete-from-relation-text": "Doðrulama varlýðýnýn, {{entityName}} kuruluþundan alakasýz olacaðýndan emin olun.", - "delete-from-relations-title": "{Count, çoðul, 1 {1 iliþki} diðer {# relations}} silmek istediðinizden emin misiniz?", - "delete-from-relations-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen iliþkiler kaldýrýlacak ve mevcut varlýk ilgili varlýklardan ilgisiz olacaktýr.", - "remove-relation-filter": "Ýliþki filtresini kaldýr", - "add-relation-filter": "Ýliþki filtresi ekle", - "any-relation": "Herhangi bir iliþki", - "relation-filters": "Ýliþki filtreleri", + "add": "İlişki ekle", + "edit": "İlişkisi düzenle", + "delete-to-relation-title": "{{EntityName}} varlığına ilişkin ilişkiyi silmek istediğinizden emin misiniz? ", + "delete-to-relation-text": "Doğrulamadan sonra, varlığın {{entityName}} öğesinin mevcut varlıktan alakasız olacağından emin olun. ", + "delete-to-relations-title": "{Count, çoğul, 1 {1 ilişki} diğer {# relations}} silmek istediğinizden emin misiniz?", + "delete-to-relations-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen ilişkiler kaldırılacak ve ilgili varlıklar mevcut varlık ile ilgisiz olacaktır.", + "delete-from-relation-title": "{{EntityName}} varlığından ilişkiyi silmek istediğinizden emin misiniz? ", + "delete-from-relation-text": "Doğrulama varlığının, {{entityName}} kuruluşundan alakasız olacağından emin olun.", + "delete-from-relations-title": "{Count, çoğul, 1 {1 ilişki} diğer {# relations}} silmek istediğinizden emin misiniz?", + "delete-from-relations-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen ilişkiler kaldırılacak ve mevcut varlık ilgili varlıklardan ilgisiz olacaktır.", + "remove-relation-filter": "İlişki filtresini kaldır", + "add-relation-filter": "İlişki filtresi ekle", + "any-relation": "Herhangi bir ilişki", + "relation-filters": "İlişki filtreleri", "additional-info": "Ek bilgi (JSON)", - "invalid-additional-info": "Ek bilgi json ayrýþtýrýlamýyor." + "invalid-additional-info": "Ek bilgi json ayrıştırılamıyor." }, "rulechain": { "rulechain": "Kural zinciri", @@ -1163,356 +1163,356 @@ "root": "Kök", "delete": "Kural zincirini sil", "name": "Ad", - "name-required": "Ýsim gerekli.", - "description": "Açýklama", + "name-required": "İsim gerekli.", + "description": "Açıklama", "add": "Kural Zinciri Ekleme", "set-root": "Kural zincirinin kökü yap", - "set-root-rulechain-title": "Kural zincirini {{ruleChainName}} root? Yapmak istediðinizden emin misiniz?", - "set-root-rulechain-text": "Onaydan sonra kural zinciri kökleþecek ve gelen tüm iletilerle ilgilenecek.", - "delete-rulechain-title": "Kural zincirini {{ruleChainName}} silmek istediðinizden emin misiniz?", - "delete-rulechain-text": "Dikkatli olun, onaylamadan sonra kural zinciri ve ilgili tüm veriler kurtarýlamaz.", - "delete-rulechains-title": "{Count, çoðul, 1 {1 kural zinciri} diðer {# kural zincirleri}} silmek istediðinizden emin misiniz?", - "delete-rulechains-action-title": "Sil {count, çoðul, 1 {1 kural zinciri} diðer {# kural zincirleri}}", - "delete-rulechains-text": "Dikkatli olun, onaylandýktan sonra seçilen tüm kural zincirleri silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", + "set-root-rulechain-title": "Kural zincirini {{ruleChainName}} root? Yapmak istediğinizden emin misiniz?", + "set-root-rulechain-text": "Onaydan sonra kural zinciri kökleşecek ve gelen tüm iletilerle ilgilenecek.", + "delete-rulechain-title": "Kural zincirini {{ruleChainName}} silmek istediğinizden emin misiniz?", + "delete-rulechain-text": "Dikkatli olun, onaylamadan sonra kural zinciri ve ilgili tüm veriler kurtarılamaz.", + "delete-rulechains-title": "{Count, çoğul, 1 {1 kural zinciri} diğer {# kural zincirleri}} silmek istediğinizden emin misiniz?", + "delete-rulechains-action-title": "Sil {count, çoğul, 1 {1 kural zinciri} diğer {# kural zincirleri}}", + "delete-rulechains-text": "Dikkatli olun, onaylandıktan sonra seçilen tüm kural zincirleri silinecek ve ilgili tüm veriler kurtarılamayacaktır.", "add-rulechain-text": "Yeni kural zinciri ekle", - "no-rulechains-text": "Kural zinciri bulunamadý", - "rulechain-details": "Kural zinciri detaylarý", - "details": "Ayrýntýlar", + "no-rulechains-text": "Kural zinciri bulunamadı", + "rulechain-details": "Kural zinciri detayları", + "details": "Ayrıntılar", "events": "Etkinlikler", "system": "Sistem", "import": "Kural zincirini içe aktar", - "export": "Kural zinciri dýþa aktar", - "export-failed-error": "Kural zinciri dýþa aktarýlamadý: {{error}}", - "create-new-rulechain": "Yeni kural zinciri oluþtur", - "rulechain-file": "Kural zinciri dosyasý", - "invalid-rulechain-file-error": "Kural zinciri içe aktarýlamýyor: Geçersiz kural zinciri veri yapýsý.", - "copyId": "Kural zinciri kimliðini kopyala", - "idCopiedMessage": "Kural zinciri kimliði panoya kopyalandý", + "export": "Kural zinciri dışa aktar", + "export-failed-error": "Kural zinciri dışa aktarılamadı: {{error}}", + "create-new-rulechain": "Yeni kural zinciri oluştur", + "rulechain-file": "Kural zinciri dosyası", + "invalid-rulechain-file-error": "Kural zinciri içe aktarılamıyor: Geçersiz kural zinciri veri yapısı.", + "copyId": "Kural zinciri kimliğini kopyala", + "idCopiedMessage": "Kural zinciri kimliği panoya kopyalandı", "select-rulechain": "Kural zincirini seç", - "no-rulechains-matching": "{{Entity}} ile eþleþen kural zinciri bulunamadý. ", + "no-rulechains-matching": "{{Entity}} ile eşleşen kural zinciri bulunamadı. ", "rulechain-required": "Kural zinciri gerekli", "management": "Kural yönetimi", - "debug-mode": "Hata ayýklama modu" + "debug-mode": "Hata ayıklama modu" }, "rulenode": { - "details": "Ayrýntýlar", + "details": "Ayrıntılar", "events": "Etkinlikler", - "search": "Arama düðümleri", - "open-node-library": "Düðüm kütüphanesini aç", - "add": "Kural düðümü ekle", + "search": "Arama düğümleri", + "open-node-library": "Düğüm kütüphanesini aç", + "add": "Kural düğümü ekle", "name": "Ad", - "name-required": "Ýsim gerekli.", + "name-required": "İsim gerekli.", "type": "Tür", - "description": "Açýklama", - "delete": "Kural düðümünü sil", - "select-all-objects": "Tüm düðümleri ve baðlantýlarý seç", - "deselect-all-objects": "Tüm düðümlerin ve baðlantýlarýn seçimini kaldýrýn", - "delete-selected-objects": "Seçilen düðümleri ve baðlantýlarý sil", + "description": "Açıklama", + "delete": "Kural düğümünü sil", + "select-all-objects": "Tüm düğümleri ve bağlantıları seç", + "deselect-all-objects": "Tüm düğümlerin ve bağlantıların seçimini kaldırın", + "delete-selected-objects": "Seçilen düğümleri ve bağlantıları sil", "delete-selected": "Silme seçildi", "select-all": "Hepsini seç", "copy-selected": "Seçilenleri kopyala", "deselect-all": "Hiçbirini seçme", - "rulenode-details": "Kural düðümü ayrýntýlarý", - "debug-mode": "Hata ayýklama modu", - "configuration": "Yapýlandýrma", - "link": "Baðlantý", - "link-details": "Kural düðüm baðlantý detaylarý", + "rulenode-details": "Kural düğümü ayrıntıları", + "debug-mode": "Hata ayıklama modu", + "configuration": "Yapılandırma", + "link": "Bağlantı", + "link-details": "Kural düğüm bağlantı detayları", "add-link": "Link ekle", - "link-label": "Baðlantý etiketi", - "link-label-required": "Baðlantý etiketi gerekli.", - "custom-link-label": "Özel baðlantý etiketi", - "custom-link-label-required": "Özel baðlantý etiketi gerekli.", + "link-label": "Bağlantı etiketi", + "link-label-required": "Bağlantı etiketi gerekli.", + "custom-link-label": "Özel bağlantı etiketi", + "custom-link-label-required": "Özel bağlantı etiketi gerekli.", "link-labels": "Link etiketleri", "link-labels-required": "Link etiketleri gerekli.", - "no-link-labels-found": "Baðlantý etiketi bulunamadý", - "no-link-label-matching": "{{label}} bulunamadý. ", - "create-new-link-label": "Yeni bir tane oluþtur!", + "no-link-labels-found": "Bağlantı etiketi bulunamadı", + "no-link-label-matching": "{{label}} bulunamadı. ", + "create-new-link-label": "Yeni bir tane oluştur!", "type-filter": "Filtre", - "type-filter-details": "Gelen iletileri yapýlandýrýlmýþ koþullara göre filtrele", - "type-enrichment": "Zenginleþtirme", + "type-filter-details": "Gelen iletileri yapılandırılmış koşullara göre filtrele", + "type-enrichment": "Zenginleştirme", "type-enrichment-details": "Mesaj Meta Verilerine ek bilgi", - "type-transformation": "Dönüþüm", - "type-transformation-details": "Mesaj yükünü ve Meta Verileri Deðiþtir", + "type-transformation": "Dönüşüm", + "type-transformation-details": "Mesaj yükünü ve Meta Verileri Değiştir", "type-action": "Aksiyon", - "type-action-details": "Özel eylem gerçekleþtir", - "type-external": "Dýþ", - "type-external-details": "Dýþ sistemle etkileþir", + "type-action-details": "Özel eylem gerçekleştir", + "type-external": "Dış", + "type-external-details": "Dış sistemle etkileşir", "type-rule-chain": "Kural Zinciri", - "type-rule-chain-details": "Belirtilen Kural Zincirine gelen mesajlarý ilet", - "type-input": "Giriþ", - "type-input-details": "Kural Zinciri'nin mantýksal girdisi, bir sonraki ilgili Kural Düðümüne gelen iletileri iletme", + "type-rule-chain-details": "Belirtilen Kural Zincirine gelen mesajları ilet", + "type-input": "Giriş", + "type-input-details": "Kural Zinciri'nin mantıksal girdisi, bir sonraki ilgili Kural Düğümüne gelen iletileri iletme", "type-unknown": "Bilinmeyen", - "type-unknown-details": "Çözümlenmemiþ Kural Düðümü", - "directive-is-not-loaded": "Tanýmlanmýþ yapýlandýrma yönergesi {{directiveName}} 'mevcut deðil. ", - "ui-resources-load-error": "Yapýlandýrma kullanýcý arayüzü kaynaklarý yüklenemedi.", + "type-unknown-details": "Çözümlenmemiş Kural Düğümü", + "directive-is-not-loaded": "Tanımlanmış yapılandırma yönergesi {{directiveName}} 'mevcut değil. ", + "ui-resources-load-error": "Yapılandırma kullanıcı arayüzü kaynakları yüklenemedi.", "invalid-target-rulechain": "Hedef kural zinciri çözülemiyor!", - "test-script-function": "Test komut dosyasý iþlevi", + "test-script-function": "Test komut dosyası işlevi", "message": "Mesaj", "message-type": "Mesaj tipi", "select-message-type": "Mesaj tipini seç", "message-type-required": "Mesaj türü gerekli", "metadata": "Meta veri", - "metadata-required": "Meta veri giriþleri boþ býrakýlamaz.", - "output": "Çýktý", + "metadata-required": "Meta veri girişleri boş bırakılamaz.", + "output": "Çıktı", "test": "Ölçek", - "help": "Yardým et" + "help": "Yardım et" }, "tenant": { - "tenant": "Kiracý", - "tenants": "Kiracýlar", - "management": "Kiracý yönetimi", - "add": "Kiracý ekle", + "tenant": "Kiracı", + "tenants": "Kiracılar", + "management": "Kiracı yönetimi", + "add": "Kiracı ekle", "admins": "Yöneticiler", - "manage-tenant-admins": "Kiracý yöneticileri yönet", - "delete": "Kiracýyý sil", - "add-tenant-text": "Yeni kiracý ekle", - "no-tenants-text": "Kiracý bulunamadý", - "tenant-details": "Kiracý detaylarý", - "delete-tenant-title": "Kiracýyý silmek istediðinizden emin misiniz? {{TenantTitle}}? ", - "delete-tenant-text": "Dikkatli olun, onayýndan sonra kiracý ve ilgili tüm veriler kurtarýlamaz.", - "delete-tenants-title": "{Count, çoðul, 1 {1 kiracý} diðer {# kiracý}} silmek istediðinizden emin misiniz?", - "delete-tenants-action-title": "Sil {count, çoðul, 1 {1 kiracý} diðer {# kiracý}}", - "delete-tenants-text": "Dikkatli olun, onaylandýktan sonra tüm kiracýlar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", - "title": "Baþlýk", - "title-required": "Baþlýk gerekli.", - "description": "Açýklama", - "details": "Ayrýntýlar", + "manage-tenant-admins": "Kiracı yöneticileri yönet", + "delete": "Kiracıyı sil", + "add-tenant-text": "Yeni kiracı ekle", + "no-tenants-text": "Kiracı bulunamadı", + "tenant-details": "Kiracı detayları", + "delete-tenant-title": "Kiracıyı silmek istediğinizden emin misiniz? {{TenantTitle}}? ", + "delete-tenant-text": "Dikkatli olun, onayından sonra kiracı ve ilgili tüm veriler kurtarılamaz.", + "delete-tenants-title": "{Count, çoğul, 1 {1 kiracı} diğer {# kiracı}} silmek istediğinizden emin misiniz?", + "delete-tenants-action-title": "Sil {count, çoğul, 1 {1 kiracı} diğer {# kiracı}}", + "delete-tenants-text": "Dikkatli olun, onaylandıktan sonra tüm kiracılar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Ayrıntılar", "events": "Etkinlikler", - "copyId": "Kiracý Kimliði Kimliði", - "idCopiedMessage": "Kiracý Kimliði panoya kopyalandý", - "select-tenant": "Kiracý seç", - "no-tenants-matching": "{{Entity}} ile eþleþen kiracý bulunamadý. ", - "tenant-required": "Kiracý gerekli" + "copyId": "Kiracı Kimliği Kimliği", + "idCopiedMessage": "Kiracı Kimliği panoya kopyalandı", + "select-tenant": "Kiracı seç", + "no-tenants-matching": "{{Entity}} ile eşleşen kiracı bulunamadı. ", + "tenant-required": "Kiracı gerekli" }, "timeinterval": { - "seconds-interval": "{saniye, çoðul, 1 {1 saniye} diðer {# seconds}}", - "minutes-interval": "{dakika, çoðul, 1 {1 dakika} diðer {# dakika}}", - "hours-interval": "{saat, çoðul, 1 {1 saat} diðer {# hours}}", - "days-interval": "{günler, çoðul, 1 {1 gün} diðer {# gün}}", + "seconds-interval": "{saniye, çoğul, 1 {1 saniye} diğer {# seconds}}", + "minutes-interval": "{dakika, çoğul, 1 {1 dakika} diğer {# dakika}}", + "hours-interval": "{saat, çoğul, 1 {1 saat} diğer {# hours}}", + "days-interval": "{günler, çoğul, 1 {1 gün} diğer {# gün}}", "days": "Gün", "hours": "Saatler", "minutes": "Dakika", "seconds": "Saniye", - "advanced": "Ýleri" + "advanced": "İleri" }, "timewindow": { - "days": "{günler, çoðul, 1 {gün} diðer {# gün}}", - "hours": "{saat, çoðul, 0 {saat} 1 {1 saat} diðer {# hours}}", - "minutes": "{dakika, çoðul, 0 {dakika} 1 {1 dakika} diðer {# dakika}}", - "seconds": "{saniye, çoðul, 0 {saniye} 1 {1 saniye} diðer {# seconds}}", + "days": "{günler, çoğul, 1 {gün} diğer {# gün}}", + "hours": "{saat, çoğul, 0 {saat} 1 {1 saat} diğer {# hours}}", + "minutes": "{dakika, çoğul, 0 {dakika} 1 {1 dakika} diğer {# dakika}}", + "seconds": "{saniye, çoğul, 0 {saniye} 1 {1 saniye} diğer {# seconds}}", "realtime": "Gerçek zaman", "history": "Tarihçe", "last-prefix": "son", - "period": "{{startTime}} - {{endTime}} arasýnda ", + "period": "{{startTime}} - {{endTime}} arasında ", "edit": "Zaman penceresini düzenle", - "date-range": "Tarih aralýðý", + "date-range": "Tarih aralığı", "last": "Son", "time-period": "Zaman dilimi" }, "user": { - "user": "Kullanýcý", - "users": "Kullanýcýlar", - "customer-users": "Müþteri Kullanýcýlarý", - "tenant-admins": "Kiracý Admins", + "user": "Kullanıcı", + "users": "Kullanıcılar", + "customer-users": "Müşteri Kullanıcıları", + "tenant-admins": "Kiracı Admins", "sys-admin": "Sistem yöneticisi", - "tenant-admin": "Kiracý yöneticisi", - "customer": "Müþteri", + "tenant-admin": "Kiracı yöneticisi", + "customer": "Müşteri", "anonymous": "Anonim", - "add": "Kullanýcý Ekle", - "delete": "Kullanýcýyý sil", - "add-user-text": "Yeni kullanýcý Ekle", - "no-users-text": "Kullanýcý bulunamadý", - "user-details": "Kullanýcý detaylarý", - "delete-user-title": "Kullanýcýyý silmek istediðinizden emin misiniz? {{UserEmail}} '? ", - "delete-user-text": "Dikkatli olun, onaylandýktan sonra kullanýcý ve ilgili tüm veriler kurtarýlamayacaktýr.", - "delete-users-title": "{Count, çoðul, 1 {1 kullanýcý} diðer {# users}} silmek istediðinizden emin misiniz?", - "delete-users-action-title": "Sil {count, çoðul, 1 {1 kullanýcý} diðer {# users}}", - "delete-users-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen kullanýcýlar silinecek ve ilgili tüm veriler kurtarýlamayacaktýr.", - "activation-email-sent-message": "Etkinleþtirme e-postasý baþarýyla gönderildi!", - "resend-activation": "Etkinleþtirmeyi tekrar gönder", + "add": "Kullanıcı Ekle", + "delete": "Kullanıcıyı sil", + "add-user-text": "Yeni kullanıcı Ekle", + "no-users-text": "Kullanıcı bulunamadı", + "user-details": "Kullanıcı detayları", + "delete-user-title": "Kullanıcıyı silmek istediğinizden emin misiniz? {{UserEmail}} '? ", + "delete-user-text": "Dikkatli olun, onaylandıktan sonra kullanıcı ve ilgili tüm veriler kurtarılamayacaktır.", + "delete-users-title": "{Count, çoğul, 1 {1 kullanıcı} diğer {# users}} silmek istediğinizden emin misiniz?", + "delete-users-action-title": "Sil {count, çoğul, 1 {1 kullanıcı} diğer {# users}}", + "delete-users-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen kullanıcılar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", + "activation-email-sent-message": "Etkinleştirme e-postası başarıyla gönderildi!", + "resend-activation": "Etkinleştirmeyi tekrar gönder", "email": "E-posta", "email-required": "Email gereklidir.", - "invalid-email-format": "Geçersiz e-posta formatý.", - "first-name": "Ýsim", - "last-name": "Soyadý", - "description": "Açýklama", - "default-dashboard": "Varsayýlan gösterge paneli", + "invalid-email-format": "Geçersiz e-posta formatı.", + "first-name": "İsim", + "last-name": "Soyadı", + "description": "Açıklama", + "default-dashboard": "Varsayılan gösterge paneli", "always-fullscreen": "Her zaman tam ekran", - "select-user": "Kullanýcý seç", - "no-users-matching": "{{Entity}} ile eþleþen kullanýcý bulunamadý. ", - "user-required": "Kullanýcý gerekli", + "select-user": "Kullanıcı seç", + "no-users-matching": "{{Entity}} ile eşleşen kullanıcı bulunamadı. ", + "user-required": "Kullanıcı gerekli", "activation-method": "Aktivasyon yöntemi", - "display-activation-link": "Etkinleþtirme baðlantýsý göster", - "send-activation-mail": "Etkinleþtirme postasý gönder", - "activation-link": "Kullanýcý aktivasyon linki", - "activation-link-text": "Kullanýcýyý aktif hale getirmek için aþaðýdaki aktivasyon linki kullanýn.", - "copy-activation-link": "Etkinleþtirme baðlantýsýný kopyala", - "activation-link-copied-message": "Kullanýcý aktivasyon linki panoya kopyalandý", - "details": "Ayrýntýlar", - "login-as-tenant-admin": "Kiracý Yönetici Giriþi", - "login-as-customer-user": "Müþteri olarak giriþ yap" + "display-activation-link": "Etkinleştirme bağlantısı göster", + "send-activation-mail": "Etkinleştirme postası gönder", + "activation-link": "Kullanıcı aktivasyon linki", + "activation-link-text": "Kullanıcıyı aktif hale getirmek için aşağıdaki aktivasyon linki kullanın.", + "copy-activation-link": "Etkinleştirme bağlantısını kopyala", + "activation-link-copied-message": "Kullanıcı aktivasyon linki panoya kopyalandı", + "details": "Ayrıntılar", + "login-as-tenant-admin": "Kiracı Yönetici Girişi", + "login-as-customer-user": "Müşteri olarak giriş yap" }, "value": { - "type": "Deðer türü", + "type": "Değer türü", "string": "Dize", - "string-value": "Dize deðeri", + "string-value": "Dize değeri", "integer": "Integer", - "integer-value": "Tamsayý deðeri", - "invalid-integer-value": "Geçersiz tam sayý", + "integer-value": "Tamsayı değeri", + "invalid-integer-value": "Geçersiz tam sayı", "double": "Çift", - "double-value": "Çift deðer", + "double-value": "Çift değer", "boolean": "Boole", - "boolean-value": "Boole deðeri", - "false": "Yanlýþ", - "true": "Doðru", + "boolean-value": "Boole değeri", + "false": "Yanlış", + "true": "Doğru", "long": "Uzun" }, "widget": { - "widget-library": "Widgets Kitaplýðý", + "widget-library": "Widgets Kitaplığı", "widget-bundle": "Widget Paketi", "select-widgets-bundle": "Widget paketini seç", "management": "Widget yönetimi", "editor": "Widget Düzenleyici", - "widget-type-not-found": "Sorun yükleme pencere öðesi yapýlandýrmasý.
Muhtemelen iliþkili /n pencere öðesi türü kaldýrýldý.", - "widget-type-load-error": "Widget, aþaðýdaki hatalardan dolayý yüklenmedi:", - "remove": "Widget'ý kaldýr", + "widget-type-not-found": "Sorun yükleme pencere öğesi yapılandırması.
Muhtemelen ilişkili /n pencere öğesi türü kaldırıldı.", + "widget-type-load-error": "Widget, aşağıdaki hatalardan dolayı yüklenmedi:", + "remove": "Widget'ı kaldır", "edit": "Widget düzenle", - "remove-widget-title": "Widget'ý kaldýrmak istediðinizden emin misiniz? {{WidgetTitle}}? ", - "remove-widget-text": "Onaydan sonra widget ve ilgili tüm veriler kurtarýlamaz.", + "remove-widget-title": "Widget'ı kaldırmak istediğinizden emin misiniz? {{WidgetTitle}}? ", + "remove-widget-text": "Onaydan sonra widget ve ilgili tüm veriler kurtarılamaz.", "timeseries": "Zaman serisi", "search-data": "Arama verileri", - "no-data-found": "Veri bulunamadý", - "latest-values": "Son deðerler", - "rpc": "Kontrol aracý", + "no-data-found": "Veri bulunamadı", + "latest-values": "Son değerler", + "rpc": "Kontrol aracı", "alarm": "Alarm gereci", - "static": "Statik pencere öðesi", + "static": "Statik pencere öğesi", "select-widget-type": "Widget tipi seç", - "missing-widget-title-error": "Widget baþlýðý belirtilmelidir!", + "missing-widget-title-error": "Widget başlığı belirtilmelidir!", "widget-saved": "Widget kaydedildi", - "unable-to-save-widget-error": "Widget kaydedilemiyor! Widget'ýn hatalarý var!", + "unable-to-save-widget-error": "Widget kaydedilemiyor! Widget'ın hataları var!", "save": "Widget kaydet", "saveAs": "Widget olarak kaydet", "save-widget-type-as": "Widget türünü kaydet", - "save-widget-type-as-text": "Lütfen yeni pencere öðesi baþlýðýný girin ve / veya hedef widget'larý seçin", + "save-widget-type-as-text": "Lütfen yeni pencere öğesi başlığını girin ve / veya hedef widget'ları seçin", "toggle-fullscreen": "Tam ekrana geç", - "run": "Widget'ý çalýþtýr", - "title": "Widget Baþlýðý", - "title-required": "Widget baþlýðý gerekiyor.", + "run": "Widget'ı çalıştır", + "title": "Widget Başlığı", + "title-required": "Widget başlığı gerekiyor.", "type": "Widget türü", "resources": "Kaynaklar", "resource-url": "JavaScript / CSS URL", - "remove-resource": "Kaynaðý kaldýr", + "remove-resource": "Kaynağı kaldır", "add-resource": "Kaynak ekle", "html": "HTML", "tidy": "Düzenli", "css": "CSS", - "settings-schema": "Ayarlar þemasý", - "datakey-settings-schema": "Veri anahtarý ayarlarý þemasý", + "settings-schema": "Ayarlar şeması", + "datakey-settings-schema": "Veri anahtarı ayarları şeması", "javascript": "JavaScript", - "remove-widget-type-title": "'{{WidgetName}}' widget türünü kaldýrmak istediðinizden emin misiniz?", - "remove-widget-type-text": "Onaydan sonra widget tipi ve ilgili tüm veriler kurtarýlamayacak.", - "remove-widget-type": "Widget türünü kaldýr", + "remove-widget-type-title": "'{{WidgetName}}' widget türünü kaldırmak istediğinizden emin misiniz?", + "remove-widget-type-text": "Onaydan sonra widget tipi ve ilgili tüm veriler kurtarılamayacak.", + "remove-widget-type": "Widget türünü kaldır", "add-widget-type": "Yeni widget türü ekle", "widget-type-load-failed-error": "Widget türü yüklenemedi!", - "widget-template-load-failed-error": "Widget þablonu yüklenemedi!", + "widget-template-load-failed-error": "Widget şablonu yüklenemedi!", "add": "Widget Ekle", - "undo": "Widget deðiþikliklerini geri al", - "export": "Widget'ý dýþa aktar" + "undo": "Widget değişikliklerini geri al", + "export": "Widget'ı dışa aktar" }, "widget-action": { - "header-button": "Widget baþlýk düðmesi", + "header-button": "Widget başlık düğmesi", "open-dashboard-state": "Yeni gösterge panosuna git", "update-dashboard-state": "Mevcut kontrol paneli durumunu güncelle", - "open-dashboard": "Diðer kontrol paneline git", + "open-dashboard": "Diğer kontrol paneline git", "custom": "Özel eylem", "target-dashboard-state": "Hedef gösterge panosu durumu", "target-dashboard-state-required": "Hedef gösterge tablosu gerekli", - "set-entity-from-widget": "Öðeyi pencere öðesinden ayarla", + "set-entity-from-widget": "Öğeyi pencere öğesinden ayarla", "target-dashboard": "Hedef gösterge panosu", - "open-right-layout": "Saðdaki gösterge tablosu düzeni (mobil görünüm)" + "open-right-layout": "Sağdaki gösterge tablosu düzeni (mobil görünüm)" }, "widgets-bundle": { "current": "Mevcut paket", "widgets-bundles": "Widget Paketleri", "add": "Widget Paketi Ekle", "delete": "Widget paketini sil", - "title": "Baþlýk", - "title-required": "Baþlýk gerekli.", + "title": "Başlık", + "title-required": "Başlık gerekli.", "add-widgets-bundle-text": "Yeni widget'lar paketi ekle", - "no-widgets-bundles-text": "Widget paketi bulunamadý", - "empty": "Widget'ler paketi boþ", - "details": "Ayrýntýlar", - "widgets-bundle-details": "Widget'lar paket ayrýntýlarýný", - "delete-widgets-bundle-title": "Widget paketini {{widgetsBundleTitle}} silmek istediðinizden emin misiniz?", - "delete-widgets-bundle-text": "Dikkatli olun, onaylandýktan sonra widget'lar paketi ve tüm ilgili veriler kurtarýlamayacak.", - "delete-widgets-bundles-title": "{Count, çoðul, 1 {1 widgets bundle} diðer {# widgets bundles}} silmek istediðinizden emin misiniz?", - "delete-widgets-bundles-action-title": "Sil {count, çoðul, 1 {1 widgets bundle} diðer {# widgets bundles}}", - "delete-widgets-bundles-text": "Dikkatli olun, onaylandýktan sonra tüm seçilen widget'lar paketler kaldýrýlacak ve ilgili tüm veriler kurtarýlamayacaktýr.", - "no-widgets-bundles-matching": "{{WidgetsBundle}} ile eþleþen hiçbir widget grubu bulunamadý. ", + "no-widgets-bundles-text": "Widget paketi bulunamadı", + "empty": "Widget'ler paketi boş", + "details": "Ayrıntılar", + "widgets-bundle-details": "Widget'lar paket ayrıntılarını", + "delete-widgets-bundle-title": "Widget paketini {{widgetsBundleTitle}} silmek istediğinizden emin misiniz?", + "delete-widgets-bundle-text": "Dikkatli olun, onaylandıktan sonra widget'lar paketi ve tüm ilgili veriler kurtarılamayacak.", + "delete-widgets-bundles-title": "{Count, çoğul, 1 {1 widgets bundle} diğer {# widgets bundles}} silmek istediğinizden emin misiniz?", + "delete-widgets-bundles-action-title": "Sil {count, çoğul, 1 {1 widgets bundle} diğer {# widgets bundles}}", + "delete-widgets-bundles-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen widget'lar paketler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", + "no-widgets-bundles-matching": "{{WidgetsBundle}} ile eşleşen hiçbir widget grubu bulunamadı. ", "widgets-bundle-required": "Widget paketi gerekli.", "system": "Sistem", "import": "Widget paketlerini içe aktar", - "export": "Widget paketini dýþa aktar", - "export-failed-error": "Widget grubu dýþa aktarýlamadý: {{error}}", - "create-new-widgets-bundle": "Yeni widget'lar paketi oluþtur", - "widgets-bundle-file": "Widgets paket dosyasý", - "invalid-widgets-bundle-file-error": "Widget grubu içe aktarýlamýyor: Geçersiz widget'lar veri yapýsýný paketliyor." + "export": "Widget paketini dışa aktar", + "export-failed-error": "Widget grubu dışa aktarılamadı: {{error}}", + "create-new-widgets-bundle": "Yeni widget'lar paketi oluştur", + "widgets-bundle-file": "Widgets paket dosyası", + "invalid-widgets-bundle-file-error": "Widget grubu içe aktarılamıyor: Geçersiz widget'lar veri yapısını paketliyor." }, "widget-config": { "data": "Veri", "settings": "Ayarlar", - "advanced": "Ýleri", - "title": "Baþlýk", + "advanced": "İleri", + "title": "Başlık", "general-settings": "Genel Ayarlar", - "display-title": "Baþlýk", - "drop-shadow": "Düþen gölge", - "enable-fullscreen": "Tam ekraný etkinleþtir", + "display-title": "Başlık", + "drop-shadow": "Düşen gölge", + "enable-fullscreen": "Tam ekranı etkinleştir", "background-color": "Arka plan rengi", "text-color": "Metin rengi", "padding": "Dolgu malzemesi", "margin": "Kenar", "widget-style": "Widget stili", - "title-style": "Baþlýk stili", - "mobile-mode-settings": "Mobil mod ayarlarý", - "order": "Sipariþ", + "title-style": "Başlık stili", + "mobile-mode-settings": "Mobil mod ayarları", + "order": "Sipariş", "height": "Yükseklik", - "units": "Deðerin yanýnda gösterilecek özel sembol", - "decimals": "Kayan noktadan sonraki basamak sayýsý", + "units": "Değerin yanında gösterilecek özel sembol", + "decimals": "Kayan noktadan sonraki basamak sayısı", "timewindow": "Timewindow", "use-dashboard-timewindow": "Gösterge panosu zaman tüneli", "display-legend": "Gösterge efsanesi", - "datasources": "Veri kaynaklarý", - "maximum-datasources": "Maksimum {sayým, çoðul, 1 {1 veri kaynaðý izinli.} Diðer {# veri kaynaklarýna izin verilir}}", + "datasources": "Veri kaynakları", + "maximum-datasources": "Maksimum {sayım, çoğul, 1 {1 veri kaynağı izinli.} Diğer {# veri kaynaklarına izin verilir}}", "datasource-type": "Tür", "datasource-parameters": "Parametreler", - "remove-datasource": "Veri kaynaðýný kaldýr", - "add-datasource": "Veri kaynaðý ekle", + "remove-datasource": "Veri kaynağını kaldır", + "add-datasource": "Veri kaynağı ekle", "target-device": "Hedef cihaz", - "alarm-source": "Alarm kaynaðý", - "actions": "Ýþlemler", + "alarm-source": "Alarm kaynağı", + "actions": "İşlemler", "action": "Aksiyon", "add-action": "Eylem ekle", - "search-actions": "Arama iþlemleri", - "action-source": "Eylem kaynaðý", - "action-source-required": "Eylem kaynaðý gerekli.", + "search-actions": "Arama işlemleri", + "action-source": "Eylem kaynağı", + "action-source-required": "Eylem kaynağı gerekli.", "action-name": "Ad", - "action-name-required": "Eylem adý gerekli.", - "action-name-not-unique": "Ayný ada sahip baþka bir eylem zaten var.
Eylem adý, ayný eylem kaynaðý içinde benzersiz olmalýdýr.", + "action-name-required": "Eylem adı gerekli.", + "action-name-not-unique": "Aynı ada sahip başka bir eylem zaten var.
Eylem adı, aynı eylem kaynağı içinde benzersiz olmalıdır.", "action-icon": "Simge", "action-type": "Tür", "action-type-required": "Eylem türü gerekli", "edit-action": "Eylemi düzenle", "delete-action": "Eylemi sil", "delete-action-title": "Widget eylemini sil", - "delete-action-text": "Widget eylemini {{actionName}} adýyla silmek istediðinizden emin misiniz?" + "delete-action-text": "Widget eylemini {{actionName}} adıyla silmek istediğinizden emin misiniz?" }, "widget-type": { "import": "Widget türü içe aktar", - "export": "Widget türünü dýþa aktar", + "export": "Widget türünü dışa aktar", "export-failed-error": "Widget türü verilemiyor: {{error}}", - "create-new-widget-type": "Yeni pencere öðesi türü oluþtur", + "create-new-widget-type": "Yeni pencere öğesi türü oluştur", "widget-type-file": "Widget tipi dosya", - "invalid-widget-type-file-error": "Widget türü içe aktarýlamýyor: Geçersiz pencere öðesi türü yapýsý." + "invalid-widget-type-file-error": "Widget türü içe aktarılamıyor: Geçersiz pencere öğesi türü yapısı." }, "icon": { "icon": "Simge", @@ -1522,24 +1522,24 @@ }, "custom": { "widget-action": { - "action-cell-button": "Eylem hücresi düðmesi", - "row-click": "Satýr týklamasý", - "marker-click": "Ýþaretçi týklamasýnda ", - "tooltip-tag-action": "Araç ipucu etiketi iþlemi" + "action-cell-button": "Eylem hücresi düğmesi", + "row-click": "Satır tıklaması", + "marker-click": "İşaretçi tıklamasında", + "tooltip-tag-action": "Araç ipucu etiketi işlemi" } }, "language": { "language": "Dil", "locales": { - "fr_FR": "Fransýzca", + "fr_FR": "Fransızca", "zh_CN": "Çince", - "en_US": "Ýngilizce", - "it_IT": "Ýtalyan", + "en_US": "İngilizce", + "it_IT": "İtalyan", "ko_KR": "Koreli", "ru_RU": "Rusça", - "es_ES": "Ýspanyol", + "es_ES": "İspanyol", "ja_JA": "Japonca", - "TR": "Türk" + "TR": "Türkçe" } } } From 78436f9baa4af41b114b8ff7e8a06bb1595cd141 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 19 Oct 2018 17:59:48 +0300 Subject: [PATCH 239/307] Critical security fixes --- .../server/controller/BaseController.java | 36 +++++++------- .../service/security/ValidationCallback.java | 47 ++++++++++--------- .../DefaultTelemetryWebSocketService.java | 14 +++++- 3 files changed, 54 insertions(+), 43 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 51a5e4c96c..582bbedebd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -259,7 +259,6 @@ public abstract class BaseController { Customer checkCustomerId(CustomerId customerId) throws ThingsboardException { try { - validateId(customerId, "Incorrect customerId " + customerId); SecurityUser authUser = getCurrentUser(); if (authUser.getAuthority() == Authority.SYS_ADMIN || (authUser.getAuthority() != Authority.TENANT_ADMIN && @@ -267,9 +266,13 @@ public abstract class BaseController { throw new ThingsboardException(YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION, ThingsboardErrorCode.PERMISSION_DENIED); } - Customer customer = customerService.findCustomerById(customerId); - checkCustomer(customer); - return customer; + if (customerId != null && !customerId.isNullUid()) { + Customer customer = customerService.findCustomerById(customerId); + checkCustomer(customer); + return customer; + } else { + return null; + } } catch (Exception e) { throw handleException(e, false); } @@ -350,9 +353,7 @@ public abstract class BaseController { protected void checkDevice(Device device) throws ThingsboardException { checkNotNull(device); checkTenantId(device.getTenantId()); - if (device.getCustomerId() != null && !device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { - checkCustomerId(device.getCustomerId()); - } + checkCustomerId(device.getCustomerId()); } protected EntityView checkEntityViewId(EntityViewId entityViewId) throws ThingsboardException { @@ -369,9 +370,7 @@ public abstract class BaseController { protected void checkEntityView(EntityView entityView) throws ThingsboardException { checkNotNull(entityView); checkTenantId(entityView.getTenantId()); - if (entityView.getCustomerId() != null && !entityView.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { - checkCustomerId(entityView.getCustomerId()); - } + checkCustomerId(entityView.getCustomerId()); } Asset checkAssetId(AssetId assetId) throws ThingsboardException { @@ -388,9 +387,7 @@ public abstract class BaseController { protected void checkAsset(Asset asset) throws ThingsboardException { checkNotNull(asset); checkTenantId(asset.getTenantId()); - if (asset.getCustomerId() != null && !asset.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { - checkCustomerId(asset.getCustomerId()); - } + checkCustomerId(asset.getCustomerId()); } Alarm checkAlarmId(AlarmId alarmId) throws ThingsboardException { @@ -499,8 +496,7 @@ public abstract class BaseController { ComponentDescriptor checkComponentDescriptorByClazz(String clazz) throws ThingsboardException { try { log.debug("[{}] Lookup component descriptor", clazz); - ComponentDescriptor componentDescriptor = checkNotNull(componentDescriptorService.getComponent(clazz)); - return componentDescriptor; + return checkNotNull(componentDescriptorService.getComponent(clazz)); } catch (Exception e) { throw handleException(e, false); } @@ -564,16 +560,16 @@ public abstract class BaseController { } protected I emptyId(EntityType entityType) { - return (I)EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); + return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } protected void logEntityAction(I entityId, E entity, CustomerId customerId, - ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { + ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { logEntityAction(getCurrentUser(), entityId, entity, customerId, actionType, e, additionalInfo); } protected void logEntityAction(User user, I entityId, E entity, CustomerId customerId, - ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { + ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { if (customerId == null || customerId.isNullUid()) { customerId = user.getCustomerId(); } @@ -589,7 +585,7 @@ public abstract class BaseController { } private void pushEntityActionToRuleEngine(I entityId, E entity, User user, CustomerId customerId, - ActionType actionType, Object... additionalInfo) { + ActionType actionType, Object... additionalInfo) { String msgType = null; switch (actionType) { case ADDED: @@ -668,7 +664,7 @@ public abstract class BaseController { String scope = extractParameter(String.class, 0, additionalInfo); List keys = extractParameter(List.class, 1, additionalInfo); metaData.putValue("scope", scope); - ArrayNode attrsArrayNode = entityNode.putArray("attributes"); + ArrayNode attrsArrayNode = entityNode.putArray("attributes"); if (keys != null) { keys.forEach(attrsArrayNode::add); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java b/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java index 81ab9b4eb6..d4ac75374f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java +++ b/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java @@ -36,29 +36,10 @@ public class ValidationCallback implements FutureCallback { @Override public void onSuccess(ValidationResult result) { - ValidationResultCode resultCode = result.getResultCode(); - if (resultCode == ValidationResultCode.OK) { + if (result.getResultCode() == ValidationResultCode.OK) { action.onSuccess(response); } else { - Exception e; - switch (resultCode) { - case ENTITY_NOT_FOUND: - e = new EntityNotFoundException(result.getMessage()); - break; - case UNAUTHORIZED: - e = new UnauthorizedException(result.getMessage()); - break; - case ACCESS_DENIED: - e = new AccessDeniedException(result.getMessage()); - break; - case INTERNAL_ERROR: - e = new InternalErrorException(result.getMessage()); - break; - default: - e = new UnauthorizedException("Permission denied."); - break; - } - onFailure(e); + onFailure(getException(result)); } } @@ -66,4 +47,28 @@ public class ValidationCallback implements FutureCallback { public void onFailure(Throwable e) { action.onFailure(e); } + + public static Exception getException(ValidationResult result) { + ValidationResultCode resultCode = result.getResultCode(); + Exception e; + switch (resultCode) { + case ENTITY_NOT_FOUND: + e = new EntityNotFoundException(result.getMessage()); + break; + case UNAUTHORIZED: + e = new UnauthorizedException(result.getMessage()); + break; + case ACCESS_DENIED: + e = new AccessDeniedException(result.getMessage()); + break; + case INTERNAL_ERROR: + e = new InternalErrorException(result.getMessage()); + break; + default: + e = new UnauthorizedException("Permission denied."); + break; + } + return e; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java index 2ff8e89070..1251ca3b7e 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java @@ -37,13 +37,18 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.ValidationCallback; import org.thingsboard.server.service.security.ValidationResult; +import org.thingsboard.server.service.security.ValidationResultCode; import org.thingsboard.server.service.telemetry.cmd.AttributesSubscriptionCmd; import org.thingsboard.server.service.telemetry.cmd.GetHistoryCmd; import org.thingsboard.server.service.telemetry.cmd.SubscriptionCmd; import org.thingsboard.server.service.telemetry.cmd.TelemetryPluginCmd; import org.thingsboard.server.service.telemetry.cmd.TelemetryPluginCmdsWrapper; import org.thingsboard.server.service.telemetry.cmd.TimeseriesSubscriptionCmd; +import org.thingsboard.server.service.telemetry.exception.AccessDeniedException; +import org.thingsboard.server.service.telemetry.exception.EntityNotFoundException; +import org.thingsboard.server.service.telemetry.exception.InternalErrorException; import org.thingsboard.server.service.telemetry.exception.UnauthorizedException; import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; import org.thingsboard.server.service.telemetry.sub.SubscriptionState; @@ -535,11 +540,16 @@ public class DefaultTelemetryWebSocketService implements TelemetryWebSocketServi }; } - private FutureCallback on(Consumer success, Consumer failure) { + private FutureCallback on(Consumer success, Consumer failure) { return new FutureCallback() { @Override public void onSuccess(@Nullable ValidationResult result) { - success.accept(result); + ValidationResultCode resultCode = result.getResultCode(); + if (resultCode == ValidationResultCode.OK) { + success.accept(null); + } else { + onFailure(ValidationCallback.getException(result)); + } } @Override From 39e6b904c17577e8e143ce5d8e2cf9e74b849c0c Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Sat, 20 Oct 2018 13:14:42 +0300 Subject: [PATCH 240/307] Fixed entity view cache evict --- .../server/dao/entityview/EntityViewServiceImpl.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 0cc6008ffb..2d94cc2eab 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -91,9 +91,6 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Autowired private CustomerDao customerDao; - @Autowired - private AttributesService attributesService; - @Autowired private CacheManager cacheManager; @@ -108,6 +105,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return savedEntityView; } + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") @Override public EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId) { EntityView entityView = findEntityViewById(entityViewId); From b773ee0c66742cdef3223009a04845ef70858928 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Mon, 22 Oct 2018 08:34:44 +0300 Subject: [PATCH 241/307] Rate limits draft --- .../transport/LocalTransportService.java | 32 +++++-- .../src/main/resources/thingsboard.yml | 2 +- .../transport/mqtt/MqttTransportHandler.java | 2 +- common/transport/transport-api/pom.xml | 4 + .../common/transport/TransportService.java | 2 + .../service/AbstractTransportService.java | 52 +++++++++- .../service/RemoteTransportService.java | 96 +++++++++++-------- .../service/TbRateLimitsException.java | 29 ++++++ .../service/TbTransportRateLimits.java | 53 ++++++++++ pom.xml | 6 ++ .../src/main/resources/tb-mqtt-transport.yml | 9 +- 11 files changed, 234 insertions(+), 53 deletions(-) create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java index d670db3c19..0f722306ff 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -134,42 +134,58 @@ public class LocalTransportService extends AbstractTransportService implements R @Override public void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSessionEvent(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSessionEvent(msg).build(), callback); + } } @Override public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostTelemetry(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostTelemetry(msg).build(), callback); + } } @Override public void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostAttributes(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostAttributes(msg).build(), callback); + } } @Override public void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setGetAttributes(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setGetAttributes(msg).build(), callback); + } } @Override public void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToAttributes(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToAttributes(msg).build(), callback); + } } @Override public void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToRPC(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToRPC(msg).build(), callback); + } } @Override public void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToDeviceRPCCallResponse(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToDeviceRPCCallResponse(msg).build(), callback); + } } @Override public void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback); + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback); + } } @Override diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 984fa280d6..8d65e3dd33 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -444,7 +444,7 @@ transport: bind_port: "${MQTT_BIND_PORT:1883}" timeout: "${MQTT_TIMEOUT:10000}" netty: - leak_detector_level: "${NETTY_LEASK_DETECTOR_LVL:DISABLED}" + leak_detector_level: "${NETTY_LEAK_DETECTOR_LVL:DISABLED}" boss_group_thread_count: "${NETTY_BOSS_GROUP_THREADS:1}" worker_group_thread_count: "${NETTY_WORKER_GROUP_THREADS:12}" max_payload_size: "${NETTY_MAX_PAYLOAD_SIZE:65536}" diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 89ecdd583e..46a890f19e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -249,7 +249,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement @Override public void onError(Throwable e) { log.trace("[{}] Failed to publish msg: {}", sessionId, msg, e); - ctx.close(); + processDisconnect(ctx); } }; } diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 3538e468d4..4ed6ed784d 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -99,6 +99,10 @@ com.google.protobuf protobuf-java
+ + com.github.vladimir-bukhtoyarov + bucket4j-core + diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index caf178a641..a47438f30d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -43,6 +43,8 @@ public interface TransportService { void process(TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg msg, TransportServiceCallback callback); + boolean checkLimits(SessionInfoProto sessionInfo, TransportServiceCallback callback); + void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback); void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java index e299a027e8..265dacb5de 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java @@ -16,8 +16,13 @@ package org.thingsboard.server.common.transport.service; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.gen.transport.TransportProtos; import java.util.UUID; @@ -36,9 +41,20 @@ import java.util.concurrent.TimeUnit; @Slf4j public abstract class AbstractTransportService implements TransportService { + @Value("${transport.rate_limits.enabled}") + private boolean rateLimitEnabled; + @Value("${transport.rate_limits.tenant}") + private String perTenantLimitsConf; + @Value("${transport.rate_limits.tenant}") + private String perDevicesLimitsConf; + protected ScheduledExecutorService schedulerExecutor; protected ExecutorService transportCallbackExecutor; - protected ConcurrentMap sessions = new ConcurrentHashMap<>(); + private ConcurrentMap sessions = new ConcurrentHashMap<>(); + + //TODO: Implement cleanup of this maps. + private ConcurrentMap perTenantLimits = new ConcurrentHashMap<>(); + private ConcurrentMap perDeviceLimits = new ConcurrentHashMap<>(); @Override public void registerAsyncSession(TransportProtos.SessionInfoProto sessionInfo, SessionMsgListener listener) { @@ -53,7 +69,6 @@ public abstract class AbstractTransportService implements TransportService { listener.onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto.getDefaultInstance()); deregisterSession(sessionInfo); }, timeout, TimeUnit.MILLISECONDS); - } @Override @@ -61,6 +76,30 @@ public abstract class AbstractTransportService implements TransportService { sessions.remove(toId(sessionInfo)); } + @Override + public boolean checkLimits(TransportProtos.SessionInfoProto sessionInfo, TransportServiceCallback callback) { + if (!rateLimitEnabled) { + return true; + } + TenantId tenantId = new TenantId(new UUID(sessionInfo.getTenantIdMSB(), sessionInfo.getTenantIdLSB())); + TbTransportRateLimits rateLimits = perTenantLimits.computeIfAbsent(tenantId, id -> new TbTransportRateLimits(perTenantLimitsConf)); + if (!rateLimits.tryConsume()) { + if (callback != null) { + callback.onError(new TbRateLimitsException(EntityType.TENANT)); + } + return false; + } + DeviceId deviceId = new DeviceId(new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB())); + rateLimits = perDeviceLimits.computeIfAbsent(deviceId, id -> new TbTransportRateLimits(perDevicesLimitsConf)); + if (!rateLimits.tryConsume()) { + if (callback != null) { + callback.onError(new TbRateLimitsException(EntityType.DEVICE)); + } + return false; + } + return true; + } + protected void processToTransportMsg(TransportProtos.DeviceActorToTransportMsg toSessionMsg) { UUID sessionId = new UUID(toSessionMsg.getSessionIdMSB(), toSessionMsg.getSessionIdLSB()); SessionMetaData md = sessions.get(sessionId); @@ -101,11 +140,20 @@ public abstract class AbstractTransportService implements TransportService { } public void init() { + if (rateLimitEnabled) { + //Just checking the configuration parameters + new TbTransportRateLimits(perTenantLimitsConf); + new TbTransportRateLimits(perDevicesLimitsConf); + } this.schedulerExecutor = Executors.newSingleThreadScheduledExecutor(); this.transportCallbackExecutor = new ThreadPoolExecutor(0, 20, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); } public void destroy() { + if (rateLimitEnabled) { + perTenantLimits.clear(); + perDeviceLimits.clear(); + } if (schedulerExecutor != null) { schedulerExecutor.shutdownNow(); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index aa3b42b58c..6774942de3 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -218,74 +218,90 @@ public class RemoteTransportService extends AbstractTransportService { @Override public void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setSessionEvent(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSessionEvent(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } @Override public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setPostTelemetry(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostTelemetry(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } @Override public void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setPostAttributes(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } @Override public void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setGetAttributes(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setGetAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } @Override public void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setSubscribeToAttributes(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } @Override public void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setSubscribeToRPC(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToRPC(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } @Override public void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setToDeviceRPCCallResponse(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToDeviceRPCCallResponse(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } @Override public void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setToServerRPCCallRequest(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToServerRPCCallRequest(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } } private static class TransportCallbackAdaptor implements Callback { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java new file mode 100644 index 0000000000..9d2669da99 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import org.thingsboard.server.common.data.EntityType; + +/** + * Created by ashvayka on 22.10.18. + */ +public class TbRateLimitsException extends Exception { + private final EntityType entityType; + + TbRateLimitsException(EntityType entityType) { + this.entityType = entityType; + } +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java new file mode 100644 index 0000000000..d598734a5a --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.local.LocalBucket; +import io.github.bucket4j.local.LocalBucketBuilder; + +import java.time.Duration; + +/** + * Created by ashvayka on 22.10.18. + */ +class TbTransportRateLimits { + private final LocalBucket bucket; + + public TbTransportRateLimits(String limitsConfiguration) { + LocalBucketBuilder builder = Bucket4j.builder(); + boolean initialized = false; + for (String limitSrc : limitsConfiguration.split(",")) { + long capacity = Long.parseLong(limitSrc.split(":")[0]); + long duration = Long.parseLong(limitSrc.split(":")[1]); + builder.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(duration))); + initialized = true; + } + if (initialized) { + bucket = builder.build(); + } else { + throw new IllegalArgumentException("Failed to parse rate limits configuration: " + limitsConfiguration); + } + + + } + + boolean tryConsume() { + return bucket.tryConsume(1); + } + +} diff --git a/pom.xml b/pom.xml index 0ac38cdf8b..387e0eda42 100755 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,7 @@ 5.0.2 0.1.14 2.0.0 + 4.1.1 @@ -778,6 +779,11 @@ delight-nashorn-sandbox ${delight-nashorn-sandbox.version} + + com.github.vladimir-bukhtoyarov + bucket4j-core + ${bucket4j.version} + diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 1d425e4761..a0e86bb1c2 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -25,7 +25,7 @@ transport: adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" timeout: "${MQTT_TIMEOUT:10000}" netty: - leak_detector_level: "${NETTY_LEASK_DETECTOR_LVL:DISABLED}" + leak_detector_level: "${NETTY_LEAK_DETECTOR_LVL:DISABLED}" boss_group_thread_count: "${NETTY_BOSS_GROUP_THREADS:1}" worker_group_thread_count: "${NETTY_WORKER_GROUP_THREADS:12}" max_payload_size: "${NETTY_MAX_PAYLOAD_SIZE:65536}" @@ -43,6 +43,13 @@ transport: key_password: "${MQTT_SSL_KEY_PASSWORD:server_key_password}" # Type of the key store key_store_type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" + sessions: + max_per_tenant: "${TB_TRANSPORT_SESSIONS_MAX_PER_TENANT:1000}" + max_per_device: "${TB_TRANSPORT_SESSIONS_MAX_PER_DEVICE:2}" + rate_limits: + enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" + tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" + device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" #Quota parameters quota: From 18866238b3af726a0959772702090c731e07933d Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 22 Oct 2018 14:28:53 +0300 Subject: [PATCH 242/307] Update rabbitmq version. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 387e0eda42..00c9c28567 100755 --- a/pom.xml +++ b/pom.xml @@ -66,7 +66,7 @@ 1.1.0 4.1.22.Final 1.5.0 - 3.6.5 + 4.8.0 2.19.1 3.0.2 2.6.1 From a5e86c2e5edefd193094c164bdbe1b107d9eeb69 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 22 Oct 2018 17:24:32 +0300 Subject: [PATCH 243/307] Fix configuration. Update kafka topics configuration. --- .../server/actors/shared/rulechain/RuleChainManager.java | 2 +- application/src/main/resources/thingsboard.yml | 4 ++++ msa/docker/.env | 2 +- msa/docker/docker-compose.yml | 1 + transport/coap/src/main/resources/tb-coap-transport.yml | 4 ++++ transport/http/src/main/resources/tb-http-transport.yml | 4 ++++ 6 files changed, 15 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java b/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java index 11ed5a3363..d349fec71d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java @@ -50,7 +50,7 @@ public abstract class RuleChainManager extends EntityActorsManager Date: Mon, 22 Oct 2018 18:01:39 +0300 Subject: [PATCH 244/307] Remove Quota service. --- .../src/main/resources/thingsboard.yml | 40 --------- .../transport/coap/CoapTransportResource.java | 12 --- .../transport/coap/CoapTransportService.java | 2 - .../transport/http/DeviceApiController.java | 31 ------- .../transport/mqtt/MqttTransportContext.java | 1 - .../transport/mqtt/MqttTransportHandler.java | 10 --- .../common/transport/TransportContext.java | 4 - .../transport/quota/AbstractQuotaService.java | 67 --------------- .../server/common/transport/quota/Clock.java | 45 ---------- .../common/transport/quota/QuotaService.java | 25 ------ .../transport/quota/RequestLimitPolicy.java | 30 ------- .../host/HostIntervalRegistryCleaner.java | 29 ------- .../host/HostIntervalRegistryLogger.java | 52 ------------ .../host/HostRequestIntervalRegistry.java | 37 --------- .../quota/host/HostRequestLimitPolicy.java | 33 -------- .../quota/host/HostRequestsQuotaService.java | 37 --------- .../quota/inmemory/IntervalCount.java | 68 --------------- .../inmemory/IntervalRegistryCleaner.java | 62 -------------- .../inmemory/IntervalRegistryLogger.java | 79 ------------------ .../inmemory/KeyBasedIntervalRegistry.java | 73 ---------------- .../tenant/TenantIntervalRegistryCleaner.java | 31 ------- .../tenant/TenantIntervalRegistryLogger.java | 54 ------------ .../tenant/TenantMsgsIntervalRegistry.java | 33 -------- .../quota/tenant/TenantQuotaService.java | 32 ------- .../tenant/TenantRequestLimitPolicy.java | 30 ------- .../common/transport/quota/ClockTest.java | 66 --------------- .../quota/HostRequestLimitPolicyTest.java | 47 ----------- .../quota/HostRequestsQuotaServiceTest.java | 78 ----------------- .../HostRequestIntervalRegistryTest.java | 83 ------------------- .../quota/inmemory/IntervalCountTest.java | 65 --------------- .../inmemory/IntervalRegistryLoggerTest.java | 63 -------------- .../src/main/resources/tb-coap-transport.yml | 21 ----- .../src/main/resources/tb-http-transport.yml | 21 ----- .../src/main/resources/tb-mqtt-transport.yml | 21 ----- 34 files changed, 1382 deletions(-) delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java delete mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java delete mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java delete mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java delete mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java delete mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java delete mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java delete mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 17123b5f16..6cb36218b0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -82,46 +82,6 @@ dashboard: # Maximum allowed datapoints fetched by widgets max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" -#Quota parameters -quota: - host: - # Max allowed number of API requests in interval for single host - limit: "${QUOTA_HOST_LIMIT:10000}" - # Interval duration - intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" - # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs - ttlMs: "${QUOTA_HOST_TTL_MS:60000}" - # Interval for scheduled task that cleans expired records. TTL is used for expiring - cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" - # Enable Host API Limits - enabled: "${QUOTA_HOST_ENABLED:false}" - # Array of whitelist hosts - whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" - # Array of blacklist hosts - blacklist: "${QUOTA_HOST_BLACKLIST:}" - log: - topSize: "${QUOTA_HOST_LOG_TOP_SIZE:10}" - intervalMin: "${QUOTA_HOST_LOG_INTERVAL_MIN:2}" - rule: - tenant: - # Max allowed number of API requests in interval for single tenant - limit: "${QUOTA_TENANT_LIMIT:100000}" - # Interval duration - intervalMs: "${QUOTA_TENANT_INTERVAL_MS:60000}" - # Maximum silence duration for tenant after which Tenant removed from QuotaService. Must be bigger than intervalMs - ttlMs: "${QUOTA_TENANT_TTL_MS:60000}" - # Interval for scheduled task that cleans expired records. TTL is used for expiring - cleanPeriodMs: "${QUOTA_TENANT_CLEAN_PERIOD_MS:300000}" - # Enable Host API Limits - enabled: "${QUOTA_TENANT_ENABLED:true}" - # Array of whitelist tenants - whitelist: "${QUOTA_TENANT_WHITELIST:}" - # Array of blacklist tenants - blacklist: "${QUOTA_HOST_TENANT_BLACKLIST:}" - log: - topSize: "${QUOTA_TENANT_LOG_TOP_SIZE:10}" - intervalMin: "${QUOTA_TENANT_LOG_INTERVAL_MIN:2}" - database: entities: type: "${DATABASE_ENTITIES_TYPE:sql}" # cassandra OR sql diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java index 0e2a794a45..9cf36e3096 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -72,12 +72,6 @@ public class CoapTransportResource extends CoapResource { @Override public void handleGET(CoapExchange exchange) { - if (transportContext.getQuotaService().isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { - log.warn("CoAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); - exchange.respond(ResponseCode.BAD_REQUEST); - return; - } - Optional featureType = getFeatureType(exchange.advanced().getRequest()); if (!featureType.isPresent()) { log.trace("Missing feature type parameter"); @@ -108,12 +102,6 @@ public class CoapTransportResource extends CoapResource { @Override public void handlePOST(CoapExchange exchange) { - if (transportContext.getQuotaService().isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { - log.warn("CoAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); - exchange.respond(ResponseCode.BAD_REQUEST); - return; - } - Optional featureType = getFeatureType(exchange.advanced().getRequest()); if (!featureType.isPresent()) { log.trace("Missing feature type parameter"); diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java index 278ca6d8c5..c0bd7f9c1b 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java @@ -27,8 +27,6 @@ import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; import javax.annotation.PostConstruct; diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index b40fbc194f..18458d13ad 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -75,9 +75,6 @@ public class DeviceApiController { @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys, HttpServletRequest httpRequest) { DeferredResult responseWriter = new DeferredResult<>(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { GetAttributeRequestMsg.Builder request = GetAttributeRequestMsg.newBuilder().setRequestId(0); @@ -100,9 +97,6 @@ public class DeviceApiController { public DeferredResult postDeviceAttributes(@PathVariable("deviceToken") String deviceToken, @RequestBody String json, HttpServletRequest request) { DeferredResult responseWriter = new DeferredResult<>(); - if (quotaExceeded(request, responseWriter)) { - return responseWriter; - } transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { TransportService transportService = transportContext.getTransportService(); @@ -116,9 +110,6 @@ public class DeviceApiController { public DeferredResult postTelemetry(@PathVariable("deviceToken") String deviceToken, @RequestBody String json, HttpServletRequest request) { DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(request, responseWriter)) { - return responseWriter; - } transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { TransportService transportService = transportContext.getTransportService(); @@ -133,9 +124,6 @@ public class DeviceApiController { @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, HttpServletRequest httpRequest) { DeferredResult responseWriter = new DeferredResult<>(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { TransportService transportService = transportContext.getTransportService(); @@ -153,9 +141,6 @@ public class DeviceApiController { @PathVariable("requestId") Integer requestId, @RequestBody String json, HttpServletRequest request) { DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(request, responseWriter)) { - return responseWriter; - } transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { TransportService transportService = transportContext.getTransportService(); @@ -168,9 +153,6 @@ public class DeviceApiController { public DeferredResult postRpcRequest(@PathVariable("deviceToken") String deviceToken, @RequestBody String json, HttpServletRequest httpRequest) { DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { JsonObject request = new JsonParser().parse(json).getAsJsonObject(); @@ -189,9 +171,6 @@ public class DeviceApiController { @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, HttpServletRequest httpRequest) { DeferredResult responseWriter = new DeferredResult<>(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { TransportService transportService = transportContext.getTransportService(); @@ -204,16 +183,6 @@ public class DeviceApiController { return responseWriter; } - private boolean quotaExceeded(HttpServletRequest request, DeferredResult responseWriter) { - if (transportContext.getQuotaService().isQuotaExceeded(request.getRemoteAddr())) { - log.warn("REST Quota exceeded for [{}] . Disconnect", request.getRemoteAddr()); - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)); - return true; - } else { - return false; - } - } - private static class DeviceAuthCallback implements TransportServiceCallback { private final TransportContext transportContext; private final DeferredResult responseWriter; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 538daa1f9c..184ac49c25 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -28,7 +28,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; import org.thingsboard.server.common.transport.TransportService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.kafka.TbNodeIdProvider; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 46a890f19e..da8b3a62ca 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -42,7 +42,6 @@ import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.quota.QuotaService; import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; @@ -92,7 +91,6 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private final MqttTransportContext context; private final MqttTransportAdaptor adaptor; private final TransportService transportService; - private final QuotaService quotaService; private final SslHandler sslHandler; private final ConcurrentMap mqttQoSMap; @@ -106,7 +104,6 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement this.context = context; this.transportService = context.getTransportService(); this.adaptor = context.getAdaptor(); - this.quotaService = context.getQuotaService(); this.sslHandler = context.getSslHandler(); this.mqttQoSMap = new ConcurrentHashMap<>(); this.deviceSessionCtx = new DeviceSessionCtx(sessionId, mqttQoSMap); @@ -129,13 +126,6 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement processDisconnect(ctx); return; } - - if (quotaService.isQuotaExceeded(address.getHostName())) { - log.warn("MQTT Quota exceeded for [{}:{}] . Disconnect", address.getHostName(), address.getPort()); - processDisconnect(ctx); - return; - } - deviceSessionCtx.setChannel(ctx); switch (msg.fixedHeader().messageType()) { case CONNECT: diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java index ab429829e4..58aaa78989 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -20,7 +20,6 @@ import lombok.Data; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.kafka.TbNodeIdProvider; import javax.annotation.PostConstruct; @@ -43,9 +42,6 @@ public class TransportContext { @Autowired private TbNodeIdProvider nodeIdProvider; - @Autowired(required = false) - private HostRequestsQuotaService quotaService; - @Getter private ExecutorService executor; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java deleted file mode 100644 index 9d7bd1720c..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryCleaner; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryLogger; -import org.thingsboard.server.common.transport.quota.inmemory.KeyBasedIntervalRegistry; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; - -public class AbstractQuotaService implements QuotaService { - - private final KeyBasedIntervalRegistry requestRegistry; - private final RequestLimitPolicy requestsPolicy; - private final IntervalRegistryCleaner registryCleaner; - private final IntervalRegistryLogger registryLogger; - private final boolean enabled; - - public AbstractQuotaService(KeyBasedIntervalRegistry requestRegistry, RequestLimitPolicy requestsPolicy, - IntervalRegistryCleaner registryCleaner, IntervalRegistryLogger registryLogger, - boolean enabled) { - this.requestRegistry = requestRegistry; - this.requestsPolicy = requestsPolicy; - this.registryCleaner = registryCleaner; - this.registryLogger = registryLogger; - this.enabled = enabled; - } - - @PostConstruct - public void init() { - if (enabled) { - registryCleaner.schedule(); - registryLogger.schedule(); - } - } - - @PreDestroy - public void close() { - if (enabled) { - registryCleaner.stop(); - registryLogger.stop(); - } - } - - @Override - public boolean isQuotaExceeded(String key) { - if (enabled) { - long count = requestRegistry.tick(key); - return !requestsPolicy.isValid(count); - } - return false; - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java deleted file mode 100644 index dcc2d2306c..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public final class Clock { - - private static long time = 0L; - - private Clock() { - } - - - public static long millis() { - return time == 0 ? System.currentTimeMillis() : time; - } - - public static void setMillis(long millis) { - time = millis; - } - - public static void shift(long delta) { - time += delta; - } - - public static void reset() { - time = 0; - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java deleted file mode 100644 index f841749049..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public interface QuotaService { - - boolean isQuotaExceeded(String key); -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java deleted file mode 100644 index 0ff1230b1c..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - - -public abstract class RequestLimitPolicy { - - private final long limit; - - public RequestLimitPolicy(long limit) { - this.limit = limit; - } - - public boolean isValid(long currentValue) { - return currentValue <= limit; - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java deleted file mode 100644 index ea75f5dc09..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryCleaner; - -@Component -public class HostIntervalRegistryCleaner extends IntervalRegistryCleaner { - - public HostIntervalRegistryCleaner(HostRequestIntervalRegistry intervalRegistry, - @Value("${quota.host.cleanPeriodMs}") long cleanPeriodMs) { - super(intervalRegistry, cleanPeriodMs); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java deleted file mode 100644 index 65767f1648..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryLogger; - -import java.util.Map; -import java.util.concurrent.TimeUnit; - -@Component -@Slf4j -public class HostIntervalRegistryLogger extends IntervalRegistryLogger { - - private final long logIntervalMin; - - public HostIntervalRegistryLogger(@Value("${quota.host.log.topSize}") int topSize, - @Value("${quota.host.log.intervalMin}") long logIntervalMin, - HostRequestIntervalRegistry intervalRegistry) { - super(topSize, logIntervalMin, intervalRegistry); - this.logIntervalMin = logIntervalMin; - } - - protected void log(Map top, int uniqHosts, long requestsCount) { - long rps = requestsCount / TimeUnit.MINUTES.toSeconds(logIntervalMin); - StringBuilder builder = new StringBuilder("Quota Statistic : "); - builder.append("uniqHosts : ").append(uniqHosts).append("; "); - builder.append("requestsCount : ").append(requestsCount).append("; "); - builder.append("RPS : ").append(rps).append(" "); - builder.append("top -> "); - for (Map.Entry host : top.entrySet()) { - builder.append(host.getKey()).append(" : ").append(host.getValue()).append("; "); - } - - log.info(builder.toString()); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java deleted file mode 100644 index 9b3b4614e4..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.KeyBasedIntervalRegistry; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Component -@Slf4j -public class HostRequestIntervalRegistry extends KeyBasedIntervalRegistry { - - public HostRequestIntervalRegistry(@Value("${quota.host.intervalMs}") long intervalDurationMs, - @Value("${quota.host.ttlMs}") long ttlMs, - @Value("${quota.host.whitelist}") String whiteList, - @Value("${quota.host.blacklist}") String blackList) { - super(intervalDurationMs, ttlMs, whiteList, blackList, "host"); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java deleted file mode 100644 index eeef9247e6..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.RequestLimitPolicy; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Component -public class HostRequestLimitPolicy extends RequestLimitPolicy { - - public HostRequestLimitPolicy(@Value("${quota.host.limit}") long limit) { - super(limit); - } - -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java deleted file mode 100644 index 69342b538b..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.thingsboard.server.common.transport.quota.AbstractQuotaService; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Service -@Slf4j -public class HostRequestsQuotaService extends AbstractQuotaService { - - public HostRequestsQuotaService(HostRequestIntervalRegistry requestRegistry, HostRequestLimitPolicy requestsPolicy, - HostIntervalRegistryCleaner registryCleaner, HostIntervalRegistryLogger registryLogger, - @Value("${quota.host.enabled}") boolean enabled) { - super(requestRegistry, requestsPolicy, registryCleaner, registryLogger, enabled); - } - -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java deleted file mode 100644 index 0ac6c9b5e2..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - - -import org.thingsboard.server.common.transport.quota.Clock; - -import java.util.concurrent.atomic.LongAdder; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class IntervalCount { - - private final LongAdder adder = new LongAdder(); - private final long intervalDurationMs; - private volatile long startTime; - private volatile long lastTickTime; - - public IntervalCount(long intervalDurationMs) { - this.intervalDurationMs = intervalDurationMs; - startTime = Clock.millis(); - } - - public long resetIfExpiredAndTick() { - if (isExpired()) { - reset(); - } - tick(); - return adder.sum(); - } - - public long silenceDuration() { - return Clock.millis() - lastTickTime; - } - - public long getCount() { - return adder.sum(); - } - - private void tick() { - adder.add(1); - lastTickTime = Clock.millis(); - } - - private void reset() { - adder.reset(); - startTime = Clock.millis(); - } - - private boolean isExpired() { - return (Clock.millis() - startTime) > intervalDurationMs; - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java deleted file mode 100644 index 0c510ff815..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import lombok.extern.slf4j.Slf4j; - -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Slf4j -public abstract class IntervalRegistryCleaner { - - private final KeyBasedIntervalRegistry intervalRegistry; - private final long cleanPeriodMs; - private ScheduledExecutorService executor; - - public IntervalRegistryCleaner(KeyBasedIntervalRegistry intervalRegistry, long cleanPeriodMs) { - this.intervalRegistry = intervalRegistry; - this.cleanPeriodMs = cleanPeriodMs; - } - - public void schedule() { - if (executor != null) { - throw new IllegalStateException("Registry Cleaner already scheduled"); - } - executor = Executors.newSingleThreadScheduledExecutor(); - executor.scheduleAtFixedRate(this::clean, cleanPeriodMs, cleanPeriodMs, TimeUnit.MILLISECONDS); - } - - public void stop() { - if (executor != null) { - executor.shutdown(); - } - } - - public void clean() { - try { - intervalRegistry.clean(); - } catch (RuntimeException ex) { - log.error("Could not clear Interval Registry", ex); - } - } - -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java deleted file mode 100644 index 30399a140f..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import com.google.common.collect.MinMaxPriorityQueue; -import lombok.extern.slf4j.Slf4j; - -import java.util.Comparator; -import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Slf4j -public abstract class IntervalRegistryLogger { - - private final int topSize; - private final KeyBasedIntervalRegistry intervalRegistry; - private final long logIntervalMin; - private ScheduledExecutorService executor; - - public IntervalRegistryLogger(int topSize, long logIntervalMin, KeyBasedIntervalRegistry intervalRegistry) { - this.topSize = topSize; - this.logIntervalMin = logIntervalMin; - this.intervalRegistry = intervalRegistry; - } - - public void schedule() { - if (executor != null) { - throw new IllegalStateException("Registry Cleaner already scheduled"); - } - executor = Executors.newSingleThreadScheduledExecutor(); - executor.scheduleAtFixedRate(this::logStatistic, logIntervalMin, logIntervalMin, TimeUnit.MINUTES); - } - - public void stop() { - if (executor != null) { - executor.shutdown(); - } - } - - public void logStatistic() { - Map registryContent = intervalRegistry.getContent(); - int uniqHosts = registryContent.size(); - long requestsCount = registryContent.values().stream().mapToLong(i -> i).sum(); - Map top = getTopElements(registryContent); - log(top, uniqHosts, requestsCount); - } - - protected Map getTopElements(Map countMap) { - MinMaxPriorityQueue> topQueue = MinMaxPriorityQueue - .orderedBy(Comparator.comparing((Function, Long>) Map.Entry::getValue).reversed()) - .maximumSize(topSize) - .create(countMap.entrySet()); - - return topQueue.stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - protected abstract void log(Map top, int uniqHosts, long requestsCount); -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java deleted file mode 100644 index 0b0fef85e5..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import com.google.common.collect.Sets; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; - -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -@Slf4j -public abstract class KeyBasedIntervalRegistry { - - private final Map hostCounts = new ConcurrentHashMap<>(); - private final long intervalDurationMs; - private final long ttlMs; - private final Set whiteList; - private final Set blackList; - - public KeyBasedIntervalRegistry(long intervalDurationMs, long ttlMs, String whiteList, String blackList, String name) { - this.intervalDurationMs = intervalDurationMs; - this.ttlMs = ttlMs; - this.whiteList = Sets.newHashSet(StringUtils.split(whiteList, ',')); - this.blackList = Sets.newHashSet(StringUtils.split(blackList, ',')); - validate(name); - } - - private void validate(String name) { - if (ttlMs < intervalDurationMs) { - log.warn("TTL for {} IntervalRegistry [{}] smaller than interval duration [{}]", name, ttlMs, intervalDurationMs); - } - log.info("Start {} KeyBasedIntervalRegistry with whitelist {}", name, whiteList); - log.info("Start {} KeyBasedIntervalRegistry with blacklist {}", name, blackList); - } - - public long tick(String clientHostId) { - IntervalCount intervalCount = hostCounts.computeIfAbsent(clientHostId, s -> new IntervalCount(intervalDurationMs)); - long currentCount = intervalCount.resetIfExpiredAndTick(); - if (whiteList.contains(clientHostId)) { - return 0; - } else if (blackList.contains(clientHostId)) { - return Long.MAX_VALUE; - } - return currentCount; - } - - public void clean() { - hostCounts.entrySet().removeIf(entry -> entry.getValue().silenceDuration() > ttlMs); - } - - public Map getContent() { - return hostCounts.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - interval -> interval.getValue().getCount())); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java deleted file mode 100644 index 47b427840a..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryCleaner; - -@Component -@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) -public class TenantIntervalRegistryCleaner extends IntervalRegistryCleaner { - - public TenantIntervalRegistryCleaner(TenantMsgsIntervalRegistry intervalRegistry, - @Value("${quota.rule.tenant.cleanPeriodMs}") long cleanPeriodMs) { - super(intervalRegistry, cleanPeriodMs); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java deleted file mode 100644 index 3bdcf93d07..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryLogger; - -import java.util.Map; -import java.util.concurrent.TimeUnit; - -@Slf4j -@Component -@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) -public class TenantIntervalRegistryLogger extends IntervalRegistryLogger { - - private final long logIntervalMin; - - public TenantIntervalRegistryLogger(@Value("${quota.rule.tenant.log.topSize}") int topSize, - @Value("${quota.rule.tenant.log.intervalMin}") long logIntervalMin, - TenantMsgsIntervalRegistry intervalRegistry) { - super(topSize, logIntervalMin, intervalRegistry); - this.logIntervalMin = logIntervalMin; - } - - protected void log(Map top, int uniqHosts, long requestsCount) { - long rps = requestsCount / TimeUnit.MINUTES.toSeconds(logIntervalMin); - StringBuilder builder = new StringBuilder("Tenant Quota Statistic : "); - builder.append("uniqTenants : ").append(uniqHosts).append("; "); - builder.append("requestsCount : ").append(requestsCount).append("; "); - builder.append("RPS : ").append(rps).append(" "); - builder.append("top -> "); - for (Map.Entry host : top.entrySet()) { - builder.append(host.getKey()).append(" : ").append(host.getValue()).append("; "); - } - - log.info(builder.toString()); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java deleted file mode 100644 index 3402e62472..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.KeyBasedIntervalRegistry; - -@Component -@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) -public class TenantMsgsIntervalRegistry extends KeyBasedIntervalRegistry { - - public TenantMsgsIntervalRegistry(@Value("${quota.rule.tenant.intervalMs}") long intervalDurationMs, - @Value("${quota.rule.tenant.ttlMs}") long ttlMs, - @Value("${quota.rule.tenant.whitelist}") String whiteList, - @Value("${quota.rule.tenant.blacklist}") String blackList) { - super(intervalDurationMs, ttlMs, whiteList, blackList, "Rule Tenant"); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java deleted file mode 100644 index 875a666a28..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.AbstractQuotaService; - -@Component -@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) -public class TenantQuotaService extends AbstractQuotaService { - - public TenantQuotaService(TenantMsgsIntervalRegistry requestRegistry, TenantRequestLimitPolicy requestsPolicy, - TenantIntervalRegistryCleaner registryCleaner, TenantIntervalRegistryLogger registryLogger, - @Value("${quota.rule.tenant.enabled}") boolean enabled) { - super(requestRegistry, requestsPolicy, registryCleaner, registryLogger, enabled); - } -} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java deleted file mode 100644 index c5dfc40480..0000000000 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.RequestLimitPolicy; - -@Component -@ConditionalOnProperty(prefix = "quota.rule.tenant", value = "enabled", havingValue = "true", matchIfMissing = false) -public class TenantRequestLimitPolicy extends RequestLimitPolicy { - - public TenantRequestLimitPolicy(@Value("${quota.rule.tenant.limit}") long limit) { - super(limit); - } -} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java deleted file mode 100644 index 403bdc65b3..0000000000 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class ClockTest { - - @Before - public void init() { - Clock.reset(); - } - - @After - public void clear() { - Clock.reset(); - } - - @Test - public void defaultClockUseSystemTime() { - assertFalse(Clock.millis() > System.currentTimeMillis()); - } - - @Test - public void timeCanBeSet() { - Clock.setMillis(100L); - assertEquals(100L, Clock.millis()); - } - - @Test - public void clockCanBeReseted() { - Clock.setMillis(100L); - assertEquals(100L, Clock.millis()); - Clock.reset(); - assertFalse(Clock.millis() > System.currentTimeMillis()); - } - - @Test - public void timeIsShifted() { - Clock.setMillis(100L); - Clock.shift(50L); - assertEquals(150L, Clock.millis()); - } - -} \ No newline at end of file diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java deleted file mode 100644 index 07e03ef0ac..0000000000 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.HostRequestLimitPolicy; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class HostRequestLimitPolicyTest { - - private HostRequestLimitPolicy limitPolicy = new HostRequestLimitPolicy(10L); - - @Test - public void ifCurrentValueLessThenLimitItIsValid() { - assertTrue(limitPolicy.isValid(9)); - } - - @Test - public void ifCurrentValueEqualsToLimitItIsValid() { - assertTrue(limitPolicy.isValid(10)); - } - - @Test - public void ifCurrentValueGreaterThenLimitItIsValid() { - assertFalse(limitPolicy.isValid(11)); - } - -} \ No newline at end of file diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java deleted file mode 100644 index 37441e9d41..0000000000 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.HostIntervalRegistryCleaner; -import org.thingsboard.server.common.transport.quota.host.HostIntervalRegistryLogger; -import org.thingsboard.server.common.transport.quota.host.HostRequestIntervalRegistry; -import org.thingsboard.server.common.transport.quota.host.HostRequestLimitPolicy; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.*; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class HostRequestsQuotaServiceTest { - - private HostRequestsQuotaService quotaService; - - private HostRequestIntervalRegistry requestRegistry = mock(HostRequestIntervalRegistry.class); - private HostRequestLimitPolicy requestsPolicy = mock(HostRequestLimitPolicy.class); - private HostIntervalRegistryCleaner registryCleaner = mock(HostIntervalRegistryCleaner.class); - private HostIntervalRegistryLogger registryLogger = mock(HostIntervalRegistryLogger.class); - - @Before - public void init() { - quotaService = new HostRequestsQuotaService(requestRegistry, requestsPolicy, registryCleaner, registryLogger, true); - } - - @Test - public void quotaExceededIfRequestCountBiggerThanAllowed() { - when(requestRegistry.tick("key")).thenReturn(10L); - when(requestsPolicy.isValid(10L)).thenReturn(false); - - assertTrue(quotaService.isQuotaExceeded("key")); - - verify(requestRegistry).tick("key"); - verify(requestsPolicy).isValid(10L); - verifyNoMoreInteractions(requestRegistry, requestsPolicy); - } - - @Test - public void quotaNotExceededIfRequestCountLessThanAllowed() { - when(requestRegistry.tick("key")).thenReturn(10L); - when(requestsPolicy.isValid(10L)).thenReturn(true); - - assertFalse(quotaService.isQuotaExceeded("key")); - - verify(requestRegistry).tick("key"); - verify(requestsPolicy).isValid(10L); - verifyNoMoreInteractions(requestRegistry, requestsPolicy); - } - - @Test - public void serviceCanBeDisabled() { - quotaService = new HostRequestsQuotaService(requestRegistry, requestsPolicy, registryCleaner, registryLogger, false); - assertFalse(quotaService.isQuotaExceeded("key")); - verifyNoMoreInteractions(requestRegistry, requestsPolicy); - } -} \ No newline at end of file diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java deleted file mode 100644 index b49dd00bc9..0000000000 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.HostRequestIntervalRegistry; - -import static org.junit.Assert.assertEquals; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class HostRequestIntervalRegistryTest { - - private HostRequestIntervalRegistry registry; - - @Before - public void init() { - registry = new HostRequestIntervalRegistry(10000L, 100L,"g1,g2", "b1"); - } - - @Test - public void newHostCreateNewInterval() { - assertEquals(1L, registry.tick("host1")); - } - - @Test - public void existingHostUpdated() { - registry.tick("aaa"); - assertEquals(1L, registry.tick("bbb")); - assertEquals(2L, registry.tick("aaa")); - } - - @Test - public void expiredIntervalsCleaned() throws InterruptedException { - registry.tick("aaa"); - Thread.sleep(150L); - registry.tick("bbb"); - registry.clean(); - assertEquals(1L, registry.tick("aaa")); - assertEquals(2L, registry.tick("bbb")); - } - - @Test - public void domainFromWhitelistNotCounted(){ - assertEquals(0L, registry.tick("g1")); - assertEquals(0L, registry.tick("g1")); - assertEquals(0L, registry.tick("g2")); - } - - @Test - public void domainFromBlackListReturnMaxValue(){ - assertEquals(Long.MAX_VALUE, registry.tick("b1")); - assertEquals(Long.MAX_VALUE, registry.tick("b1")); - } - - @Test - public void emptyWhitelistParsedOk(){ - registry = new HostRequestIntervalRegistry(10000L, 100L,"", "b1"); - assertEquals(1L, registry.tick("aaa")); - } - - @Test - public void emptyBlacklistParsedOk(){ - registry = new HostRequestIntervalRegistry(10000L, 100L,"", ""); - assertEquals(1L, registry.tick("aaa")); - } -} \ No newline at end of file diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java deleted file mode 100644 index 4fdce61fd3..0000000000 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.Clock; - -import static org.junit.Assert.assertEquals; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class IntervalCountTest { - - @Before - public void init() { - Clock.setMillis(1000L); - } - - @After - public void clear() { - Clock.reset(); - } - - @Test - public void ticksInSameIntervalAreSummed() { - IntervalCount intervalCount = new IntervalCount(100L); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - Clock.shift(100); - assertEquals(2L, intervalCount.resetIfExpiredAndTick()); - } - - @Test - public void oldDataCleanedWhenIntervalExpired() { - IntervalCount intervalCount = new IntervalCount(100L); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - Clock.shift(101); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - } - - @Test - public void silenceDurationCalculatedFromLastTick() { - IntervalCount intervalCount = new IntervalCount(100L); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - Clock.shift(10L); - assertEquals(10L, intervalCount.silenceDuration()); - } - -} \ No newline at end of file diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java deleted file mode 100644 index 6e51420cd5..0000000000 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import com.google.common.collect.ImmutableMap; -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.HostIntervalRegistryLogger; -import org.thingsboard.server.common.transport.quota.host.HostRequestIntervalRegistry; - -import java.util.Collections; -import java.util.Map; - -import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.mock; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class IntervalRegistryLoggerTest { - - private IntervalRegistryLogger logger; - - private HostRequestIntervalRegistry requestRegistry = mock(HostRequestIntervalRegistry.class); - - @Before - public void init() { - logger = new HostIntervalRegistryLogger(3, 10, requestRegistry); - } - - @Test - public void onlyMaxHostsCollected() { - Map map = ImmutableMap.of("a", 8L, "b", 3L, "c", 1L, "d", 3L); - Map actual = logger.getTopElements(map); - Map expected = ImmutableMap.of("a", 8L, "b", 3L, "d", 3L); - - assertEquals(expected, actual); - } - - @Test - public void emptyMapProcessedCorrectly() { - Map map = Collections.emptyMap(); - Map actual = logger.getTopElements(map); - Map expected = Collections.emptyMap(); - - assertEquals(expected, actual); - } - -} \ No newline at end of file diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 822d104617..30351b0bfa 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -28,27 +28,6 @@ transport: tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" -#Quota parameters -quota: - host: - # Max allowed number of API requests in interval for single host - limit: "${QUOTA_HOST_LIMIT:10000}" - # Interval duration - intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" - # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs - ttlMs: "${QUOTA_HOST_TTL_MS:60000}" - # Interval for scheduled task that cleans expired records. TTL is used for expiring - cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" - # Enable Host API Limits - enabled: "${QUOTA_HOST_ENABLED:true}" - # Array of whitelist hosts - whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" - # Array of blacklist hosts - blacklist: "${QUOTA_HOST_BLACKLIST:}" - log: - topSize: 10 - intervalMin: 2 - kafka: enabled: true bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 650bfdf1e9..001e08abdc 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -29,27 +29,6 @@ transport: tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" -#Quota parameters -quota: - host: - # Max allowed number of API requests in interval for single host - limit: "${QUOTA_HOST_LIMIT:10000}" - # Interval duration - intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" - # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs - ttlMs: "${QUOTA_HOST_TTL_MS:60000}" - # Interval for scheduled task that cleans expired records. TTL is used for expiring - cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" - # Enable Host API Limits - enabled: "${QUOTA_HOST_ENABLED:true}" - # Array of whitelist hosts - whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" - # Array of blacklist hosts - blacklist: "${QUOTA_HOST_BLACKLIST:}" - log: - topSize: 10 - intervalMin: 2 - kafka: enabled: true bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index a0e86bb1c2..e37d14e874 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -51,27 +51,6 @@ transport: tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" -#Quota parameters -quota: - host: - # Max allowed number of API requests in interval for single host - limit: "${QUOTA_HOST_LIMIT:10000}" - # Interval duration - intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" - # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs - ttlMs: "${QUOTA_HOST_TTL_MS:60000}" - # Interval for scheduled task that cleans expired records. TTL is used for expiring - cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" - # Enable Host API Limits - enabled: "${QUOTA_HOST_ENABLED:true}" - # Array of whitelist hosts - whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" - # Array of blacklist hosts - blacklist: "${QUOTA_HOST_BLACKLIST:}" - log: - topSize: 10 - intervalMin: 2 - kafka: enabled: true bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" From d46cf8f485fa02da8007afd0a03316889c3b6853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20M=C3=BCcahit=20=C3=87etinkaya?= Date: Mon, 22 Oct 2018 18:40:36 +0300 Subject: [PATCH 245/307] Typo fixed on saveOrUpdate method. --- .../server/dao/device/DeviceCredentialsServiceImpl.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index 4b25f2b38a..101bff2bdd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -62,15 +62,15 @@ public class DeviceCredentialsServiceImpl implements DeviceCredentialsService { @Override @CacheEvict(cacheNames = DEVICE_CREDENTIALS_CACHE, keyGenerator="previousDeviceCredentialsId", beforeInvocation = true) public DeviceCredentials updateDeviceCredentials(DeviceCredentials deviceCredentials) { - return saveOrUpdare(deviceCredentials); + return saveOrUpdate(deviceCredentials); } @Override public DeviceCredentials createDeviceCredentials(DeviceCredentials deviceCredentials) { - return saveOrUpdare(deviceCredentials); + return saveOrUpdate(deviceCredentials); } - private DeviceCredentials saveOrUpdare(DeviceCredentials deviceCredentials) { + private DeviceCredentials saveOrUpdate(DeviceCredentials deviceCredentials) { if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { formatCertData(deviceCredentials); } From 95cc86d242daf9391f4e4f6d18468a70405cff9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorukhan=20G=C3=BCl=C3=B6z?= Date: Mon, 22 Oct 2018 18:59:06 +0200 Subject: [PATCH 246/307] Turkish language translation mistakes were fixed. Template entity names were changed with camelCase. --- ui/src/app/locale/locale.constant-TR.json | 1644 ++++++++++----------- 1 file changed, 822 insertions(+), 822 deletions(-) diff --git a/ui/src/app/locale/locale.constant-TR.json b/ui/src/app/locale/locale.constant-TR.json index bb5a3222c0..de88116fe5 100644 --- a/ui/src/app/locale/locale.constant-TR.json +++ b/ui/src/app/locale/locale.constant-TR.json @@ -4,51 +4,51 @@ "unauthorized-access": "Yetkisiz Erişim", "unauthorized-access-text": "Bu kaynağa erişmek için giriş yapmalısınız!", "access-forbidden": "Erişim Yasaklandı", - "access-forbidden-text": "Bu yere erişim haklarına sahip değilsiniz!
Bu konuma erişmek istiyorsanız yine de farklı kullanıcılarla giriş yapmayı deneyin.", + "access-forbidden-text": "Bu konuma erişim haklarınız yok!
Bu yere hala erişmek istiyorsanız farklı kullanıcılarla oturum açmayı deneyin.", "refresh-token-expired": "Oturum süresi doldu", - "refresh-token-failed": "Seans yenilenemiyor" + "refresh-token-failed": "Oturum yenilenemiyor" }, "action": { - "activate": "Aktive Et", - "suspend": "Askıya Alma", - "save": "Kayıt etmek", - "saveAs": "Farklı kaydet", - "cancel": "İptal etmek", - "ok": "TAMAM", + "activate": "Etkinleştir", + "suspend": "Askıya al", + "save": "Kaydet", + "saveAs": "Farklı Kaydet", + "cancel": "İptal", + "ok": "Tamam", "delete": "Sil", - "add": "Eklemek", + "add": "Ekle", "yes": "Evet", - "no": "Yok hayır", - "update": "Güncelleştirme", + "no": "Hayır", + "update": "Güncelle", "remove": "Kaldır", - "search": "Arama", + "search": "Ara", "clear-search": "Aramayı Temizle", - "assign": "Atamak", - "unassign": "Atamayı kaldırma", - "share": "Pay", + "assign": "Ata", + "unassign": "Atamayı kaldır", + "share": "Paylaş", "make-private": "Özel yap", - "apply": "Uygulamak", - "apply-changes": "Değişiklikleri uygula", - "edit-mode": "Düzenleme modu", - "enter-edit-mode": "Düzenleme moduna girin", + "apply": "Uygula", + "apply-changes": "Değişiklikleri Uygula", + "edit-mode": "Düzenleme Modu", + "enter-edit-mode": "Düzenleme moduna gir", "decline-changes": "Değişiklikleri reddet", "close": "Kapat", "back": "Geri", - "run": "Koşmak", - "sign-in": "Oturum aç!", + "run": "Çalıştır", + "sign-in": "Giriş yap!", "edit": "Düzenle", - "view": "Görünüm", - "create": "Yaratmak", - "drag": "Sürüklemek", + "view": "Görüntüle", + "create": "Oluştur", + "drag": "Sürükle", "refresh": "Yenile", - "undo": "Geri alma", + "undo": "Geri al", "copy": "Kopyala", - "paste": "Yapıştırmak", + "paste": "Yapıştır", "copy-reference": "Referansı kopyala", "paste-reference": "Referansı yapıştır", - "import": "İthalat", - "export": "İhracat", - "share-via": "{{Provider}} aracılığıyla paylaş" + "import": "İçe aktar", + "export": "Dışa aktar", + "share-via": "{{provider}} ile paylaş" }, "aggregation": { "aggregation": "Toplama", @@ -56,265 +56,265 @@ "limit": "Maksimum değerler", "group-interval": "Gruplama aralığı", "min": "Min", - "max": "Max", + "max": "Maks", "avg": "Ortalama", - "sum": "Sum", + "sum": "Toplam", "count": "Sayı", "none": "Yok" }, "admin": { "general": "Genel", "general-settings": "Genel Ayarlar", - "outgoing-mail": "Giden posta", + "outgoing-mail": "Giden Posta", "outgoing-mail-settings": "Giden Posta Ayarları", - "system-settings": "Sistem ayarları", - "test-mail-sent": "Test postası başarıyla gönderildi!", + "system-settings": "Sistem Ayarları", + "test-mail-sent": "Test e-postası başarıyla gönderildi!", "base-url": "Temel URL", "base-url-required": "Temel URL gerekli.", - "mail-from": "Mail şu kişiden geldi", - "mail-from-required": "Mail From gereklidir.", + "mail-from": "Gönderen Kişi", + "mail-from-required": "Gönderen Kişi gerekli.", "smtp-protocol": "SMTP protokolü", - "smtp-host": "SMTP ana bilgisayarı", - "smtp-host-required": "SMTP ana bilgisayarı gerekli.", + "smtp-host": "SMTP sunucusu", + "smtp-host-required": "SMTP sunucusu gerekli.", "smtp-port": "SMTP portu", - "smtp-port-required": "Bir smtp portu sağlamalısınız.", - "smtp-port-invalid": "Bu geçerli bir smtp portuna benzemiyor.", - "timeout-msec": "Zaman aşımı (msn)", - "timeout-required": "Zaman aşımı gerekli.", + "smtp-port-required": "Bir SMTP portu sağlamalısınız.", + "smtp-port-invalid": "Bu geçerli bir smtp portu gibi görünmüyor.", + "timeout-msec": "Zaman aşımı (milisaniye)", + "timeout-required": "Zaman aşımı değeri gerekli.", "timeout-invalid": "Bu geçerli bir zaman aşımı gibi görünmüyor.", - "enable-tls": "TLS'yi etkinleştir", - "send-test-mail": "Test postası gönder" + "enable-tls": "TLS'i etkinleştir.", + "send-test-mail": "Test e-postası gönder" }, "alarm": { "alarm": "Alarm", "alarms": "Alarmlar", - "select-alarm": "Alarmı seç", - "no-alarms-matching": " '{{Entity}}' ile eşleşen hiçbir alarm bulunamadı. ", + "select-alarm": "Alarm seç", + "no-alarms-matching": "'{{entity}}' ile eşleşen alarm bulunamadı.", "alarm-required": "Alarm gerekli", "alarm-status": "Alarm durumu", "search-status": { "ANY": "Herhangi biri", "ACTIVE": "Aktif", "CLEARED": "Temizlendi", - "ACK": "Kabul edilen", - "UNACK": "Unacknowledged" + "ACK": "Onaylandı", + "UNACK": "Onaylanmadı" }, "display-status": { - "ACTIVE_UNACK": "Aktif Olmamış", - "ACTIVE_ACK": "Aktif Kabul", - "CLEARED_UNACK": "Onaylanmamış Onaylandı", - "CLEARED_ACK": "Onaylandı onaylandı" + "ACTIVE_UNACK": "Aktif Onaylanmadı", + "ACTIVE_ACK": "Aktif Onaylandı", + "CLEARED_UNACK": "Temizlendi Onaylanmadı", + "CLEARED_ACK": "Temizlendi Onaylandı" }, "no-alarms-prompt": "Alarm bulunamadı", - "created-time": "Zaman yaratıldı", - "type": "Tür", - "severity": "Önem", - "originator": "Originator", - "originator-type": "Gönderen türü", - "details": "Ayrıntılar", + "created-time": "Oluşma zamanı", + "type": "Tip", + "severity": "Şiddet", + "originator": "Kaynak", + "originator-type": "Kaynak tipi", + "details": "Detaylar", "status": "Durum", "alarm-details": "Alarm detayları", - "start-time": "Başlama zamanı", + "start-time": "Başlangıç zamanı", "end-time": "Bitiş zamanı", - "ack-time": "Onaylanmış zaman", - "clear-time": "Temizlenmiş zaman", + "ack-time": "Onaylanma zamanı", + "clear-time": "Temizlenme zamanı", "severity-critical": "Kritik", - "severity-major": "Majör", - "severity-minor": "Minör", + "severity-major": "Birincil", + "severity-minor": "İkincil", "severity-warning": "Uyarı", "severity-indeterminate": "Belirsiz", - "acknowledge": "Onay", - "clear": "Açık", - "search": "Arama alarmları", - "selected-alarms": "{count, çoğul, 1 {1 alarm} diğer {# alarm}} seçildi", - "no-data": "Gösterilecek bilgi yok", - "polling-interval": "Alarmlar sorgulama aralığı (sn)", - "polling-interval-required": "Alarmlar sorgulama aralığı gerekli.", - "min-polling-interval-message": "En az 1 san yoklama aralığına izin verilir.", - "aknowledge-alarms-title": "Kabul et { count, plural, 1 {1 alarm} other {# alarms}} selected ", - "aknowledge-alarms-text": "{Count, plural, 1 {1 alarm} dother {# alarm}} kimliğini kabul etmek istediğinizden emin misiniz?", - "clear-alarms-title": "Temizle {count, plural, 1 {1 alarm} other {# alarm}}", - "clear-alarms-text": "count, plural, 1 {1 alarm} diğer {# alarms}} silmek istediğinizden emin misiniz?" + "acknowledge": "Onayla", + "clear": "Temizle", + "search": "Alarm ara", + "selected-alarms": "{ count, select, 1 {1 alarm} other {# alarm} } seçildi", + "no-data": "Görüntülenecek veri bulunmuyor", + "polling-interval": "Alarm yoklama aralığı (saniye)", + "polling-interval-required": "Alarm yoklama aralığı gerekli.", + "min-polling-interval-message": "Alarm yoklama aralığı en az 1 saniye olmalıdır.", + "aknowledge-alarms-title": "{ count, select, 1 {1 alarmı} other {# alarmı} } onayla", + "aknowledge-alarms-text": "{ count, select, 1 {1 alarmı} other {# alarmı} } onaylamak istediğinize emin misiniz?", + "clear-alarms-title": "{ count, select, 1 {1 alarmı} other {# alarmı} } temizle", + "clear-alarms-text": "{ count, select, 1 {1 alarmı} other {# alarmı} } temizlemek istediğinize emin misiniz?" }, "alias": { - "add": "Takma ad ekle", - "edit": "Takma adı düzenle", - "name": "Takma ad", - "name-required": "Takma ad gerekli", - "duplicate-alias": "Aynı ada sahip diğer ad zaten var.", - "filter-type-single-entity": "Tek varlık", - "filter-type-entity-list": "Varlık listesi", - "filter-type-entity-name": "Varlık adı", - "filter-type-state-entity": "Gösterge panosu durumundan varlık", - "filter-type-state-entity-description": "Gösterge panosu durum parametrelerinden alınan varlık", + "add": "Kısa ad ekle", + "edit": "Kısa ad düzenle", + "name": "Kısa ad", + "name-required": "Kısa ad gerekli", + "duplicate-alias": "Aynı kısa ad daha önce kullanılmış.", + "filter-type-single-entity": "Tek öğe", + "filter-type-entity-list": "Öğe listesi", + "filter-type-entity-name": "Öğe adı", + "filter-type-state-entity": "Kontrol panelinden öğe", + "filter-type-state-entity-description": "Kontrol tablosu durum parametrelerinden alınan öğeler", "filter-type-asset-type": "Varlık türü", - "filter-type-asset-type-description": "Öğe varlıkları {{assetType}} '", - "filter-type-asset-type-and-name-description": "{{AssetType}} türündeki varlıklar ve {{prefix}} ile başlayan adla ", - "filter-type-device-type": "Cihaz tipi", - "filter-type-device-type-description": "Türlü cihazlar {{deviceType}} ", - "filter-type-device-type-and-name-description": "{{DeviceType}} türündeki cihazlar ve {{prefix}} ile başlayan adla ", + "filter-type-asset-type-description": "'{{assetType}}' türünde varlıklar", + "filter-type-asset-type-and-name-description": "Adı '{{prefix}}' ile başlayan '{{assetType}}' türünde varlıklar", + "filter-type-device-type": "Aygıt türü", + "filter-type-device-type-description": "'{{deviceType}}' türünde aygıtlar", + "filter-type-device-type-and-name-description": "Adı '{{prefix}}' ile başlayan'{{deviceType}}' türünde aygıtlar", "filter-type-relations-query": "İlişkiler sorgusu", - "filter-type-relations-query-description": "{{relationType}} ilişkisine sahip {{direction}} {{rootEntity}} olan {{entities}}", - "filter-type-asset-search-query": "Öğe arama sorgusu", - "filter-type-asset-search-query-description": "{{RelationType}} ilişkisine sahip {{direction}}} türlerine sahip öğeler {{direction}} {{rootEntity}} ", - "filter-type-device-search-query": "Cihaz arama sorgusu", - "filter-type-device-search-query-description": "{{RelationType}} ilişkisi olan {{direction}} {{rootEntity}} içeren {{deviceTypes}} türüne sahip cihazlar", - "entity-filter": "Varlık filtresi", - "resolve-multiple": "Birden çok varlık olarak çöz", + "filter-type-relations-query-description": "{{relationType}} türünde ilişkili olan varlıklar: {{entities}}. {{direction}}: {{rootEntity}}", + "filter-type-asset-search-query": "Varlık arama sorgusu", + "filter-type-asset-search-query-description": "{{relationType}} türünde ilişkisi olan varlıklar {{assetTypes}}. {{direction}}: {{rootEntity}}", + "filter-type-device-search-query": "Aygıt arama sorgusu", + "filter-type-device-search-query-description": "{{relationType}} türünde ilişkisi olan aygıt tipleri {{deviceTypes}}. {{direction}}: {{rootEntity}}", + "entity-filter": "Öğe filtresi", + "resolve-multiple": "Çoklu öğe olarak çözümle", "filter-type": "Filtre tipi", - "filter-type-required": "Filtre türü gerekli.", - "entity-filter-no-entity-matched": "Belirtilen filtreyle eşleşen varlık bulunamadı.", - "no-entity-filter-specified": "Hiçbir varlık filtresi belirtilmemiş", - "root-state-entity": "Gösterge panosu durum varlığını root olarak kullan", - "root-entity": "Kök varlık", - "state-entity-parameter-name": "Durum öğesi parametre adı", - "default-state-entity": "Varsayılan durum varlığı", - "default-entity-parameter-name": "Varsayılan olarak", - "max-relation-level": "Maksimum ilişki seviyesi", + "filter-type-required": "Filtre tipi gerekli.", + "entity-filter-no-entity-matched": "Belirlenen filtre ile eşleşen bir öğe bulunamadı.", + "no-entity-filter-specified": "Hiçbir öğe filtresi belirtilmedi", + "root-state-entity": "Kontrol panelini kök olarak kullan", + "root-entity": "Kök öğe", + "state-entity-parameter-name": "Durum varlığı parametre adı", + "default-state-entity": "Varsayılan durum öğesi", + "default-entity-parameter-name": "Varsayılan", + "max-relation-level": "Maksimum ilişki düzeyi", "unlimited-level": "Sınırsız seviye", - "state-entity": "Gösterge panosu durumu", - "all-entities": "Tüm varlıklar", - "any-relation": "Herhangi" + "state-entity": "Kontrol paneli öğesi", + "all-entities": "Tüm öğeler", + "any-relation": "Herhangi biri" }, "asset": { "asset": "Varlık", "assets": "Varlıklar", "management": "Varlık Yönetimi", "view-assets": "Varlıkları Görüntüle", - "add": "Öğe Ekle", - "assign-to-customer": "Müşteriye atama", - "assign-asset-to-customer": "Müşteriye Varlık (lar) Atama", - "assign-asset-to-customer-text": "Lütfen müşteriye atamak için varlıkları seçin", - "no-assets-text": "Öğe bulunamadı", - "assign-to-customer-text": "Lütfen varlık (lar) a atamak için müşteriyi seçin", - "public": "Halka açık", - "assignedToCustomer": "Müşteriye atandı", - "make-public": "Varlığı herkese açık yap", - "make-private": "Varlık özel yap", - "unassign-from-customer": "Müşteriden atama", - "delete": "Öğeyi sil", - "asset-public": "Varlık halka açık", + "add": "Varlık ekle", + "assign-to-customer": "Kullanıcı grubuna ata", + "assign-asset-to-customer": "Varlıkları Kullanıcı Grubuna Ata", + "assign-asset-to-customer-text": "Lütfen kullanıcı grubuna atanacak varlıkları seçin", + "no-assets-text": "Varlık bulunamadı", + "assign-to-customer-text": "Lütfen varlıkları atamak için kullanıcı grubu seçin", + "public": "Açık", + "assignedToCustomer": "Kullanıcı grubuna atandı", + "make-public": "Varlığı açık hale getir", + "make-private": "Varlığı özel hale getir", + "unassign-from-customer": "Kullanıcı grubundan atamayı kaldır", + "delete": "Varlığı sil", + "asset-public": "Varlık açık halde", "asset-type": "Varlık türü", - "asset-type-required": "Öğe türü gerekli.", - "select-asset-type": "Varlık türünü seç", - "enter-asset-type": "Varlık türünü girin", + "asset-type-required": "Varlık türü gerekli.", + "select-asset-type": "Varlık türü seçin", + "enter-asset-type": "Varlık türü girin", "any-asset": "Herhangi bir varlık", - "no-asset-types-matching": "{{EntitySubtype}} ile eşleşen öğe türü bulunamadı. ", - "asset-type-list-empty": "Hiçbir öğe türü seçilmedi.", + "no-asset-types-matching": "'{{entitySubtype}}' ile eşleşen varlık bulunamadı.", + "asset-type-list-empty": "Herhangi bir varlık türü bulunamadı.", "asset-types": "Varlık türleri", - "name": "Ad", + "name": "İsim", "name-required": "İsim gerekli.", "description": "Açıklama", "type": "Tür", "type-required": "Tür gerekli.", - "details": "Ayrıntılar", - "events": "Etkinlikler", + "details": "Detaylar", + "events": "Olaylar", "add-asset-text": "Yeni varlık ekle", - "asset-details": "Varlık ayrıntıları", - "assign-assets": "Öğeleri atama", - "assign-assets-text": "Müşteriye {count, çoğul, 1 {1 asset}} diğer {# asset}} atayın ", - "delete-assets": "Öğeleri sil", - "unassign-assets": "Varlıkları atama", - "unassign-assets-action-title": "Müşteriden tekil, sayım, çoğul, 1 {1 öğe} diğer {# varlık}} atama ", - "assign-new-asset": "Yeni varlık atama", - "delete-asset-title": "Öğeyi {{assetName}} silmek istediğinizden emin misiniz?", - "delete-asset-text": "Dikkatli olun, onaylandıktan sonra varlık ve ilgili tüm veriler kurtarılamaz.", - "delete-assets-title": "{Count, çoğul, 1 {1 asset} diğer {# asset}} silmek istediğinizden emin misiniz?", - "delete-assets-action-title": "Sil {count, çoğul, 1 {1 asset} diğer {# asset}}", - "delete-assets-text": "Dikkatli olun, onaylandıktan sonra tüm seçilmiş varlıklar kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", - "make-public-asset-title": "Varlığı {{assetName}} herkese açık yapmak istediğinizden emin misiniz? ", - "make-public-asset-text": "Onaydan sonra varlık ve tüm verileri kamuya açık ve başkaları tarafından erişilebilir olacak.", - "make-private-asset-title": "Öğeyi {{assetName}} özel yapmak istediğinizden emin misiniz? ", - "make-private-asset-text": "Onaydan sonra varlık ve tüm verileri gizli tutulacak ve başkaları tarafından erişilemeyecektir.", - "unassign-asset-title": "Öğeyi {{assetName}} öğesinin atamasını kaldırmak istediğinizden emin misiniz?", - "unassign-asset-text": "Onaydan sonra varlık atama yapılmayacak ve müşteri tarafından erişilemeyecektir.", - "unassign-asset": "Atama atamayı kaldır", - "unassign-assets-title": "count, plural, 1 {1 öğe} other {# asset}} atama atamak istediğinizden emin misiniz?", - "unassign-assets-text": "Onaylandıktan sonra, seçilen tüm öğeler atamadan kaldırılacak ve müşteri tarafından erişilemeyecektir.", - "copyId": "Öğe Kimliği Kopyala", - "idCopiedMessage": "Öğe Kimliği panoya kopyalandı", + "asset-details": "Varlık detayları", + "assign-assets": "Varlıkları ata", + "assign-assets-text": "{ count, select, 1 {1 varlığı} other {# varlığı} } kullanıcı grubuna ata", + "delete-assets": "Varlıkları sil", + "unassign-assets": "Varlıkların atamalarını kaldır", + "unassign-assets-action-title": "{ count, select, 1 {1 varlığın} other {# varlığın} } atamalarını kullanıcı grubundan kaldır", + "assign-new-asset": "Yeni varlık ata", + "delete-asset-title": "'{{assetName}}' isimli varlığı silmek istediğinize emin misiniz?", + "delete-asset-text": "UYARI: Onaylandıktan sonra varlık ve ilgili tüm veriler geri yüklenemeyecek şekilde silinecek.", + "delete-assets-title": "{ count, select, 1 {1 varlığı} other {# varlığı} } silmek istediğinize emin misiniz?", + "delete-assets-action-title": "{ count, select, 1 {1 varlığı} other {# varlığı} } sil", + "delete-assets-text": "UYARI: Onaylandıktan sonra tüm seçili varlıklar ver ilgili tüm veriler geri yüklenemyeck şekilde silinecek.", + "make-public-asset-title": "'{{assetName}}' isimli varlığı açık hale getirmek istediğinize emin misiniz?", + "make-public-asset-text": "Onaylandıktan sonra varlık ve ilgili veriler açık hale gelecek ve başkaları tarafından erişilebilir olacaktır.", + "make-private-asset-title": "'{{assetName}}' isimli varlığı özel hale getirmek istediğinize emin misiniz?", + "make-private-asset-text": "Onaylandıktan sonra varlık ve ilgili veriler özel hale gelecek ve başkaları tarafından erişilemez olacaktır.", + "unassign-asset-title": "'{{assetName}}' isimli varlığın atamasını kaldırmak istediğinize emin misiniz?", + "unassign-asset-text": "Onaylandıktan sonra varlığın ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacaktır.", + "unassign-asset": "Varlık atamasını kaldır", + "unassign-assets-title": " { count, select, 1 {1 varlık} other {# varlık} } atamasını kaldırmak istediğinize emin misiniz?", + "unassign-assets-text": "Onaylandıktan sonra tüm seçili varlıkların ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacaktır.", + "copyId": "Varlık kimliğini kopyala", + "idCopiedMessage": "Varlık kimliği panoya kopyalandı", "select-asset": "Varlık seç", - "no-assets-matching": "{{Entity}} ile eşleşen hiçbir öğe bulunamadı. ", + "no-assets-matching": "'{{entity}}' isimli varlık bulunamadı.", "asset-required": "Varlık gerekli", - "name-starts-with": "Öğe adı ile başlar" + "name-starts-with": "... ile başlayan varlık adı" }, "attribute": { - "attributes": "Öznitellikler", + "attributes": "Öznitelikler", "latest-telemetry": "Son telemetri", - "attributes-scope": "Varlık öznitelikleri kapsamı", + "attributes-scope": "Varlık öznitelik kapsamı", "scope-latest-telemetry": "Son telemetri", - "scope-client": "Müşteri özellikleri", - "scope-server": "Sunucu öznitelikleri", - "scope-shared": "Paylaşılan özellikler", - "add": "Özellik ekle", + "scope-client": "İstemci öznitelikler", + "scope-server": "Sunucu öznitelikler", + "scope-shared": "Paylaşılan öznitelikler", + "add": "Öznitelik ekle", "key": "Anahtar", "last-update-time": "Son güncelleme zamanı", - "key-required": "Özellik anahtarı gerekli.", + "key-required": "Öznitelik anahtarı gerekli.", "value": "Değer", - "value-required": "Özellik değeri gerekli.", - "delete-attributes-title": "{Count, çoğul, 1 {1 özellik} diğer {# attributes}} silmek istediğinizden emin misiniz?", - "delete-attributes-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen özellikler kaldırılacak.", - "delete-attributes": "Nitelikleri sil", - "enter-attribute-value": "Özellik değerini girin", - "show-on-widget": "Widget'ta göster", - "widget-mode": "Widget modu", - "next-widget": "Sonraki pencere öğesi", - "prev-widget": "Önceki pencere öğesi", - "add-to-dashboard": "Gösterge tablosuna ekle", - "add-widget-to-dashboard": "Gösterge tablosuna widget ekle", - "selected-attributes": "{count, çoğul, 1 {1 özellik} diğer {# attributes}} seçildi", - "selected-telemetry": "{count, çoğul, 1 {1 telemetri birimi} diğer {# telemetri birimleri}} seçildi" + "value-required": "Öznitelik değeri gerekli.", + "delete-attributes-title": "Silmek istediğinize emin misiniz { count, select, 1 {1 öznitelik} other {# öznitelik} }?", + "delete-attributes-text": "UYARI: Onaylandıktan sonra tüm seçili öznitelikler kaldırılacak.", + "delete-attributes": "Öznitelikleri sil", + "enter-attribute-value": "Öznitelik değeri gir", + "show-on-widget": "Göstergede göster", + "widget-mode": "Gösterge modu", + "next-widget": "Sonraki gösterge", + "prev-widget": "Önceki gösterge", + "add-to-dashboard": "Kontrol paneline ekle", + "add-widget-to-dashboard": "Göstergeyi kontrol paneline ekle", + "selected-attributes": "{ count, select, 1 {1 öznitelik} other {# öznitelik} } seçildi", + "selected-telemetry": "{ count, select, 1 {1 telemetri birimi} other {# telemetri birimi} } seçildi" }, "audit-log": { - "audit": "Denetim", - "audit-logs": "Denetim Kayıtları", - "timestamp": "Zaman Damgası", - "entity-type": "Varlık Türü", - "entity-name": "Varlık adı", + "audit": "Log ve Hata Yönetimi", + "audit-logs": "Loglar ve Hatalar", + "timestamp": "Zaman", + "entity-type": "Kaynak", + "entity-name": "İsim", "user": "Kullanıcı", "type": "Tür", "status": "Durum", - "details": "Ayrıntılar", - "type-added": "Katma", + "details": "Detaylar", + "type-added": "Eklendi", "type-deleted": "Silindi", - "type-updated": "Güncellenmiş", - "type-attributes-updated": "Öznitelikler güncellendi", - "type-attributes-deleted": "Öznitelikler silindi", - "type-rpc-call": "RPC çağrısı", + "type-updated": "Güncellendi", + "type-attributes-updated": "Özellikler güncellendi", + "type-attributes-deleted": "Özellikler silindi", + "type-rpc-call": "Uzaktan işlem çağrısı", "type-credentials-updated": "Kimlik bilgileri güncellendi", - "type-assigned-to-customer": "Müşteriye Atanan", - "type-unassigned-from-customer": "Müşteriden Atanmamış", - "type-activated": "Aktif", + "type-assigned-to-customer": "Kullanıcı grubuna atandı", + "type-unassigned-from-customer": "Kullanıcı grubundan atama kaldırıldı", + "type-activated": "Etkinleştirildi", "type-suspended": "Askıya alındı", "type-credentials-read": "Kimlik bilgileri okundu", - "type-attributes-read": "Nitelikler oku", + "type-attributes-read": "Özellikler okundu", "type-relation-add-or-update": "İlişki güncellendi", "type-relation-delete": "İlişki silindi", "type-relations-delete": "Tüm ilişki silindi", "type-alarm-ack": "Kabul edilen", "type-alarm-clear": "Temizlendi", - "status-success": "Başarı", - "status-failure": "Başarısızlık", - "audit-log-details": "Denetim kaydı ayrıntıları", - "no-audit-logs-prompt": "Kayıt bulunamadı", - "action-data": "Eylem verileri", - "failure-details": "Arıza detayları", - "search": "Denetim günlüklerini ara", - "clear-search": "Aramayı Temizle" + "status-success": "Başarılı", + "status-failure": "Başarısız", + "audit-log-details": "Log ve hata detayları", + "no-audit-logs-prompt": "Log ve hata bulunamadı", + "action-data": "Eylem verisi", + "failure-details": "Başarısız işlem detayları", + "search": "Hata ve Log Geçmişinde Ara", + "clear-search": "Aramayı temizle" }, "confirm-on-exit": { - "message": "Kaydedilmemiş değişiklikleriniz var. Bu sayfadan ayrılmak istediğinizden emin misiniz?", - "html-message": "Kaydedilmemiş değişiklikleriniz var.
Bu sayfadan ayrılmak istediğinizden emin misiniz?", - "title": "Kaydedilmemiş değişiklikler" + "message": "Kaydedilmemiş değişiklikler var. Sayfadan ayrılmak istediğinize emin misiniz?", + "html-message": "Kaydedilmemiş değişiklikler var.
Sayfadan ayrılmak istediğinize emin misiniz?", + "title": "Kaydedilmemiş Değişiklikler" }, "contact": { "country": "Ülke", - "city": "Kent", + "city": "Şehir", "state": "Eyalet / İl", "postal-code": "Posta Kodu", - "postal-code-invalid": "Geçersiz Posta Kodu / Posta Kodu biçimi.", - "address": "Adres", - "address2": "Adres 2", + "postal-code-invalid": "Geçersiz Posta Kodu.", + "address": "Addres", + "address2": "Addres 2", "phone": "Telefon", "email": "E-posta", "no-address": "Adres yok" @@ -322,9 +322,9 @@ "common": { "username": "Kullanıcı adı", "password": "Parola", - "enter-username": "Kullanıcı adı girin", - "enter-password": "Parolanı Gir", - "enter-search": "Arama girin" + "enter-username": "Kullanıcı adı gir", + "enter-password": "Parola gir", + "enter-search": "Arama gir" }, "content-type": { "json": "Json", @@ -332,414 +332,414 @@ "binary": "İkili (Base64)" }, "customer": { - "customer": "Müşteri", - "customers": "Müşteriler", - "management": "Müşteri yönetimi", - "dashboard": "Müşteri Kontrol Paneli", - "dashboards": "Müşteri Kontrol Panelleri", - "devices": "Müşteri Cihazları", + "customer": "Kullanıcı Grubu", + "customers": "Kullanıcı Grupları", + "management": "Kullanıcı Grubu Yönetimi", + "dashboard": "Kullanıcı Grubu Kontrol Paneli", + "dashboards": "Kullanıcı Grubu Kontrol Panellleri", + "devices": "Kullanıcı Grubu Aygıtları", "entity-views": "Müşteri Varlığı Görüntüleme Sayısı", - "assets": "Müşteri Varlıkları", - "public-dashboards": "Genel Panolar", - "public-devices": "Kamu Aygıtları", - "public-assets": "Kamu Varlıkları", + "assets": "Kullanıcı Grubu Varlıkları", + "public-dashboards": "Açık Kontrol Panelleri", + "public-devices": "Açık Aygıtlar", + "public-assets": "Açık Varlıklar", "public-entity-views": "Kamu Varlık Görüntüleme Sayısı", - "add": "Müşteri Ekle", - "delete": "Müşteriyi sil", - "manage-customer-users": "Müşteri kullanıcılarını yönet", - "manage-customer-devices": "Müşteri cihazlarını yönet", - "manage-customer-dashboards": "Müşteri panolarını yönet", - "manage-public-devices": "Genel cihazları yönet", - "manage-public-dashboards": "Genel panoları yönet", - "manage-customer-assets": "Müşteri varlıklarını yönet", - "manage-public-assets": "Kamu varlıklarını yönet", - "add-customer-text": "Yeni müşteri ekle", - "no-customers-text": "Müşteri bulunamadı", - "customer-details": "Müşteri detayları", - "delete-customer-title": "Müşteriyi silmek istediğinizden emin misiniz? {{CustomerTitle}} ? ", - "delete-customer-text": "Dikkatli olun, onaylandıktan sonra müşteri ve ilgili tüm veriler kurtarılamaz.", - "delete-customers-title": "{Count, çoğul, 1 {1 müşteri} diğer {# customers}} silmek istediğinizden emin misiniz?", - "delete-customers-action-title": "Sil {count, çoğul, 1 {1 müşteri} diğer {# customers}}", - "delete-customers-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen müşteriler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", - "manage-users": "Kullanıcıları Yönet", + "add": "Kullanıcı grubu ekle", + "delete": "Kullanıcı grubunu sil", + "manage-customer-users": "Kullanıcı grubu kullanıcılarını yönet", + "manage-customer-devices": "Kullanıcı grubu aygıtlarını yönet", + "manage-customer-dashboards": "Kullanıcı grubu kontrol panellerini yönet", + "manage-public-devices": "Açık aygıtları yönet", + "manage-public-dashboards": "Açık kontrol panellerini yönet", + "manage-customer-assets": "Kullanıcı Grubu varlıklarını yönet", + "manage-public-assets": "Açık varlıkları yönet", + "add-customer-text": "Yeni Kullanıcı Grubu ekle", + "no-customers-text": "Kullanıcı Grubu bulunamadı", + "customer-details": "Kullanıcı Grubu detayları", + "delete-customer-title": "'{{customerTitle}}' isimli kullanıcı grubunu silmek istediğinize emin misiniz?", + "delete-customer-text": "UYARI: Onaylandıktan sonra kullanıcı grubu ve tüm ilişkili veriler geri yüklenemeyecek şekilde silinecek.", + "delete-customers-title": "{ count, select, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } silmek istediğinize emin misiniz?", + "delete-customers-action-title": "{ count, select, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } sil", + "delete-customers-text": "UYARI: Onaylandıktan sonra tüm seçili kullanıcı grupları ve ilişkili veriler geri yüklenemez şekilde silinecek.", + "manage-users": "Kullanıcıları yönet", "manage-assets": "Varlıkları yönet", - "manage-devices": "Cihazları yönet", - "manage-dashboards": "Gösterge tablolarını yönet", + "manage-devices": "Aygıtları yönet", + "manage-dashboards": "Kontrol panellerini yönet", "title": "Başlık", "title-required": "Başlık gerekli.", "description": "Açıklama", - "details": "Ayrıntılar", - "events": "Etkinlikler", - "copyId": "Müşteri kimliğini kopyala", - "idCopiedMessage": "Müşteri Kimliği panoya kopyalandı", - "select-customer": "Müşteriyi seç", - "no-customers-matching": "{{Entity}} ile eşleşen hiçbir müşteri bulunamadı.", - "customer-required": "Müşteri gerekli", + "details": "Detaylar", + "events": "Olaylar", + "copyId": "Kullanıcı kimliğini kopyala", + "idCopiedMessage": "Kullanıcı kimliği panoya kopyalandı", + "select-customer": "Kullanıcı grubunu seç", + "no-customers-matching": "'{{entity}}' ile eşleşen kullanıcı grubu bulunamadı.", + "customer-required": "Kullanıcı grubu gerekli", "select-default-customer": "Varsayılan müşteriyi seç", "default-customer": "Varsayılan müşteri", "default-customer-required": "Kiracı düzeyinde gösterge tablosunda hata ayıklamak için varsayılan müşteri gerekiyor" }, "datetime": { - "date-from": "Tarih", - "time-from": "Zaman", - "date-to": "Tarih", - "time-to": "Zaman" + "date-from": "Tarihinden", + "time-from": "Saatinden", + "date-to": "Tarihine", + "time-to": "Saatine" }, "dashboard": { - "dashboard": "Pano", - "dashboards": "Gösterge tabloları", - "management": "Gösterge tablosu yönetimi", - "view-dashboards": "Gösterge Panoları", - "add": "Gösterge Tablosu Ekle", - "assign-dashboard-to-customer": "Gösterge Tablosunu / Müşterilerini Müşteriye Atama", - "assign-dashboard-to-customer-text": "Lütfen müşteriye atamak için kontrol panellerini seçin", - "assign-to-customer-text": "Gösterge panellerini atamak için lütfen müşteriyi seçiniz", - "assign-to-customer": "Müşteriye atama", - "unassign-from-customer": "Müşteriden atama", - "make-public": "Gösterge panosunu herkese açık yap", - "make-private": "Gösterge panosunu özel yap", + "dashboard": "Kontrol Paneli", + "dashboards": "Kontrol Panelleri", + "management": "Kontrol Paneli Yönetimi", + "view-dashboards": "Kontrol Panellerini Görüntüle", + "add": "Kontrol Paneli Ekle", + "assign-dashboard-to-customer": "Kullanıcı Grubuna Kontrol Panel(ler)i Ata", + "assign-dashboard-to-customer-text": "Lütfen kullanıcı grubuna atanacak kontrol panellerini seçin", + "assign-to-customer-text": "Lütfen kontrol panel(ler)ini atayacak kullanıcı grubu seçin", + "assign-to-customer": "Kullanıcı grubuna ata", + "unassign-from-customer": "Kullanıcı grubundan atamayı kaldır", + "make-public": "Kontrol panelini açık hale getir", + "make-private": "Kontrol panelini özel hale getir", "manage-assigned-customers": "Atanan müşterileri yönet", "assigned-customers": "Atanan müşteriler", "assign-to-customers": "Gösterge Tablosunu / Müşterilerini Müşterilere Atama", "assign-to-customers-text": "Lütfen gösterge panosunu atamak için müşterileri seçin", "unassign-from-customers": "Müşterilerden Gösterge Tablosunu (Notlarını) Atama", "unassign-from-customers-text": "Lütfen gösterge tablosundan atamak için müşterileri seçin", - "no-dashboards-text": "Gösterge panelleri bulunamadı", - "no-widgets": "Hiçbir widget yapılandırılmamış", - "add-widget": "Yeni pencere öğesi ekle", + "no-dashboards-text": "Kontrol paneli bulunamadı", + "no-widgets": "Hiçbir gösterge yapılandırılmadı", + "add-widget": "Yeni gösterge ekle", "title": "Başlık", - "select-widget-title": "Widget seç", - "select-widget-subtitle": "Kullanılabilir pencere öğesi türleri", - "delete": "Gösterge tablosunu sil", + "select-widget-title": "Gösterge seç", + "select-widget-subtitle": "Kullanılabilir gösterge türleri listesi", + "delete": "Kontrol paneli sil", "title-required": "Başlık gerekli.", "description": "Açıklama", - "details": "Ayrıntılar", - "dashboard-details": "Gösterge tablosu ayrıntıları", - "add-dashboard-text": "Yeni gösterge tablosu ekle", - "assign-dashboards": "Gösterge tabloları atama", - "assign-new-dashboard": "Yeni kontrol paneli atama", - "assign-dashboards-text": "Müşterilere {count, plural, 1 {1 gösterge tablosu} other {# dashboard}}} atayın ", + "details": "Detaylar", + "dashboard-details": "Kontrol paneli detayları", + "add-dashboard-text": "Yeni kontrol paneli ekle", + "assign-dashboards": "Kontrol panelleri ata", + "assign-new-dashboard": "Yeni kontrol paneli ata", + "assign-dashboards-text": "{ count, select, 1 {1 kontrol panelini} other {# kontrol panelini} } kullanıcı grubuna ata", "unassign-dashboards-action-text": "Müşterilerden atama sayısı, çoğul, 1 {1 gösterge tablosu} diğer {# panolar}}", - "delete-dashboards": "Gösterge tablolarını sil", - "unassign-dashboards": "Atanmamış gösterge panoları", - "unassign-dashboards-action-title": "Müşteriden atama sayım, çoğul, 1 {1 gösterge tablosu} diğer {# pano}}", - "delete-dashboard-title": "'{{DashboardTitle}}' gösterge panosunu silmek istediğinizden emin misiniz?", - "delete-dashboard-text": "Doğrulamadan sonra, kontrol panelinden ve ilgili tüm veriler kurtarılamayacak.", - "delete-dashboards-title": "{Count, çoğul, 1 {1 dashboard} diğer {# dashboards}} silmek istediğinizden emin misiniz?", - "delete-dashboards-action-title": "Sil {count, çoğul, 1 {1 gösterge tablosu} diğer {# dashboard}}", - "delete-dashboards-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen panolar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", - "unassign-dashboard-title": "{{DashboardTitle}} gösterge tablosunun atamasını kaldırmak istediğinizden emin misiniz? ", - "unassign-dashboard-text": "Onaydan sonra gösterge panosu atanmamış olacak ve müşteri tarafından erişilemeyecektir.", - "unassign-dashboard": "Atanmamış gösterge panosu", - "unassign-dashboards-title": "Sayım, çoğul, 1 {1 gösterge tablosu} diğer {# panolar}} atama atamak istediğinizden emin misiniz?", - "unassign-dashboards-text": "Onaydan sonra tüm seçilen gösterge tabloları atanmamış olacak ve müşteri tarafından erişilemeyecektir.", - "public-dashboard-title": "Gösterge panosu artık herkese açık", - "public-dashboard-text": "Gösterge tablonuz {{dashboardTitle}} artık herkese açık ve bir sonraki herkese açık link üzerinden erişilebilir:", - "public-dashboard-notice": " Not: Verilerine erişmek için ilgili cihazları herkese açık yapmayı unutmayın.", - "make-private-dashboard-title": "Gösterge panosunu {{dashboardTitle}} özel yapmak istediğinizden emin misiniz?", - "make-private-dashboard-text": "Onaydan sonra gösterge panosu özel hale getirilecek ve başkaları tarafından erişilemeyecektir.", - "make-private-dashboard": "Gösterge panosunu özel yap", - "socialshare-text": "'{{dashboardTitle}} ThingsBoard tarafından desteklenmektedir ", - "socialshare-title": "'{{dashboardTitle}} ThingsBoard tarafından desteklenmektedir ", - "select-dashboard": "Gösterge tablosu seç", - "no-dashboards-matching": "{{Entity}} ile eşleşen hiçbir gösterge paneli bulunamadı. ", - "dashboard-required": "Gösterge tablosu gerekli.", - "select-existing": "Mevcut kontrol panelini seç", - "create-new": "Yeni gösterge tablosu oluştur", + "delete-dashboards": "Kontrol panellerini sil", + "unassign-dashboards": "Kontrol panellerinden atamayı kaldır", + "unassign-dashboards-action-title": "{ count, select, 1 {1 kontrol panelinin} other {# kontrol panelinin} } atamaları kullanıcı grubundan kaldır", + "delete-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelini silmek istediğinize emin misiniz?", + "delete-dashboard-text": "UYARI: Onaylandıktan sonra kontrol paneli ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "delete-dashboards-title": "{ count, select, 1 {1 kontrol panelini} other {# kontrol panelini} } silmek istediğinize emin misiniz?", + "delete-dashboards-action-title": "{ count, select, 1 {1 kontrol panelini} other {# kontrol panelini} } sil", + "delete-dashboards-text": "UYARI: Onaylandıktan sonra tüm seçili kontrol panelleri ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "unassign-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelindeki atamayı kaldırmak istediğinize emin misiniz?", + "unassign-dashboard-text": "Onaylandıktan sonra kontrol panelinin ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez hale gelecektir.", + "unassign-dashboard": "Kontrol panelinin ataması kaldır", + "unassign-dashboards-title": "{count, select, 1 {1 kontrol panelindeki} other {# kontrol panelindeki} } atamayı kaldırmak istediğinize emin misiniz?", + "unassign-dashboards-text": "Onaylandıktan {{dashboardTitle}} açık hale getirildi ve bu bağlantıdan erişilebilir durumda", + "public-dashboard-notice": "Not: Kontrol panelinden tüm verilere erişebilmek adına ilişkili aygıtları da açık hale getirmeniz gerekmektedir.", + "make-private-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelini özel hale getirmek istediğinize emin misiniz?", + "make-private-dashboard-text": "Onaylandıktan sonra kontrol paneli özel hale getirilecek ve başkaları tarafından erişilemez olacak.", + "make-private-dashboard": "Kontrol panelini özel hale getir", + "socialshare-text": "'{{dashboardTitle}}'", + "socialshare-title": "'{{dashboardTitle}}'", + "select-dashboard": "Kontrol paneli seç", + "no-dashboards-matching": "'{{entity}}' ile eşleşen kontrol paneli bulunamadı.", + "dashboard-required": "Kontrol paneli gerekli.", + "select-existing": "Var olan bir kontrol paneli seç", + "create-new": "Yeni bir kontrol paneli oluştur", "new-dashboard-title": "Yeni kontrol paneli başlığı", - "open-dashboard": "Açık kontrol paneli", - "set-background": "Arka planı ayarla", + "open-dashboard": "Kontrol panelini aç", + "set-background": "Arka plan belirle", "background-color": "Arka plan rengi", - "background-image": "Arka plan görüntüsü", - "background-size-mode": "Arka plan boyut modu", - "no-image": "Görüntü seçilmedi", - "drop-image": "Bir resim bırakın veya yüklenecek bir dosya seçmek için tıklayın.", + "background-image": "Arka plan resmi", + "background-size-mode": "Arka plan boyutu modu", + "no-image": "Hiçbir resim seçilmedi", + "drop-image": "Bir resim bırakın veya yüklenecek dosyayı seçmek için tıklayın.", "settings": "Ayarlar", - "columns-count": "Sütun sayısı", - "columns-count-required": "Sütun sayısı gerekli.", - "min-columns-count-message": "Sadece 10 minimum sütun sayısına izin verilir.", - "max-columns-count-message": "Sadece maksimum 1000 sütun sayısına izin verilir.", - "widgets-margins": "Widget arasında marj", - "horizontal-margin": "Yatay kenar boşluğu", - "horizontal-margin-required": "Yatay kenar boşluğu değeri gerekiyor.", - "min-horizontal-margin-message": "Minimum yatay kenar boşluğu değeri olarak yalnızca 0'a izin verilir.", - "max-horizontal-margin-message": "Maksimum yatay kenar boşluğu değeri olarak yalnızca 50'ye izin verilir.", - "vertical-margin": "Dikey kenar boşluğu", - "vertical-margin-required": "Dikey kenar boşluğu değeri gereklidir.", - "min-vertical-margin-message": "En az dikey kenar boşluğu değeri olarak yalnızca 0'a izin verilir.", - "max-vertical-margin-message": "Maksimum dikey kenar boşluğu değeri olarak yalnızca 50'ye izin verilir.", - "autofill-height": "Otomatik dolgu düzeni yüksekliği", + "columns-count": "Kolon sayısı", + "columns-count-required": "Kolon sayısı gerekli.", + "min-columns-count-message": "Kolon sayısı en az 10 olabilir.", + "max-columns-count-message": "Kolon sayısı en fazla 1000 olabilir.", + "widgets-margins": "Göstergeler arasındaki aralık", + "horizontal-margin": "Yatay aralık", + "horizontal-margin-required": "Yatay aralık değeri gerekli.", + "min-horizontal-margin-message": "Yatay aralık değeri en az 0 olabilir.", + "max-horizontal-margin-message": "Yatay aralık değeri en fazla 50 olabilir.", + "vertical-margin": "Dikey aralık", + "vertical-margin-required": "Dikey aralık değeri gerekli.", + "min-vertical-margin-message": "Dikey aralık değeri en az 0 olabilir.", + "max-vertical-margin-message": "Dikey aralık değeri en fazla 50 olabilir.", + "autofill-height": "Otomatik doldurma düzeni yüksekliği", "mobile-layout": "Mobil düzen ayarları", "mobile-row-height": "Mobil satır yüksekliği, px", "mobile-row-height-required": "Mobil satır yüksekliği değeri gerekli.", - "min-mobile-row-height-message": "Minimum mobil satır yüksekliği değeri olarak yalnızca 5 piksele izin verilir.", - "max-mobile-row-height-message": "Maksimum mobil satır yüksekliği değeri olarak yalnızca 200 piksele izin verilir.", - "display-title": "Gösterge tablosu başlığı", - "toolbar-always-open": "Araç çubuğunu açık tut", + "min-mobile-row-height-message": "Mobil satır yükseliği değeri en az 5 px olabilir.", + "max-mobile-row-height-message": "Mobil satır yükseliği değeri en çok 200 px olabilir.", + "display-title": "Kontrol paneli başlığını göster", + "toolbar-always-open": "Araç çubuğunu her zaman açık tut", "title-color": "Başlık rengi", - "display-dashboards-selection": "Gösterge panoları seçimi", - "display-entities-selection": "Öğe varlıkları seçimi", - "display-dashboard-timewindow": "Zaman penceresi göster", - "display-dashboard-export": "Görüntülü dışa aktarma", - "import": "Gösterge paneli", - "export": "Dış gösterge panosu", - "export-failed-error": "Gösterge tablosu gönderilemiyor: {{error}}", - "create-new-dashboard": "Yeni gösterge tablosu oluştur", - "dashboard-file": "Gösterge tablosu dosyası", - "invalid-dashboard-file-error": "Gösterge tablosu alınamadı: Geçersiz pano veri yapısı.", - "dashboard-import-missing-aliases-title": "Alınan pano tarafından kullanılan takma adları yapılandır", - "create-new-widget": "Yeni pencere öğesi oluştur", - "import-widget": "Widget'ı içe aktar", - "widget-file": "Widget dosyası", - "invalid-widget-file-error": "Widget içe aktarılamıyor: Geçersiz pencere öğesi yapısı.", - "widget-import-missing-aliases-title": "İçe aktarılan pencere aracı tarafından kullanılan takma adları yapılandır", - "open-toolbar": "Gösterge tablosu araç çubuğunu aç", + "display-dashboards-selection": "Kontrol paneli seçimlerinş göster", + "display-entities-selection": "Varlık seçimlerini göster", + "display-dashboard-timewindow": "Zaman aralığını göster", + "display-dashboard-export": "Dışa aktar seçeneğini göster", + "import": "Kontrol panelini içe aktar", + "export": "Kontrol panelini dışa aktar", + "export-failed-error": "Kontrol paneli dışa aktarılamıyor: {{error}}", + "create-new-dashboard": "Yeni kontrol paneli oluştur", + "dashboard-file": "Kontrol paneli dosyası", + "invalid-dashboard-file-error": "Kontrol paneli içe aktarılamadı: Geçersiz kontrol paneli veri yapısı.", + "dashboard-import-missing-aliases-title": "İçe aktarılan kontrol paneli tarafından kullanılan aygıt kısa adlarını yapılandırın", + "create-new-widget": "Yeni gösterge oluştur", + "import-widget": "Göstergeyi içe aktar", + "widget-file": "Gösterge dosyası", + "invalid-widget-file-error": "Gösterge içe aktarılamadı: Geçersiz gösterge veri yapısı.", + "widget-import-missing-aliases-title": "İçe aktarılan gösterge tarafından kullanılan aygıt kısa adlarını yapılandırın", + "open-toolbar": "Kontrol paneli araç çubuğunu aç", "close-toolbar": "Araç çubuğunu kapat", "configuration-error": "Yapılandırma hatası", - "alias-resolution-error-title": "Gösterge tablosu takma ad hatası", - "invalid-aliases-config": "Diğer ad filtrelerinin biriyle eşleşen herhangi bir cihaz bulunamadı.
Lütfen bu sorunu çözmek için yöneticinize başvurun.", - "select-devices": "Cihaz seç", - "assignedToCustomer": "Müşteriye atandı", - "assignedToCustomers": "Müşterilere atandı", - "public": "Halka açık", - "public-link": "Genel bağlantı", - "copy-public-link": "Genel bağlantıyı kopyala", - "public-link-copied-message": "Pano genel bağlantısı panoya kopyalandı", - "manage-states": "Gösterge panosu durumlarını yönet", - "states": "Gösterge durumu", - "search-states": "Gösterge panosu durumları", - "selected-states": "{count, çoğul, 1 {1 gösterge tablosu durumu} diğer {# dashboard durumları}} seçildi", - "edit-state": "Gösterge panosu durumunu düzenle", - "delete-state": "Gösterge panosu durumunu sil", - "add-state": "Gösterge paneli durumu", - "state": "Gösterge paneli durumu", - "state-name": "Ad", - "state-name-required": "Gösterge panosu durum adı gerekli.", + "alias-resolution-error-title": "Kontro paneli kısa adları yapılandırma hatası", + "invalid-aliases-config": "Kısa ad filtresiyle eşleşen aygıt bulunamadı.
", + "select-devices": "Aygıt seçin", + "assignedToCustomer": "Kullanıcı grubuna atandı", + "assignedToCustomers": "Kullanıcılara atandı", + "public": "Açık", + "public-link": "Açık bağlantı", + "copy-public-link": "Açık bağlantıyı kopyala", + "public-link-copied-message": "Kontrol paneli açık bağlantısı panoya kopyalandı", + "manage-states": "Kontrol paneli durumlarını yönet", + "states": "Kontrol paneli durumları", + "search-states": "Kontrol paneli durumu ara", + "selected-states": "{ count, select, 1 {1 kontrol paneli durumu} other {# kontrol paneli durumu} } seçildi", + "edit-state": "Kontrol paneli durumu düzenle", + "delete-state": "Kontrol paneli durumunu sil", + "add-state": "Kontrol paneli durumu ekle", + "state": "Kontrol paneli durumu", + "state-name": "İsim", + "state-name-required": "Kontrol paneli durumu ismi gerekli.", "state-id": "Durum Kimliği", - "state-id-required": "Gösterge durumu kimliği gerekiyor.", - "state-id-exists": "Aynı kimliğe sahip gösterge tablosu zaten var.", - "is-root-state": "Kök devlet", - "delete-state-title": "Gösterge panosu durumunu sil", - "delete-state-text": "Gösterge panosu durumunu '{{stateName}}' adıyla silmek istediğinizden emin misiniz?", + "state-id-required": "Kontrol paneli durum kimliği gerekli.", + "state-id-exists": "Aynı kimlikte bir kontrol paneli durumu mevcut.", + "is-root-state": "Kök durum", + "delete-state-title": "Kontrol paneli durumunu sil", + "delete-state-text": "'{{stateName}}' isimli kontrol paneli durumunu silmek istediğinize emin misiniz?", "show-details": "Detayları göster", "hide-details": "Detayları gizle", "select-state": "Hedef durumu seç", - "state-controller": "Durum kontrolörü" + "state-controller": "Durum denetleyicisi" }, "datakey": { "settings": "Ayarlar", - "advanced": "İleri", + "advanced": "İleri düzey", "label": "Etiket", "color": "Renk", - "units": "Değerin yanında gösterilecek özel sembol", - "decimals": "Kayan noktadan sonraki basamak sayısı", - "data-generation-func": "Veri oluşturma işlevi", - "use-data-post-processing-func": "Veri işleme sonrası işlevini kullan", + "units": "Değerin yanında göstermek için özel simge", + "decimals": "Noktadan sonraki basamak sayısı", + "data-generation-func": "Veri oluşturma fonksiyonu", + "use-data-post-processing-func": "Veri işleme sonrası fonksiyonunu kullanın", "configuration": "Veri anahtarı yapılandırması", "timeseries": "Zaman serisi", - "attributes": "Öznitellikler", + "attributes": "Öznitelikler", "alarm": "Alarm alanları", - "timeseries-required": "Varlık zamanlamaları gerekli.", - "timeseries-or-attributes-required": "Varlık zaman çizelgeleri / öznitelikler gereklidir.", - "maximum-timeseries-or-attributes": "Maksimum {count, çoğul, 1 {1 timeseries / attribute.} Diğer {# timeseries / attributes {} izin verilir}}", + "timeseries-required": "Zaman serisi öğesi gerekli.", + "timeseries-or-attributes-required": "Zaman serisi/öznitelikler öğesi gerekli.", + "maximum-timeseries-or-attributes": "Maksimum { count, select, 1 {1 zamanserisi/öznitelik kabul edilir.} other {# zamanserisi/öznitelik kabul edilir} }", "alarm-fields-required": "Alarm alanları gerekli.", - "function-types": "İşlev türleri", - "function-types-required": "İşlev tipleri gereklidir.", - "maximum-function-types": "Maksimum {sayım, çoğul, 1 {1 işlev türüne izin verilir.} Diğer {# işlev türlerine izin verilir}}" + "function-types": "Fonksiyon türleri", + "function-types-required": "Fonksiyon türleri gerekli.", + "maximum-function-types": "Maksimum { count, select, 1 {1 fonksiyon türü kabul edilir.} other {# fonksiyon türü kabul edilir} }" }, "datasource": { "type": "Veri kaynağı türü", - "name": "Ad", - "add-datasource-prompt": "Lütfen veri kaynağı ekle" + "name": "İsim", + "add-datasource-prompt": "Lütfen veri kaynağı ekleyin" }, "details": { "edit-mode": "Düzenleme modu", - "toggle-edit-mode": "Düzenleme modunu değiştir" + "toggle-edit-mode": "Düzenleme modunu aç/kapat" }, "device": { - "device": "Cihaz", - "device-required": "Cihaz gerekli.", - "devices": "Cihazlar", - "management": "Cihaz yönetimi", - "view-devices": "Cihazları Görüntüle", - "device-alias": "Cihaz takma adı", - "aliases": "Cihaz takma adları", - "no-alias-matching": "'{{alias}} bulunamadı. ", - "no-aliases-found": "Takma ad bulunamadı", - "no-key-matching": "'{{anahtar bulunamadı.", - "no-keys-found": "Anahtar bulunamadı.", + "device": "Aygıt", + "device-required": "Aygıt gerekli.", + "devices": "Aygıtlar", + "management": "Aygıt Yönetimi", + "view-devices": "Aygıtları görüntüle", + "device-alias": "Aygıt kısa adı", + "aliases": "Aygıt kısa adları", + "no-alias-matching": "'{{alias}}' bulunamadı.", + "no-aliases-found": "Hiçbir kısa ad bulunamadı.", + "no-key-matching": "'{{key}}' bulunamadı.", + "no-keys-found": "Hiçbir anahtar bulunamadı.", "create-new-alias": "Yeni bir tane oluştur!", "create-new-key": "Yeni bir tane oluştur!", - "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}}..
Cihaz takma adları, kontrol panelinde benzersiz olmalıdır. ", - "configure-alias": "Yapılandırma {{alias}} takma ad", - "no-devices-matching": "{{Entity}} ile eşleşen hiçbir cihaz bulunamadı. ", - "alias": "Alias", - "alias-required": "Cihaz takma adı gerekiyor.", - "remove-alias": "Cihaz takma adını kaldır", - "add-alias": "Cihaz takma adı ekle", - "name-starts-with": "Cihaz adı ile başlıyor", + "duplicate-alias-error": "'{{alias}}' daha önce kaydedilmiş.
Aygıt kısa adları kontrol paneli özelinde emsalsiz olmalıdır.", + "configure-alias": "'{{alias}}' kısa adını yapılandırın", + "no-devices-matching": "'{{entity}}' ile eşleşen aygıt bulunamadı.", + "alias": "Kısa ad", + "alias-required": "Aygıt kısa adı gerekli.", + "remove-alias": "Aygıt kısa adını kaldır", + "add-alias": "Aygıt kısa adı ekle", + "name-starts-with": "... ile başlayan aygıt adı", "device-list": "Aygıt listesi", "use-device-name-filter": "Filtre kullan", - "device-list-empty": "Cihaz seçilmedi.", - "device-name-filter-required": "Cihaz adı filtresi gerekli.", - "device-name-filter-no-device-matched": "{{Device}} ile başlayan hiçbir cihaz bulunamadı. ", - "add": "Cihaz ekle", - "assign-to-customer": "Müşteriye atama", - "assign-device-to-customer": "Aygıtı / Aygıtları Müşteriye Atama", - "assign-device-to-customer-text": "Lütfen müşteriye atamak istediğiniz cihazları seçin", - "make-public": "Cihazı herkese açık yap", - "make-private": "Cihazı özel yap", - "no-devices-text": "Hiçbir cihaz bulunamadı", - "assign-to-customer-text": "Lütfen cihazı atamak için müşteriyi seçin", - "device-details": "Cihaz detayları", - "add-device-text": "Yeni cihaz ekle", + "device-list-empty": "Hiçbir aygıt seçilmedi.", + "device-name-filter-required": "Aygıt adı filtresi gerekli.", + "device-name-filter-no-device-matched": "'{{device}}' ile başlayan herhangi bir aygıt bulunamadı.", + "add": "Aygıt ekle", + "assign-to-customer": "Kullanıcı grubuna ata", + "assign-device-to-customer": "Aygıt(lar)ı Kullanıcı Grubuna Ata", + "assign-device-to-customer-text": "Lütfen kullanıcı grubuna atanacak aygıtları seçin", + "make-public": "Aygıtı açık hale getir", + "make-private": "Aygıtı gizli hale getir", + "no-devices-text": "Hiçbir aygıt bulunamadı", + "assign-to-customer-text": "Lütfen aygıt(lar)ı atayacak kullanıcı grubu seçin", + "device-details": "Aygıt detayları", + "add-device-text": "Yeni aygıt ekle", "credentials": "Kimlik bilgileri", "manage-credentials": "Kimlik bilgilerini yönet", - "delete": "Cihazı sil", - "assign-devices": "Aygıtları atama", - "assign-devices-text": "Müşteriye {count, çoğul, 1 {1 cihaz} diğer {# devices}} atayın ", - "delete-devices": "Cihazları sil", - "unassign-from-customer": "Müşteriden atama", - "unassign-devices": "Atanmamış cihazları", - "unassign-devices-action-title": "Müşteriden atayın, çoğul, 1 {1 cihaz} diğer {# devices}} atama ", - "assign-new-device": "Yeni cihaz atama", - "make-public-device-title": "Cihazı {{deviceName}} herkese açık yapmak istediğinizden emin misiniz?", - "make-public-device-text": "Onaydan sonra cihaz ve tüm verileri kamuya açık ve başkaları tarafından erişilebilir olacak.", - "make-private-device-title": "Cihazı {{deviceName}} özel yapmak istediğinizden emin misiniz?", - "make-private-device-text": "Onaylandıktan sonra cihaz ve tüm verileri gizli tutulacak ve başkaları tarafından erişilemeyecektir.", + "delete": "Aygıt sil", + "assign-devices": "Aygıt ata", + "assign-devices-text": "{ count, select, 1 {1 aygıtı} other {# aygıtı} } kullanıcı grubuna ata", + "delete-devices": "Aygıtları sil", + "unassign-from-customer": "Kullanıcı Grubundan atamayı kaldır", + "unassign-devices": "Aygıtlardan atamayı kaldır", + "unassign-devices-action-title": "{ count, select, 1 {1 aygıtın} other {# aygıtın} } atamasını kullanıcı grubundan kaldır", + "assign-new-device": "Yeni aygıt ata", + "make-public-device-title": "'{{deviceName}}' isimli aygıtı açık hale getirmek istediğinizden emin misiniz?", + "make-public-device-text": "Onaylandıktan sonra aygıt ve verileri açık hale getirilecek ve diğerleri tarafından erişilebilir olacak.", + "make-private-device-title": "'{{deviceName}}' isimli aygıtı gizli hale getirmek istediğinizden emin misiniz?", + "make-private-device-text": "Onaylandıktan sonra aygıt ve verileri gizli hale getirilecek ve diğerleri tarafından erişilemez olacak.", "view-credentials": "Kimlik bilgilerini görüntüle", - "delete-device-title": "Cihazı {{deviceName}} silmek istediğinizden emin misiniz? ", - "delete-device-text": "Dikkatli olun, onaylamadan sonra cihaz ve tüm ilgili veriler kurtarılamaz.", - "delete-devices-title": "{Count, çoğul, 1 {1 cihaz} diğer {# devices}} silmek istediğinizden emin misiniz?", - "delete-devices-action-title": "Sil {count, çoğul, 1 {1 cihaz} diğer {# devices}}", - "delete-devices-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen cihazlar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", - "unassign-device-title": "Cihazın atamasını kaldırmak istediğinizden emin misiniz? {{DeviceName}} ? ", - "unassign-device-text": "Onaydan sonra cihaz atanmamış olacak ve müşteri tarafından erişilemeyecektir.", - "unassign-device": "Atanmamış cihaz", - "unassign-devices-title": "Sayım, çoğul, 1 {1 cihaz} diğer {# devices}} atama atamak istediğinizden emin misiniz?", - "unassign-devices-text": "Onaylandıktan sonra tüm seçilen cihazlar atanmamış olacak ve müşteri tarafından erişilemeyecektir.", - "device-credentials": "Cihaz Kimlik Bilgileri", - "credentials-type": "Kimlik bilgileri türü", - "access-token": "Erişim belirteci", - "access-token-required": "Erişim belirteci gerekli.", - "access-token-invalid": "Erişim belirteci uzunluğu 1 ile 20 karakter arasında olmalıdır.", - "rsa-key": "RSA ortak anahtarı", - "rsa-key-required": "RSA ortak anahtarı gerekli.", - "secret": "Gizli", - "secret-required": "Gizli gerekli", - "device-type": "Cihaz tipi", - "device-type-required": "Cihaz tipi gerekli.", - "select-device-type": "Cihaz türünü seç", - "enter-device-type": "Cihaz türünü girin", - "any-device": "Herhangi bir cihaz", - "no-device-types-matching": "{{EntitySubtype}} ile eşleşen cihaz türü bulunamadı. ", - "device-type-list-empty": "Hiçbir cihaz türü seçilmedi.", - "device-types": "Cihaz türleri", - "name": "Ad", + "delete-device-title": "'{{deviceName}}' isimli aygıtı silmek istediğinize emin misiniz?", + "delete-device-text": "UYARI: Onaylandıktan sonra aygıt ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "delete-devices-title": "{ count, select, 1 {1 aygıtı} other {# aygıtı} } silmek istediğinize emin misiniz?", + "delete-devices-action-title": "{ count, select, 1 {1 aygıtı} other {# aygıtı} } sil", + "delete-devices-text": "UYARI: Onaylandıktan sonra tüm seçili aygıtlar ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "unassign-device-title": "'{{deviceName}}' isimli aygıtın atamasını kaldırmak istediğinize emin misiniz?", + "unassign-device-text": "Onaylandıktan sonra aygıtın ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", + "unassign-device": "Aygıt atamasını kaldır", + "unassign-devices-title": "{ count, select, 1 {1 aygıtın} other {# aygıtın} } atamasını kaldırmak istediğinize emin misiniz?", + "unassign-devices-text": "Onaylandıktan sonra seçili aygıtların atamaları kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", + "device-credentials": "Aygıt Kimlik Bilgileri", + "credentials-type": "Kimlik Bilgi Türü", + "access-token": "Erişim şifresi", + "access-token-required": "Erişim şifresi gerekli.", + "access-token-invalid": "Erişim şifresi uzunluğu 1 ile 20 karakter arasında olmalıdır.", + "rsa-key": "RSA açık anahtarı", + "rsa-key-required": "RSA açık anahtarı gerekli.", + "secret": "Secret", + "secret-required": "Secret gerekli.", + "device-type": "Aygıt Türü", + "device-type-required": "Aygıt türü gereli.", + "select-device-type": "Aygıt türü seç", + "enter-device-type": "Aygıt türü gir", + "any-device": "Herhangi bir aygıt", + "no-device-types-matching": "'{{entitySubtype}}' ile eşleşen aygıt türü bulunamadı.", + "device-type-list-empty": "Hiçbir aygıt türü seçilmedi.", + "device-types": "Aygıt türleri", + "name": "İsim", "name-required": "İsim gerekli.", "description": "Açıklama", - "events": "Etkinlikler", - "details": "Ayrıntılar", - "copyId": "Cihaz kimliğini kopyala", - "copyAccessToken": "Erişim belirteci kopyala", - "idCopiedMessage": "Cihaz Kimliği panoya kopyalandı", - "accessTokenCopiedMessage": "Cihaz erişim belirteci panoya kopyalandı", - "assignedToCustomer": "Müşteriye atandı", - "unable-delete-device-alias-title": "Cihaz takma adı silinemiyor", - "unable-delete-device-alias-text": "Cihaz takma adı {{deviceAlias}} ', şu widget (lar) tarafından kullanıldığı şekliyle silinemiyor:
{{widgetsList}} ", - "is-gateway": "Ağ geçidi", - "public": "Halka açık", - "device-public": "Cihaz herkese açık", - "select-device": "Cihaz seç" + "events": "Olaylar", + "details": "Detaylar", + "copyId": "Aygıt kimliğini kopyala", + "copyAccessToken": "Erişim şifresini kopyala", + "idCopiedMessage": "Aygıt kimliği panoya kopyalandı.", + "accessTokenCopiedMessage": "Aygıt erişim şifresi panoya kopyalandı", + "assignedToCustomer": "Kullanıcı Grubuna atandı", + "unable-delete-device-alias-title": "Aygıt kısa adı silinemedi", + "unable-delete-device-alias-text": "Aygıt kısa adı('{{deviceAlias}}'), şu göstergeler tarafından kullanıldığı için silinemedi:
{{widgetsList}}", + "is-gateway": "Ağ geçidi mi?", + "public": "Açık", + "device-public": "Aygıt açık", + "select-device": "Aygıt seç" }, "dialog": { - "close": "İletişim kutusunu kapat" + "close": "Kapat" }, "error": { - "unable-to-connect": "Sunucuya bağlanılamıyor! Lütfen İnternet bağlantınızı kontrol edin.", - "unhandled-error-code": "İşlenmemiş hata kodu: {{errorCode}}", + "unable-to-connect": "Sunucuya bağlanamadı! Lütfen internet bağlantınızı kontrol edin.", + "unhandled-error-code": "İşlenmeyen hata koud: {{errorCode}}", "unknown-error": "Bilinmeyen hata" }, "entity": { - "entity": "Varlık", - "entities": "Varlıkları", - "aliases": "Varlık takma adları", - "entity-alias": "Varlık takma adı", - "unable-delete-entity-alias-title": "Varlık takma adı silinemiyor", - "unable-delete-entity-alias-text": "Varlık takma adı {{entityAlias}} ', şu widget (lar) tarafından kullanıldığı şekliyle silinemez:
{{widgetsList}} ", - "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '... Entity takma adlar, gösterge panosunda benzersiz olmalıdır. ", - "missing-entity-filter-error": "Diğer adlar için filtre eksik {{alias}}. ", - "configure-alias": "Yapılandırma {{alias}} takma ad", - "alias": "Alias", - "alias-required": "Varlık takma adı gerekiyor.", - "remove-alias": "Varlık takma adını kaldırma", - "add-alias": "Varlık takma adı ekle", - "entity-list": "Varlık listesi", - "entity-type": "Varlık türü", - "entity-types": "Varlık türleri", - "entity-type-list": "Varlık türü listesi", - "any-entity": "Herhangi bir varlık", - "enter-entity-type": "Varlık türü girin", - "no-entities-matching": "{{Entity}} ile eşleşen hiçbir varlık bulunamadı. ", - "no-entity-types-matching": "{{EntityType}} ile eşleşen hiçbir varlık türü bulunamadı. ", - "name-starts-with": "İsim ile başlar", + "entity": "Öğe", + "entities": "Öğeler", + "aliases": "Öğe kısa adları", + "entity-alias": "Öğe kısa adı", + "unable-delete-entity-alias-title": "Öğe kısa adı silinemedi", + "unable-delete-entity-alias-text": "Öğe kısa adı('{{entityAlias}}'), şu göstergeler tarafından kullanıldığı için silinemiyor:
{{widgetsList}}", + "duplicate-alias-error": "'{{alias}}' daha önce kaydedilmiş.
Öğe kısa adları kontrol paneli özelinde emsalsiz olmalı.", + "missing-entity-filter-error": "'{{alias}}' için filtre bulunmuyor.", + "configure-alias": "'{{alias}}' kısa adını yapılandır", + "alias": "Kısa ad", + "alias-required": "Öğe kısa adı gerekli.", + "remove-alias": "Öğe kısa adını kaldır", + "add-alias": "Öğe kısa adı ekle", + "entity-list": "Öğe listesi", + "entity-type": "Öğe türü", + "entity-types": "Öğe türleri", + "entity-type-list": "Öğe türü listesi", + "any-entity": "Herhangi bir öğe", + "enter-entity-type": "Öğe türü girin", + "no-entities-matching": "'{{entity}}' ile eşleşen öğe bulunamadı.", + "no-entity-types-matching": "'{{entityType}}' ile eşleşen öğe türü bulunamadı.", + "name-starts-with": "... ile başlayan isim", "use-entity-name-filter": "Filtre kullan", - "entity-list-empty": "Hiçbir varlık seçilmedi.", - "entity-type-list-empty": "Hiçbir varlık türü seçilmedi.", - "entity-name-filter-required": "Varlık adı filtresi gerekli.", - "entity-name-filter-no-entity-matched": "{{Entity}} ile başlayan hiçbir varlık bulunamadı. ", - "all-subtypes": "Herşey", + "entity-list-empty": "Hiçbir öğe seçilmedi.", + "entity-type-list-empty": "Hiçbir öğe türü seçilmedi.", + "entity-name-filter-required": "Öğe ismi filtresi gerekli.", + "entity-name-filter-no-entity-matched": "'{{entity}}' ile başlayan hiçbir öğe bulunamadı.", + "all-subtypes": "Tümü", "select-entities": "Öğeleri seç", - "no-aliases-found": "Takma ad bulunamadı", - "no-alias-matching": "'{{alias}} bulunamadı. ", + "no-aliases-found": "Hiçbir kısa ad bulunamadı.", + "no-alias-matching": "'{{alias}}' bulunamadı.", "create-new-alias": "Yeni bir tane oluştur!", "key": "Anahtar", "key-name": "Anahtar adı", - "no-keys-found": "Anahtar bulunamadı.", - "no-key-matching": "'{{anahtar bulunamadı.", + "no-keys-found": "Hiçbir anahtar bulunamadı.", + "no-key-matching": "'{{key}}' bulunamadı.", "create-new-key": "Yeni bir tane oluştur!", "type": "Tür", - "type-required": "Varlık türü gerekli.", - "type-device": "Cihaz", - "type-devices": "Cihazlar", - "list-of-devices": "{sayım, çoğul, 1 {Bir cihaz} diğer {# cihazların listesi}}", - "device-name-starts-with": "İsimleri '{{prefix}} ile başlayan cihazlar ", + "type-required": "Öğe türü gerekli.", + "type-device": "Aygıt", + "type-devices": "Aygıtlar", + "list-of-devices": "{ count, select, 1 {Bir aygıt} other {# Aygıtın Listesi} }", + "device-name-starts-with": "İsimleri '{{prefix}}' ile başlayan aygıtlar", "type-asset": "Varlık", "type-assets": "Varlıklar", - "list-of-assets": "{count, plural, 1 {One asset} diğer {# asset}}", - "asset-name-starts-with": "Adları {{prefix}} ile başlayan varlıklar ", + "list-of-assets": "{ count, select, 1 {Bir varlık} other {# Varlığın Listesi} }", + "asset-name-starts-with": "İsmi '{{prefix}}' ile başlayan varlıklar", "type-entity-view": "Varlık Görünümü", "type-entity-views": "Varlık Görünümleri", "list-of-entity-views": "{count, çoğul, 1 {Bir varlık görünümü} diğer {# varlık görüntüleme}} listesi", "entity-view-name-starts-with": "Adı {{önek}} ile başlayan varlık görünümleri", "type-rule": "Kural", "type-rules": "Kurallar", - "list-of-rules": "{count, çoğul, 1 {Bir kural} diğer {# kuralların}} listesi", - "rule-name-starts-with": "İsimleri {{prefix}} ile başlayan kurallar", + "list-of-rules": "{ count, select, 1 {Bir kural} other {# Kuralın Listesi} }", + "rule-name-starts-with": "İsmi '{{prefix}}' ile başlayan kurallar", "type-plugin": "Eklenti", "type-plugins": "Eklentiler", - "list-of-plugins": "{count, çoğul, 1 {Bir eklenti} diğer {# eklenti listesi}}", - "plugin-name-starts-with": "İsimleri {{prefix}} ile başlayan eklentiler", - "type-tenant": "Kiracı", - "type-tenants": "Kiracılar", - "list-of-tenants": "{count, çoğul, 1 {Bir kiracı} diğer {# kiracıların listesi}}", - "tenant-name-starts-with": "İsimleri {{prefix}} ile başlayan kiracılar, ", - "type-customer": "Müşteri", - "type-customers": "Müşteriler", - "list-of-customers": "{count, çoğul, 1 {Bir müşteri} diğer {# müşteri}} listesi", - "customer-name-starts-with": "İsimleri {{prefix}} ile başlayan müşteriler,", + "list-of-plugins": "{ count, select, 1 {Bir eklenti} other {# Eklentinin Listesi} }", + "plugin-name-starts-with": "İsmi '{{prefix}}' ile başlayan eklentiler", + "type-tenant": "Tenant", + "type-tenants": "Tenantlar", + "list-of-tenants": "{ count, select, 1 {Bir tenant} other {# Tenantın Listesi} }", + "tenant-name-starts-with": "İsmi '{{prefix}}' ile başlayan tenantlar", + "type-customer": "Kullanıcı Grubu", + "type-customers": "Kullanıcı Grupları", + "list-of-customers": "{ count, select, 1 {Bir Kullanıcı Grubu} other {# Kullanıcı Grupları} }", + "customer-name-starts-with": "İsmi '{{prefix}}' ile başlayan Kullanıcı Grupları", "type-user": "Kullanıcı", "type-users": "Kullanıcılar", - "list-of-users": "{count, çoğul, 1 {Bir kullanıcı} diğer {# user}} listesi", - "user-name-starts-with": "İsimleri {{prefix}} ile başlayan kullanıcılar", - "type-dashboard": "Pano", - "type-dashboards": "Gösterge tabloları", - "list-of-dashboards": "{count, çoğul, 1 {Bir pano} Diğer {# panoların}}} listesi", - "dashboard-name-starts-with": "İsimleri {{prefix}} ile başlayan panolar", + "list-of-users": "{ count, select, 1 {Bir kullanıcı} other {# Kullanıcının Listesi} }", + "user-name-starts-with": "İsmi '{{prefix}}' ile başlayan kullanıcılar", + "type-dashboard": "Kontrol paneli", + "type-dashboards": "Kontrol panelleri", + "list-of-dashboards": "{ count, select, 1 {Bir kontrol paneli} other {# Kontrol Panelinin Listesi} }", + "dashboard-name-starts-with": "İsmi '{{prefix}}' ile başlayan kontrol panelleri", "type-alarm": "Alarm", "type-alarms": "Alarmlar", - "list-of-alarms": "{count, çoğul, 1 {Bir alarm} diğer {{# alarm}} listesi", - "alarm-name-starts-with": "İsimleri {{prefix}} ile başlayan alarmlar", + "list-of-alarms": "{ count, select, 1 {Bir alarm} other {# Alarmın Listesi} }", + "alarm-name-starts-with": "İsmi '{{prefix}}' ile başlayan alarmlar", "type-rulechain": "Kural zinciri", "type-rulechains": "Kural zincirleri", "list-of-rulechains": "{count, çoğul, 1 {Bir kural zinciri} diğer {# kural zincirinin listesi}}", @@ -749,12 +749,12 @@ "list-of-rulenodes": "{count, çoğul, 1 {Bir kural node} diğer {# kural düğümünün listesi}}", "rulenode-name-starts-with": "İsimleri '{{prefix}} ile başlayan kural düğümleri", "type-current-customer": "Mevcut Müşteri", - "search": "Varlıklar ara", - "selected-entities": "{count, çoğul, 1 {1 varlık} diğer {# entities}} seçildi", - "entity-name": "Varlık adı", - "details": "Varlık ayrıntıları", - "no-entities-prompt": "Hiçbir varlık bulunamadı", - "no-data": "Gösterilecek bilgi yok" + "search": "Öğeleri ara", + "selected-entities": "{ count, select, 1 {1 öğe} other {# öğe} } seçildi", + "entity-name": "Öğe adı", + "details": "Öğe detayları", + "no-entities-prompt": "Hiçbir öğe bulunamadı", + "no-data": "Görüntülenecek veri yok" }, "entity-view": { "entity-view": "Varlık Görünümü", @@ -771,7 +771,7 @@ "create-new-key": "Yeni bir tane oluştur!", "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '.. Entity View diğer adlar, gösterge panosunda benzersiz olmalıdır. ", "configure-alias": "Yapılandırma {{alias}} takma ad", - "no-entity-views-matching": "{{Entity}} ile eşleşen hiçbir varlık yorumu bulunamadı. ", + "no-entity-views-matching": "{{entity}} ile eşleşen hiçbir varlık yorumu bulunamadı. ", "alias": "Alias", "alias-required": "Varlık Görünümü takma adı gerekiyor.", "remove-alias": "Varlık görünümü takma adını kaldır", @@ -781,7 +781,7 @@ "use-entity-view-name-filter": "Filtre kullan", "entity-view-list-empty": "Hiçbir varlık görüşü seçilmedi.", "entity-view-name-filter-required": "Varlık görünüm adı filtresi gerekli.", - "entity-view-name-filter-no-entity-view-matched": "{{EntityView}} ile başlayan hiçbir varlık sayısı bulunamadı.", + "entity-view-name-filter-no-entity-view-matched": "{{entityView}} ile başlayan hiçbir varlık sayısı bulunamadı.", "add": "Varlık Görünümü Ekle", "assign-to-customer": "Müşteriye atama", "assign-entity-view-to-customer": "Varlık Görünümlerini Müşteriye Atama", @@ -798,12 +798,12 @@ "unassign-entity-views": "Varlık görünümlerini atama", "unassign-entity-views-action-title": "Müşteriden atama sayım, çoğul, 1 {1 entityView} diğer {# entityViews}}", "assign-new-entity-view": "Yeni varlık görünümü atama", - "delete-entity-view-title": "Varlık görünümünü silmek istediğinizden emin misiniz?, {{EntityViewName}} '? ", + "delete-entity-view-title": "Varlık görünümünü silmek istediğinizden emin misiniz?, {{entityViewName}} '? ", "delete-entity-view-text": "Dikkatli olun, onaylandıktan sonra varlık görünümü ve ilgili tüm veriler kurtarılamayacak.", - "delete-entity-views-title": "{Count, çoğul, 1 {1 entityView} diğer {# entityViews}} varlık görünümüne sahip olmak istediğinizden emin misiniz?", + "delete-entity-views-title": "{count, çoğul, 1 {1 entityView} diğer {# entityViews}} varlık görünümüne sahip olmak istediğinizden emin misiniz?", "delete-entity-views-action-title": "Sil {count, çoğul, 1 {1 entityView} diğer {# entityViews}}", "delete-entity-views-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen görünümler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", - "unassign-entity-view-title": "Varlık görünümünün atamasını kaldırmak istediğinizden emin misiniz? {{EntityViewName}} '? ", + "unassign-entity-view-title": "Varlık görünümünün atamasını kaldırmak istediğinizden emin misiniz? {{entityViewName}} '? ", "unassign-entity-view-text": "Onaydan sonra varlık görünümü atanmamış olacak ve müşteri tarafından erişilemeyecektir.", "unassign-entity-view": "Varlık görünümünün atamasını kaldır", "unassign-entity-views-title": "Sayım, çoğul, 1 {1 entityView} diğer {# entityViews}} hesabının atamasını kaldırmak istediğinizden emin misiniz?", @@ -813,7 +813,7 @@ "select-entity-view-type": "Varlık görüntüleme türünü seç", "enter-entity-view-type": "Varlık görüntüleme türünü girin", "any-entity-view": "Herhangi bir varlık görünümü", - "no-entity-view-types-matching": "{{EntitySubtype}} ile eşleşen hiçbir varlık görüntüleme türü bulunamadı. ", + "no-entity-view-types-matching": "{{entitySubtype}} ile eşleşen hiçbir varlık görüntüleme türü bulunamadı. ", "entity-view-type-list-empty": "Hiçbir varlık görünümü türü seçilmemiş.", "entity-view-types": "Varlık Görünümü türleri", "name": "Ad", @@ -831,18 +831,18 @@ "end-ts": "End ts" }, "event": { - "event-type": "Etkinlik tipi", + "event-type": "Olay türü", "type-error": "Hata", - "type-lc-event": "Yaşam döngüsü etkinliği", - "type-stats": "İstatistik", + "type-lc-event": "Yaşam döngüsü olayı", + "type-stats": "İstatistikler", "type-debug-rule-node": "Hata ayıklama", "type-debug-rule-chain": "Hata ayıklama", - "no-events-prompt": "Etkinlik bulunamadı", + "no-events-prompt": "Hiçbir olay bulunamadı", "error": "Hata", "alarm": "Alarm", - "event-time": "Etkinlik zamanı", + "event-time": "Olay zamanı", "server": "Sunucu", - "body": "Vücut", + "body": "İçerik //(Body)", "method": "Yöntem", "type": "Tür", "entity": "Varlık", @@ -852,7 +852,7 @@ "relation-type": "İlişki Türü", "metadata": "Meta veri", "data": "Veri", - "event": "Etkinlik", + "event": "Olay", "status": "Durum", "success": "Başarı", "failed": "Başarısız oldu", @@ -1015,48 +1015,48 @@ "invalid-file-error": "Geçersiz uzantı dosyası" }, "fullscreen": { - "expand": "Tam ekrana genişlet", + "expand": "Tam ekran yap", "exit": "Tam ekrandan çık", - "toggle": "Tam ekran modunu değiştir", + "toggle": "Tam ekran modu aç/kapat", "fullscreen": "Tam ekran" }, "function": { - "function": "İşlev" + "function": "Fonksiyon" }, "grid": { - "delete-item-title": "Bu maddeyi silmek istediğinden emin misin?", - "delete-item-text": "Dikkatli olun, onaylandıktan sonra bu öğe ve ilgili tüm veriler kurtarılamaz.", - "delete-items-title": "{Count, çoğul, 1 {1 item} diğer {# items}} silmek istediğinizden emin misiniz?", - "delete-items-action-title": "Sil {count, çoğul, 1 {1 öğe} diğer {# items}}", - "delete-items-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen öğeler silinecek ve ilgili tüm veriler kurtarılamayacaktır.", + "delete-item-title": "Bu öğeyi silmek istediğinizden emin misiniz?", + "delete-item-text": "UYARI: Onayladıktan sonra bu öğe ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", + "delete-items-title": "{ count, select, 1 {1 öğeyi} other {# öğeyi} } silmek istediğinizden emin misiniz?", + "delete-items-action-title": "{ count, select, 1 {1 öğeyi} other {# öğeyi} } sil", + "delete-items-text": "UYARI: Onayladıktan sonra tüm seçili öğeler ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", "add-item-text": "Yeni öğe ekle", "no-items-text": "Hiç bir öğe bulunamadı", - "item-details": "Ürün detayları", - "delete-item": "Öğeyi silmek", - "delete-items": "Ürünleri sil", - "scroll-to-top": "Başa kaydır" + "item-details": "Öğe detayları", + "delete-item": "Öğeyi sil", + "delete-items": "Öğeleri sil", + "scroll-to-top": "Üste kaydır" }, "help": { "goto-help-page": "Yardım sayfasına git" }, "home": { - "home": "Ev", + "home": "Ana sayfa", "profile": "Profil", - "logout": "Çıkış Yap", + "logout": "Çıkış", "menu": "Menü", "avatar": "Avatar", "open-user-menu": "Kullanıcı menüsünü aç" }, "import": { - "no-file": "Dosya seçilmedi", - "drop-file": "Bir JSON dosyasını indirin veya yüklemek için bir dosya seçmek için tıklayın." + "no-file": "Hiçbir dosya seçilmedi", + "drop-file": "Bir JSON dosyası bırakın veya yüklenecek bir dosyayı seçmek için tıklayın." }, "item": { - "selected": "Seçilmiş" + "selected": "Seçildi" }, "js-func": { - "no-return-error": "İşlev değer döndürmeli!", - "return-type-mismatch": "İşlev '{{type}}' tipinin değerini döndürmelidir!", + "no-return-error": "Fonksiyon bir değer dönmeli!", + "return-type-mismatch": "Fonksiyon '{{type}}' türünde bir değer dönmeli!", "tidy": "Düzenli" }, "key-val": { @@ -1067,130 +1067,130 @@ "no-data": "Giriş yok" }, "layout": { - "layout": "Düzen", - "manage": "Düzenleri yönet", - "settings": "Yerleşim ayarları", + "layout": "Arayüz Düzeni", + "manage": "Arayüz düzenini yönet", + "settings": "Arayüz düzeni ayarları", "color": "Renk", "main": "Ana", "right": "Sağ", - "select": "Hedef yerleşimini seç" + "select": "Hedef düzen seç" }, "legend": { - "position": "Efsane pozisyonu", + "position": "Lejant konumu", "show-max": "Maksimum değeri göster", - "show-min": "Min değerini göster", + "show-min": "Minimum değeri göster", "show-avg": "Ortalama değeri göster", "show-total": "Toplam değeri göster", - "settings": "Açıklama ayarları", + "settings": "Lejant ayarları", "min": "min", - "max": "max", - "avg": "avg", - "total": "Genel Toplam" + "max": "maks", + "avg": "ort.", + "total": "toplam" }, "login": { "login": "Oturum aç", - "request-password-reset": "şifre sıfırlama isteği", - "reset-password": "şifreyi yenile", - "create-password": "şifre oluştur", - "passwords-mismatch-error": "Girilen şifreler aynı olmalıdır!", - "password-again": "şifre Tekrar", - "sign-in": "Lütfen giriş yapın", - "username": "Kullanıcı adı (email)", + "request-password-reset": "Parola Sıfırlama İsteği Gönder", + "reset-password": "Parola Sıfırla", + "create-password": "Parola Oluştur", + "passwords-mismatch-error": "Girilen parolalar eşleşmeli!", + "password-again": "Parola tekrarı", + "sign-in": "Lütfen girişi yapın", + "username": "Kullanıcı adı (e-posta)", "remember-me": "Beni hatırla", - "forgot-password": "Parolanızı mı unuttunuz?", - "password-reset": "Parola sıfırlama", - "new-password": "Yeni şifre", - "new-password-again": "Yeni şifre Tekrar", - "password-link-sent-message": "şifre sıfırlama bağlantısı başarıyla gönderildi!", + "forgot-password": "Parolamı unuttum", + "password-reset": "Parola sıfırla", + "new-password": "Yeni parola", + "new-password-again": "Yeni parola tekrarı", + "password-link-sent-message": "Parola sıfırlama e-postası başarıyla gönderildi!", "email": "E-posta" }, "position": { "top": "Üst", "bottom": "Alt", - "left": "Ayrıldı", + "left": "Sol", "right": "Sağ" }, "profile": { "profile": "Profil", - "change-password": "şifre değiştir", - "current-password": "şimdiki şifre" + "change-password": "Şifre değiştir", + "current-password": "Şimdiki şifre" }, "relation": { "relations": "İlişkiler", - "direction": "Yön", + "direction": "Yönelim", "search-direction": { - "FROM": "Kimden", - "TO": "Alıcı" + "FROM": "KAYNAK", + "TO": "HEDEF" }, "direction-type": { - "FROM": "den", - "TO": "to" + "FROM": "kaynak", + "TO": "hedef" }, "from-relations": "Giden ilişkiler", "to-relations": "Gelen ilişkiler", - "selected-relations": "{count, çoğul, 1 {1 ilişki} diğer {# relations}} seçildi", + "selected-relations": "{ count, select, 1 {1 ilişki} other {# ilişki} } seçildi", "type": "Tür", - "to-entity-type": "Varlık türüne", - "to-entity-name": "Varlık adına", - "from-entity-type": "Varlık türünden", - "from-entity-name": "Varlık adından", - "to-entity": "Varlık", - "from-entity": "Varlıktan", - "delete": "İlişkisi sil", + "to-entity-type": "Hedef Öğe Türü", + "to-entity-name": "Hedef Öğe Adı", + "from-entity-type": "Kaynak Öğe Türü", + "from-entity-name": "Kaynak Öğe Adı", + "to-entity": "Hedef Öğe", + "from-entity": "Kaynak Öğe", + "delete": "İlişkiyi sil", "relation-type": "İlişki türü", "relation-type-required": "İlişki türü gerekli.", "any-relation-type": "Her hangi bir tür", "add": "İlişki ekle", - "edit": "İlişkisi düzenle", - "delete-to-relation-title": "{{EntityName}} varlığına ilişkin ilişkiyi silmek istediğinizden emin misiniz? ", - "delete-to-relation-text": "Doğrulamadan sonra, varlığın {{entityName}} öğesinin mevcut varlıktan alakasız olacağından emin olun. ", - "delete-to-relations-title": "{Count, çoğul, 1 {1 ilişki} diğer {# relations}} silmek istediğinizden emin misiniz?", - "delete-to-relations-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen ilişkiler kaldırılacak ve ilgili varlıklar mevcut varlık ile ilgisiz olacaktır.", - "delete-from-relation-title": "{{EntityName}} varlığından ilişkiyi silmek istediğinizden emin misiniz? ", - "delete-from-relation-text": "Doğrulama varlığının, {{entityName}} kuruluşundan alakasız olacağından emin olun.", - "delete-from-relations-title": "{Count, çoğul, 1 {1 ilişki} diğer {# relations}} silmek istediğinizden emin misiniz?", - "delete-from-relations-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen ilişkiler kaldırılacak ve mevcut varlık ilgili varlıklardan ilgisiz olacaktır.", + "edit": "İlişki düzenle", + "delete-to-relation-title": "'{{entityName}}' öğesine olan ilişkiyi silmek istediğinize emin misiniz?", + "delete-to-relation-text": "UYARI: Onaylandıktan sonra '{{entityName}}' öğesinin şimdiki öğeyle olan ilişkisi sona erecektir.", + "delete-to-relations-title": "{ count, select, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", + "delete-to-relations-text": "UYARI: Onaylandıktan sonra tüm seçili ilişkiler kaldırılacaktır ve ilgili öğelerin şimdiki öğeyle ilişkisi sona erecektir.", + "delete-from-relation-title": "'{{entityName}}' öğesinden ilişkiyi silmek istediğinize emin misiniz?", + "delete-from-relation-text": "UYARI: Onaylandıktan sonra şimdiki öğenin '{{entityName}}' öğesiyle ilişkisi sonlandırılacaktır.", + "delete-from-relations-title": "{ count, select, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", + "delete-from-relations-text": "UYARI: Onaylandıktan sonra tüm seçili ilişkiler kaldırılacak ve şimdiki öğenin ilgili tüm öğelerle ilişkisi sona erecektir.", "remove-relation-filter": "İlişki filtresini kaldır", - "add-relation-filter": "İlişki filtresi ekle", + "add-relation-filter": "İlişkisi ekle", "any-relation": "Herhangi bir ilişki", "relation-filters": "İlişki filtreleri", "additional-info": "Ek bilgi (JSON)", - "invalid-additional-info": "Ek bilgi json ayrıştırılamıyor." + "invalid-additional-info": "Ek bilgi JSON'ı parse edilip işlenemedi." }, "rulechain": { - "rulechain": "Kural zinciri", - "rulechains": "Kural zincirleri", + "rulechain": "Kural", + "rulechains": "Kurallar", "root": "Kök", - "delete": "Kural zincirini sil", - "name": "Ad", + "delete": "Kuralı sil", + "name": "İsim", "name-required": "İsim gerekli.", "description": "Açıklama", - "add": "Kural Zinciri Ekleme", + "add": "Kural Ekle", "set-root": "Kural zincirinin kökü yap", "set-root-rulechain-title": "Kural zincirini {{ruleChainName}} root? Yapmak istediğinizden emin misiniz?", "set-root-rulechain-text": "Onaydan sonra kural zinciri kökleşecek ve gelen tüm iletilerle ilgilenecek.", - "delete-rulechain-title": "Kural zincirini {{ruleChainName}} silmek istediğinizden emin misiniz?", - "delete-rulechain-text": "Dikkatli olun, onaylamadan sonra kural zinciri ve ilgili tüm veriler kurtarılamaz.", - "delete-rulechains-title": "{Count, çoğul, 1 {1 kural zinciri} diğer {# kural zincirleri}} silmek istediğinizden emin misiniz?", - "delete-rulechains-action-title": "Sil {count, çoğul, 1 {1 kural zinciri} diğer {# kural zincirleri}}", - "delete-rulechains-text": "Dikkatli olun, onaylandıktan sonra seçilen tüm kural zincirleri silinecek ve ilgili tüm veriler kurtarılamayacaktır.", - "add-rulechain-text": "Yeni kural zinciri ekle", - "no-rulechains-text": "Kural zinciri bulunamadı", - "rulechain-details": "Kural zinciri detayları", - "details": "Ayrıntılar", - "events": "Etkinlikler", + "delete-rulechain-title": "'{{ruleName}}' isimli kuralı silmek istediğinize emin misiniz?", + "delete-rulechain-text": "UYARI: Onaylandıktan sonra kural ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "delete-rulechains-title": "{ count, select, 1 {1 kuralı} other {# kuralı} } sikmek istediğinize emin misiniz?", + "delete-rulechains-action-title": "{ count, select, 1 {1 kuralı} other {# kuralı} sil}", + "delete-rulechains-text": "UYARI: Onaylandıktan sonra seçili tüm kurallar ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "add-rulechain-text": "Yeni kural ekle", + "no-rulechains-text":"Hiçbir kural bulunamadı", + "rulechain-details": "Kural detayları", + "details": "Detaylar", + "events": "Olaylar", "system": "Sistem", - "import": "Kural zincirini içe aktar", - "export": "Kural zinciri dışa aktar", - "export-failed-error": "Kural zinciri dışa aktarılamadı: {{error}}", - "create-new-rulechain": "Yeni kural zinciri oluştur", - "rulechain-file": "Kural zinciri dosyası", - "invalid-rulechain-file-error": "Kural zinciri içe aktarılamıyor: Geçersiz kural zinciri veri yapısı.", - "copyId": "Kural zinciri kimliğini kopyala", - "idCopiedMessage": "Kural zinciri kimliği panoya kopyalandı", - "select-rulechain": "Kural zincirini seç", - "no-rulechains-matching": "{{Entity}} ile eşleşen kural zinciri bulunamadı. ", - "rulechain-required": "Kural zinciri gerekli", + "import": "Kuralı içe aktar", + "export": "Kuralı dışa aktar", + "export-failed-error": "Kural dışa aktarılamadı: {{error}}", + "create-new-rule": "Yeni kural oluştur", + "rulechain-file": "Kural dosyası", + "invalid-rulechain-file-error": "Kural içe aktarılamadı: Geçersiz kural veri yapısı.", + "copyId": "Kural kimliğini kopyala", + "idCopiedMessage": "Kural kimliği panoya kopyalandı", + "select-rulechain": "Kural seç", + "no-rulechains-matching": "'{{entity}}' ile eşleşen kural bulunamadı.", + "rulechain-required": "Kural gerekli", "management": "Kural yönetimi", "debug-mode": "Hata ayıklama modu" }, @@ -1258,274 +1258,274 @@ "help": "Yardım et" }, "tenant": { - "tenant": "Kiracı", - "tenants": "Kiracılar", - "management": "Kiracı yönetimi", - "add": "Kiracı ekle", - "admins": "Yöneticiler", - "manage-tenant-admins": "Kiracı yöneticileri yönet", - "delete": "Kiracıyı sil", - "add-tenant-text": "Yeni kiracı ekle", - "no-tenants-text": "Kiracı bulunamadı", - "tenant-details": "Kiracı detayları", - "delete-tenant-title": "Kiracıyı silmek istediğinizden emin misiniz? {{TenantTitle}}? ", - "delete-tenant-text": "Dikkatli olun, onayından sonra kiracı ve ilgili tüm veriler kurtarılamaz.", - "delete-tenants-title": "{Count, çoğul, 1 {1 kiracı} diğer {# kiracı}} silmek istediğinizden emin misiniz?", - "delete-tenants-action-title": "Sil {count, çoğul, 1 {1 kiracı} diğer {# kiracı}}", - "delete-tenants-text": "Dikkatli olun, onaylandıktan sonra tüm kiracılar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", + "tenant": "Tenant", + "tenants": "Tenantlar", + "management": "Tenant yönetimi", + "add": "Tenant Ekle", + "admins": "Adminler", + "manage-tenant-admins": "Tenant Adminlerini Yönet", + "delete": "Tenant sil", + "add-tenant-text": "Yeni tenant ekle", + "no-tenants-text": "Hiçbir tenant bulunamadı", + "tenant-details": "Tenant detayları", + "delete-tenant-title": "'{{tenantTitle}}' isimli tenantı silmek istediğinize emin misiniz?", + "delete-tenant-text": "UYARI: Onaylandıktan sonra tenant ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "delete-tenants-title": "{ count, select, 1 {1 tenantı} other {# tenantı} } silmek istediğinize emin misiniz?", + "delete-tenants-action-title": "{ count, select, 1 {1 tenantı} other {# tenantı} } sil", + "delete-tenants-text": "UYARI: Onaylandıktan sonra seçili tüm tenantlar ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir", "title": "Başlık", "title-required": "Başlık gerekli.", "description": "Açıklama", - "details": "Ayrıntılar", - "events": "Etkinlikler", - "copyId": "Kiracı Kimliği Kimliği", - "idCopiedMessage": "Kiracı Kimliği panoya kopyalandı", - "select-tenant": "Kiracı seç", - "no-tenants-matching": "{{Entity}} ile eşleşen kiracı bulunamadı. ", - "tenant-required": "Kiracı gerekli" + "details": "Detaylar", + "events": "Olaylar", + "copyId": "Tenant kimliğini kopyala", + "idCopiedMessage": "Tenant kimliği panoya kopyalandı", + "select-tenant": "Tenant seç", + "no-tenants-matching": "'{{entity}}' ile eşleşen tenant bulunamadı.", + "tenant-required": "Tenant gerekli" }, "timeinterval": { - "seconds-interval": "{saniye, çoğul, 1 {1 saniye} diğer {# seconds}}", - "minutes-interval": "{dakika, çoğul, 1 {1 dakika} diğer {# dakika}}", - "hours-interval": "{saat, çoğul, 1 {1 saat} diğer {# hours}}", - "days-interval": "{günler, çoğul, 1 {1 gün} diğer {# gün}}", + "seconds-interval": "{ seconds, select, 1 {1 saniye} other {# saniye} }", + "minutes-interval": "{ minutes, select, 1 {1 dakika} other {# dakika} }", + "hours-interval": "{ hours, select, 1 {1 saat} other {# saat} }", + "days-interval": "{ days, select, 1 {1 gün} other {# gün} }", "days": "Gün", - "hours": "Saatler", + "hours": "Saat", "minutes": "Dakika", "seconds": "Saniye", - "advanced": "İleri" + "advanced": "İleri düzey" }, "timewindow": { - "days": "{günler, çoğul, 1 {gün} diğer {# gün}}", - "hours": "{saat, çoğul, 0 {saat} 1 {1 saat} diğer {# hours}}", - "minutes": "{dakika, çoğul, 0 {dakika} 1 {1 dakika} diğer {# dakika}}", - "seconds": "{saniye, çoğul, 0 {saniye} 1 {1 saniye} diğer {# seconds}}", + "days": "{ days, select, 1 { gün } other {# gün } }", + "hours": "{ hours, select, 0 { saat } 1 {1 saat } other {# saat } }", + "minutes": "{ minutes, select, 0 { dakika } 1 {1 dakika } other {# dakika } }", + "seconds": "{ seconds, select, 0 { saniye } 1 {1 saniye } other {# saniye } }", "realtime": "Gerçek zaman", - "history": "Tarihçe", + "history": "Tarih", "last-prefix": "son", - "period": "{{startTime}} - {{endTime}} arasında ", - "edit": "Zaman penceresini düzenle", + "period": "{{ startTime }}'dan {{ endTime }}'a kadar", + "edit": "Zaman aralığını düzenle", "date-range": "Tarih aralığı", "last": "Son", - "time-period": "Zaman dilimi" + "time-period": "Zaman periyodu" }, "user": { "user": "Kullanıcı", "users": "Kullanıcılar", - "customer-users": "Müşteri Kullanıcıları", - "tenant-admins": "Kiracı Admins", + "customer-users": "Kullanıcılar", + "tenant-admins": "Tenant Adminleri", "sys-admin": "Sistem yöneticisi", - "tenant-admin": "Kiracı yöneticisi", - "customer": "Müşteri", + "tenant-admin": "Tenant yöneticisi", + "customer": "Kullanıcı Grubu", "anonymous": "Anonim", - "add": "Kullanıcı Ekle", - "delete": "Kullanıcıyı sil", - "add-user-text": "Yeni kullanıcı Ekle", - "no-users-text": "Kullanıcı bulunamadı", + "add": "Kullanıcı ekle", + "delete": "Kullanıcı sil", + "add-user-text": "Yeni kullanıcı ekle", + "no-users-text": "Hiçbir kullanıcı bulunamadı", "user-details": "Kullanıcı detayları", - "delete-user-title": "Kullanıcıyı silmek istediğinizden emin misiniz? {{UserEmail}} '? ", - "delete-user-text": "Dikkatli olun, onaylandıktan sonra kullanıcı ve ilgili tüm veriler kurtarılamayacaktır.", - "delete-users-title": "{Count, çoğul, 1 {1 kullanıcı} diğer {# users}} silmek istediğinizden emin misiniz?", - "delete-users-action-title": "Sil {count, çoğul, 1 {1 kullanıcı} diğer {# users}}", - "delete-users-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen kullanıcılar silinecek ve ilgili tüm veriler kurtarılamayacaktır.", - "activation-email-sent-message": "Etkinleştirme e-postası başarıyla gönderildi!", - "resend-activation": "Etkinleştirmeyi tekrar gönder", + "delete-user-title": "'{{userEmail}}' kullanıcısını silmek istediğinize emin misiniz?", + "delete-user-text": "UYARI: Onaylandıktan sonra kullanıcı ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", + "delete-users-title": "{ count, select, 1 {1 kullanıcıyı} other {# kullanıcıyı} } sikmek istediğinize emin misiniz?", + "delete-users-action-title": "{ count, select, 1 {1 kullancıyı} other {# kullanıcıyı} } sil", + "delete-users-text": "UYARI: Onaylandıktan sonra kullanıcı ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", + "activation-email-sent-message": "Etkinleştirme e-postası başarılı bir şekilde gönderildi!", + "resend-activation": "Etkinleştirme e-postasını yeniden gönder", "email": "E-posta", - "email-required": "Email gereklidir.", + "email-required": "E-posta gerekli.", "invalid-email-format": "Geçersiz e-posta formatı.", - "first-name": "İsim", - "last-name": "Soyadı", + "first-name": "Ad", + "last-name": "Soyad", "description": "Açıklama", - "default-dashboard": "Varsayılan gösterge paneli", + "default-dashboard": "Varsayılan kontrol paneli", "always-fullscreen": "Her zaman tam ekran", - "select-user": "Kullanıcı seç", - "no-users-matching": "{{Entity}} ile eşleşen kullanıcı bulunamadı. ", + "select-user": "Kullanıcı se.", + "no-users-matching": "'{{entity}}' ile eşleşen kullanıcı bulunamadı.", "user-required": "Kullanıcı gerekli", - "activation-method": "Aktivasyon yöntemi", - "display-activation-link": "Etkinleştirme bağlantısı göster", - "send-activation-mail": "Etkinleştirme postası gönder", - "activation-link": "Kullanıcı aktivasyon linki", - "activation-link-text": "Kullanıcıyı aktif hale getirmek için aşağıdaki aktivasyon linki kullanın.", + "activation-method": "Etkinleştirme yöntemi", + "display-activation-link": "Etkinleştirme bağlantısını görüntüle", + "send-activation-mail": "Etkinleştirme e-postası gönder", + "activation-link": "Kullanıcı hesabını etkinleştirme bağlantısı", + "activation-link-text": "Kullanıcı hesabını etkinleştirmek için bağlantıyı kullanın:", "copy-activation-link": "Etkinleştirme bağlantısını kopyala", - "activation-link-copied-message": "Kullanıcı aktivasyon linki panoya kopyalandı", + "activation-link-copied-message": "Kullanıcı hesabı etkinleştirme bağlantısı panoya kopyalandı", "details": "Ayrıntılar", - "login-as-tenant-admin": "Kiracı Yönetici Girişi", - "login-as-customer-user": "Müşteri olarak giriş yap" + "login-as-tenant-admin": "Tenant Yönetici Girişi", + "login-as-customer-user": "Kullanıcı olarak giriş yap" }, "value": { - "type": "Değer türü", - "string": "Dize", - "string-value": "Dize değeri", + "type": "Değer tğrğ", + "string": "String", + "string-value": "String değeri", "integer": "Integer", - "integer-value": "Tamsayı değeri", - "invalid-integer-value": "Geçersiz tam sayı", - "double": "Çift", - "double-value": "Çift değer", - "boolean": "Boole", - "boolean-value": "Boole değeri", + "integer-value": "Integer değeri", + "invalid-integer-value": "Geçersiz integer değeri", + "double": "Double", + "double-value": "Double değeri", + "boolean": "Boolean", + "boolean-value": "Boolean değeri", "false": "Yanlış", "true": "Doğru", "long": "Uzun" }, "widget": { - "widget-library": "Widgets Kitaplığı", - "widget-bundle": "Widget Paketi", - "select-widgets-bundle": "Widget paketini seç", - "management": "Widget yönetimi", - "editor": "Widget Düzenleyici", - "widget-type-not-found": "Sorun yükleme pencere öğesi yapılandırması.
Muhtemelen ilişkili /n pencere öğesi türü kaldırıldı.", - "widget-type-load-error": "Widget, aşağıdaki hatalardan dolayı yüklenmedi:", - "remove": "Widget'ı kaldır", - "edit": "Widget düzenle", - "remove-widget-title": "Widget'ı kaldırmak istediğinizden emin misiniz? {{WidgetTitle}}? ", - "remove-widget-text": "Onaydan sonra widget ve ilgili tüm veriler kurtarılamaz.", + "widget-library": "Gösterge Kütüphanesi", + "widget-bundle": "Gösterge Demeti", + "select-widgets-bundle": "Gösterge demeti seç", + "management": "Gösterge yönetimi", + "editor": "Gösterge düzenleyici", + "widget-type-not-found": "Gösterge yapılandırması yüklenemedi.
Muhtemelen ilgili\n gösterge türü kaldırılmış.", + "widget-type-load-error": "Gösterge şu sebeplerden dolayı yüklenemedi:", + "remove": "Göstergeyi kaldır", + "edit": "Göstergeyi düzenle", + "remove-widget-title": "'{{widgetTitle}}' isimli göstermeyi kaldırmak istediğinizden emin misiniz?", + "remove-widget-text": "UYARI: Onaylandıktan sonra gösterge ve tüm ilişkili verileri geri yüklenemez şekilde silinecek.", "timeseries": "Zaman serisi", "search-data": "Arama verileri", "no-data-found": "Veri bulunamadı", "latest-values": "Son değerler", - "rpc": "Kontrol aracı", - "alarm": "Alarm gereci", - "static": "Statik pencere öğesi", - "select-widget-type": "Widget tipi seç", - "missing-widget-title-error": "Widget başlığı belirtilmelidir!", - "widget-saved": "Widget kaydedildi", - "unable-to-save-widget-error": "Widget kaydedilemiyor! Widget'ın hataları var!", - "save": "Widget kaydet", - "saveAs": "Widget olarak kaydet", - "save-widget-type-as": "Widget türünü kaydet", - "save-widget-type-as-text": "Lütfen yeni pencere öğesi başlığını girin ve / veya hedef widget'ları seçin", - "toggle-fullscreen": "Tam ekrana geç", - "run": "Widget'ı çalıştır", - "title": "Widget Başlığı", - "title-required": "Widget başlığı gerekiyor.", - "type": "Widget türü", + "rpc": "Kontrol göstergesi", + "alarm": "Alarm göstergesi", + "static": "Statik gösterge", + "select-widget-type": "Gösterge türü seç", + "missing-widget-title-error": "Gösterge başlığı belirtilmelidir!", + "widget-saved": "Gösterge kaydedildi", + "unable-to-save-widget-error": "Gösterge kaydedilemedi! Göstergede hatalar mevcut!", + "save": "Göstergeyi kaydet", + "saveAs": "Göstergeyi farklı kaydet", + "save-widget-type-as": "Gösterge türünü farklı kaydet", + "save-widget-type-as-text": "Lütfen gösterge başlığı girin veya hedef gösterge demeti seçin", + "toggle-fullscreen": "Tam ekran aç/kapat", + "run": "Göstergeyi çalıştır", + "title": "Gösterge başlığı", + "title-required": "Gösterge başlığı gerekli.", + "type": "Gösterge türü", "resources": "Kaynaklar", "resource-url": "JavaScript / CSS URL", "remove-resource": "Kaynağı kaldır", "add-resource": "Kaynak ekle", "html": "HTML", - "tidy": "Düzenli", + "tidy": "Tertiple", "css": "CSS", "settings-schema": "Ayarlar şeması", "datakey-settings-schema": "Veri anahtarı ayarları şeması", - "javascript": "JavaScript", - "remove-widget-type-title": "'{{WidgetName}}' widget türünü kaldırmak istediğinizden emin misiniz?", - "remove-widget-type-text": "Onaydan sonra widget tipi ve ilgili tüm veriler kurtarılamayacak.", - "remove-widget-type": "Widget türünü kaldır", - "add-widget-type": "Yeni widget türü ekle", - "widget-type-load-failed-error": "Widget türü yüklenemedi!", - "widget-template-load-failed-error": "Widget şablonu yüklenemedi!", - "add": "Widget Ekle", - "undo": "Widget değişikliklerini geri al", - "export": "Widget'ı dışa aktar" + "javascript": "Javascript", + "remove-widget-type-title": "'{{widgetName}}' isimli gösterge türünü kaldırmak istediğinizden emin misiniz?", + "remove-widget-type-text": "UYARI: Onaylandıktan sonra, gösterge türü ve ilgili tüm veriler geri yüklenemez şekilde silinecektir.", + "remove-widget-type": "Gösterge türünü kaldır", + "add-widget-type": "Yeni gösterge türü ekle", + "widget-type-load-failed-error": "Gösterge türü yüklenemedi!", + "widget-template-load-failed-error": "Gösterge şablonu yüklenemedi!", + "add": "Gösterge ekle", + "undo": "Gösterge değişikliklerini geri al", + "export": "Göstergeyi dışa aktar" }, "widget-action": { - "header-button": "Widget başlık düğmesi", - "open-dashboard-state": "Yeni gösterge panosuna git", - "update-dashboard-state": "Mevcut kontrol paneli durumunu güncelle", + "header-button": "Gösterge başlık butonu", + "open-dashboard-state": "Yeni kontrol paneli durumunua git", + "update-dashboard-state": "Kontrol paneli durumunu güncelle", "open-dashboard": "Diğer kontrol paneline git", "custom": "Özel eylem", - "target-dashboard-state": "Hedef gösterge panosu durumu", - "target-dashboard-state-required": "Hedef gösterge tablosu gerekli", - "set-entity-from-widget": "Öğeyi pencere öğesinden ayarla", - "target-dashboard": "Hedef gösterge panosu", - "open-right-layout": "Sağdaki gösterge tablosu düzeni (mobil görünüm)" + "target-dashboard-state": "Hedef kontrol paneli durumu", + "target-dashboard-state-required": "Hedef kontrol paneli durumu gerekli", + "set-entity-from-widget": "Göstergeden öğe belirle", + "target-dashboard": "Hedef kontrol paneli", + "open-right-layout": "Sağdaki kontrol paneli arayüz düzenini aç(mobil görünüm)" }, "widgets-bundle": { - "current": "Mevcut paket", - "widgets-bundles": "Widget Paketleri", - "add": "Widget Paketi Ekle", - "delete": "Widget paketini sil", + "current": "Şimdiki demet", + "widgets-bundles": "Gösterge Demetleri", + "add": "Gösterge Demeti Ekle", + "delete": "Gösterge demeti sil", "title": "Başlık", "title-required": "Başlık gerekli.", - "add-widgets-bundle-text": "Yeni widget'lar paketi ekle", - "no-widgets-bundles-text": "Widget paketi bulunamadı", - "empty": "Widget'ler paketi boş", - "details": "Ayrıntılar", - "widgets-bundle-details": "Widget'lar paket ayrıntılarını", - "delete-widgets-bundle-title": "Widget paketini {{widgetsBundleTitle}} silmek istediğinizden emin misiniz?", - "delete-widgets-bundle-text": "Dikkatli olun, onaylandıktan sonra widget'lar paketi ve tüm ilgili veriler kurtarılamayacak.", - "delete-widgets-bundles-title": "{Count, çoğul, 1 {1 widgets bundle} diğer {# widgets bundles}} silmek istediğinizden emin misiniz?", - "delete-widgets-bundles-action-title": "Sil {count, çoğul, 1 {1 widgets bundle} diğer {# widgets bundles}}", - "delete-widgets-bundles-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen widget'lar paketler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", - "no-widgets-bundles-matching": "{{WidgetsBundle}} ile eşleşen hiçbir widget grubu bulunamadı. ", - "widgets-bundle-required": "Widget paketi gerekli.", + "add-widgets-bundle-text": "Yeni gösterge demeti ekle", + "no-widgets-bundles-text": "Hiçbir gösterge demeti bulunamadı", + "empty": "Gösterge demeti boş", + "details": "Detaylar", + "widgets-bundle-details": "Gösterge demeti detayları", + "delete-widgets-bundle-title": "'{{widgetsBundleTitle}}' isimli gösterge demetini silmek istediğinize emin misiniz?", + "delete-widgets-bundle-text": "UYARI: Onaylandıktan sonra gösterge demeti ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "delete-widgets-bundles-title": "{ count, select, 1 {1 gösterge demetini} other {# gösterge demetini} } silmek istediğinize emin misiniz?", + "delete-widgets-bundles-action-title": "{ count, select, 1 {1 gösterge demetini} other {# gösterge demetini} } sil", + "delete-widgets-bundles-text": "UYARI: Onaylandıktan sonra seçili tüm gösterge demetleri ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "no-widgets-bundles-matching": "'{{widgetsBundle}}' ile eşleşen gösterge demeti bulunamadı.", + "widgets-bundle-required": "Gösterge demeti gerekli.", "system": "Sistem", - "import": "Widget paketlerini içe aktar", - "export": "Widget paketini dışa aktar", - "export-failed-error": "Widget grubu dışa aktarılamadı: {{error}}", - "create-new-widgets-bundle": "Yeni widget'lar paketi oluştur", - "widgets-bundle-file": "Widgets paket dosyası", - "invalid-widgets-bundle-file-error": "Widget grubu içe aktarılamıyor: Geçersiz widget'lar veri yapısını paketliyor." + "import": "Gösterge demetini içe aktar", + "export": "Gösterge demetini dışa aktar", + "export-failed-error": "Gösterge demetini dışa aktaramadı: {{error}}", + "create-new-widgets-bundle": "Yeni gösterge demeti oluştur", + "widgets-bundle-file": "Gösterge demeti dosyası", + "invalid-widgets-bundle-file-error": "Gösterge demeti içe aktarılamadı: Geçersiz gösterge demeti veri yapısı." }, "widget-config": { "data": "Veri", "settings": "Ayarlar", - "advanced": "İleri", + "advanced": "İleri düzey", "title": "Başlık", - "general-settings": "Genel Ayarlar", - "display-title": "Başlık", - "drop-shadow": "Düşen gölge", + "general-settings": "Genel ayarlar", + "display-title": "Başlığı göster", + "drop-shadow": "Gölge", "enable-fullscreen": "Tam ekranı etkinleştir", "background-color": "Arka plan rengi", - "text-color": "Metin rengi", - "padding": "Dolgu malzemesi", - "margin": "Kenar", - "widget-style": "Widget stili", + "text-color": "Yazı rengi", + "padding": "İç aralık (Padding)", + "margin": "Dış aralık (Margin)", + "widget-style": "Gösterge stili", "title-style": "Başlık stili", "mobile-mode-settings": "Mobil mod ayarları", - "order": "Sipariş", + "order": "Sıra", "height": "Yükseklik", - "units": "Değerin yanında gösterilecek özel sembol", - "decimals": "Kayan noktadan sonraki basamak sayısı", - "timewindow": "Timewindow", - "use-dashboard-timewindow": "Gösterge panosu zaman tüneli", - "display-legend": "Gösterge efsanesi", + "units": "Değerin yanında göstermek için özel simge", + "decimals": "Noktadan sonraki basamak sayısı", + "timewindow": "Zaman aralığı", + "use-dashboard-timewindow": "Kontrol paneli zaman aralığı kullan", + "display-legend": "Lejant göster", "datasources": "Veri kaynakları", - "maximum-datasources": "Maksimum {sayım, çoğul, 1 {1 veri kaynağı izinli.} Diğer {# veri kaynaklarına izin verilir}}", + "maximum-datasources": "En fazla { count, select, 1 {1 veri kaynağı kullanılabilir.} other {# veri kaynağı kullanılabilir} }", "datasource-type": "Tür", "datasource-parameters": "Parametreler", "remove-datasource": "Veri kaynağını kaldır", "add-datasource": "Veri kaynağı ekle", - "target-device": "Hedef cihaz", + "target-device": "Hedef aygıt", "alarm-source": "Alarm kaynağı", - "actions": "İşlemler", - "action": "Aksiyon", + "actions": "Eylemler", + "action": "Eylem", "add-action": "Eylem ekle", - "search-actions": "Arama işlemleri", + "search-actions": "Eylem ara", "action-source": "Eylem kaynağı", "action-source-required": "Eylem kaynağı gerekli.", - "action-name": "Ad", - "action-name-required": "Eylem adı gerekli.", - "action-name-not-unique": "Aynı ada sahip başka bir eylem zaten var.
Eylem adı, aynı eylem kaynağı içinde benzersiz olmalıdır.", - "action-icon": "Simge", + "action-name": "İsim", + "action-name-required": "Eylem ismi gerekli.", + "action-name-not-unique": "Aynı ada sahip başka bir işlem zaten var.
Eylem adı, aynı eylem kaynağı içinde emsalsiz olmalıdır.", + "action-icon": "İkon", "action-type": "Tür", - "action-type-required": "Eylem türü gerekli", + "action-type-required": "Eylem türü gerekli.", "edit-action": "Eylemi düzenle", "delete-action": "Eylemi sil", - "delete-action-title": "Widget eylemini sil", - "delete-action-text": "Widget eylemini {{actionName}} adıyla silmek istediğinizden emin misiniz?" + "delete-action-title": "Gösterge eylemini sil", + "delete-action-text": "'{{actionName}}' isimli gösterge eylemini silmek istediğinizden emin misiniz?" }, "widget-type": { - "import": "Widget türü içe aktar", - "export": "Widget türünü dışa aktar", - "export-failed-error": "Widget türü verilemiyor: {{error}}", - "create-new-widget-type": "Yeni pencere öğesi türü oluştur", - "widget-type-file": "Widget tipi dosya", - "invalid-widget-type-file-error": "Widget türü içe aktarılamıyor: Geçersiz pencere öğesi türü yapısı." + "import": "Gösterge türünü içer aktar", + "export": "Gösterge türünü dışa aktar", + "export-failed-error": "Gösterge türü dışa aktarılamadı: {{error}}", + "create-new-widget-type": "Yeni gösterge türü oluştur", + "widget-type-file": "Gösterge türü dosyası", + "invalid-widget-type-file-error": "Gösterge türü içe aktarılamadı: Geçersiz gösterge türü veri yapısı." }, "icon": { - "icon": "Simge", - "select-icon": "Simge seç", - "material-icons": "Malzeme simgeleri", - "show-all": "Tüm simgeleri göster" + "icon": "İkon", + "select-icon": "İkon seç", + "material-icons": "Material konları", + "show-all": "Tüm ikonları göster" }, "custom": { "widget-action": { - "action-cell-button": "Eylem hücresi düğmesi", - "row-click": "Satır tıklaması", - "marker-click": "İşaretçi tıklamasında", - "tooltip-tag-action": "Araç ipucu etiketi işlemi" + "action-cell-button": "Eylem hücre butonu", + "row-click": "Satır tıklama eylemi", + "marker-click": "İşaretçi tıklama eylemi", + "tooltip-tag-action": "İpucu etiket eylemi" } }, "language": { @@ -1542,4 +1542,4 @@ "TR": "Türkçe" } } -} +} \ No newline at end of file From a805c3c69628c6b478a030468a1cd5e2ae90b05b Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 23 Oct 2018 10:14:15 +0300 Subject: [PATCH 247/307] Fix entity view tests. Fix transports docker configs. --- .../server/controller/BaseEntityViewControllerTest.java | 7 +++++-- msa/transport/coap/pom.xml | 2 +- msa/transport/http/pom.xml | 2 +- msa/transport/mqtt/pom.xml | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java index ce55e710b9..31869f798b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -329,9 +329,11 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Set expectedActualAttributesSet = new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4")); assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); - Thread.sleep(1000); EntityView savedView = getNewSavedEntityView("Test entity view"); + + Thread.sleep(1000); + List> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); @@ -348,7 +350,6 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes Set expectedActualAttributesSet = new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4")); assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); - Thread.sleep(1000); List> valueTelemetryOfDevices = doGetAsync("/api/plugins/telemetry/DEVICE/" + testDevice.getId().getId().toString() + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); @@ -363,6 +364,8 @@ public abstract class BaseEntityViewControllerTest extends AbstractControllerTes view.setEndTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") / 10); EntityView savedView = doPost("/api/entityView", view, EntityView.class); + Thread.sleep(1000); + List> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); assertEquals(0, values.size()); diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 07c4e63342..94399c7715 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -37,7 +37,7 @@ tb-coap-transport tb-coap-transport thingsboard - /var/log/${pkg.name} + /var/log/${pkg.name} /usr/share/${pkg.name} diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 22eb299dd5..6bbfc3f782 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -37,7 +37,7 @@ tb-http-transport tb-http-transport thingsboard - /var/log/${pkg.name} + /var/log/${pkg.name} /usr/share/${pkg.name} diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 975f9fcdd0..0a3b07771b 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -37,7 +37,7 @@ tb-mqtt-transport tb-mqtt-transport thingsboard - /var/log/${pkg.name} + /var/log/${pkg.name} /usr/share/${pkg.name} From c1675db1a1464839f9b8fab21dbf57b8684f8c72 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 23 Oct 2018 16:30:44 +0300 Subject: [PATCH 248/307] Default partitioning is restored to MONTHS To avoid issues with "lost telemetry" after upgrade. --- application/src/main/resources/thingsboard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 6cb36218b0..da6db707f1 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -135,7 +135,7 @@ cassandra: write_consistency_level: "${CASSANDRA_WRITE_CONSISTENCY_LEVEL:ONE}" default_fetch_size: "${CASSANDRA_DEFAULT_FETCH_SIZE:2000}" # Specify partitioning size for timestamp key-value storage. Example MINUTES, HOURS, DAYS, MONTHS,INDEFINITE - ts_key_value_partitioning: "${TS_KV_PARTITIONING:INDEFINITE}" + ts_key_value_partitioning: "${TS_KV_PARTITIONING:MONTHS}" ts_key_value_ttl: "${TS_KV_TTL:0}" buffer_size: "${CASSANDRA_QUERY_BUFFER_SIZE:200000}" concurrent_limit: "${CASSANDRA_QUERY_CONCURRENT_LIMIT:1000}" From fb098e1bae920b89358596ee274f4fd960673952 Mon Sep 17 00:00:00 2001 From: Viacheslav Kukhtyn Date: Tue, 23 Oct 2018 16:41:43 +0300 Subject: [PATCH 249/307] Cover MQTT API with black box tests --- msa/integration-tests/README.md | 2 +- msa/integration-tests/pom.xml | 10 +- .../server/msa/AbstractContainerTest.java | 113 +++++-- .../server/msa/ContainerTestSuite.java | 3 +- .../org/thingsboard/server/msa/WsClient.java | 8 +- .../msa/connectivity/HttpClientTest.java | 14 +- .../msa/connectivity/MqttClientTest.java | 304 +++++++++++++++++- .../server/msa/mapper/AttributesResponse.java | 26 ++ .../msa/{ => mapper}/WsTelemetryResponse.java | 2 +- .../RpcResponseRuleChainMetadata.json | 59 ++++ 10 files changed, 484 insertions(+), 57 deletions(-) create mode 100644 msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java rename msa/integration-tests/src/test/java/org/thingsboard/server/msa/{ => mapper}/WsTelemetryResponse.java (96%) create mode 100644 msa/integration-tests/src/test/resources/RpcResponseRuleChainMetadata.json diff --git a/msa/integration-tests/README.md b/msa/integration-tests/README.md index 5ae6354740..93df2bf77f 100644 --- a/msa/integration-tests/README.md +++ b/msa/integration-tests/README.md @@ -15,4 +15,4 @@ As result, in REPOSITORY column, next images should be present: - Run the integration tests in the [msa/integration-tests](../integration-tests) directory: - mvn clean install -Dintegrationtests.skip=false \ No newline at end of file + mvn clean install -DintegrationTests.skip=false \ No newline at end of file diff --git a/msa/integration-tests/pom.xml b/msa/integration-tests/pom.xml index a1c24f39c6..a9c67ccf93 100644 --- a/msa/integration-tests/pom.xml +++ b/msa/integration-tests/pom.xml @@ -34,9 +34,10 @@ UTF-8 ${basedir}/../.. - true + true 1.9.1 1.3.9 + 4.5.6 @@ -50,6 +51,11 @@ Java-WebSocket ${java-websocket.version} + + org.apache.httpcomponents + httpclient + ${httpclient.version} + io.takari.junit takari-cpsuite @@ -89,7 +95,7 @@ **/*TestSuite.java - ${integrationtests.skip} + ${integrationTests.skip}
diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java index ab7f101f98..5ebab780be 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java @@ -21,55 +21,68 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; +import org.apache.http.config.Registry; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.conn.ssl.TrustStrategy; +import org.apache.http.conn.ssl.X509HostnameVerifier; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.SSLContexts; import org.junit.*; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.thingsboard.client.tools.RestClient; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; +import javax.net.ssl.*; import java.net.URI; -import java.net.URISyntaxException; +import java.security.cert.X509Certificate; import java.util.List; import java.util.Map; import java.util.Random; -import java.util.concurrent.TimeUnit; @Slf4j public abstract class AbstractContainerTest { - protected static String httpUrl; - protected static String wsUrl; + protected static final String HTTPS_URL = "https://localhost"; + protected static final String WSS_URL = "wss://localhost"; protected static RestClient restClient; protected ObjectMapper mapper = new ObjectMapper(); @BeforeClass - public static void before() { - httpUrl = "http://localhost:" + ContainerTestSuite.composeContainer.getServicePort("tb-web-ui1", ContainerTestSuite.EXPOSED_PORT); - wsUrl = "ws://localhost:" + ContainerTestSuite.composeContainer.getServicePort("tb-web-ui1", ContainerTestSuite.EXPOSED_PORT); - restClient = new RestClient(httpUrl); + public static void before() throws Exception { + restClient = new RestClient(HTTPS_URL); + restClient.getRestTemplate().setRequestFactory(getRequestFactoryForSelfSignedCert()); } protected Device createDevice(String name) { return restClient.createDevice(name + RandomStringUtils.randomAlphanumeric(7), "DEFAULT"); } - protected WsClient subscribeToTelemetryWebSocket(DeviceId deviceId) throws URISyntaxException, InterruptedException { - WsClient mWs = new WsClient(new URI(wsUrl + "/api/ws/plugins/telemetry?token=" + restClient.getToken())); - mWs.connectBlocking(1, TimeUnit.SECONDS); - - JsonObject tsSubCmd = new JsonObject(); - tsSubCmd.addProperty("entityType", EntityType.DEVICE.name()); - tsSubCmd.addProperty("entityId", deviceId.toString()); - tsSubCmd.addProperty("scope", "LATEST_TELEMETRY"); - tsSubCmd.addProperty("cmdId", new Random().nextInt(100)); - tsSubCmd.addProperty("unsubscribe", false); - JsonArray wsTsSubCmds = new JsonArray(); - wsTsSubCmds.add(tsSubCmd); + protected WsClient subscribeToWebSocket(DeviceId deviceId, String scope, CmdsType property) throws Exception { + WsClient wsClient = new WsClient(new URI(WSS_URL + "/api/ws/plugins/telemetry?token=" + restClient.getToken())); + SSLContextBuilder builder = SSLContexts.custom(); + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true); + wsClient.setSocket(builder.build().getSocketFactory().createSocket()); + wsClient.connectBlocking(); + + JsonObject cmdsObject = new JsonObject(); + cmdsObject.addProperty("entityType", EntityType.DEVICE.name()); + cmdsObject.addProperty("entityId", deviceId.toString()); + cmdsObject.addProperty("scope", scope); + cmdsObject.addProperty("cmdId", new Random().nextInt(100)); + + JsonArray cmd = new JsonArray(); + cmd.add(cmdsObject); JsonObject wsRequest = new JsonObject(); - wsRequest.add("tsSubCmds", wsTsSubCmds); - wsRequest.add("historyCmds", new JsonArray()); - wsRequest.add("attrSubCmds", new JsonArray()); - mWs.send(wsRequest.toString()); - return mWs; + wsRequest.add(property.toString(), cmd); + wsClient.send(wsRequest.toString()); + return wsClient; } protected Map getExpectedLatestValues(long ts) { @@ -109,4 +122,54 @@ public abstract class AbstractContainerTest { return values; } + protected enum CmdsType { + TS_SUB_CMDS("tsSubCmds"), + HISTORY_CMDS("historyCmds"), + ATTR_SUB_CMDS("attrSubCmds"); + + private final String text; + + CmdsType(final String text) { + this.text = text; + } + + @Override + public String toString() { + return text; + } + } + + private static HttpComponentsClientHttpRequestFactory getRequestFactoryForSelfSignedCert() throws Exception { + SSLContextBuilder builder = SSLContexts.custom(); + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true); + SSLContext sslContext = builder.build(); + SSLConnectionSocketFactory sslSelfSigned = new SSLConnectionSocketFactory(sslContext, new X509HostnameVerifier() { + @Override + public void verify(String host, SSLSocket ssl) { + } + + @Override + public void verify(String host, X509Certificate cert) { + } + + @Override + public void verify(String host, String[] cns, String[] subjectAlts) { + } + + @Override + public boolean verify(String s, SSLSession sslSession) { + return true; + } + }); + + Registry socketFactoryRegistry = RegistryBuilder + .create() + .register("https", sslSelfSigned) + .build(); + + PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(socketFactoryRegistry); + CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(cm).build(); + return new HttpComponentsClientHttpRequestFactory(httpClient); + } + } diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java index fd2de229d0..0629960b24 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -26,12 +26,11 @@ import java.io.File; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*"}) public class ContainerTestSuite { - static final int EXPOSED_PORT = 8080; @ClassRule public static DockerComposeContainer composeContainer = new DockerComposeContainer(new File("./../docker/docker-compose.yml")) .withPull(false) .withLocalCompose(true) .withTailChildContainers(true) - .withExposedService("tb-web-ui1", EXPOSED_PORT, Wait.forHttp("/login")); + .withExposedService("tb-web-ui1", 8080, Wait.forHttp("/login")); } diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java index 2f05eee4ce..5ef238f8aa 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java @@ -19,16 +19,12 @@ import org.java_websocket.client.WebSocketClient; import org.java_websocket.handshake.ServerHandshake; import java.net.URI; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; public class WsClient extends WebSocketClient { - private final BlockingQueue events; private String message; public WsClient(URI serverUri) { super(serverUri); - events = new ArrayBlockingQueue<>(100); } @Override @@ -37,13 +33,11 @@ public class WsClient extends WebSocketClient { @Override public void onMessage(String message) { - events.add(message); this.message = message; } @Override public void onClose(int code, String reason, boolean remote) { - events.clear(); } @Override @@ -54,4 +48,4 @@ public class WsClient extends WebSocketClient { public String getLastMessage() { return this.message; } -} \ No newline at end of file +} diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java index 7cc0a0f6e3..f8e1041252 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.msa.connectivity; +import com.google.common.collect.Sets; import org.junit.Assert; import org.junit.Test; import org.springframework.http.ResponseEntity; @@ -22,7 +23,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.WsClient; -import org.thingsboard.server.msa.WsTelemetryResponse; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; import java.util.concurrent.TimeUnit; @@ -35,23 +36,24 @@ public class HttpClientTest extends AbstractContainerTest { Device device = createDevice("http_"); DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); - WsClient mWs = subscribeToTelemetryWebSocket(device.getId()); + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); ResponseEntity deviceTelemetryResponse = restClient.getRestTemplate() - .postForEntity(httpUrl + "/api/v1/{credentialsId}/telemetry", + .postForEntity(HTTPS_URL + "/api/v1/{credentialsId}/telemetry", mapper.readTree(createPayload().toString()), ResponseEntity.class, deviceCredentials.getCredentialsId()); Assert.assertTrue(deviceTelemetryResponse.getStatusCode().is2xxSuccessful()); TimeUnit.SECONDS.sleep(1); - WsTelemetryResponse actualLatestTelemetry = mapper.readValue(mWs.getLastMessage(), WsTelemetryResponse.class); + WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); - Assert.assertEquals(getExpectedLatestValues(123456789L).keySet(), actualLatestTelemetry.getLatestValues().keySet()); + Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"), + actualLatestTelemetry.getLatestValues().keySet()); Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString())); Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1")); Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0))); Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73))); - restClient.getRestTemplate().delete(httpUrl + "/api/device/" + device.getId()); + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); } } diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index 4ad638ed15..eae98b9f1e 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -15,20 +15,39 @@ */ package org.thingsboard.server.msa.connectivity; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.Sets; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.gson.JsonObject; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import lombok.Data; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.*; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; import org.thingsboard.mqtt.MqttHandler; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.rule.NodeConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.WsClient; -import org.thingsboard.server.msa.WsTelemetryResponse; +import org.thingsboard.server.msa.mapper.AttributesResponse; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.*; import java.util.concurrent.*; public class MqttClientTest extends AbstractContainerTest { @@ -39,20 +58,22 @@ public class MqttClientTest extends AbstractContainerTest { Device device = createDevice("mqtt_"); DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); - WsClient mWs = subscribeToTelemetryWebSocket(device.getId()); - MqttClient mqttClient = getMqttClient(deviceCredentials); + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + MqttClient mqttClient = getMqttClient(deviceCredentials, null); mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload().toString().getBytes())); TimeUnit.SECONDS.sleep(1); - WsTelemetryResponse actualLatestTelemetry = mapper.readValue(mWs.getLastMessage(), WsTelemetryResponse.class); + WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); - Assert.assertEquals(getExpectedLatestValues(123456789L).keySet(), actualLatestTelemetry.getLatestValues().keySet()); + Assert.assertEquals(4, actualLatestTelemetry.getData().size()); + Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"), + actualLatestTelemetry.getLatestValues().keySet()); Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString())); Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1")); Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0))); Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73))); - restClient.getRestTemplate().delete(httpUrl + "/api/device/" + device.getId()); + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); } @Test @@ -63,12 +84,13 @@ public class MqttClientTest extends AbstractContainerTest { Device device = createDevice("mqtt_"); DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); - WsClient mWs = subscribeToTelemetryWebSocket(device.getId()); - MqttClient mqttClient = getMqttClient(deviceCredentials); + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + MqttClient mqttClient = getMqttClient(deviceCredentials, null); mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload(ts).toString().getBytes())); TimeUnit.SECONDS.sleep(1); - WsTelemetryResponse actualLatestTelemetry = mapper.readValue(mWs.getLastMessage(), WsTelemetryResponse.class); + WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); + Assert.assertEquals(4, actualLatestTelemetry.getData().size()); Assert.assertEquals(getExpectedLatestValues(ts), actualLatestTelemetry.getLatestValues()); Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", ts, Boolean.TRUE.toString())); @@ -76,15 +98,271 @@ public class MqttClientTest extends AbstractContainerTest { Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", ts, Double.toString(42.0))); Assert.assertTrue(verify(actualLatestTelemetry, "longKey", ts, Long.toString(73))); - restClient.getRestTemplate().delete(httpUrl + "/api/device/" + device.getId()); + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); } - private MqttClient getMqttClient(DeviceCredentials deviceCredentials) throws InterruptedException { - MqttMessageListener queue = new MqttMessageListener(); + @Test + public void publishAttributeUpdateToServer() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient wsClient = subscribeToWebSocket(device.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS); + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + JsonObject clientAttributes = new JsonObject(); + clientAttributes.addProperty("attr1", "value1"); + clientAttributes.addProperty("attr2", true); + clientAttributes.addProperty("attr3", 42.0); + clientAttributes.addProperty("attr4", 73); + mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes())); + TimeUnit.SECONDS.sleep(1); + WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); + + Assert.assertEquals(4, actualLatestTelemetry.getData().size()); + Assert.assertEquals(Sets.newHashSet("attr1", "attr2", "attr3", "attr4"), + actualLatestTelemetry.getLatestValues().keySet()); + + Assert.assertTrue(verify(actualLatestTelemetry, "attr1", "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "attr2", Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "attr3", Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "attr4", Long.toString(73))); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void requestAttributeValuesFromServer() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + + // Add a new client attribute + JsonObject clientAttributes = new JsonObject(); + String clientAttributeValue = RandomStringUtils.randomAlphanumeric(8); + clientAttributes.addProperty("clientAttr", clientAttributeValue); + mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes())); + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty("sharedAttr", sharedAttributeValue); + ResponseEntity sharedAttributesResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE", + mapper.readTree(sharedAttributes.toString()), ResponseEntity.class, + device.getId()); + Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful()); + + // Subscribe to attributes response + mqttClient.on("v1/devices/me/attributes/response/+", listener); + // Request attributes + JsonObject request = new JsonObject(); + request.addProperty("clientKeys", "clientAttr"); + request.addProperty("sharedKeys", "sharedAttr"); + mqttClient.publish("v1/devices/me/attributes/request/" + new Random().nextInt(100), Unpooled.wrappedBuffer(request.toString().getBytes())); + MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS); + AttributesResponse attributes = mapper.readValue(Objects.requireNonNull(event).getMessage(), AttributesResponse.class); + + Assert.assertEquals(1, attributes.getClient().size()); + Assert.assertEquals(clientAttributeValue, attributes.getClient().get("clientAttr")); + + Assert.assertEquals(1, attributes.getShared().size()); + Assert.assertEquals(sharedAttributeValue, attributes.getShared().get("sharedAttr")); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void subscribeToAttributeUpdatesFromServer() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/attributes", listener); + + String sharedAttributeName = "sharedAttr"; + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty(sharedAttributeName, sharedAttributeValue); + ResponseEntity sharedAttributesResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE", + mapper.readTree(sharedAttributes.toString()), ResponseEntity.class, + device.getId()); + Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful()); + + MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS); + Assert.assertEquals(sharedAttributeValue, + mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()); + + // Update the shared attribute value + JsonObject updatedSharedAttributes = new JsonObject(); + String updatedSharedAttributeValue = RandomStringUtils.randomAlphanumeric(8); + updatedSharedAttributes.addProperty(sharedAttributeName, updatedSharedAttributeValue); + ResponseEntity updatedSharedAttributesResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE", + mapper.readTree(updatedSharedAttributes.toString()), ResponseEntity.class, + device.getId()); + Assert.assertTrue(updatedSharedAttributesResponse.getStatusCode().is2xxSuccessful()); + + event = listener.getEvents().poll(10, TimeUnit.SECONDS); + Assert.assertEquals(updatedSharedAttributeValue, + mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void serverSideRpc() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener); + + // Send an RPC from the server + JsonObject serverRpcPayload = new JsonObject(); + serverRpcPayload.addProperty("method", "getValue"); + serverRpcPayload.addProperty("params", true); + serverRpcPayload.addProperty("timeout", 1000); + ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + ListenableFuture future = service.submit(() -> { + try { + return restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/rpc/twoway/{deviceId}", + mapper.readTree(serverRpcPayload.toString()), String.class, + device.getId()); + } catch (IOException e) { + return ResponseEntity.badRequest().build(); + } + }); + + // Wait for RPC call from the server and send the response + MqttEvent requestFromServer = listener.getEvents().poll(10, TimeUnit.SECONDS); + + Assert.assertEquals("{\"method\":\"getValue\",\"params\":true}", Objects.requireNonNull(requestFromServer).getMessage()); + + Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length())); + JsonObject clientResponse = new JsonObject(); + clientResponse.addProperty("response", "someResponse"); + // Send a response to the server's RPC request + mqttClient.publish("v1/devices/me/rpc/response/" + requestId, Unpooled.wrappedBuffer(clientResponse.toString().getBytes())); + + ResponseEntity serverResponse = future.get(5, TimeUnit.SECONDS); + Assert.assertTrue(serverResponse.getStatusCode().is2xxSuccessful()); + Assert.assertEquals(clientResponse.toString(), serverResponse.getBody()); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void clientSideRpc() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener); + + // Get the default rule chain id to make it root again after test finished + RuleChainId defaultRuleChainId = getDefaultRuleChainId(); + + // Create a new root rule chain + RuleChainId ruleChainId = createRootRuleChainForRpcResponse(); + + // Send the request to the server + JsonObject clientRequest = new JsonObject(); + clientRequest.addProperty("method", "getResponse"); + clientRequest.addProperty("params", true); + Integer requestId = 42; + mqttClient.publish("v1/devices/me/rpc/request/" + requestId, Unpooled.wrappedBuffer(clientRequest.toString().getBytes())); + + // Check the response from the server + TimeUnit.SECONDS.sleep(1); + MqttEvent responseFromServer = listener.getEvents().poll(1, TimeUnit.SECONDS); + Integer responseId = Integer.valueOf(Objects.requireNonNull(responseFromServer).getTopic().substring("v1/devices/me/rpc/response/".length())); + Assert.assertEquals(requestId, responseId); + Assert.assertEquals("requestReceived", mapper.readTree(responseFromServer.getMessage()).get("response").asText()); + + // Make the default rule chain a root again + ResponseEntity rootRuleChainResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root", + null, + RuleChain.class, + defaultRuleChainId); + Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful()); + + // Delete the created rule chain + restClient.getRestTemplate().delete(HTTPS_URL + "/api/ruleChain/{ruleChainId}", ruleChainId); + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + private RuleChainId createRootRuleChainForRpcResponse() throws Exception { + RuleChain newRuleChain = new RuleChain(); + newRuleChain.setName("testRuleChain"); + ResponseEntity ruleChainResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain", + newRuleChain, + RuleChain.class); + Assert.assertTrue(ruleChainResponse.getStatusCode().is2xxSuccessful()); + RuleChain ruleChain = ruleChainResponse.getBody(); + + JsonNode configuration = mapper.readTree(this.getClass().getClassLoader().getResourceAsStream("RpcResponseRuleChainMetadata.json")); + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainMetaData.setFirstNodeIndex(configuration.get("firstNodeIndex").asInt()); + ruleChainMetaData.setNodes(Arrays.asList(mapper.treeToValue(configuration.get("nodes"), RuleNode[].class))); + ruleChainMetaData.setConnections(Arrays.asList(mapper.treeToValue(configuration.get("connections"), NodeConnectionInfo[].class))); + + ResponseEntity ruleChainMetadataResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain/metadata", + ruleChainMetaData, + RuleChainMetaData.class); + Assert.assertTrue(ruleChainMetadataResponse.getStatusCode().is2xxSuccessful()); + + // Set a new rule chain as root + ResponseEntity rootRuleChainResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root", + null, + RuleChain.class, + ruleChain.getId()); + Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful()); + + return ruleChain.getId(); + } + + private RuleChainId getDefaultRuleChainId() { + ResponseEntity> ruleChains = restClient.getRestTemplate().exchange( + HTTPS_URL + "/api/ruleChains?limit=40&textSearch=", + HttpMethod.GET, + null, + new ParameterizedTypeReference>() { + }); + + Optional defaultRuleChain = ruleChains.getBody().getData() + .stream() + .filter(RuleChain::isRoot) + .findFirst(); + if (!defaultRuleChain.isPresent()) { + Assert.fail("Root rule chain wasn't found"); + } + return defaultRuleChain.get().getId(); + } + + private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException { MqttClientConfig clientConfig = new MqttClientConfig(); clientConfig.setClientId("MQTT client from test"); clientConfig.setUsername(deviceCredentials.getCredentialsId()); - MqttClient mqttClient = MqttClient.create(clientConfig, queue); + MqttClient mqttClient = MqttClient.create(clientConfig, listener); mqttClient.connect("localhost", 1883).sync(); return mqttClient; } diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java new file mode 100644 index 0000000000..f9774eef20 --- /dev/null +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.mapper; + +import lombok.Data; + +import java.util.Map; + +@Data +public class AttributesResponse { + private Map client; + private Map shared; +} diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsTelemetryResponse.java b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java similarity index 96% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsTelemetryResponse.java rename to msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java index 834c5d1449..b22244f3a7 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsTelemetryResponse.java +++ b/msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.msa; +package org.thingsboard.server.msa.mapper; import lombok.Data; diff --git a/msa/integration-tests/src/test/resources/RpcResponseRuleChainMetadata.json b/msa/integration-tests/src/test/resources/RpcResponseRuleChainMetadata.json new file mode 100644 index 0000000000..09178ef781 --- /dev/null +++ b/msa/integration-tests/src/test/resources/RpcResponseRuleChainMetadata.json @@ -0,0 +1,59 @@ +{ + "firstNodeIndex": 0, + "nodes": [ + { + "additionalInfo": { + "layoutX": 325, + "layoutY": 150 + }, + "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode", + "name": "msgTypeSwitch", + "debugMode": true, + "configuration": { + "version": 0 + } + }, + { + "additionalInfo": { + "layoutX": 60, + "layoutY": 300 + }, + "type": "org.thingsboard.rule.engine.transform.TbTransformMsgNode", + "name": "formResponse", + "debugMode": true, + "configuration": { + "jsScript": "if (msg.method == \"getResponse\") {\n return {msg: {\"response\": \"requestReceived\"}, metadata: metadata, msgType: msgType};\n}\n\nreturn {msg: msg, metadata: metadata, msgType: msgType};" + } + }, + { + "additionalInfo": { + "layoutX": 450, + "layoutY": 300 + }, + "type": "org.thingsboard.rule.engine.rpc.TbSendRPCReplyNode", + "name": "rpcReply", + "debugMode": true, + "configuration": { + "requestIdMetaDataAttribute": "requestId" + } + } + ], + "connections": [ + { + "fromIndex": 0, + "toIndex": 1, + "type": "RPC Request from Device" + }, + { + "fromIndex": 1, + "toIndex": 2, + "type": "Success" + }, + { + "fromIndex": 1, + "toIndex": 2, + "type": "Failure" + } + ], + "ruleChainConnections": null +} \ No newline at end of file From 9c414a951102362c3d8e7e2e3ed04dcc3a11a6e8 Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 24 Oct 2018 13:48:59 +0300 Subject: [PATCH 250/307] UI. Outsource CSS should be added before custom CSS --- ui/src/app/app.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ui/src/app/app.js b/ui/src/app/app.js index a3a179eb12..824dbb0f69 100644 --- a/ui/src/app/app.js +++ b/ui/src/app/app.js @@ -51,6 +51,20 @@ import react from 'ngreact'; import '@flowjs/ng-flow/dist/ng-flow-standalone.min'; import 'ngFlowchart/dist/ngFlowchart'; +import 'typeface-roboto'; +import 'font-awesome/css/font-awesome.min.css'; +import 'angular-material/angular-material.min.css'; +import 'angular-material-icons/angular-material-icons.css'; +import 'angular-gridster/dist/angular-gridster.min.css'; +import 'v-accordion/dist/v-accordion.min.css'; +import 'md-color-picker/dist/mdColorPicker.min.css'; +import 'mdPickers/dist/mdPickers.min.css'; +import 'angular-hotkeys/build/hotkeys.min.css'; +import 'angular-carousel/dist/angular-carousel.min.css'; +import 'angular-material-expansion-panel/dist/md-expansion-panel.min.css'; +import 'ngFlowchart/dist/flowchart.css'; +import '../scss/main.scss'; + import thingsboardTranslateHandler from './locale/translate-handler'; import thingsboardLogin from './login'; import thingsboardDialogs from './components/datakey-config-dialog.controller'; @@ -78,20 +92,6 @@ import thingsboardApiAuditLog from './api/audit-log.service'; import thingsboardApiComponentDescriptor from './api/component-descriptor.service'; import thingsboardApiRuleChain from './api/rule-chain.service'; -import 'typeface-roboto'; -import 'font-awesome/css/font-awesome.min.css'; -import 'angular-material/angular-material.min.css'; -import 'angular-material-icons/angular-material-icons.css'; -import 'angular-gridster/dist/angular-gridster.min.css'; -import 'v-accordion/dist/v-accordion.min.css'; -import 'md-color-picker/dist/mdColorPicker.min.css'; -import 'mdPickers/dist/mdPickers.min.css'; -import 'angular-hotkeys/build/hotkeys.min.css'; -import 'angular-carousel/dist/angular-carousel.min.css'; -import 'angular-material-expansion-panel/dist/md-expansion-panel.min.css'; -import 'ngFlowchart/dist/flowchart.css'; -import '../scss/main.scss'; - import AppConfig from './app.config'; import GlobalInterceptor from './global-interceptor.service'; import AppRun from './app.run'; From 2181eeaf3e9c0a2784f53b6e075623b2f6dc9300 Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 24 Oct 2018 16:38:24 +0300 Subject: [PATCH 251/307] UI. Entities table widget (raised its height) --- application/src/main/data/json/system/widget_bundles/cards.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 803f1d3953..ea404e5ecd 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -27,7 +27,7 @@ "descriptor": { "type": "latest", "sizeX": 7.5, - "sizeY": 4.5, + "sizeY": 6.5, "resources": [], "templateHtml": "\n", "templateCss": "", From 571f96c4a99d189023886fe0e5903151a4776814 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 24 Oct 2018 19:28:11 +0300 Subject: [PATCH 252/307] New Buffered Rate Limit implementation --- .../src/main/resources/thingsboard.yml | 2 +- .../dao/nosql/CassandraAbstractDao.java | 7 +- .../nosql/CassandraBufferedRateExecutor.java | 78 ++++++++ .../dao/nosql/CassandraStatementTask.java | 32 +++ .../server/dao/nosql/TbResultSetFuture.java | 94 +++++++++ .../tmp/AbstractBufferedRateExecutor.java | 169 ++++++++++++++++ .../server/dao/nosql/tmp/AsyncTask.java | 22 +++ .../dao/nosql/tmp/AsyncTaskContext.java | 34 ++++ .../dao/nosql/tmp/BufferedRateExecutor.java | 27 +++ .../server/dao/util/BufferedRateLimiter.java | 182 ------------------ .../dao/util/BufferedRateLimiterTest.java | 142 -------------- 11 files changed, 460 insertions(+), 329 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AbstractBufferedRateExecutor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTask.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTaskContext.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/BufferedRateExecutor.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java delete mode 100644 dao/src/test/java/org/thingsboard/server/dao/util/BufferedRateLimiterTest.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index da6db707f1..ec5dde5cdd 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -140,7 +140,7 @@ cassandra: buffer_size: "${CASSANDRA_QUERY_BUFFER_SIZE:200000}" concurrent_limit: "${CASSANDRA_QUERY_CONCURRENT_LIMIT:1000}" permit_max_wait_time: "${PERMIT_MAX_WAIT_TIME:120000}" - rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:30000}" + rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:10000}" # SQL configuration parameters sql: diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java index d1af1677db..b38110b460 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java @@ -35,7 +35,6 @@ import org.thingsboard.server.dao.model.type.ComponentTypeCodec; import org.thingsboard.server.dao.model.type.DeviceCredentialsTypeCodec; import org.thingsboard.server.dao.model.type.EntityTypeCodec; import org.thingsboard.server.dao.model.type.JsonCodec; -import org.thingsboard.server.dao.util.BufferedRateLimiter; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -49,7 +48,7 @@ public abstract class CassandraAbstractDao { private ConcurrentMap preparedStatementMap = new ConcurrentHashMap<>(); @Autowired - private BufferedRateLimiter rateLimiter; + private CassandraBufferedRateExecutor rateLimiter; private Session session; @@ -115,12 +114,12 @@ public abstract class CassandraAbstractDao { if (statement.getConsistencyLevel() == null) { statement.setConsistencyLevel(level); } - return new RateLimitedResultSetFuture(getSession(), rateLimiter, statement); + return rateLimiter.submit(new CassandraStatementTask(getSession(), statement)); } private static String statementToString(Statement statement) { if (statement instanceof BoundStatement) { - return ((BoundStatement)statement).preparedStatement().getQueryString(); + return ((BoundStatement) statement).preparedStatement().getQueryString(); } else { return statement.toString(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java new file mode 100644 index 0000000000..478c76d5c0 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.ResultSetFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.thingsboard.server.dao.nosql.tmp.AbstractBufferedRateExecutor; +import org.thingsboard.server.dao.nosql.tmp.AsyncTaskContext; +import org.thingsboard.server.dao.util.NoSqlAnyDao; + +import javax.annotation.PreDestroy; + +/** + * Created by ashvayka on 24.10.18. + */ +@Component +@Slf4j +@NoSqlAnyDao +public class CassandraBufferedRateExecutor extends AbstractBufferedRateExecutor { + + public CassandraBufferedRateExecutor( + @Value("${cassandra.query.buffer_size}") int queueLimit, + @Value("${cassandra.query.concurrent_limit}") int concurrencyLimit, + @Value("${cassandra.query.permit_max_wait_time}") long maxWaitTime, + @Value("${cassandra.query.dispatcher_threads:2}") int dispatcherThreads, + @Value("${cassandra.query.callback_threads:2}") int callbackThreads, + @Value("${cassandra.query.poll_ms:50}") long pollMs) { + super(queueLimit, concurrencyLimit, maxWaitTime, dispatcherThreads, callbackThreads, pollMs); + } + + @Scheduled(fixedDelayString = "${cassandra.query.rate_limit_print_interval_ms}") + public void printStats() { + log.info("Permits totalAdded [{}] totalLaunched [{}] totalReleased [{}] totalFailed [{}] totalExpired [{}] totalRejected [{}] currBuffer [{}] ", + totalAdded.getAndSet(0), totalLaunched.getAndSet(0), totalReleased.getAndSet(0), + totalFailed.getAndSet(0), totalExpired.getAndSet(0), totalRejected.getAndSet(0), + concurrencyLevel.get()); + } + + @PreDestroy + public void stop() { + super.stop(); + } + + @Override + protected SettableFuture create() { + return SettableFuture.create(); + } + + @Override + protected ResultSetFuture wrap(CassandraStatementTask task, SettableFuture future) { + return new TbResultSetFuture(future); + } + + @Override + protected ResultSetFuture execute(AsyncTaskContext taskCtx) { + CassandraStatementTask task = taskCtx.getTask(); + return task.getSession().executeAsync(task.getStatement()); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java new file mode 100644 index 0000000000..ea13679b7c --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql; + +import com.datastax.driver.core.Session; +import com.datastax.driver.core.Statement; +import lombok.Data; +import org.thingsboard.server.dao.nosql.tmp.AsyncTask; + +/** + * Created by ashvayka on 24.10.18. + */ +@Data +public class CassandraStatementTask implements AsyncTask { + + private final Session session; + private final Statement statement; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java new file mode 100644 index 0000000000..574a5f5e69 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.ResultSetFuture; +import com.google.common.util.concurrent.SettableFuture; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Created by ashvayka on 24.10.18. + */ +public class TbResultSetFuture implements ResultSetFuture { + + private final SettableFuture mainFuture; + + public TbResultSetFuture(SettableFuture mainFuture) { + this.mainFuture = mainFuture; + } + + @Override + public ResultSet getUninterruptibly() { + return getSafe(); + } + + @Override + public ResultSet getUninterruptibly(long timeout, TimeUnit unit) throws TimeoutException { + return getSafe(timeout, unit); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return mainFuture.cancel(mayInterruptIfRunning); + } + + @Override + public boolean isCancelled() { + return mainFuture.isCancelled(); + } + + @Override + public boolean isDone() { + return mainFuture.isDone(); + } + + @Override + public ResultSet get() throws InterruptedException, ExecutionException { + return mainFuture.get(); + } + + @Override + public ResultSet get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + return mainFuture.get(timeout, unit); + } + + @Override + public void addListener(Runnable listener, Executor executor) { + mainFuture.addListener(listener, executor); + } + + private ResultSet getSafe() { + try { + return mainFuture.get(); + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } + } + + private ResultSet getSafe(long timeout, TimeUnit unit) throws TimeoutException { + try { + return mainFuture.get(timeout, unit); + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AbstractBufferedRateExecutor.java new file mode 100644 index 0000000000..9ad8e18745 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AbstractBufferedRateExecutor.java @@ -0,0 +1,169 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql.tmp; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Created by ashvayka on 24.10.18. + */ +@Slf4j +public abstract class AbstractBufferedRateExecutor, V> implements BufferedRateExecutor { + + private final long maxWaitTime; + private final long pollMs; + private final BlockingQueue> queue; + private final ExecutorService dispatcherExecutor; + private final ExecutorService callbackExecutor; + private final ScheduledExecutorService timeoutExecutor; + private final int concurrencyLimit; + + protected final AtomicInteger concurrencyLevel = new AtomicInteger(); + protected final AtomicInteger totalAdded = new AtomicInteger(); + protected final AtomicInteger totalLaunched = new AtomicInteger(); + protected final AtomicInteger totalReleased = new AtomicInteger(); + protected final AtomicInteger totalFailed = new AtomicInteger(); + protected final AtomicInteger totalExpired = new AtomicInteger(); + protected final AtomicInteger totalRejected = new AtomicInteger(); + + public AbstractBufferedRateExecutor(int queueLimit, int concurrencyLimit, long maxWaitTime, int dispatcherThreads, int callbackThreads, long pollMs) { + this.maxWaitTime = maxWaitTime; + this.pollMs = pollMs; + this.concurrencyLimit = concurrencyLimit; + this.queue = new LinkedBlockingDeque<>(queueLimit); + this.dispatcherExecutor = Executors.newFixedThreadPool(dispatcherThreads); + this.callbackExecutor = Executors.newFixedThreadPool(callbackThreads); + this.timeoutExecutor = Executors.newSingleThreadScheduledExecutor(); + for (int i = 0; i < dispatcherThreads; i++) { + dispatcherExecutor.submit(this::dispatch); + } + } + + @Override + public F submit(T task) { + SettableFuture settableFuture = create(); + F result = wrap(task, settableFuture); + try { + totalAdded.incrementAndGet(); + queue.add(new AsyncTaskContext<>(UUID.randomUUID(), task, settableFuture, System.currentTimeMillis())); + } catch (IllegalStateException e) { + totalRejected.incrementAndGet(); + settableFuture.setException(e); + } + return result; + } + + public void stop() { + if (dispatcherExecutor != null) { + dispatcherExecutor.shutdownNow(); + } + if (callbackExecutor != null) { + callbackExecutor.shutdownNow(); + } + if (timeoutExecutor != null) { + timeoutExecutor.shutdownNow(); + } + } + + protected abstract SettableFuture create(); + + protected abstract F wrap(T task, SettableFuture future); + + protected abstract ListenableFuture execute(AsyncTaskContext taskCtx); + + private void dispatch() { + log.info("Buffered rate executor thread started"); + while (!Thread.interrupted()) { + int curLvl = concurrencyLevel.get(); + AsyncTaskContext taskCtx = null; + try { + if (curLvl <= concurrencyLimit) { + taskCtx = queue.take(); + final AsyncTaskContext finalTaskCtx = taskCtx; + logTask("Processing", finalTaskCtx); + concurrencyLevel.incrementAndGet(); + long timeout = finalTaskCtx.getCreateTime() + maxWaitTime - System.currentTimeMillis(); + if (timeout > 0) { + totalLaunched.incrementAndGet(); + ListenableFuture result = execute(finalTaskCtx); + result = Futures.withTimeout(result, timeout, TimeUnit.MILLISECONDS, timeoutExecutor); + Futures.addCallback(result, new FutureCallback() { + @Override + public void onSuccess(@Nullable V result) { + logTask("Releasing", finalTaskCtx); + totalReleased.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + finalTaskCtx.getFuture().set(result); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof TimeoutException) { + logTask("Expired During Execution", finalTaskCtx); + } else { + logTask("Failed", finalTaskCtx); + } + totalFailed.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + finalTaskCtx.getFuture().setException(t); + log.debug("[{}] Failed to execute task: {}", finalTaskCtx.getId(), finalTaskCtx.getTask(), t); + } + }, callbackExecutor); + } else { + logTask("Expired Before Execution", finalTaskCtx); + totalExpired.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + taskCtx.getFuture().setException(new TimeoutException()); + } + } else { + Thread.sleep(pollMs); + } + } catch (Throwable e) { + if (taskCtx != null) { + log.debug("[{}] Failed to execute task: {}", taskCtx.getId(), taskCtx, e); + totalFailed.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + } else { + log.debug("Failed to queue task:", e); + } + } + } + log.info("Buffered rate executor thread stopped"); + } + + private void logTask(String action, AsyncTaskContext taskCtx) { + if (log.isTraceEnabled()) { + log.trace("[{}] {} task: {}", taskCtx.getId(), action, taskCtx); + } else { + log.debug("[{}] {} task", taskCtx.getId(), action); + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTask.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTask.java new file mode 100644 index 0000000000..a16568b6bd --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTask.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql.tmp; + +/** + * Created by ashvayka on 24.10.18. + */ +public interface AsyncTask { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTaskContext.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTaskContext.java new file mode 100644 index 0000000000..c3c98ee361 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTaskContext.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql.tmp; + +import com.google.common.util.concurrent.SettableFuture; +import lombok.Data; + +import java.util.UUID; + +/** + * Created by ashvayka on 24.10.18. + */ +@Data +public class AsyncTaskContext { + + private final UUID id; + private final T task; + private final SettableFuture future; + private final long createTime; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/BufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/BufferedRateExecutor.java new file mode 100644 index 0000000000..7adc914885 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/BufferedRateExecutor.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql.tmp; + +import com.google.common.util.concurrent.ListenableFuture; + +/** + * Created by ashvayka on 24.10.18. + */ +public interface BufferedRateExecutor { + + F submit(T task); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java deleted file mode 100644 index eab05b2fdb..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.util; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.exception.BufferLimitException; - -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executors; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -@Component -@Slf4j -@NoSqlAnyDao -public class BufferedRateLimiter implements AsyncRateLimiter { - - private final ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); - - private final int permitsLimit; - private final int maxPermitWaitTime; - private final AtomicInteger permits; - private final BlockingQueue queue; - - private final AtomicInteger maxQueueSize = new AtomicInteger(); - private final AtomicInteger maxGrantedPermissions = new AtomicInteger(); - private final AtomicInteger totalGranted = new AtomicInteger(); - private final AtomicInteger totalReleased = new AtomicInteger(); - private final AtomicInteger totalRequested = new AtomicInteger(); - - public BufferedRateLimiter(@Value("${cassandra.query.buffer_size}") int queueLimit, - @Value("${cassandra.query.concurrent_limit}") int permitsLimit, - @Value("${cassandra.query.permit_max_wait_time}") int maxPermitWaitTime) { - this.permitsLimit = permitsLimit; - this.maxPermitWaitTime = maxPermitWaitTime; - this.permits = new AtomicInteger(); - this.queue = new LinkedBlockingQueue<>(queueLimit); - } - - @Override - public ListenableFuture acquireAsync() { - totalRequested.incrementAndGet(); - if (queue.isEmpty()) { - if (permits.incrementAndGet() <= permitsLimit) { - if (permits.get() > maxGrantedPermissions.get()) { - maxGrantedPermissions.set(permits.get()); - } - totalGranted.incrementAndGet(); - return Futures.immediateFuture(null); - } - permits.decrementAndGet(); - } - - return putInQueue(); - } - - @Override - public void release() { - permits.decrementAndGet(); - totalReleased.incrementAndGet(); - reprocessQueue(); - } - - private void reprocessQueue() { - while (permits.get() < permitsLimit) { - if (permits.incrementAndGet() <= permitsLimit) { - if (permits.get() > maxGrantedPermissions.get()) { - maxGrantedPermissions.set(permits.get()); - } - LockedFuture lockedFuture = queue.poll(); - if (lockedFuture != null) { - totalGranted.incrementAndGet(); - lockedFuture.latch.countDown(); - } else { - permits.decrementAndGet(); - break; - } - } else { - permits.decrementAndGet(); - } - } - } - - private LockedFuture createLockedFuture() { - CountDownLatch latch = new CountDownLatch(1); - ListenableFuture future = pool.submit(() -> { - latch.await(); - return null; - }); - return new LockedFuture(latch, future, System.currentTimeMillis()); - } - - private ListenableFuture putInQueue() { - - int size = queue.size(); - if (size > maxQueueSize.get()) { - maxQueueSize.set(size); - } - - if (queue.remainingCapacity() > 0) { - try { - LockedFuture lockedFuture = createLockedFuture(); - if (!queue.offer(lockedFuture, 1, TimeUnit.SECONDS)) { - lockedFuture.cancelFuture(); - return Futures.immediateFailedFuture(new BufferLimitException()); - } - if(permits.get() < permitsLimit) { - reprocessQueue(); - } - if(permits.get() < permitsLimit) { - reprocessQueue(); - } - return lockedFuture.future; - } catch (InterruptedException e) { - return Futures.immediateFailedFuture(new BufferLimitException()); - } - } - return Futures.immediateFailedFuture(new BufferLimitException()); - } - - @Scheduled(fixedDelayString = "${cassandra.query.rate_limit_print_interval_ms}") - public void printStats() { - int expiredCount = 0; - for (LockedFuture lockedFuture : queue) { - if (lockedFuture.isExpired()) { - lockedFuture.cancelFuture(); - expiredCount++; - } - } - log.info("Permits maxBuffer [{}] maxPermits [{}] expired [{}] currPermits [{}] currBuffer [{}] " + - "totalPermits [{}] totalRequests [{}] totalReleased [{}]", - maxQueueSize.getAndSet(0), maxGrantedPermissions.getAndSet(0), expiredCount, - permits.get(), queue.size(), - totalGranted.getAndSet(0), totalRequested.getAndSet(0), totalReleased.getAndSet(0)); - } - - private class LockedFuture { - final CountDownLatch latch; - final ListenableFuture future; - final long createTime; - - public LockedFuture(CountDownLatch latch, ListenableFuture future, long createTime) { - this.latch = latch; - this.future = future; - this.createTime = createTime; - } - - void cancelFuture() { - future.cancel(false); - latch.countDown(); - } - - boolean isExpired() { - return (System.currentTimeMillis() - createTime) > maxPermitWaitTime; - } - - } - - -} diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/BufferedRateLimiterTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/BufferedRateLimiterTest.java deleted file mode 100644 index 366cefd5d8..0000000000 --- a/dao/src/test/java/org/thingsboard/server/dao/util/BufferedRateLimiterTest.java +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.util; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import org.junit.Test; -import org.thingsboard.server.dao.exception.BufferLimitException; - -import javax.annotation.Nullable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - - -public class BufferedRateLimiterTest { - - @Test - public void finishedFutureReturnedIfPermitsAreGranted() { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 10, 100); - ListenableFuture actual = limiter.acquireAsync(); - assertTrue(actual.isDone()); - } - - @Test - public void notFinishedFutureReturnedIfPermitsAreNotGranted() { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 1, 100); - ListenableFuture actual1 = limiter.acquireAsync(); - ListenableFuture actual2 = limiter.acquireAsync(); - assertTrue(actual1.isDone()); - assertFalse(actual2.isDone()); - } - - @Test - public void failedFutureReturnedIfQueueIsfull() { - BufferedRateLimiter limiter = new BufferedRateLimiter(1, 1, 100); - ListenableFuture actual1 = limiter.acquireAsync(); - ListenableFuture actual2 = limiter.acquireAsync(); - ListenableFuture actual3 = limiter.acquireAsync(); - - assertTrue(actual1.isDone()); - assertFalse(actual2.isDone()); - assertTrue(actual3.isDone()); - try { - actual3.get(); - fail(); - } catch (Exception e) { - assertTrue(e instanceof ExecutionException); - Throwable actualCause = e.getCause(); - assertTrue(actualCause instanceof BufferLimitException); - assertEquals("Rate Limit Buffer is full", actualCause.getMessage()); - } - } - - @Test - public void releasedPermitTriggerTasksFromQueue() throws InterruptedException { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 2, 100); - ListenableFuture actual1 = limiter.acquireAsync(); - ListenableFuture actual2 = limiter.acquireAsync(); - ListenableFuture actual3 = limiter.acquireAsync(); - ListenableFuture actual4 = limiter.acquireAsync(); - assertTrue(actual1.isDone()); - assertTrue(actual2.isDone()); - assertFalse(actual3.isDone()); - assertFalse(actual4.isDone()); - limiter.release(); - TimeUnit.MILLISECONDS.sleep(100L); - assertTrue(actual3.isDone()); - assertFalse(actual4.isDone()); - limiter.release(); - TimeUnit.MILLISECONDS.sleep(100L); - assertTrue(actual4.isDone()); - } - - @Test - public void permitsReleasedInConcurrentMode() throws InterruptedException { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 2, 100); - AtomicInteger actualReleased = new AtomicInteger(); - AtomicInteger actualRejected = new AtomicInteger(); - ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(5)); - for (int i = 0; i < 100; i++) { - ListenableFuture> submit = pool.submit(limiter::acquireAsync); - Futures.addCallback(submit, new FutureCallback>() { - @Override - public void onSuccess(@Nullable ListenableFuture result) { - Futures.addCallback(result, new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException e) { - e.printStackTrace(); - } - limiter.release(); - actualReleased.incrementAndGet(); - } - - @Override - public void onFailure(Throwable t) { - actualRejected.incrementAndGet(); - } - }); - } - - @Override - public void onFailure(Throwable t) { - } - }); - } - - TimeUnit.SECONDS.sleep(2); - assertTrue("Unexpected released count " + actualReleased.get(), - actualReleased.get() > 10 && actualReleased.get() < 20); - assertTrue("Unexpected rejected count " + actualRejected.get(), - actualRejected.get() > 80 && actualRejected.get() < 90); - - } - - -} \ No newline at end of file From 00e89617d507a93730018e61884bceb899871023 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 24 Oct 2018 19:56:25 +0300 Subject: [PATCH 253/307] Create single instance docker builds. Move MSA docker configuration to the root. --- docker/.env | 25 +- {msa/docker => docker}/.gitignore | 2 + docker/README.md | 51 +++ docker/cassandra-setup/Dockerfile | 24 -- docker/cassandra-setup/Makefile | 12 - docker/cassandra-upgrade/Dockerfile | 24 -- docker/cassandra-upgrade/Makefile | 12 - docker/cassandra/Makefile | 10 - docker/cassandra/ready-probe.sh | 28 -- {msa/docker => docker}/check-dirs.sh | 2 +- docker/compose-utils.sh | 54 +++ docker/docker-compose-tests.yml | 27 -- ...tatic.yml => docker-compose.cassandra.yml} | 23 +- ...dparty.yml => docker-compose.postgres.yml} | 49 ++- docker/docker-compose.yml | 175 +++++++-- {msa/docker => docker}/docker-install-tb.sh | 14 +- docker/docker-remove-services.sh | 24 ++ docker/docker-start-services.sh | 26 ++ docker/docker-stop-services.sh | 24 ++ docker/docker-update-service.sh | 25 ++ {msa/docker => docker}/docker-upgrade-tb.sh | 14 +- .../haproxy/config/haproxy.cfg | 0 docker/k8s/cassandra-setup.yaml | 43 -- docker/k8s/cassandra-upgrade.yaml | 45 --- docker/k8s/cassandra.yaml | 132 ------- docker/k8s/common.yaml | 33 -- docker/k8s/redis.yaml | 93 ----- docker/k8s/tb.yaml | 156 -------- docker/k8s/zookeeper.yaml | 190 --------- {msa/docker => docker}/tb-coap-transport.env | 0 {msa/docker => docker}/tb-http-transport.env | 0 {msa/docker => docker}/tb-js-executor.env | 0 {msa/docker => docker}/tb-mqtt-transport.env | 0 docker/tb-node.env | 3 + .../tb-node/conf/logback.xml | 0 .../tb-node/conf/thingsboard.conf | 0 {msa/docker => docker}/tb-web-ui.env | 0 docker/tb.env | 26 -- docker/tb/Dockerfile | 26 -- docker/tb/Makefile | 12 - docker/tb/run-application.sh | 59 --- docker/zookeeper/Dockerfile | 71 ---- docker/zookeeper/Makefile | 10 - docker/zookeeper/zk-gen-config.sh | 153 -------- docker/zookeeper/zk-ok.sh | 28 -- msa/docker/.env | 13 - msa/docker/docker-compose.yml | 177 --------- msa/docker/tb-node.env | 22 -- msa/js-executor/pom.xml | 67 +++- msa/pom.xml | 3 +- msa/tb-node/pom.xml | 69 +++- msa/tb/README.md | 68 ++++ msa/tb/docker-cassandra/Dockerfile | 59 +++ .../tb/docker-cassandra/start-db.sh | 25 +- .../docker-cassandra/stop-db.sh} | 4 +- msa/tb/docker-postgres/Dockerfile | 62 +++ .../tb/docker-postgres/start-db.sh | 18 +- .../docker-postgres/stop-db.sh} | 3 +- msa/tb/docker-tb/Dockerfile | 48 +++ .../docker-tb/start-db.sh} | 2 +- .../Dockerfile => msa/tb/docker-tb/stop-db.sh | 9 +- msa/tb/docker/install-tb.sh | 56 +++ msa/tb/docker/logback.xml | 51 +++ msa/tb/docker/start-tb.sh | 39 ++ msa/tb/docker/thingsboard.conf | 24 ++ msa/tb/docker/upgrade-tb.sh | 47 +++ msa/tb/pom.xml | 370 ++++++++++++++++++ msa/transport/coap/pom.xml | 69 +++- msa/transport/http/pom.xml | 69 +++- msa/transport/mqtt/pom.xml | 69 +++- msa/web-ui/pom.xml | 67 +++- 71 files changed, 1656 insertions(+), 1579 deletions(-) rename {msa/docker => docker}/.gitignore (65%) create mode 100644 docker/README.md delete mode 100644 docker/cassandra-setup/Dockerfile delete mode 100644 docker/cassandra-setup/Makefile delete mode 100644 docker/cassandra-upgrade/Dockerfile delete mode 100644 docker/cassandra-upgrade/Makefile delete mode 100644 docker/cassandra/Makefile delete mode 100644 docker/cassandra/ready-probe.sh rename {msa/docker => docker}/check-dirs.sh (92%) create mode 100755 docker/compose-utils.sh delete mode 100644 docker/docker-compose-tests.yml rename docker/{docker-compose.static.yml => docker-compose.cassandra.yml} (66%) rename docker/{cluster-mode-thirdparty.yml => docker-compose.postgres.yml} (50%) rename {msa/docker => docker}/docker-install-tb.sh (66%) create mode 100755 docker/docker-remove-services.sh create mode 100755 docker/docker-start-services.sh create mode 100755 docker/docker-stop-services.sh create mode 100755 docker/docker-update-service.sh rename {msa/docker => docker}/docker-upgrade-tb.sh (68%) rename {msa/docker => docker}/haproxy/config/haproxy.cfg (100%) delete mode 100644 docker/k8s/cassandra-setup.yaml delete mode 100644 docker/k8s/cassandra-upgrade.yaml delete mode 100644 docker/k8s/cassandra.yaml delete mode 100644 docker/k8s/common.yaml delete mode 100644 docker/k8s/redis.yaml delete mode 100644 docker/k8s/tb.yaml delete mode 100644 docker/k8s/zookeeper.yaml rename {msa/docker => docker}/tb-coap-transport.env (100%) rename {msa/docker => docker}/tb-http-transport.env (100%) rename {msa/docker => docker}/tb-js-executor.env (100%) rename {msa/docker => docker}/tb-mqtt-transport.env (100%) create mode 100644 docker/tb-node.env rename {msa/docker => docker}/tb-node/conf/logback.xml (100%) rename {msa/docker => docker}/tb-node/conf/thingsboard.conf (100%) rename {msa/docker => docker}/tb-web-ui.env (100%) delete mode 100644 docker/tb.env delete mode 100644 docker/tb/Dockerfile delete mode 100644 docker/tb/Makefile delete mode 100755 docker/tb/run-application.sh delete mode 100644 docker/zookeeper/Dockerfile delete mode 100644 docker/zookeeper/Makefile delete mode 100755 docker/zookeeper/zk-gen-config.sh delete mode 100755 docker/zookeeper/zk-ok.sh delete mode 100644 msa/docker/.env delete mode 100644 msa/docker/docker-compose.yml delete mode 100644 msa/docker/tb-node.env create mode 100644 msa/tb/README.md create mode 100644 msa/tb/docker-cassandra/Dockerfile rename docker/cassandra-setup/install.sh => msa/tb/docker-cassandra/start-db.sh (62%) mode change 100755 => 100644 rename msa/{docker/docker-start-services.sh => tb/docker-cassandra/stop-db.sh} (94%) mode change 100755 => 100644 create mode 100644 msa/tb/docker-postgres/Dockerfile rename docker/cassandra-upgrade/upgrade.sh => msa/tb/docker-postgres/start-db.sh (59%) mode change 100755 => 100644 rename msa/{docker/docker-update-service.sh => tb/docker-postgres/stop-db.sh} (90%) mode change 100755 => 100644 create mode 100644 msa/tb/docker-tb/Dockerfile rename msa/{docker/docker-stop-services.sh => tb/docker-tb/start-db.sh} (96%) mode change 100755 => 100644 rename docker/cassandra/Dockerfile => msa/tb/docker-tb/stop-db.sh (84%) create mode 100644 msa/tb/docker/install-tb.sh create mode 100644 msa/tb/docker/logback.xml create mode 100755 msa/tb/docker/start-tb.sh create mode 100644 msa/tb/docker/thingsboard.conf create mode 100644 msa/tb/docker/upgrade-tb.sh create mode 100644 msa/tb/pom.xml diff --git a/docker/.env b/docker/.env index e3306324d4..17f8af8c4e 100644 --- a/docker/.env +++ b/docker/.env @@ -1,13 +1,18 @@ -# cassandra environment variables -CASSANDRA_DATA_DIR=/home/docker/cassandra_volume -# postgres environment variables -POSTGRES_DATA_DIR=/home/docker/postgres_volume -POSTGRES_DB=thingsboard +DOCKER_REPO=thingsboard -# hsqldb environment variables -HSQLDB_DATA_DIR=/home/docker/hsqldb_volume +JS_EXECUTOR_DOCKER_NAME=tb-js-executor +TB_NODE_DOCKER_NAME=tb-node +WEB_UI_DOCKER_NAME=tb-web-ui +MQTT_TRANSPORT_DOCKER_NAME=tb-mqtt-transport +HTTP_TRANSPORT_DOCKER_NAME=tb-http-transport +COAP_TRANSPORT_DOCKER_NAME=tb-coap-transport -# environment variables for schema init and insert system and demo data -ADD_SCHEMA_AND_SYSTEM_DATA=false -ADD_DEMO_DATA=false \ No newline at end of file +TB_VERSION=latest + +# Database used by ThingsBoard, can be either local (local HSQLDB), postgres (PostgreSQL), cassandra (Cassandra). +# In case of postgres or cassandra corresponding docker service will be deployed (see docker-compose.postgres.yml, docker-compose.cassandra.yml for details). + +DATABASE=cassandra + +KAFKA_TOPICS="js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1" diff --git a/msa/docker/.gitignore b/docker/.gitignore similarity index 65% rename from msa/docker/.gitignore rename to docker/.gitignore index c9843dc5a5..bc09e846b0 100644 --- a/msa/docker/.gitignore +++ b/docker/.gitignore @@ -2,4 +2,6 @@ haproxy/certs.d/** haproxy/letsencrypt/** tb-node/log/** tb-node/db/** +tb-node/postgres/** +tb-node/cassandra/** !.env diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..23ff9b22cc --- /dev/null +++ b/docker/README.md @@ -0,0 +1,51 @@ +# Docker configuration for ThingsBoard Microservices + +This folder containing scripts and Docker Compose configurations to run ThingsBoard in Microservices mode. + +## Installation + +Execute the following command to run DataBase installation: + +` +$ ./docker-install-tb.sh --loadDemo +` + +- `--loadDemo` - optional argument. Whether to load additional demo data. + +## Running + +Execute the following command to run services: + +` +$ ./docker-start-services.sh +` + +Execute the following command to stop services: + +` +$ ./docker-stop-services.sh +` + +Execute the following command to stop and completely remove deployed docker containers: + +` +$ ./docker-remove-services.sh +` + +Execute the following command to update particular services (pull newer docker image and rebuild container): + +` +$ ./docker-update-service.sh [SERVICE...] +` + +## Upgrading + +In case when database upgrade is needed, execute the following commands: + +` +$ ./docker-stop-services.sh +$ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION] +$ ./docker-start-services.sh +` + +- `FROM_VERSION` - from which version upgrade should be started. diff --git a/docker/cassandra-setup/Dockerfile b/docker/cassandra-setup/Dockerfile deleted file mode 100644 index c3f7ddb725..0000000000 --- a/docker/cassandra-setup/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -FROM openjdk:8-jre - -ADD install.sh /install.sh -ADD thingsboard.deb /thingsboard.deb - -RUN apt-get update \ - && apt-get install -y nmap \ - && chmod +x /install.sh diff --git a/docker/cassandra-setup/Makefile b/docker/cassandra-setup/Makefile deleted file mode 100644 index 7990a3e7e8..0000000000 --- a/docker/cassandra-setup/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=cassandra-setup - -build: - cp ../../application/target/thingsboard.deb . - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - rm thingsboard.deb - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/cassandra-upgrade/Dockerfile b/docker/cassandra-upgrade/Dockerfile deleted file mode 100644 index 312db0d4e3..0000000000 --- a/docker/cassandra-upgrade/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -FROM openjdk:8-jre - -ADD upgrade.sh /upgrade.sh -ADD thingsboard.deb /thingsboard.deb - -RUN apt-get update \ - && apt-get install -y nmap \ - && chmod +x /upgrade.sh diff --git a/docker/cassandra-upgrade/Makefile b/docker/cassandra-upgrade/Makefile deleted file mode 100644 index b2e8501a2b..0000000000 --- a/docker/cassandra-upgrade/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=cassandra-upgrade - -build: - cp ../../application/target/thingsboard.deb . - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - rm thingsboard.deb - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/cassandra/Makefile b/docker/cassandra/Makefile deleted file mode 100644 index d1fb67705f..0000000000 --- a/docker/cassandra/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=cassandra - -build: - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/cassandra/ready-probe.sh b/docker/cassandra/ready-probe.sh deleted file mode 100644 index 8533b6a6d1..0000000000 --- a/docker/cassandra/ready-probe.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -if [[ $(nodetool status | grep $POD_IP) == *"UN"* ]]; then - if [[ $DEBUG ]]; then - echo "UN"; - fi - exit 0; -else - if [[ $DEBUG ]]; then - echo "Not Up"; - fi - exit 1; -fi \ No newline at end of file diff --git a/msa/docker/check-dirs.sh b/docker/check-dirs.sh similarity index 92% rename from msa/docker/check-dirs.sh rename to docker/check-dirs.sh index 9c683e6a51..6ae3f880a4 100755 --- a/msa/docker/check-dirs.sh +++ b/docker/check-dirs.sh @@ -15,7 +15,7 @@ # limitations under the License. # -dirsArray=("./haproxy/certs.d" "./haproxy/letsencrypt" "./tb-node/db" "./tb-node/log") +dirsArray=("./haproxy/certs.d" "./haproxy/letsencrypt" "./tb-node/db" "./tb-node/postgres" "./tb-node/cassandra" "./tb-node/log") for dir in ${dirsArray[@]} do diff --git a/docker/compose-utils.sh b/docker/compose-utils.sh new file mode 100755 index 0000000000..02a8e393e1 --- /dev/null +++ b/docker/compose-utils.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +function additionalComposeArgs() { + source .env + ADDITIONAL_COMPOSE_ARGS="" + case $DATABASE in + local) + ;; + postgres) + ADDITIONAL_COMPOSE_ARGS="-f docker-compose.postgres.yml" + ;; + cassandra) + ADDITIONAL_COMPOSE_ARGS="-f docker-compose.cassandra.yml" + ;; + *) + echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either local, postgres or cassandra." >&2 + exit 1 + esac + echo $ADDITIONAL_COMPOSE_ARGS +} + +function additionalStartupServices() { + source .env + ADDITIONAL_STARTUP_SERVICES="" + case $DATABASE in + local) + ;; + postgres) + ADDITIONAL_STARTUP_SERVICES=postgres + ;; + cassandra) + ADDITIONAL_STARTUP_SERVICES=cassandra + ;; + *) + echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either local, postgres or cassandra." >&2 + exit 1 + esac + echo $ADDITIONAL_STARTUP_SERVICES +} diff --git a/docker/docker-compose-tests.yml b/docker/docker-compose-tests.yml deleted file mode 100644 index 64868b5a1f..0000000000 --- a/docker/docker-compose-tests.yml +++ /dev/null @@ -1,27 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -version: '3.3' -services: - redis: - image: redis:4.0 - networks: - - core - ports: - - "6379:6379" - -networks: - core: diff --git a/docker/docker-compose.static.yml b/docker/docker-compose.cassandra.yml similarity index 66% rename from docker/docker-compose.static.yml rename to docker/docker-compose.cassandra.yml index 4a0688a2da..2bfb748b16 100644 --- a/docker/docker-compose.static.yml +++ b/docker/docker-compose.cassandra.yml @@ -14,16 +14,21 @@ # limitations under the License. # -version: '2' +version: '2.2' services: cassandra: + restart: always + image: "cassandra:3.11.3" ports: - - "9042:9042" - - "9160:9160" - zk: - ports: - - "2181:2181" - postgres: - ports: - - "5432:5432" \ No newline at end of file + - "9042" + volumes: + - ./tb-node/cassandra:/var/lib/cassandra + tb: + environment: + DATABASE_TS_TYPE: cassandra + DATABASE_ENTITIES_TYPE: cassandra + CASSANDRA_URL: cassandra:9042 + depends_on: + - kafka + - cassandra diff --git a/docker/cluster-mode-thirdparty.yml b/docker/docker-compose.postgres.yml similarity index 50% rename from docker/cluster-mode-thirdparty.yml rename to docker/docker-compose.postgres.yml index 3aa5bb5959..e2f2737b07 100644 --- a/docker/cluster-mode-thirdparty.yml +++ b/docker/docker-compose.postgres.yml @@ -14,32 +14,27 @@ # limitations under the License. # -version: '3.3' -services: - zookeeper: - image: wurstmeister/zookeeper - networks: - - core - ports: - - "2181:2181" - - cassandra: - image: cassandra:3.11.2 - networks: - - core - ports: - - "7199:7199" - - "9160:9160" - - "9042:9042" +version: '2.2' - redis: - image: redis:4.0 - networks: - - core - command: redis-server --maxclients 2000 +services: + postgres: + restart: always + image: "postgres:9.6" ports: - - "6379:6379" - -networks: - core: - + - "5432" + environment: + POSTGRES_DB: thingsboard + volumes: + - ./tb-node/postgres:/var/lib/postgresql/data + tb: + environment: + DATABASE_TS_TYPE: sql + DATABASE_ENTITIES_TYPE: sql + SPRING_JPA_DATABASE_PLATFORM: org.hibernate.dialect.PostgreSQLDialect + SPRING_DRIVER_CLASS_NAME: org.postgresql.Driver + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/thingsboard + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + depends_on: + - kafka + - postgres diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 89a83696f3..a319606c03 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,40 +14,167 @@ # limitations under the License. # -version: '2' + +version: '2.2' services: + zookeeper: + restart: always + image: "wurstmeister/zookeeper" + ports: + - "2181" + kafka: + restart: always + image: "wurstmeister/kafka" + ports: + - "9092:9092" + environment: + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_LISTENERS: INSIDE://:9093,OUTSIDE://:9092 + KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://kafka:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE + KAFKA_CREATE_TOPICS: "${KAFKA_TOPICS}" + KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false' + KAFKA_LOG_RETENTION_BYTES: 1073741824 + KAFKA_LOG_SEGMENT_BYTES: 268435456 + KAFKA_LOG_RETENTION_MS: 300000 + KAFKA_LOG_CLEANUP_POLICY: delete + depends_on: + - zookeeper + tb-js-executor: + restart: always + image: "${DOCKER_REPO}/${JS_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" + scale: 20 + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-js-executor.env + depends_on: + - kafka tb: - image: "thingsboard/application:2.1.0" + restart: always + image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" ports: - - "8080:8080" - - "1883:1883" - - "5683:5683/udp" + - "8080" + logging: + driver: "json-file" + options: + max-size: "200m" + max-file: "30" env_file: - - tb.env + - tb-node.env environment: - - ADD_SCHEMA_AND_SYSTEM_DATA=${ADD_SCHEMA_AND_SYSTEM_DATA} - - ADD_DEMO_DATA=${ADD_DEMO_DATA} + ZOOKEEPER_URL: zk:2181 + TB_KAFKA_SERVERS: kafka:9092 + JS_EVALUATOR: remote + DATABASE_TS_TYPE: sql + DATABASE_ENTITIES_TYPE: sql + SQL_DATA_FOLDER: /usr/share/thingsboard/data/db volumes: - - "${HSQLDB_DATA_DIR}:/usr/share/thingsboard/data/sql" - entrypoint: /run-application.sh - cassandra: - image: "cassandra:3.11.2" + - ./tb-node/db:/usr/share/thingsboard/data/db + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - kafka + tb-mqtt-transport1: + restart: always + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - - "9042" - - "9160" - volumes: - - "${CASSANDRA_DATA_DIR}:/var/lib/cassandra" - zk: - image: "zookeeper:3.4.10" + - "1883" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-mqtt-transport.env + depends_on: + - kafka + tb-mqtt-transport2: + restart: always + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - - "2181" + - "1883" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-mqtt-transport.env + depends_on: + - kafka + tb-http-transport1: restart: always - postgres: - image: "postgres:9.6" + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - - "5432" + - "8081" environment: - - POSTGRES_DB=${POSTGRES_DB} + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-http-transport.env + depends_on: + - kafka + tb-http-transport2: + restart: always + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-http-transport.env + depends_on: + - kafka + tb-coap-transport: + restart: always + image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "5683:5683/udp" + environment: + TB_KAFKA_SERVERS: kafka:9092 + env_file: + - tb-coap-transport.env + depends_on: + - kafka + tb-web-ui1: + restart: always + image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + environment: + TB_HOST: tb + TB_PORT: 8080 + env_file: + - tb-web-ui.env + tb-web-ui2: + restart: always + image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + environment: + TB_HOST: tb + TB_PORT: 8080 + env_file: + - tb-web-ui.env + web: + restart: always + container_name: haproxy-certbot + image: nmarus/haproxy-certbot volumes: - - "${POSTGRES_DATA_DIR}:/var/lib/postgresql/data" + - ./haproxy/config:/config + - ./haproxy/letsencrypt:/etc/letsencrypt + - ./haproxy/certs.d:/usr/local/etc/haproxy/certs.d + ports: + - "80:80" + - "443:443" + - "1883:1883" + - "9999:9999" + cap_add: + - NET_ADMIN + environment: + HTTP_PORT: 80 + HTTPS_PORT: 443 + MQTT_PORT: 1883 + links: + - tb-web-ui1 + - tb-web-ui2 + - tb-mqtt-transport1 + - tb-mqtt-transport2 + - tb-http-transport1 + - tb-http-transport2 diff --git a/msa/docker/docker-install-tb.sh b/docker/docker-install-tb.sh similarity index 66% rename from msa/docker/docker-install-tb.sh rename to docker/docker-install-tb.sh index 5f34cc345f..4d4b9b9732 100755 --- a/msa/docker/docker-install-tb.sh +++ b/docker/docker-install-tb.sh @@ -39,6 +39,18 @@ else loadDemo=false fi -docker-compose run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? + +if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then + docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d $ADDITIONAL_STARTUP_SERVICES +fi + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb diff --git a/docker/docker-remove-services.sh b/docker/docker-remove-services.sh new file mode 100755 index 0000000000..f80a73f9cd --- /dev/null +++ b/docker/docker-remove-services.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS down -v diff --git a/docker/docker-start-services.sh b/docker/docker-start-services.sh new file mode 100755 index 0000000000..8437695a1a --- /dev/null +++ b/docker/docker-start-services.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +./check-dirs.sh + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d diff --git a/docker/docker-stop-services.sh b/docker/docker-stop-services.sh new file mode 100755 index 0000000000..63dc6cbebc --- /dev/null +++ b/docker/docker-stop-services.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS stop diff --git a/docker/docker-update-service.sh b/docker/docker-update-service.sh new file mode 100755 index 0000000000..e8702760ab --- /dev/null +++ b/docker/docker-update-service.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS pull $@ +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d --no-deps --build $@ diff --git a/msa/docker/docker-upgrade-tb.sh b/docker/docker-upgrade-tb.sh similarity index 68% rename from msa/docker/docker-upgrade-tb.sh rename to docker/docker-upgrade-tb.sh index 51202a6b0b..500b09a81e 100755 --- a/msa/docker/docker-upgrade-tb.sh +++ b/docker/docker-upgrade-tb.sh @@ -38,4 +38,16 @@ else fromVersion="${FROM_VERSION// }" fi -docker-compose run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? + +if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then + docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d $ADDITIONAL_STARTUP_SERVICES +fi + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb diff --git a/msa/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg similarity index 100% rename from msa/docker/haproxy/config/haproxy.cfg rename to docker/haproxy/config/haproxy.cfg diff --git a/docker/k8s/cassandra-setup.yaml b/docker/k8s/cassandra-setup.yaml deleted file mode 100644 index 03a2739446..0000000000 --- a/docker/k8s/cassandra-setup.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Pod -metadata: - name: cassandra-setup -spec: - containers: - - name: cassandra-setup - imagePullPolicy: Always - image: thingsboard/cassandra-setup:2.1.0 - env: - - name: ADD_DEMO_DATA - value: "true" - - name : CASSANDRA_HOST - value: "cassandra-headless" - - name : CASSANDRA_PORT - value: "9042" - - name : DATABASE_ENTITIES_TYPE - value: "cassandra" - - name : DATABASE_TS_TYPE - value: "cassandra" - - name : CASSANDRA_URL - value: "cassandra-headless:9042" - command: - - sh - - -c - - /install.sh - restartPolicy: Never diff --git a/docker/k8s/cassandra-upgrade.yaml b/docker/k8s/cassandra-upgrade.yaml deleted file mode 100644 index 9276776678..0000000000 --- a/docker/k8s/cassandra-upgrade.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Pod -metadata: - name: cassandra-upgrade -spec: - containers: - - name: cassandra-upgrade - imagePullPolicy: Always - image: thingsboard/cassandra-upgrade:2.1.0 - env: - - name: ADD_DEMO_DATA - value: "true" - - name : CASSANDRA_HOST - value: "cassandra-headless" - - name : CASSANDRA_PORT - value: "9042" - - name : DATABASE_ENTITIES_TYPE - value: "cassandra" - - name : DATABASE_TS_TYPE - value: "cassandra" - - name : CASSANDRA_URL - value: "cassandra-headless:9042" - - name : UPGRADE_FROM_VERSION - value: "1.4.0" - command: - - sh - - -c - - /upgrade.sh - restartPolicy: Never diff --git a/docker/k8s/cassandra.yaml b/docker/k8s/cassandra.yaml deleted file mode 100644 index 3165bb1a7a..0000000000 --- a/docker/k8s/cassandra.yaml +++ /dev/null @@ -1,132 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Service -metadata: - name: cassandra-headless - labels: - app: cassandra-headless -spec: - ports: - - port: 9042 - name: cql - clusterIP: None - selector: - app: cassandra ---- -apiVersion: "apps/v1beta1" -kind: StatefulSet -metadata: - name: cassandra -spec: - serviceName: cassandra-headless - replicas: 2 - template: - metadata: - labels: - app: cassandra - spec: - nodeSelector: - machinetype: other - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: "app" - operator: In - values: - - cassandra-headless - topologyKey: "kubernetes.io/hostname" - containers: - - name: cassandra - image: thingsboard/cassandra:2.1.0 - imagePullPolicy: Always - ports: - - containerPort: 7000 - name: intra-node - - containerPort: 7001 - name: tls-intra-node - - containerPort: 7199 - name: jmx - - containerPort: 9042 - name: cql - - containerPort: 9160 - name: thrift - securityContext: - capabilities: - add: - - IPC_LOCK - lifecycle: - preStop: - exec: - command: ["/bin/sh", "-c", "PID=$(pidof java) && kill $PID && while ps -p $PID > /dev/null; do sleep 1; done"] - env: - - name: MAX_HEAP_SIZE - value: 2048M - - name: HEAP_NEWSIZE - value: 100M - - name: CASSANDRA_SEEDS - value: "cassandra-0.cassandra-headless.default.svc.cluster.local" - - name: CASSANDRA_CLUSTER_NAME - value: "Thingsboard-Cluster" - - name: CASSANDRA_DC - value: "DC1-Thingsboard-Cluster" - - name: CASSANDRA_RACK - value: "Rack-Thingsboard-Cluster" - - name: CASSANDRA_AUTO_BOOTSTRAP - value: "false" - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: POD_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - readinessProbe: - exec: - command: - - /bin/bash - - -c - - /ready-probe.sh - initialDelaySeconds: 15 - timeoutSeconds: 5 - volumeMounts: - - name: cassandra-data - mountPath: /var/lib/cassandra/data - - name: cassandra-commitlog - mountPath: /var/lib/cassandra/commitlog - volumeClaimTemplates: - - metadata: - name: cassandra-data - annotations: - volume.beta.kubernetes.io/storage-class: fast - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 3Gi - - metadata: - name: cassandra-commitlog - annotations: - volume.beta.kubernetes.io/storage-class: fast - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 2Gi \ No newline at end of file diff --git a/docker/k8s/common.yaml b/docker/k8s/common.yaml deleted file mode 100644 index 75a938a0ff..0000000000 --- a/docker/k8s/common.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - ---- -apiVersion: storage.k8s.io/v1beta1 -kind: StorageClass -metadata: - name: slow -provisioner: kubernetes.io/gce-pd -parameters: - type: pd-standard ---- -apiVersion: storage.k8s.io/v1beta1 -kind: StorageClass -metadata: - name: fast -provisioner: kubernetes.io/gce-pd -parameters: - type: pd-ssd ---- \ No newline at end of file diff --git a/docker/k8s/redis.yaml b/docker/k8s/redis.yaml deleted file mode 100644 index 3af810e1e3..0000000000 --- a/docker/k8s/redis.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - ---- -apiVersion: v1 -kind: Service -metadata: - labels: - name: redis-service - name: redis-service -spec: - ports: - - name: redis-service - protocol: TCP - port: 6379 - targetPort: 6379 - selector: - app: redis ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: redis-conf -data: - redis.conf: | - appendonly yes - protected-mode no - bind 0.0.0.0 - port 6379 - dir /var/lib/redis ---- -apiVersion: apps/v1beta1 -kind: StatefulSet -metadata: - name: redis -spec: - serviceName: redis-service - replicas: 1 - template: - metadata: - labels: - app: redis - spec: - terminationGracePeriodSeconds: 10 - containers: - - name: redis - image: redis:4.0.9 - command: - - redis-server - args: - - /etc/redis/redis.conf - resources: - requests: - cpu: 100m - memory: 100Mi - ports: - - containerPort: 6379 - name: redis - volumeMounts: - - name: redis-data - mountPath: /var/lib/redis - - name: redis-conf - mountPath: /etc/redis - volumes: - - name: redis-conf - configMap: - name: redis-conf - items: - - key: redis.conf - path: redis.conf - volumeClaimTemplates: - - metadata: - name: redis-data - annotations: - volume.beta.kubernetes.io/storage-class: fast - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 1Gi \ No newline at end of file diff --git a/docker/k8s/tb.yaml b/docker/k8s/tb.yaml deleted file mode 100644 index 741bbe0f26..0000000000 --- a/docker/k8s/tb.yaml +++ /dev/null @@ -1,156 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - ---- -apiVersion: v1 -kind: Service -metadata: - name: tb-service - labels: - app: tb-service -spec: - ports: - - port: 8080 - name: ui - - port: 1883 - name: mqtt - - port: 5683 - name: coap - selector: - app: tb - type: LoadBalancer ---- -apiVersion: policy/v1beta1 -kind: PodDisruptionBudget -metadata: - name: tb-budget -spec: - selector: - matchLabels: - app: tb - minAvailable: 3 ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: tb-config -data: - zookeeper.enabled: "true" - zookeeper.url: "zk-headless" - cassandra.url: "cassandra-headless:9042" - cassandra.host: "cassandra-headless" - cassandra.port: "9042" - database.type: "cassandra" - cache.type: "redis" - redis.host: "redis-service" ---- -apiVersion: apps/v1beta1 -kind: StatefulSet -metadata: - name: tb -spec: - serviceName: "tb-service" - replicas: 3 - template: - metadata: - labels: - app: tb - spec: - nodeSelector: - machinetype: tb - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: "app" - operator: In - values: - - tb-service - topologyKey: "kubernetes.io/hostname" - containers: - - name: tb - imagePullPolicy: Always - image: thingsboard/application:2.1.0 - ports: - - containerPort: 8080 - name: ui - - containerPort: 1883 - name: mqtt - - containerPort: 5683 - name: coap - - containerPort: 9001 - name: rpc - env: - - name: ZOOKEEPER_ENABLED - valueFrom: - configMapKeyRef: - name: tb-config - key: zookeeper.enabled - - name: ZOOKEEPER_URL - valueFrom: - configMapKeyRef: - name: tb-config - key: zookeeper.url - - name : CASSANDRA_HOST - valueFrom: - configMapKeyRef: - name: tb-config - key: cassandra.host - - name : CASSANDRA_PORT - valueFrom: - configMapKeyRef: - name: tb-config - key: cassandra.port - - name : CASSANDRA_URL - valueFrom: - configMapKeyRef: - name: tb-config - key: cassandra.url - - name: DATABASE_ENTITIES_TYPE - valueFrom: - configMapKeyRef: - name: tb-config - key: database.type - - name: DATABASE_TS_TYPE - valueFrom: - configMapKeyRef: - name: tb-config - key: database.type - - name : RPC_HOST - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CACHE_TYPE - valueFrom: - configMapKeyRef: - name: tb-config - key: cache.type - - name: REDIS_HOST - valueFrom: - configMapKeyRef: - name: tb-config - key: redis.host - command: - - sh - - -c - - /run-application.sh - livenessProbe: - httpGet: - path: /login - port: ui-port - initialDelaySeconds: 120 - timeoutSeconds: 10 \ No newline at end of file diff --git a/docker/k8s/zookeeper.yaml b/docker/k8s/zookeeper.yaml deleted file mode 100644 index ae33ea2efa..0000000000 --- a/docker/k8s/zookeeper.yaml +++ /dev/null @@ -1,190 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Service -metadata: - name: zk-headless - labels: - app: zk-headless -spec: - ports: - - port: 2888 - name: server - - port: 3888 - name: leader-election - clusterIP: None - selector: - app: zk ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: zk-config -data: - ensemble: "zk-0;zk-1;zk-2" - replicas: "3" - jvm.heap: "500m" - tick: "2000" - init: "10" - sync: "5" - client.cnxns: "60" - snap.retain: "3" - purge.interval: "1" - client.port: "2181" - server.port: "2888" - election.port: "3888" ---- -apiVersion: policy/v1beta1 -kind: PodDisruptionBudget -metadata: - name: zk-budget -spec: - selector: - matchLabels: - app: zk - minAvailable: 3 ---- -apiVersion: apps/v1beta1 -kind: StatefulSet -metadata: - name: zk -spec: - serviceName: zk-headless - replicas: 3 - template: - metadata: - labels: - app: zk - annotations: - pod.alpha.kubernetes.io/initialized: "true" - spec: - nodeSelector: - machinetype: other - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: "app" - operator: In - values: - - zk-headless - topologyKey: "kubernetes.io/hostname" - containers: - - name: zk - imagePullPolicy: Always - image: thingsboard/zk:2.1.0 - ports: - - containerPort: 2181 - name: client - - containerPort: 2888 - name: server - - containerPort: 3888 - name: leader-election - env: - - name : ZK_ENSEMBLE - valueFrom: - configMapKeyRef: - name: zk-config - key: ensemble - - name : ZK_REPLICAS - valueFrom: - configMapKeyRef: - name: zk-config - key: replicas - - name : ZK_HEAP_SIZE - valueFrom: - configMapKeyRef: - name: zk-config - key: jvm.heap - - name : ZK_TICK_TIME - valueFrom: - configMapKeyRef: - name: zk-config - key: tick - - name : ZK_INIT_LIMIT - valueFrom: - configMapKeyRef: - name: zk-config - key: init - - name : ZK_SYNC_LIMIT - valueFrom: - configMapKeyRef: - name: zk-config - key: tick - - name : ZK_MAX_CLIENT_CNXNS - valueFrom: - configMapKeyRef: - name: zk-config - key: client.cnxns - - name: ZK_SNAP_RETAIN_COUNT - valueFrom: - configMapKeyRef: - name: zk-config - key: snap.retain - - name: ZK_PURGE_INTERVAL - valueFrom: - configMapKeyRef: - name: zk-config - key: purge.interval - - name: ZK_CLIENT_PORT - valueFrom: - configMapKeyRef: - name: zk-config - key: client.port - - name: ZK_SERVER_PORT - valueFrom: - configMapKeyRef: - name: zk-config - key: server.port - - name: ZK_ELECTION_PORT - valueFrom: - configMapKeyRef: - name: zk-config - key: election.port - command: - - sh - - -c - - zk-gen-config.sh && zkServer.sh start-foreground - readinessProbe: - exec: - command: - - "zk-ok.sh" - initialDelaySeconds: 15 - timeoutSeconds: 5 - livenessProbe: - exec: - command: - - "zk-ok.sh" - initialDelaySeconds: 15 - timeoutSeconds: 5 - volumeMounts: - - name: zkdatadir - mountPath: /var/lib/zookeeper - securityContext: - runAsUser: 1000 - fsGroup: 1000 - volumeClaimTemplates: - - metadata: - name: zkdatadir - annotations: - volume.beta.kubernetes.io/storage-class: slow - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 1Gi \ No newline at end of file diff --git a/msa/docker/tb-coap-transport.env b/docker/tb-coap-transport.env similarity index 100% rename from msa/docker/tb-coap-transport.env rename to docker/tb-coap-transport.env diff --git a/msa/docker/tb-http-transport.env b/docker/tb-http-transport.env similarity index 100% rename from msa/docker/tb-http-transport.env rename to docker/tb-http-transport.env diff --git a/msa/docker/tb-js-executor.env b/docker/tb-js-executor.env similarity index 100% rename from msa/docker/tb-js-executor.env rename to docker/tb-js-executor.env diff --git a/msa/docker/tb-mqtt-transport.env b/docker/tb-mqtt-transport.env similarity index 100% rename from msa/docker/tb-mqtt-transport.env rename to docker/tb-mqtt-transport.env diff --git a/docker/tb-node.env b/docker/tb-node.env new file mode 100644 index 0000000000..311428adc6 --- /dev/null +++ b/docker/tb-node.env @@ -0,0 +1,3 @@ +# ThingsBoard server configuration + +TRANSPORT_TYPE=remote diff --git a/msa/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml similarity index 100% rename from msa/docker/tb-node/conf/logback.xml rename to docker/tb-node/conf/logback.xml diff --git a/msa/docker/tb-node/conf/thingsboard.conf b/docker/tb-node/conf/thingsboard.conf similarity index 100% rename from msa/docker/tb-node/conf/thingsboard.conf rename to docker/tb-node/conf/thingsboard.conf diff --git a/msa/docker/tb-web-ui.env b/docker/tb-web-ui.env similarity index 100% rename from msa/docker/tb-web-ui.env rename to docker/tb-web-ui.env diff --git a/docker/tb.env b/docker/tb.env deleted file mode 100644 index bc92de5c8b..0000000000 --- a/docker/tb.env +++ /dev/null @@ -1,26 +0,0 @@ -#Thingsboard server configuration -MQTT_BIND_ADDRESS=0.0.0.0 -MQTT_BIND_PORT=1883 -COAP_BIND_ADDRESS=0.0.0.0 -COAP_BIND_PORT=5683 - -# zk config -ZOOKEEPER_URL=zk:2181 - -# type of database to use: sql[DEFAULT] or cassandra -DATABASE_TS_TYPE=sql -DATABASE_ENTITIES_TYPE=sql - -# cassandra db config -CASSANDRA_URL=cassandra:9042 -CASSANDRA_HOST=cassandra -CASSANDRA_PORT=9042 - -# postgres db config -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -# SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.PostgreSQLDialect -# SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver -# SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard -# SPRING_DATASOURCE_USERNAME=postgres -# SPRING_DATASOURCE_PASSWORD=postgres diff --git a/docker/tb/Dockerfile b/docker/tb/Dockerfile deleted file mode 100644 index 24281f9f3e..0000000000 --- a/docker/tb/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -FROM openjdk:8-jre - -ADD run-application.sh /run-application.sh -ADD thingsboard.deb /thingsboard.deb - -RUN apt-get update \ - && apt-get install --no-install-recommends -y nmap \ - && apt-get clean \ - && rm -r /var/lib/apt/lists/* \ - && chmod +x /run-application.sh diff --git a/docker/tb/Makefile b/docker/tb/Makefile deleted file mode 100644 index 96803abf58..0000000000 --- a/docker/tb/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=application - -build: - cp ../../application/target/thingsboard.deb . - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - rm thingsboard.deb - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/tb/run-application.sh b/docker/tb/run-application.sh deleted file mode 100755 index e6b59f9aee..0000000000 --- a/docker/tb/run-application.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - - -dpkg -i /thingsboard.deb - -# Copying env variables into conf files -printenv | awk -F "=" '{print "export " $1 "='\''" $2 "'\''"}' >> /usr/share/thingsboard/conf/thingsboard.conf - -cat /usr/share/thingsboard/conf/thingsboard.conf - -if [ "$DATABASE_ENTITIES_TYPE" == "cassandra" ]; then - until nmap $CASSANDRA_HOST -p $CASSANDRA_PORT | grep "$CASSANDRA_PORT/tcp open\|filtered" - do - echo "Wait for cassandra db to start..." - sleep 10 - done -fi - -if [ "$DATABASE_ENTITIES_TYPE" == "sql" ]; then - if [ "$SPRING_DRIVER_CLASS_NAME" == "org.postgresql.Driver" ]; then - until nmap $POSTGRES_HOST -p $POSTGRES_PORT | grep "$POSTGRES_PORT/tcp open" - do - echo "Waiting for postgres db to start..." - sleep 10 - done - fi -fi - -if [ "$ADD_SCHEMA_AND_SYSTEM_DATA" == "true" ]; then - echo "Creating 'Thingsboard' schema and system data..." - if [ "$ADD_DEMO_DATA" == "true" ]; then - echo "plus demo data..." - /usr/share/thingsboard/bin/install/install.sh --loadDemo - elif [ "$ADD_DEMO_DATA" == "false" ]; then - /usr/share/thingsboard/bin/install/install.sh - fi -fi - -echo "Starting 'Thingsboard' service..." -service thingsboard start - -# Wait until log file is created -sleep 10 -tail -f /var/log/thingsboard/thingsboard.log diff --git a/docker/zookeeper/Dockerfile b/docker/zookeeper/Dockerfile deleted file mode 100644 index 38f13fca14..0000000000 --- a/docker/zookeeper/Dockerfile +++ /dev/null @@ -1,71 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -FROM ubuntu:16.04 -ENV ZK_USER=zookeeper \ -ZK_DATA_DIR=/var/lib/zookeeper/data \ -ZK_DATA_LOG_DIR=/var/lib/zookeeper/log \ -ZK_LOG_DIR=/var/log/zookeeper \ -JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 - -ARG GPG_KEY=C823E3E5B12AF29C67F81976F5CECB3CB5E9BD2D -ARG ZK_DIST=zookeeper-3.4.10 -RUN set -x \ - && apt-get update \ - && apt-get install -y openjdk-8-jre-headless wget netcat-openbsd \ - && wget -q "http://www.apache.org/dist/zookeeper/$ZK_DIST/$ZK_DIST.tar.gz" \ - && wget -q "http://www.apache.org/dist/zookeeper/$ZK_DIST/$ZK_DIST.tar.gz.asc" \ - && export GNUPGHOME="$(mktemp -d)" \ - && gpg --keyserver ha.pool.sks-keyservers.net --recv-key "$GPG_KEY" \ - && gpg --batch --verify "$ZK_DIST.tar.gz.asc" "$ZK_DIST.tar.gz" \ - && tar -xzf "$ZK_DIST.tar.gz" -C /opt \ - && rm -r "$GNUPGHOME" "$ZK_DIST.tar.gz" "$ZK_DIST.tar.gz.asc" \ - && ln -s /opt/$ZK_DIST /opt/zookeeper \ - && rm -rf /opt/zookeeper/CHANGES.txt \ - /opt/zookeeper/README.txt \ - /opt/zookeeper/NOTICE.txt \ - /opt/zookeeper/CHANGES.txt \ - /opt/zookeeper/README_packaging.txt \ - /opt/zookeeper/build.xml \ - /opt/zookeeper/config \ - /opt/zookeeper/contrib \ - /opt/zookeeper/dist-maven \ - /opt/zookeeper/docs \ - /opt/zookeeper/ivy.xml \ - /opt/zookeeper/ivysettings.xml \ - /opt/zookeeper/recipes \ - /opt/zookeeper/src \ - /opt/zookeeper/$ZK_DIST.jar.asc \ - /opt/zookeeper/$ZK_DIST.jar.md5 \ - /opt/zookeeper/$ZK_DIST.jar.sha1 \ - && apt-get autoremove -y wget \ - && rm -rf /var/lib/apt/lists/* - -#Copy configuration generator script to bin -COPY zk-gen-config.sh zk-ok.sh /opt/zookeeper/bin/ - -# Create a user for the zookeeper process and configure file system ownership -# for nessecary directories and symlink the distribution as a user executable -RUN set -x \ - && useradd $ZK_USER \ - && [ `id -u $ZK_USER` -eq 1000 ] \ - && [ `id -g $ZK_USER` -eq 1000 ] \ - && mkdir -p $ZK_DATA_DIR $ZK_DATA_LOG_DIR $ZK_LOG_DIR /usr/share/zookeeper /tmp/zookeeper /usr/etc/ \ - && chown -R "$ZK_USER:$ZK_USER" /opt/$ZK_DIST $ZK_DATA_DIR $ZK_LOG_DIR $ZK_DATA_LOG_DIR /tmp/zookeeper \ - && ln -s /opt/zookeeper/conf/ /usr/etc/zookeeper \ - && ln -s /opt/zookeeper/bin/* /usr/bin \ - && ln -s /opt/zookeeper/$ZK_DIST.jar /usr/share/zookeeper/ \ - && ln -s /opt/zookeeper/lib/* /usr/share/zookeeper diff --git a/docker/zookeeper/Makefile b/docker/zookeeper/Makefile deleted file mode 100644 index 5c58a74cf2..0000000000 --- a/docker/zookeeper/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=zk - -build: - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/zookeeper/zk-gen-config.sh b/docker/zookeeper/zk-gen-config.sh deleted file mode 100755 index 5516f570c1..0000000000 --- a/docker/zookeeper/zk-gen-config.sh +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -ZK_USER=${ZK_USER:-"zookeeper"} -ZK_LOG_LEVEL=${ZK_LOG_LEVEL:-"INFO"} -ZK_DATA_DIR=${ZK_DATA_DIR:-"/var/lib/zookeeper/data"} -ZK_DATA_LOG_DIR=${ZK_DATA_LOG_DIR:-"/var/lib/zookeeper/log"} -ZK_LOG_DIR=${ZK_LOG_DIR:-"var/log/zookeeper"} -ZK_CONF_DIR=${ZK_CONF_DIR:-"/opt/zookeeper/conf"} -ZK_CLIENT_PORT=${ZK_CLIENT_PORT:-2181} -ZK_SERVER_PORT=${ZK_SERVER_PORT:-2888} -ZK_ELECTION_PORT=${ZK_ELECTION_PORT:-3888} -ZK_TICK_TIME=${ZK_TICK_TIME:-2000} -ZK_INIT_LIMIT=${ZK_INIT_LIMIT:-10} -ZK_SYNC_LIMIT=${ZK_SYNC_LIMIT:-5} -ZK_HEAP_SIZE=${ZK_HEAP_SIZE:-2G} -ZK_MAX_CLIENT_CNXNS=${ZK_MAX_CLIENT_CNXNS:-60} -ZK_MIN_SESSION_TIMEOUT=${ZK_MIN_SESSION_TIMEOUT:- $((ZK_TICK_TIME*2))} -ZK_MAX_SESSION_TIMEOUT=${ZK_MAX_SESSION_TIMEOUT:- $((ZK_TICK_TIME*20))} -ZK_SNAP_RETAIN_COUNT=${ZK_SNAP_RETAIN_COUNT:-3} -ZK_PURGE_INTERVAL=${ZK_PURGE_INTERVAL:-0} -ID_FILE="$ZK_DATA_DIR/myid" -ZK_CONFIG_FILE="$ZK_CONF_DIR/zoo.cfg" -LOGGER_PROPS_FILE="$ZK_CONF_DIR/log4j.properties" -JAVA_ENV_FILE="$ZK_CONF_DIR/java.env" -HOST=`hostname -s` -DOMAIN=`hostname -d` - -function print_servers() { - for (( i=1; i<=$ZK_REPLICAS; i++ )) - do - echo "server.$i=$NAME-$((i-1)).$DOMAIN:$ZK_SERVER_PORT:$ZK_ELECTION_PORT" - done -} - -function validate_env() { - echo "Validating environment" - if [ -z $ZK_REPLICAS ]; then - echo "ZK_REPLICAS is a mandatory environment variable" - exit 1 - fi - - if [[ $HOST =~ (.*)-([0-9]+)$ ]]; then - NAME=${BASH_REMATCH[1]} - ORD=${BASH_REMATCH[2]} - else - echo "Failed to extract ordinal from hostname $HOST" - exit 1 - fi - MY_ID=$((ORD+1)) - echo "ZK_REPLICAS=$ZK_REPLICAS" - echo "MY_ID=$MY_ID" - echo "ZK_LOG_LEVEL=$ZK_LOG_LEVEL" - echo "ZK_DATA_DIR=$ZK_DATA_DIR" - echo "ZK_DATA_LOG_DIR=$ZK_DATA_LOG_DIR" - echo "ZK_LOG_DIR=$ZK_LOG_DIR" - echo "ZK_CLIENT_PORT=$ZK_CLIENT_PORT" - echo "ZK_SERVER_PORT=$ZK_SERVER_PORT" - echo "ZK_ELECTION_PORT=$ZK_ELECTION_PORT" - echo "ZK_TICK_TIME=$ZK_TICK_TIME" - echo "ZK_INIT_LIMIT=$ZK_INIT_LIMIT" - echo "ZK_SYNC_LIMIT=$ZK_SYNC_LIMIT" - echo "ZK_MAX_CLIENT_CNXNS=$ZK_MAX_CLIENT_CNXNS" - echo "ZK_MIN_SESSION_TIMEOUT=$ZK_MIN_SESSION_TIMEOUT" - echo "ZK_MAX_SESSION_TIMEOUT=$ZK_MAX_SESSION_TIMEOUT" - echo "ZK_HEAP_SIZE=$ZK_HEAP_SIZE" - echo "ZK_SNAP_RETAIN_COUNT=$ZK_SNAP_RETAIN_COUNT" - echo "ZK_PURGE_INTERVAL=$ZK_PURGE_INTERVAL" - echo "ENSEMBLE" - print_servers - echo "Environment validation successful" -} - -function create_config() { - rm -f $ZK_CONFIG_FILE - echo "Creating ZooKeeper configuration" - echo "#This file was autogenerated by zk DO NOT EDIT" >> $ZK_CONFIG_FILE - echo "clientPort=$ZK_CLIENT_PORT" >> $ZK_CONFIG_FILE - echo "dataDir=$ZK_DATA_DIR" >> $ZK_CONFIG_FILE - echo "dataLogDir=$ZK_DATA_LOG_DIR" >> $ZK_CONFIG_FILE - echo "tickTime=$ZK_TICK_TIME" >> $ZK_CONFIG_FILE - echo "initLimit=$ZK_INIT_LIMIT" >> $ZK_CONFIG_FILE - echo "syncLimit=$ZK_SYNC_LIMIT" >> $ZK_CONFIG_FILE - echo "maxClientCnxns=$ZK_MAX_CLIENT_CNXNS" >> $ZK_CONFIG_FILE - echo "minSessionTimeout=$ZK_MIN_SESSION_TIMEOUT" >> $ZK_CONFIG_FILE - echo "maxSessionTimeout=$ZK_MAX_SESSION_TIMEOUT" >> $ZK_CONFIG_FILE - echo "autopurge.snapRetainCount=$ZK_SNAP_RETAIN_COUNT" >> $ZK_CONFIG_FILE - echo "autopurge.purgeInteval=$ZK_PURGE_INTERVAL" >> $ZK_CONFIG_FILE - - if [ $ZK_REPLICAS -gt 1 ]; then - print_servers >> $ZK_CONFIG_FILE - fi - echo "Wrote ZooKeeper configuration file to $ZK_CONFIG_FILE" -} - -function create_data_dirs() { - echo "Creating ZooKeeper data directories and setting permissions" - if [ ! -d $ZK_DATA_DIR ]; then - mkdir -p $ZK_DATA_DIR - chown -R $ZK_USER:$ZK_USER $ZK_DATA_DIR - fi - - if [ ! -d $ZK_DATA_LOG_DIR ]; then - mkdir -p $ZK_DATA_LOG_DIR - chown -R $ZK_USER:$ZK_USER $ZK_DATA_LOG_DIR - fi - - if [ ! -d $ZK_LOG_DIR ]; then - mkdir -p $ZK_LOG_DIR - chown -R $ZK_USER:$ZK_USER $ZK_LOG_DIR - fi - if [ ! -f $ID_FILE ]; then - echo $MY_ID >> $ID_FILE - fi - echo "Created ZooKeeper data directories and set permissions in $ZK_DATA_DIR" -} - -function create_log_props () { - rm -f $LOGGER_PROPS_FILE - echo "Creating ZooKeeper log4j configuration" - echo "zookeeper.root.logger=CONSOLE" >> $LOGGER_PROPS_FILE - echo "zookeeper.console.threshold="$ZK_LOG_LEVEL >> $LOGGER_PROPS_FILE - echo "log4j.rootLogger=\${zookeeper.root.logger}" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE.Threshold=\${zookeeper.console.threshold}" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE.layout.ConversionPattern=%d{ISO8601} [myid:%X{myid}] - %-5p [%t:%C{1}@%L] - %m%n" >> $LOGGER_PROPS_FILE - echo "Wrote log4j configuration to $LOGGER_PROPS_FILE" -} - -function create_java_env() { - rm -f $JAVA_ENV_FILE - echo "Creating JVM configuration file" - echo "ZOO_LOG_DIR=$ZK_LOG_DIR" >> $JAVA_ENV_FILE - echo "JVMFLAGS=\"-Xmx$ZK_HEAP_SIZE -Xms$ZK_HEAP_SIZE\"" >> $JAVA_ENV_FILE - echo "Wrote JVM configuration to $JAVA_ENV_FILE" -} - -validate_env && create_config && create_log_props && create_data_dirs && create_java_env diff --git a/docker/zookeeper/zk-ok.sh b/docker/zookeeper/zk-ok.sh deleted file mode 100755 index 9f45db259d..0000000000 --- a/docker/zookeeper/zk-ok.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# zk-ok.sh uses the ruok ZooKeeper four letter work to determine if the instance -# is health. The $? variable will be set to 0 if server responds that it is -# healthy, or 1 if the server fails to respond. - -ZK_CLIENT_PORT=${ZK_CLIENT_PORT:-2181} -OK=$(echo ruok | nc 127.0.0.1 $ZK_CLIENT_PORT) -if [ "$OK" == "imok" ]; then - exit 0 -else - exit 1 -fi diff --git a/msa/docker/.env b/msa/docker/.env deleted file mode 100644 index afcd91ced5..0000000000 --- a/msa/docker/.env +++ /dev/null @@ -1,13 +0,0 @@ - -DOCKER_REPO=local-maven-build - -JS_EXECUTOR_DOCKER_NAME=tb-js-executor -TB_NODE_DOCKER_NAME=tb-node -WEB_UI_DOCKER_NAME=tb-web-ui -MQTT_TRANSPORT_DOCKER_NAME=tb-mqtt-transport -HTTP_TRANSPORT_DOCKER_NAME=tb-http-transport -COAP_TRANSPORT_DOCKER_NAME=tb-coap-transport - -TB_VERSION=2.2.0-SNAPSHOT - -KAFKA_TOPICS=js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1 diff --git a/msa/docker/docker-compose.yml b/msa/docker/docker-compose.yml deleted file mode 100644 index 5410533822..0000000000 --- a/msa/docker/docker-compose.yml +++ /dev/null @@ -1,177 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - - -version: '2.2' - -services: - zookeeper: - restart: always - image: "wurstmeister/zookeeper" - ports: - - "2181" - kafka: - restart: always - image: "wurstmeister/kafka" - ports: - - "9092:9092" - environment: - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_LISTENERS: INSIDE://:9093,OUTSIDE://:9092 - KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://kafka:9092 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT - KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE - KAFKA_CREATE_TOPICS: "${KAFKA_TOPICS}" - KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false' - KAFKA_LOG_RETENTION_BYTES: 1073741824 - KAFKA_LOG_SEGMENT_BYTES: 268435456 - KAFKA_LOG_RETENTION_MS: 300000 - KAFKA_LOG_CLEANUP_POLICY: delete - depends_on: - - zookeeper - tb-js-executor: - restart: always - image: "${DOCKER_REPO}/${JS_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" - scale: 20 - environment: - TB_KAFKA_SERVERS: kafka:9092 - env_file: - - tb-js-executor.env - depends_on: - - kafka - tb: - restart: always - image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" - ports: - - "8080" - logging: - driver: "json-file" - options: - max-size: "200m" - max-file: "30" - env_file: - - tb-node.env - environment: - ZOOKEEPER_URL: zk:2181 - TB_KAFKA_SERVERS: kafka:9092 - JS_EVALUATOR: remote - volumes: - - ./tb-node/db:/usr/share/thingsboard/data/db - - ./tb-node/conf:/config - - ./tb-node/log:/var/log/thingsboard - depends_on: - - kafka - tb-mqtt-transport1: - restart: always - image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" - ports: - - "1883" - environment: - TB_KAFKA_SERVERS: kafka:9092 - env_file: - - tb-mqtt-transport.env - depends_on: - - kafka - tb-mqtt-transport2: - restart: always - image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" - ports: - - "1883" - environment: - TB_KAFKA_SERVERS: kafka:9092 - env_file: - - tb-mqtt-transport.env - depends_on: - - kafka - tb-http-transport1: - restart: always - image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" - ports: - - "8081" - environment: - TB_KAFKA_SERVERS: kafka:9092 - env_file: - - tb-http-transport.env - depends_on: - - kafka - tb-http-transport2: - restart: always - image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" - ports: - - "8081" - environment: - TB_KAFKA_SERVERS: kafka:9092 - env_file: - - tb-http-transport.env - depends_on: - - kafka - tb-coap-transport: - restart: always - image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" - ports: - - "5683:5683/udp" - environment: - TB_KAFKA_SERVERS: kafka:9092 - env_file: - - tb-coap-transport.env - depends_on: - - kafka - tb-web-ui1: - restart: always - image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" - ports: - - "8080" - environment: - TB_HOST: tb - TB_PORT: 8080 - env_file: - - tb-web-ui.env - tb-web-ui2: - restart: always - image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" - ports: - - "8080" - environment: - TB_HOST: tb - TB_PORT: 8080 - env_file: - - tb-web-ui.env - web: - restart: always - container_name: haproxy-certbot - image: nmarus/haproxy-certbot - volumes: - - ./haproxy/config:/config - - ./haproxy/letsencrypt:/etc/letsencrypt - - ./haproxy/certs.d:/usr/local/etc/haproxy/certs.d - ports: - - "80:80" - - "443:443" - - "1883:1883" - - "9999:9999" - cap_add: - - NET_ADMIN - environment: - HTTP_PORT: 80 - HTTPS_PORT: 443 - MQTT_PORT: 1883 - links: - - tb-web-ui1 - - tb-web-ui2 - - tb-mqtt-transport1 - - tb-mqtt-transport2 - - tb-http-transport1 - - tb-http-transport2 diff --git a/msa/docker/tb-node.env b/msa/docker/tb-node.env deleted file mode 100644 index abeccb12eb..0000000000 --- a/msa/docker/tb-node.env +++ /dev/null @@ -1,22 +0,0 @@ -# ThingsBoard server configuration - -TRANSPORT_TYPE=remote - -# type of database to use: sql[DEFAULT] or cassandra -DATABASE_TS_TYPE=sql -DATABASE_ENTITIES_TYPE=sql -SQL_DATA_FOLDER=/usr/share/thingsboard/data/db - -# cassandra db config -CASSANDRA_URL=cassandra:9042 -CASSANDRA_HOST=cassandra -CASSANDRA_PORT=9042 - -# postgres db config -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -# SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.PostgreSQLDialect -# SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver -# SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard -# SPRING_DATASOURCE_USERNAME=postgres -# SPRING_DATASOURCE_PASSWORD=postgres diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 6f11df1452..e673a5389c 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -287,16 +287,27 @@ build + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + - - ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} - true - false - ${project.build.directory} - @@ -334,6 +345,46 @@ + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + diff --git a/msa/pom.xml b/msa/pom.xml index 59f954a1ed..5fd2212e1e 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -32,11 +32,12 @@ ${basedir}/.. - local-maven-build + thingsboard true + tb js-executor web-ui tb-node diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 18a81f3970..fc62189e62 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -111,19 +111,72 @@ build + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + - - ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} - true - false - ${project.build.directory} - + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + jenkins diff --git a/msa/tb/README.md b/msa/tb/README.md new file mode 100644 index 0000000000..228aaa112a --- /dev/null +++ b/msa/tb/README.md @@ -0,0 +1,68 @@ +# ThingsBoard single docker images + +This project provides the build for the ThingsBoard single docker images. + +* `thingsboard/tb` - single instance of ThingsBoard with embedded HSQLDB database. +* `thingsboard/tb-postgres` - single instance of ThingsBoard with PostgreSQL database. +* `thingsboard/tb-cassandra` - single instance of ThingsBoard with Cassandra database. + +## Running + +Execute the following command to run this docker directly: + +` +$ docker run -it -p 9090:9090 -p 1883:1883 -p 5683:5683/udp -v ~/.mytb-data:/data --name mytb thingsboard/tb +` + +Where: + +- `docker run` - run this container +- `-it` - attach a terminal session with current ThingsBoard process output +- `-p 9090:9090` - connect local port 9090 to exposed internal HTTP port 9090 +- `-p 1883:1883` - connect local port 1883 to exposed internal MQTT port 1883 +- `-p 5683:5683` - connect local port 5683 to exposed internal COAP port 5683 +- `-v ~/.mytb-data:/data` - mounts the host's dir `~/.mytb-data` to ThingsBoard DataBase data directory +- `--name mytb` - friendly local name of this machine +- `thingsboard/tb` - docker image + +After executing this command you can open `http://{yor-host-ip}:9090` in you browser. You should see ThingsBoard login page. +Use the following default credentials: + +- **Systen Administrator**: sysadmin@thingsboard.org / sysadmin +- **Tenant Administrator**: tenant@thingsboard.org / tenant +- **Customer User**: customer@thingsboard.org / customer + +You can always change passwords for each account in account profile page. + +You can detach from session terminal with `Ctrl-p` `Ctrl-q` - the container will keep running in the background. + +To reattach to the terminal (to see ThingsBoard logs) run: + +` +$ docker attach mytb +` + +To stop the container: + +` +$ docker stop mytb +` + +To start the container: + +` +$ docker start mytb +` + +## Upgrading + +In order to update to the latest image, execute the following commands: + +` +$ docker pull thingsboard/tb +$ docker stop mytb +$ docker run -it -v ~/.mytb-data:/data --rm thingsboard/tb upgrade-tb.sh +$ docker start mytb +` + +**NOTE**: replace host's directory `~/.mytb-data` with directory used during container creation. diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile new file mode 100644 index 0000000000..175ad9abe1 --- /dev/null +++ b/msa/tb/docker-cassandra/Dockerfile @@ -0,0 +1,59 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jre + +RUN apt-get update +RUN apt-get install -y curl nmap procps +RUN echo 'deb http://www.apache.org/dist/cassandra/debian 311x main' | tee --append /etc/apt/sources.list.d/cassandra.list > /dev/null +RUN curl https://www.apache.org/dist/cassandra/KEYS | apt-key add - +RUN apt-get update +RUN apt-get install -y cassandra cassandra-tools +RUN update-rc.d cassandra disable +RUN sed -i.old '/ulimit/d' /etc/init.d/cassandra + +COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb.sh /usr/bin \ + && mv /tmp/upgrade-tb.sh /usr/bin \ + && mv /tmp/install-tb.sh /usr/bin \ + && mv /tmp/start-db.sh /usr/bin \ + && mv /tmp/stop-db.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +ENV DATA_FOLDER=/data + +ENV HTTP_BIND_PORT=9090 +ENV DATABASE_TS_TYPE=cassandra +ENV DATABASE_ENTITIES_TYPE=cassandra + +ENV CASSANDRA_HOST=localhost +ENV CASSANDRA_PORT=9042 + +EXPOSE 9090 +EXPOSE 1883 +EXPOSE 5683/udp + +VOLUME ["/data"] + +CMD ["start-tb.sh"] diff --git a/docker/cassandra-setup/install.sh b/msa/tb/docker-cassandra/start-db.sh old mode 100755 new mode 100644 similarity index 62% rename from docker/cassandra-setup/install.sh rename to msa/tb/docker-cassandra/start-db.sh index 8578b2e769..c089bf65a6 --- a/docker/cassandra-setup/install.sh +++ b/msa/tb/docker-cassandra/start-db.sh @@ -15,19 +15,24 @@ # limitations under the License. # +cassandra_data_dir=${DATA_FOLDER}/db +cassandra_data_link=/var/lib/cassandra -dpkg -i /thingsboard.deb +if [ ! -L ${cassandra_data_link} ]; then + if [ -d ${cassandra_data_link} ]; then + rm -rf ${cassandra_data_link} + fi + if [ ! -d ${cassandra_data_dir} ]; then + mkdir -p ${cassandra_data_dir} + chown -R cassandra:cassandra ${cassandra_data_dir} + fi + ln -s ${cassandra_data_dir} ${cassandra_data_link} +fi + +service cassandra start until nmap $CASSANDRA_HOST -p $CASSANDRA_PORT | grep "$CASSANDRA_PORT/tcp open" do echo "Wait for cassandra db to start..." - sleep 10 + sleep 5 done - -echo "Creating 'Thingsboard' schema and system data..." -if [ "$ADD_DEMO_DATA" == "true" ]; then - echo "plus demo data..." - /usr/share/thingsboard/bin/install/install.sh --loadDemo -elif [ "$ADD_DEMO_DATA" == "false" ]; then - /usr/share/thingsboard/bin/install/install.sh -fi diff --git a/msa/docker/docker-start-services.sh b/msa/tb/docker-cassandra/stop-db.sh old mode 100755 new mode 100644 similarity index 94% rename from msa/docker/docker-start-services.sh rename to msa/tb/docker-cassandra/stop-db.sh index 44093934cd..8cb4b1210b --- a/msa/docker/docker-start-services.sh +++ b/msa/tb/docker-cassandra/stop-db.sh @@ -15,6 +15,4 @@ # limitations under the License. # -./check-dirs.sh - -docker-compose up -d +service cassandra stop diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile new file mode 100644 index 0000000000..9447d51387 --- /dev/null +++ b/msa/tb/docker-postgres/Dockerfile @@ -0,0 +1,62 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jre + +RUN apt-get update +RUN apt-get install -y postgresql postgresql-contrib +RUN update-rc.d postgresql disable + +RUN mkdir -p /var/log/postgres +RUN chown -R postgres:postgres /var/log/postgres + +COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb.sh /usr/bin \ + && mv /tmp/upgrade-tb.sh /usr/bin \ + && mv /tmp/install-tb.sh /usr/bin \ + && mv /tmp/start-db.sh /usr/bin \ + && mv /tmp/stop-db.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +ENV DATA_FOLDER=/data + +ENV HTTP_BIND_PORT=9090 +ENV DATABASE_TS_TYPE=sql +ENV DATABASE_ENTITIES_TYPE=sql + +ENV PGDATA=/data/db + +ENV SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.PostgreSQLDialect +ENV SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver +ENV SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/thingsboard +ENV SPRING_DATASOURCE_USERNAME=postgres +ENV SPRING_DATASOURCE_PASSWORD=postgres + +EXPOSE 9090 +EXPOSE 1883 +EXPOSE 5683/udp + +VOLUME ["/data"] + +CMD ["start-tb.sh"] diff --git a/docker/cassandra-upgrade/upgrade.sh b/msa/tb/docker-postgres/start-db.sh old mode 100755 new mode 100644 similarity index 59% rename from docker/cassandra-upgrade/upgrade.sh rename to msa/tb/docker-postgres/start-db.sh index dac49191b2..4b973e6527 --- a/docker/cassandra-upgrade/upgrade.sh +++ b/msa/tb/docker-postgres/start-db.sh @@ -15,14 +15,16 @@ # limitations under the License. # +firstlaunch=${DATA_FOLDER}/.firstlaunch -dpkg -i /thingsboard.deb +if [ ! -d ${PGDATA} ]; then + mkdir -p ${PGDATA} + chown -R postgres:postgres ${PGDATA} + su postgres -c '/usr/lib/postgresql/9.6/bin/pg_ctl initdb -U postgres' +fi -until nmap $CASSANDRA_HOST -p $CASSANDRA_PORT | grep "$CASSANDRA_PORT/tcp open" -do - echo "Wait for cassandra db to start..." - sleep 10 -done +su postgres -c '/usr/lib/postgresql/9.6/bin/pg_ctl -l /var/log/postgres/postgres.log -w start' -echo "Upgrading 'Thingsboard' schema..." -/usr/share/thingsboard/bin/install/upgrade.sh --fromVersion=$UPGRADE_FROM_VERSION +if [ ! -f ${firstlaunch} ]; then + su postgres -c 'psql -U postgres -d postgres -c "CREATE DATABASE thingsboard"' +fi diff --git a/msa/docker/docker-update-service.sh b/msa/tb/docker-postgres/stop-db.sh old mode 100755 new mode 100644 similarity index 90% rename from msa/docker/docker-update-service.sh rename to msa/tb/docker-postgres/stop-db.sh index f698f9b015..b1db7caa9e --- a/msa/docker/docker-update-service.sh +++ b/msa/tb/docker-postgres/stop-db.sh @@ -15,5 +15,4 @@ # limitations under the License. # -docker-compose pull $@ -docker-compose up -d --no-deps --build $@ +su postgres -c '/usr/lib/postgresql/9.6/bin/pg_ctl stop' diff --git a/msa/tb/docker-tb/Dockerfile b/msa/tb/docker-tb/Dockerfile new file mode 100644 index 0000000000..df7e692fbe --- /dev/null +++ b/msa/tb/docker-tb/Dockerfile @@ -0,0 +1,48 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jre + +COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb.sh /usr/bin \ + && mv /tmp/upgrade-tb.sh /usr/bin \ + && mv /tmp/install-tb.sh /usr/bin \ + && mv /tmp/start-db.sh /usr/bin \ + && mv /tmp/stop-db.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +ENV DATA_FOLDER=/data + +ENV HTTP_BIND_PORT=9090 +ENV DATABASE_TS_TYPE=sql +ENV DATABASE_ENTITIES_TYPE=sql +ENV SQL_DATA_FOLDER=/data/db + +EXPOSE 9090 +EXPOSE 1883 +EXPOSE 5683/udp + +VOLUME ["/data"] + +CMD ["start-tb.sh"] diff --git a/msa/docker/docker-stop-services.sh b/msa/tb/docker-tb/start-db.sh old mode 100755 new mode 100644 similarity index 96% rename from msa/docker/docker-stop-services.sh rename to msa/tb/docker-tb/start-db.sh index 673a0b2dfc..779e69c3eb --- a/msa/docker/docker-stop-services.sh +++ b/msa/tb/docker-tb/start-db.sh @@ -15,4 +15,4 @@ # limitations under the License. # -docker-compose stop +# Do nothing \ No newline at end of file diff --git a/docker/cassandra/Dockerfile b/msa/tb/docker-tb/stop-db.sh similarity index 84% rename from docker/cassandra/Dockerfile rename to msa/tb/docker-tb/stop-db.sh index a4e5cc049b..779e69c3eb 100644 --- a/docker/cassandra/Dockerfile +++ b/msa/tb/docker-tb/stop-db.sh @@ -1,3 +1,4 @@ +#!/bin/bash # # Copyright © 2016-2018 The Thingsboard Authors # @@ -14,10 +15,4 @@ # limitations under the License. # -FROM cassandra:3.11.2 - -ADD ready-probe.sh /ready-probe.sh - -RUN chmod +x /ready-probe.sh - -CMD ["cassandra", "-f"] \ No newline at end of file +# Do nothing \ No newline at end of file diff --git a/msa/tb/docker/install-tb.sh b/msa/tb/docker/install-tb.sh new file mode 100644 index 0000000000..e22ec58ec2 --- /dev/null +++ b/msa/tb/docker/install-tb.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + --loadDemo) + LOAD_DEMO=true + shift # past argument + ;; + *) + # unknown option + ;; +esac +shift # past argument or value +done + +if [ "$LOAD_DEMO" == "true" ]; then + loadDemo=true +else + loadDemo=false +fi + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +upgradeversion=${DATA_FOLDER}/.upgradeversion + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting ThingsBoard installation ..." + +java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dinstall.load_demo=${loadDemo} \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=false \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +echo "${pkg.upgradeVersion}" > ${upgradeversion} diff --git a/msa/tb/docker/logback.xml b/msa/tb/docker/logback.xml new file mode 100644 index 0000000000..87f9bf4c06 --- /dev/null +++ b/msa/tb/docker/logback.xml @@ -0,0 +1,51 @@ + + + + + + + /var/log/thingsboard/thingsboard.log + + /var/log/thingsboard/thingsboard.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + \ No newline at end of file diff --git a/msa/tb/docker/start-tb.sh b/msa/tb/docker/start-tb.sh new file mode 100755 index 0000000000..37f5e3a72a --- /dev/null +++ b/msa/tb/docker/start-tb.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +start-db.sh + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +firstlaunch=${DATA_FOLDER}/.firstlaunch + +source "${CONF_FOLDER}/${configfile}" + +if [ ! -f ${firstlaunch} ]; then + install-tb.sh --loadDemo + touch ${firstlaunch} +fi + +echo "Starting ThingsBoard ..." + +java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardServerApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +stop-db.sh \ No newline at end of file diff --git a/msa/tb/docker/thingsboard.conf b/msa/tb/docker/thingsboard.conf new file mode 100644 index 0000000000..85d7cfd871 --- /dev/null +++ b/msa/tb/docker/thingsboard.conf @@ -0,0 +1,24 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Dplatform=deb -Dinstall.data_dir=/usr/share/thingsboard/data" +export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/thingsboard/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" +export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" +export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=thingsboard.out +export LOADER_PATH=/usr/share/thingsboard/conf,/usr/share/thingsboard/extensions diff --git a/msa/tb/docker/upgrade-tb.sh b/msa/tb/docker/upgrade-tb.sh new file mode 100644 index 0000000000..ab77cddf4c --- /dev/null +++ b/msa/tb/docker/upgrade-tb.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +start-db.sh + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +upgradeversion=${DATA_FOLDER}/.upgradeversion + +source "${CONF_FOLDER}/${configfile}" + +FROM_VERSION=`cat ${upgradeversion}` + +echo "Starting ThingsBoard upgrade ..." + +if [[ -z "${FROM_VERSION// }" ]]; then + echo "FROM_VERSION variable is invalid or unspecified!" + exit 1 +else + fromVersion="${FROM_VERSION// }" +fi + +java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=true \ + -Dinstall.upgrade.from_version=${fromVersion} \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +echo "${pkg.upgradeVersion}" > ${upgradeversion} + +stop-db.sh \ No newline at end of file diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml new file mode 100644 index 0000000000..6ea7bebae2 --- /dev/null +++ b/msa/tb/pom.xml @@ -0,0 +1,370 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + tb + pom + + ThingsBoard Docker Images + https://thingsboard.io + ThingsBoard Docker Images + + + UTF-8 + ${basedir}/../.. + thingsboard + tb + tb-postgres + tb-cassandra + thingsboard + /usr/share/${pkg.name} + 2.1.1 + + + + + org.thingsboard + application + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory}/docker-tb + + + + + + copy-tb-postgres-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory}/docker-postgres + + + + + + copy-tb-cassandra-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory}/docker-cassandra + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-tb-config + process-resources + + copy-resources + + + ${project.build.directory}/docker-tb + + + docker + true + + + docker-tb + true + + + + + + copy-docker-tb-postgres-config + process-resources + + copy-resources + + + ${project.build.directory}/docker-postgres + + + docker + true + + + docker-postgres + true + + + + + + copy-docker-tb-cassandra-config + process-resources + + copy-resources + + + ${project.build.directory}/docker-cassandra + + + docker + true + + + docker-cassandra + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-tb-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${tb.docker.name} + true + false + ${project.build.directory}/docker-tb + + + + tag-docker-tb-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${tb.docker.name} + ${project.version} + + + + build-docker-tb-postgres-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${tb-postgres.docker.name} + true + false + ${project.build.directory}/docker-postgres + + + + tag-docker-tb-postgres-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${tb-postgres.docker.name} + ${project.version} + + + + build-docker-tb-cassandra-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${tb-cassandra.docker.name} + true + false + ${project.build.directory}/docker-cassandra + + + + tag-docker-tb-cassandra-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${tb-cassandra.docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-tb-image + pre-integration-test + + push + + + latest + ${docker.repo}/${tb.docker.name} + + + + push-version-docker-tb-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${tb.docker.name} + + + + push-latest-docker-tb-postgres-image + pre-integration-test + + push + + + latest + ${docker.repo}/${tb-postgres.docker.name} + + + + push-version-docker-tb-postgres-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${tb-postgres.docker.name} + + + + push-latest-docker-tb-cassandra-image + pre-integration-test + + push + + + latest + ${docker.repo}/${tb-cassandra.docker.name} + + + + push-version-docker-tb-cassandra-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${tb-cassandra.docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 94399c7715..26eaa30e0a 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -111,19 +111,72 @@ build + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + - - ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} - true - false - ${project.build.directory} - + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + jenkins diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 6bbfc3f782..6d1707c4f7 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -111,19 +111,72 @@ build + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + - - ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} - true - false - ${project.build.directory} - + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + jenkins diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 0a3b07771b..d51c51c035 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -111,19 +111,72 @@ build + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + - - ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} - true - false - ${project.build.directory} - + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + jenkins diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 805bf8b821..83124dff06 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -311,16 +311,27 @@ build + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + - - ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} - true - false - ${project.build.directory} - @@ -358,6 +369,46 @@ + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + From a43377f8063aaa619c202d56606ebfc9d87d58b5 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 24 Oct 2018 19:58:44 +0300 Subject: [PATCH 254/307] Update README.md --- docker/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/README.md b/docker/README.md index 23ff9b22cc..b5e2ea9cbf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -44,7 +44,9 @@ In case when database upgrade is needed, execute the following commands: ` $ ./docker-stop-services.sh + $ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION] + $ ./docker-start-services.sh ` From 88fa216c9198b0c1108f4589df91c5208647857a Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 24 Oct 2018 19:59:06 +0300 Subject: [PATCH 255/307] Update README.md --- docker/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docker/README.md b/docker/README.md index b5e2ea9cbf..b05c4b3726 100644 --- a/docker/README.md +++ b/docker/README.md @@ -44,10 +44,12 @@ In case when database upgrade is needed, execute the following commands: ` $ ./docker-stop-services.sh - +` +` $ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION] - +` +` $ ./docker-start-services.sh -` +` - `FROM_VERSION` - from which version upgrade should be started. From b85d9e2047587b382af8e1be28f13fccc3d1fd32 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 24 Oct 2018 19:59:22 +0300 Subject: [PATCH 256/307] Update README.md --- docker/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/README.md b/docker/README.md index b05c4b3726..33502a8575 100644 --- a/docker/README.md +++ b/docker/README.md @@ -45,9 +45,11 @@ In case when database upgrade is needed, execute the following commands: ` $ ./docker-stop-services.sh ` + ` $ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION] ` + ` $ ./docker-start-services.sh ` From 90d2321ec2430b313e235a38ff32466b6d3234dd Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 24 Oct 2018 20:03:49 +0300 Subject: [PATCH 257/307] Update README.md --- msa/tb/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msa/tb/README.md b/msa/tb/README.md index 228aaa112a..d165fa7c3b 100644 --- a/msa/tb/README.md +++ b/msa/tb/README.md @@ -58,11 +58,11 @@ $ docker start mytb In order to update to the latest image, execute the following commands: -` +``` $ docker pull thingsboard/tb $ docker stop mytb $ docker run -it -v ~/.mytb-data:/data --rm thingsboard/tb upgrade-tb.sh $ docker start mytb -` +``` **NOTE**: replace host's directory `~/.mytb-data` with directory used during container creation. From d743b8d8b8f0d9104473b1d3f7655fb904885bd2 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 24 Oct 2018 20:04:45 +0300 Subject: [PATCH 258/307] Update README.md --- docker/README.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/docker/README.md b/docker/README.md index 33502a8575..e8fd55e532 100644 --- a/docker/README.md +++ b/docker/README.md @@ -42,16 +42,10 @@ $ ./docker-update-service.sh [SERVICE...] In case when database upgrade is needed, execute the following commands: -` +``` $ ./docker-stop-services.sh -` - -` $ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION] -` - -` $ ./docker-start-services.sh -` +``` - `FROM_VERSION` - from which version upgrade should be started. From e684c267962fce252c276f3d539c402d0f73c5ac Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 24 Oct 2018 20:06:36 +0300 Subject: [PATCH 259/307] New Buffered Rate Limit implementation --- .../server/dao/nosql/CassandraBufferedRateExecutor.java | 4 ++-- .../thingsboard/server/dao/nosql/CassandraStatementTask.java | 2 +- .../dao/{nosql/tmp => util}/AbstractBufferedRateExecutor.java | 2 +- .../thingsboard/server/dao/{nosql/tmp => util}/AsyncTask.java | 2 +- .../server/dao/{nosql/tmp => util}/AsyncTaskContext.java | 2 +- .../server/dao/{nosql/tmp => util}/BufferedRateExecutor.java | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename dao/src/main/java/org/thingsboard/server/dao/{nosql/tmp => util}/AbstractBufferedRateExecutor.java (99%) rename dao/src/main/java/org/thingsboard/server/dao/{nosql/tmp => util}/AsyncTask.java (93%) rename dao/src/main/java/org/thingsboard/server/dao/{nosql/tmp => util}/AsyncTaskContext.java (95%) rename dao/src/main/java/org/thingsboard/server/dao/{nosql/tmp => util}/BufferedRateExecutor.java (94%) diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java index 478c76d5c0..c999ef4712 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java @@ -22,8 +22,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.nosql.tmp.AbstractBufferedRateExecutor; -import org.thingsboard.server.dao.nosql.tmp.AsyncTaskContext; +import org.thingsboard.server.dao.util.AbstractBufferedRateExecutor; +import org.thingsboard.server.dao.util.AsyncTaskContext; import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PreDestroy; diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java index ea13679b7c..19e56402db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java @@ -18,7 +18,7 @@ package org.thingsboard.server.dao.nosql; import com.datastax.driver.core.Session; import com.datastax.driver.core.Statement; import lombok.Data; -import org.thingsboard.server.dao.nosql.tmp.AsyncTask; +import org.thingsboard.server.dao.util.AsyncTask; /** * Created by ashvayka on 24.10.18. diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java similarity index 99% rename from dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AbstractBufferedRateExecutor.java rename to dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java index 9ad8e18745..c5be08668d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AbstractBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.nosql.tmp; +package org.thingsboard.server.dao.util; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTask.java b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTask.java similarity index 93% rename from dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTask.java rename to dao/src/main/java/org/thingsboard/server/dao/util/AsyncTask.java index a16568b6bd..672e4f4656 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTask.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTask.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.nosql.tmp; +package org.thingsboard.server.dao.util; /** * Created by ashvayka on 24.10.18. diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTaskContext.java b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTaskContext.java similarity index 95% rename from dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTaskContext.java rename to dao/src/main/java/org/thingsboard/server/dao/util/AsyncTaskContext.java index c3c98ee361..593b3fb04c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/AsyncTaskContext.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTaskContext.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.nosql.tmp; +package org.thingsboard.server.dao.util; import com.google.common.util.concurrent.SettableFuture; import lombok.Data; diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/BufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutor.java similarity index 94% rename from dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/BufferedRateExecutor.java rename to dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutor.java index 7adc914885..21f4cd0493 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/tmp/BufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutor.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.nosql.tmp; +package org.thingsboard.server.dao.util; import com.google.common.util.concurrent.ListenableFuture; From f4fe6663b928cd1a941504b1bc15bb3147369ccc Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 25 Oct 2018 12:34:02 +0300 Subject: [PATCH 260/307] MSA docker: Configurable HTTP to HTTPS redirect. Update docker instructions. --- docker/.env | 2 +- docker/README.md | 55 ++++++++++++++++++++++++++++--- docker/docker-compose.yml | 1 + docker/docker-upgrade-tb.sh | 2 ++ docker/haproxy/config/haproxy.cfg | 2 +- msa/tb/README.md | 7 ++-- 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/docker/.env b/docker/.env index 17f8af8c4e..eb859b17c6 100644 --- a/docker/.env +++ b/docker/.env @@ -13,6 +13,6 @@ TB_VERSION=latest # Database used by ThingsBoard, can be either local (local HSQLDB), postgres (PostgreSQL), cassandra (Cassandra). # In case of postgres or cassandra corresponding docker service will be deployed (see docker-compose.postgres.yml, docker-compose.cassandra.yml for details). -DATABASE=cassandra +DATABASE=local KAFKA_TOPICS="js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1" diff --git a/docker/README.md b/docker/README.md index e8fd55e532..769a9f9a20 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,24 +2,63 @@ This folder containing scripts and Docker Compose configurations to run ThingsBoard in Microservices mode. +## Prerequisites + +ThingsBoard Microservices are running in dockerized environment. +Before starting please make sure [Docker CE](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed in your system. + ## Installation -Execute the following command to run DataBase installation: +Before performing initial installation you can configure the type of database to be used with ThinsBoard. +In order to set database type change the value of `DATABASE` variable in `.env` file to one of the following: + +- `local` - use local embedded HSQLDB database; +- `postgres` - use PostgreSQL database; +- `cassandra` - use Cassandra database; + +**NOTE**: In case of `postgres` or `cassandra` corresponding docker service will be deployed (see `docker-compose.postgres.yml`, `docker-compose.cassandra.yml` for details). + +Execute the following command to run installation: ` $ ./docker-install-tb.sh --loadDemo ` -- `--loadDemo` - optional argument. Whether to load additional demo data. +Where: + +- `--loadDemo` - optional argument. Whether to load additional demo data. ## Running -Execute the following command to run services: +Execute the following command to start services: ` $ ./docker-start-services.sh ` +After a while when all services will be successfully started you can open `http://{yor-host-ip}` in you browser (for ex. `http://localhost`). +You should see ThingsBoard login page. + +Use the following default credentials: + +- **Systen Administrator**: sysadmin@thingsboard.org / sysadmin + +If you installed DataBase with demo data (using `--loadDemo` flag) you can also use the following credentials: + +- **Tenant Administrator**: tenant@thingsboard.org / tenant +- **Customer User**: customer@thingsboard.org / customer + +In case of any issues you can examine service logs for errors. +For example to see ThingsBoard node logs execute the following command: + +` +$ docker-compose logs -f tb +` + +Or use `docker-compose ps` to see the state of all the containers. +Use `docker-compose logs --f` to inspect the logs of all running services. +See [docker-compose logs](https://docs.docker.com/compose/reference/logs/) command reference for details. + Execute the following command to stop services: ` @@ -32,12 +71,16 @@ Execute the following command to stop and completely remove deployed docker cont $ ./docker-remove-services.sh ` -Execute the following command to update particular services (pull newer docker image and rebuild container): +Execute the following command to update particular or all services (pull newer docker image and rebuild container): ` $ ./docker-update-service.sh [SERVICE...] ` +Where: + +- `[SERVICE...]` - list of services to update (defined in docker-compose configurations). If not specified all services will be updated. + ## Upgrading In case when database upgrade is needed, execute the following commands: @@ -48,4 +91,6 @@ $ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION] $ ./docker-start-services.sh ``` -- `FROM_VERSION` - from which version upgrade should be started. +Where: + +- `FROM_VERSION` - from which version upgrade should be started. See [Upgrade Instructions](https://thingsboard.io/docs/user-guide/install/upgrade-instructions) for valid `fromVersion` values. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a319606c03..fd0c80c28b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -171,6 +171,7 @@ services: HTTP_PORT: 80 HTTPS_PORT: 443 MQTT_PORT: 1883 + FORCE_HTTPS_REDIRECT: "false" links: - tb-web-ui1 - tb-web-ui2 diff --git a/docker/docker-upgrade-tb.sh b/docker/docker-upgrade-tb.sh index 500b09a81e..c594b9d3f9 100755 --- a/docker/docker-upgrade-tb.sh +++ b/docker/docker-upgrade-tb.sh @@ -46,6 +46,8 @@ ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS pull tb + if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d $ADDITIONAL_STARTUP_SERVICES fi diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg index cb70552a73..bb1062f2a2 100644 --- a/docker/haproxy/config/haproxy.cfg +++ b/docker/haproxy/config/haproxy.cfg @@ -51,7 +51,7 @@ frontend http-in acl transport_http_acl path_beg /api/v1/ acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ - redirect scheme https if !letsencrypt_http_acl !transport_http_acl + redirect scheme https if !letsencrypt_http_acl !transport_http_acl { env(FORCE_HTTPS_REDIRECT) -m str true } use_backend letsencrypt_http if letsencrypt_http_acl use_backend tb-http-backend if transport_http_acl diff --git a/msa/tb/README.md b/msa/tb/README.md index d165fa7c3b..4caeeadcb6 100644 --- a/msa/tb/README.md +++ b/msa/tb/README.md @@ -8,6 +8,7 @@ This project provides the build for the ThingsBoard single docker images. ## Running +In this example `thingsboard/tb` image will be used. You can choose any other images with different databases (see above). Execute the following command to run this docker directly: ` @@ -23,9 +24,9 @@ Where: - `-p 5683:5683` - connect local port 5683 to exposed internal COAP port 5683 - `-v ~/.mytb-data:/data` - mounts the host's dir `~/.mytb-data` to ThingsBoard DataBase data directory - `--name mytb` - friendly local name of this machine -- `thingsboard/tb` - docker image +- `thingsboard/tb` - docker image, can be also `thingsboard/tb-postgres` or `thingsboard/tb-cassandra` -After executing this command you can open `http://{yor-host-ip}:9090` in you browser. You should see ThingsBoard login page. +After executing this command you can open `http://{yor-host-ip}:9090` in you browser (for ex. `http://localhost:9090`). You should see ThingsBoard login page. Use the following default credentials: - **Systen Administrator**: sysadmin@thingsboard.org / sysadmin @@ -65,4 +66,6 @@ $ docker run -it -v ~/.mytb-data:/data --rm thingsboard/tb upgrade-tb.sh $ docker start mytb ``` +**NOTE**: if you use different database change image name in all commands from `thingsboard/tb` to `thingsboard/tb-postgres` or `thingsboard/tb-cassandra` correspondingly. + **NOTE**: replace host's directory `~/.mytb-data` with directory used during container creation. From be937278ba07bb7f136383c273b3db5a48d3f738 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 25 Oct 2018 15:11:03 +0300 Subject: [PATCH 261/307] Fix typo in docker README's. --- docker/README.md | 2 +- img/dashboard.gif | Bin 263498 -> 0 bytes msa/tb/README.md | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 img/dashboard.gif diff --git a/docker/README.md b/docker/README.md index 769a9f9a20..f48c81a50e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -36,7 +36,7 @@ Execute the following command to start services: $ ./docker-start-services.sh ` -After a while when all services will be successfully started you can open `http://{yor-host-ip}` in you browser (for ex. `http://localhost`). +After a while when all services will be successfully started you can open `http://{your-host-ip}` in you browser (for ex. `http://localhost`). You should see ThingsBoard login page. Use the following default credentials: diff --git a/img/dashboard.gif b/img/dashboard.gif deleted file mode 100644 index 9d792709a953bd21a410117fc69fd75f76cc6489..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 263498 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� 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☋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 Date: Thu, 25 Oct 2018 15:34:55 +0200 Subject: [PATCH 262/307] Add ability to setup interval through env vars --- application/src/main/resources/thingsboard.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ec5dde5cdd..54096eaaa1 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -222,7 +222,7 @@ redis: updates: # Enable/disable updates checking. enabled: "${UPDATES_ENABLED:true}" - + # spring CORS configuration spring.mvc.cors: mappings: @@ -322,8 +322,8 @@ audit_log: password: "${AUDIT_LOG_SINK_PASSWORD:}" state: - defaultInactivityTimeoutInSec: 10 - defaultStateCheckIntervalInSec: 10 + defaultInactivityTimeoutInSec: "${DEFAULT_INACTIVITY_TIMEOUT:10}" + defaultStateCheckIntervalInSec: "${DEFAULT_STATE_CHECK_INTERVAL:10}" kafka: enabled: true From dc61052bd5f68b008dc4afbe81b080681e8fa78f Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 25 Oct 2018 19:08:06 +0300 Subject: [PATCH 263/307] MSA Docker TB Cluster configuration. --- docker/.env | 6 +-- docker/README.md | 5 +-- docker/check-dirs.sh | 2 +- docker/compose-utils.sh | 8 +--- docker/docker-compose.cassandra.yml | 16 ++++--- docker/docker-compose.postgres.yml | 20 +++++---- docker/docker-compose.yml | 67 +++++++++++++--------------- docker/docker-install-tb.sh | 4 +- docker/docker-upgrade-tb.sh | 6 +-- docker/kafka.env | 12 +++++ docker/tb-coap-transport.env | 2 +- docker/tb-http-transport.env | 2 +- docker/tb-js-executor.env | 2 +- docker/tb-mqtt-transport.env | 2 +- docker/tb-node.cassandra.env | 5 +++ docker/tb-node.env | 7 +++ docker/tb-node.postgres.env | 9 ++++ docker/tb-node/conf/logback.xml | 2 +- docker/tb-node/conf/thingsboard.conf | 2 +- 19 files changed, 106 insertions(+), 73 deletions(-) create mode 100644 docker/kafka.env create mode 100644 docker/tb-node.cassandra.env create mode 100644 docker/tb-node.postgres.env diff --git a/docker/.env b/docker/.env index eb859b17c6..c03845dfe0 100644 --- a/docker/.env +++ b/docker/.env @@ -10,9 +10,9 @@ COAP_TRANSPORT_DOCKER_NAME=tb-coap-transport TB_VERSION=latest -# Database used by ThingsBoard, can be either local (local HSQLDB), postgres (PostgreSQL), cassandra (Cassandra). -# In case of postgres or cassandra corresponding docker service will be deployed (see docker-compose.postgres.yml, docker-compose.cassandra.yml for details). +# Database used by ThingsBoard, can be either postgres (PostgreSQL) or cassandra (Cassandra). +# According to the database type corresponding docker service will be deployed (see docker-compose.postgres.yml, docker-compose.cassandra.yml for details). -DATABASE=local +DATABASE=postgres KAFKA_TOPICS="js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1" diff --git a/docker/README.md b/docker/README.md index f48c81a50e..c43a136d09 100644 --- a/docker/README.md +++ b/docker/README.md @@ -12,11 +12,10 @@ Before starting please make sure [Docker CE](https://docs.docker.com/install/) a Before performing initial installation you can configure the type of database to be used with ThinsBoard. In order to set database type change the value of `DATABASE` variable in `.env` file to one of the following: -- `local` - use local embedded HSQLDB database; - `postgres` - use PostgreSQL database; - `cassandra` - use Cassandra database; -**NOTE**: In case of `postgres` or `cassandra` corresponding docker service will be deployed (see `docker-compose.postgres.yml`, `docker-compose.cassandra.yml` for details). +**NOTE**: According to the database type corresponding docker service will be deployed (see `docker-compose.postgres.yml`, `docker-compose.cassandra.yml` for details). Execute the following command to run installation: @@ -52,7 +51,7 @@ In case of any issues you can examine service logs for errors. For example to see ThingsBoard node logs execute the following command: ` -$ docker-compose logs -f tb +$ docker-compose logs -f tb1 ` Or use `docker-compose ps` to see the state of all the containers. diff --git a/docker/check-dirs.sh b/docker/check-dirs.sh index 6ae3f880a4..2042a44aea 100755 --- a/docker/check-dirs.sh +++ b/docker/check-dirs.sh @@ -15,7 +15,7 @@ # limitations under the License. # -dirsArray=("./haproxy/certs.d" "./haproxy/letsencrypt" "./tb-node/db" "./tb-node/postgres" "./tb-node/cassandra" "./tb-node/log") +dirsArray=("./haproxy/certs.d" "./haproxy/letsencrypt" "./tb-node/postgres" "./tb-node/cassandra" "./tb-node/log/tb1" "./tb-node/log/tb2") for dir in ${dirsArray[@]} do diff --git a/docker/compose-utils.sh b/docker/compose-utils.sh index 02a8e393e1..d1dc20e540 100755 --- a/docker/compose-utils.sh +++ b/docker/compose-utils.sh @@ -19,8 +19,6 @@ function additionalComposeArgs() { source .env ADDITIONAL_COMPOSE_ARGS="" case $DATABASE in - local) - ;; postgres) ADDITIONAL_COMPOSE_ARGS="-f docker-compose.postgres.yml" ;; @@ -28,7 +26,7 @@ function additionalComposeArgs() { ADDITIONAL_COMPOSE_ARGS="-f docker-compose.cassandra.yml" ;; *) - echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either local, postgres or cassandra." >&2 + echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either postgres or cassandra." >&2 exit 1 esac echo $ADDITIONAL_COMPOSE_ARGS @@ -38,8 +36,6 @@ function additionalStartupServices() { source .env ADDITIONAL_STARTUP_SERVICES="" case $DATABASE in - local) - ;; postgres) ADDITIONAL_STARTUP_SERVICES=postgres ;; @@ -47,7 +43,7 @@ function additionalStartupServices() { ADDITIONAL_STARTUP_SERVICES=cassandra ;; *) - echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either local, postgres or cassandra." >&2 + echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either postgres or cassandra." >&2 exit 1 esac echo $ADDITIONAL_STARTUP_SERVICES diff --git a/docker/docker-compose.cassandra.yml b/docker/docker-compose.cassandra.yml index 2bfb748b16..721ea3ef42 100644 --- a/docker/docker-compose.cassandra.yml +++ b/docker/docker-compose.cassandra.yml @@ -24,11 +24,17 @@ services: - "9042" volumes: - ./tb-node/cassandra:/var/lib/cassandra - tb: - environment: - DATABASE_TS_TYPE: cassandra - DATABASE_ENTITIES_TYPE: cassandra - CASSANDRA_URL: cassandra:9042 + tb1: + env_file: + - tb-node.cassandra.env depends_on: - kafka + - redis + - cassandra + tb2: + env_file: + - tb-node.cassandra.env + depends_on: + - kafka + - redis - cassandra diff --git a/docker/docker-compose.postgres.yml b/docker/docker-compose.postgres.yml index e2f2737b07..c81359aac2 100644 --- a/docker/docker-compose.postgres.yml +++ b/docker/docker-compose.postgres.yml @@ -26,15 +26,17 @@ services: POSTGRES_DB: thingsboard volumes: - ./tb-node/postgres:/var/lib/postgresql/data - tb: - environment: - DATABASE_TS_TYPE: sql - DATABASE_ENTITIES_TYPE: sql - SPRING_JPA_DATABASE_PLATFORM: org.hibernate.dialect.PostgreSQLDialect - SPRING_DRIVER_CLASS_NAME: org.postgresql.Driver - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/thingsboard - SPRING_DATASOURCE_USERNAME: postgres - SPRING_DATASOURCE_PASSWORD: postgres + tb1: + env_file: + - tb-node.postgres.env + depends_on: + - kafka + - redis + - postgres + tb2: + env_file: + - tb-node.postgres.env depends_on: - kafka + - redis - postgres diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index fd0c80c28b..6d22046291 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,7 +20,7 @@ version: '2.2' services: zookeeper: restart: always - image: "wurstmeister/zookeeper" + image: "zookeeper:3.5" ports: - "2181" kafka: @@ -28,31 +28,23 @@ services: image: "wurstmeister/kafka" ports: - "9092:9092" - environment: - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_LISTENERS: INSIDE://:9093,OUTSIDE://:9092 - KAFKA_ADVERTISED_LISTENERS: INSIDE://:9093,OUTSIDE://kafka:9092 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT - KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE - KAFKA_CREATE_TOPICS: "${KAFKA_TOPICS}" - KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false' - KAFKA_LOG_RETENTION_BYTES: 1073741824 - KAFKA_LOG_SEGMENT_BYTES: 268435456 - KAFKA_LOG_RETENTION_MS: 300000 - KAFKA_LOG_CLEANUP_POLICY: delete + env_file: + - kafka.env depends_on: - zookeeper + redis: + image: redis:4.0 + ports: + - "6379" tb-js-executor: restart: always image: "${DOCKER_REPO}/${JS_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" scale: 20 - environment: - TB_KAFKA_SERVERS: kafka:9092 env_file: - tb-js-executor.env depends_on: - kafka - tb: + tb1: restart: always image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" ports: @@ -62,28 +54,41 @@ services: options: max-size: "200m" max-file: "30" + environment: + TB_HOST: tb1 env_file: - tb-node.env + volumes: + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - kafka + - redis + tb2: + restart: always + image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + logging: + driver: "json-file" + options: + max-size: "200m" + max-file: "30" environment: - ZOOKEEPER_URL: zk:2181 - TB_KAFKA_SERVERS: kafka:9092 - JS_EVALUATOR: remote - DATABASE_TS_TYPE: sql - DATABASE_ENTITIES_TYPE: sql - SQL_DATA_FOLDER: /usr/share/thingsboard/data/db + TB_HOST: tb2 + env_file: + - tb-node.env volumes: - - ./tb-node/db:/usr/share/thingsboard/data/db - ./tb-node/conf:/config - ./tb-node/log:/var/log/thingsboard depends_on: - kafka + - redis tb-mqtt-transport1: restart: always image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - "1883" - environment: - TB_KAFKA_SERVERS: kafka:9092 env_file: - tb-mqtt-transport.env depends_on: @@ -93,8 +98,6 @@ services: image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - "1883" - environment: - TB_KAFKA_SERVERS: kafka:9092 env_file: - tb-mqtt-transport.env depends_on: @@ -104,8 +107,6 @@ services: image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - "8081" - environment: - TB_KAFKA_SERVERS: kafka:9092 env_file: - tb-http-transport.env depends_on: @@ -115,8 +116,6 @@ services: image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - "8081" - environment: - TB_KAFKA_SERVERS: kafka:9092 env_file: - tb-http-transport.env depends_on: @@ -126,8 +125,6 @@ services: image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - "5683:5683/udp" - environment: - TB_KAFKA_SERVERS: kafka:9092 env_file: - tb-coap-transport.env depends_on: @@ -138,7 +135,7 @@ services: ports: - "8080" environment: - TB_HOST: tb + TB_HOST: tb1 TB_PORT: 8080 env_file: - tb-web-ui.env @@ -148,7 +145,7 @@ services: ports: - "8080" environment: - TB_HOST: tb + TB_HOST: tb2 TB_PORT: 8080 env_file: - tb-web-ui.env diff --git a/docker/docker-install-tb.sh b/docker/docker-install-tb.sh index 4d4b9b9732..9032e2fd15 100755 --- a/docker/docker-install-tb.sh +++ b/docker/docker-install-tb.sh @@ -48,9 +48,9 @@ ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then - docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d $ADDITIONAL_STARTUP_SERVICES + docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d redis $ADDITIONAL_STARTUP_SERVICES fi -docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb1 diff --git a/docker/docker-upgrade-tb.sh b/docker/docker-upgrade-tb.sh index c594b9d3f9..d83b1b7579 100755 --- a/docker/docker-upgrade-tb.sh +++ b/docker/docker-upgrade-tb.sh @@ -46,10 +46,10 @@ ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? -docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS pull tb +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS pull tb1 if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then - docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d $ADDITIONAL_STARTUP_SERVICES + docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d redis $ADDITIONAL_STARTUP_SERVICES fi -docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb1 diff --git a/docker/kafka.env b/docker/kafka.env new file mode 100644 index 0000000000..69fbdf6116 --- /dev/null +++ b/docker/kafka.env @@ -0,0 +1,12 @@ + +KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 +KAFKA_LISTENERS=INSIDE://:9093,OUTSIDE://:9092 +KAFKA_ADVERTISED_LISTENERS=INSIDE://:9093,OUTSIDE://kafka:9092 +KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT +KAFKA_INTER_BROKER_LISTENER_NAME=INSIDE +KAFKA_CREATE_TOPICS=${KAFKA_TOPICS} +KAFKA_AUTO_CREATE_TOPICS_ENABLE=false +KAFKA_LOG_RETENTION_BYTES=1073741824 +KAFKA_LOG_SEGMENT_BYTES=268435456 +KAFKA_LOG_RETENTION_MS=300000 +KAFKA_LOG_CLEANUP_POLICY=delete diff --git a/docker/tb-coap-transport.env b/docker/tb-coap-transport.env index b3331fe849..ed8f78d116 100644 --- a/docker/tb-coap-transport.env +++ b/docker/tb-coap-transport.env @@ -3,4 +3,4 @@ COAP_BIND_ADDRESS=0.0.0.0 COAP_BIND_PORT=5683 COAP_TIMEOUT=10000 -TB_KAFKA_SERVERS=localhost:9092 \ No newline at end of file +TB_KAFKA_SERVERS=kafka:9092 \ No newline at end of file diff --git a/docker/tb-http-transport.env b/docker/tb-http-transport.env index 33c479104e..c8363230a2 100644 --- a/docker/tb-http-transport.env +++ b/docker/tb-http-transport.env @@ -3,4 +3,4 @@ HTTP_BIND_ADDRESS=0.0.0.0 HTTP_BIND_PORT=8081 HTTP_REQUEST_TIMEOUT=60000 -TB_KAFKA_SERVERS=localhost:9092 \ No newline at end of file +TB_KAFKA_SERVERS=kafka:9092 \ No newline at end of file diff --git a/docker/tb-js-executor.env b/docker/tb-js-executor.env index cf455243ee..f7f24acabf 100644 --- a/docker/tb-js-executor.env +++ b/docker/tb-js-executor.env @@ -1,6 +1,6 @@ REMOTE_JS_EVAL_REQUEST_TOPIC=js.eval.requests -TB_KAFKA_SERVERS=localhost:9092 +TB_KAFKA_SERVERS=kafka:9092 LOGGER_LEVEL=info LOG_FOLDER=logs LOGGER_FILENAME=tb-js-executor-%DATE%.log diff --git a/docker/tb-mqtt-transport.env b/docker/tb-mqtt-transport.env index 5ada3f786a..b024c7a1a5 100644 --- a/docker/tb-mqtt-transport.env +++ b/docker/tb-mqtt-transport.env @@ -3,4 +3,4 @@ MQTT_BIND_ADDRESS=0.0.0.0 MQTT_BIND_PORT=1883 MQTT_TIMEOUT=10000 -TB_KAFKA_SERVERS=localhost:9092 \ No newline at end of file +TB_KAFKA_SERVERS=kafka:9092 \ No newline at end of file diff --git a/docker/tb-node.cassandra.env b/docker/tb-node.cassandra.env new file mode 100644 index 0000000000..8d813b9059 --- /dev/null +++ b/docker/tb-node.cassandra.env @@ -0,0 +1,5 @@ +# ThingsBoard server configuration for Cassandra database + +DATABASE_TS_TYPE=cassandra +DATABASE_ENTITIES_TYPE=cassandra +CASSANDRA_URL=cassandra:9042 diff --git a/docker/tb-node.env b/docker/tb-node.env index 311428adc6..ca945ab6ed 100644 --- a/docker/tb-node.env +++ b/docker/tb-node.env @@ -1,3 +1,10 @@ # ThingsBoard server configuration +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 +RPC_HOST=${TB_HOST} +TB_KAFKA_SERVERS=kafka:9092 +JS_EVALUATOR=remote TRANSPORT_TYPE=remote +CACHE_TYPE=redis +REDIS_HOST=redis diff --git a/docker/tb-node.postgres.env b/docker/tb-node.postgres.env new file mode 100644 index 0000000000..9fa79e369c --- /dev/null +++ b/docker/tb-node.postgres.env @@ -0,0 +1,9 @@ +# ThingsBoard server configuration for PostgreSQL database + +DATABASE_TS_TYPE=sql +DATABASE_ENTITIES_TYPE=sql +SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.PostgreSQLDialect +SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver +SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard +SPRING_DATASOURCE_USERNAME=postgres +SPRING_DATASOURCE_PASSWORD=postgres diff --git a/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml index 87f9bf4c06..1c69f537bc 100644 --- a/docker/tb-node/conf/logback.xml +++ b/docker/tb-node/conf/logback.xml @@ -21,7 +21,7 @@ - /var/log/thingsboard/thingsboard.log + /var/log/thingsboard/${TB_HOST}/thingsboard.log /var/log/thingsboard/thingsboard.%d{yyyy-MM-dd}.%i.log diff --git a/docker/tb-node/conf/thingsboard.conf b/docker/tb-node/conf/thingsboard.conf index 85d7cfd871..aa430b4a79 100644 --- a/docker/tb-node/conf/thingsboard.conf +++ b/docker/tb-node/conf/thingsboard.conf @@ -15,7 +15,7 @@ # export JAVA_OPTS="$JAVA_OPTS -Dplatform=deb -Dinstall.data_dir=/usr/share/thingsboard/data" -export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/thingsboard/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" +export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/thingsboard/${TB_HOST}/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps" export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" From 0ede9eee82d37c4b0494e2cfe196fe5008cd0d41 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 26 Oct 2018 12:07:15 +0300 Subject: [PATCH 264/307] Few minor improvements --- .../actors/service/DefaultActorService.java | 5 +- .../src/main/resources/actor-system.conf | 74 +------------------ .../TbMsgGeneratorNodeConfiguration.java | 2 +- .../script/node-script-test.tpl.html | 2 +- 4 files changed, 5 insertions(+), 78 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java index c2e8fd048a..542a82f160 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -66,10 +66,7 @@ public class DefaultActorService implements ActorService { public static final String APP_DISPATCHER_NAME = "app-dispatcher"; public static final String CORE_DISPATCHER_NAME = "core-dispatcher"; public static final String SYSTEM_RULE_DISPATCHER_NAME = "system-rule-dispatcher"; - public static final String SYSTEM_PLUGIN_DISPATCHER_NAME = "system-plugin-dispatcher"; public static final String TENANT_RULE_DISPATCHER_NAME = "rule-dispatcher"; - public static final String TENANT_PLUGIN_DISPATCHER_NAME = "plugin-dispatcher"; - public static final String SESSION_DISPATCHER_NAME = "session-dispatcher"; public static final String RPC_DISPATCHER_NAME = "rpc-dispatcher"; @Autowired @@ -186,8 +183,8 @@ public class DefaultActorService implements ActorService { @Override public void onReceivedMsg(ServerAddress source, ClusterAPIProtos.ClusterMessage msg) { ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort(), source.getServerType()); - log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress); if (log.isDebugEnabled()) { + log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress); log.info("MSG: ", msg); } switch (msg.getMessageType()) { diff --git a/application/src/main/resources/actor-system.conf b/application/src/main/resources/actor-system.conf index 3c687759e2..763319e52c 100644 --- a/application/src/main/resources/actor-system.conf +++ b/application/src/main/resources/actor-system.conf @@ -92,7 +92,7 @@ core-dispatcher { throughput = 5 } -# This dispatcher is used for system rule actors +# This dispatcher is used for system rule chains and rule node actors system-rule-dispatcher { type = Dispatcher executor = "fork-join-executor" @@ -115,30 +115,7 @@ system-rule-dispatcher { throughput = 5 } -# This dispatcher is used for system plugin actors -system-plugin-dispatcher { - type = Dispatcher - executor = "fork-join-executor" - fork-join-executor { - # Min number of threads to cap factor-based parallelism number to - parallelism-min = 2 - # Max number of threads to cap factor-based parallelism number to - parallelism-max = 12 - - # The parallelism factor is used to determine thread pool size using the - # following formula: ceil(available processors * factor). Resulting size - # is then bounded by the parallelism-min and parallelism-max values. - parallelism-factor = 0.25 - } - # How long time the dispatcher will wait for new actors until it shuts down - shutdown-timeout = 1s - - # Throughput defines the number of messages that are processed in a batch - # before the thread is returned to the pool. Set to 1 for as fair as possible. - throughput = 5 -} - -# This dispatcher is used for tenant rule actors +# This dispatcher is used for tenant rule chains and rule node actors rule-dispatcher { type = Dispatcher executor = "fork-join-executor" @@ -160,50 +137,3 @@ rule-dispatcher { # before the thread is returned to the pool. Set to 1 for as fair as possible. throughput = 5 } - -# This dispatcher is used for tenant plugin actors -plugin-dispatcher { - type = Dispatcher - executor = "fork-join-executor" - fork-join-executor { - # Min number of threads to cap factor-based parallelism number to - parallelism-min = 2 - # Max number of threads to cap factor-based parallelism number to - parallelism-max = 12 - - # The parallelism factor is used to determine thread pool size using the - # following formula: ceil(available processors * factor). Resulting size - # is then bounded by the parallelism-min and parallelism-max values. - parallelism-factor = 0.25 - } - # How long time the dispatcher will wait for new actors until it shuts down - shutdown-timeout = 1s - - # Throughput defines the number of messages that are processed in a batch - # before the thread is returned to the pool. Set to 1 for as fair as possible. - throughput = 5 -} - - -# This dispatcher is used for rule actors -session-dispatcher { - type = Dispatcher - executor = "fork-join-executor" - fork-join-executor { - # Min number of threads to cap factor-based parallelism number to - parallelism-min = 2 - # Max number of threads to cap factor-based parallelism number to - parallelism-max = 12 - - # The parallelism factor is used to determine thread pool size using the - # following formula: ceil(available processors * factor). Resulting size - # is then bounded by the parallelism-min and parallelism-max values. - parallelism-factor = 0.25 - } - # How long time the dispatcher will wait for new actors until it shuts down - shutdown-timeout = 1s - - # Throughput defines the number of messages that are processed in a batch - # before the thread is returned to the pool. Set to 1 for as fair as possible. - throughput = 5 -} \ No newline at end of file diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java index c568e3dd94..e3766baf7b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java @@ -37,7 +37,7 @@ public class TbMsgGeneratorNodeConfiguration implements NodeConfiguration - generateReport +

From e1109a32107fbf0a9e2fb6320e2819e6826a2083 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 26 Oct 2018 12:47:50 +0300 Subject: [PATCH 265/307] Check for NPE --- .../DefaultTelemetrySubscriptionService.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 3b0deea78a..0b70162215 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -166,7 +166,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio private SubscriptionState getUpdatedSubscriptionState(EntityId entityId, SubscriptionState sub, EntityView entityView) { Map keyStates; - if(sub.isAllKeys()) { + if (sub.isAllKeys()) { keyStates = entityView.getKeys().getTimeseries().stream().collect(Collectors.toMap(k -> k, k -> 0L)); } else { keyStates = sub.getKeyStates().entrySet() @@ -618,7 +618,9 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio builder.setEntityId(sub.getEntityId().getId().toString()); builder.setType(sub.getType().name()); builder.setAllKeys(sub.isAllKeys()); - builder.setScope(sub.getScope()); + if (sub.getScope() != null) { + builder.setScope(sub.getScope()); + } sub.getKeyStates().entrySet().forEach(e -> builder.addKeyStates( ClusterAPIProtos.SubscriptionKetStateProto.newBuilder().setKey(e.getKey()).setTs(e.getValue()).build())); rpcService.tell(address, ClusterAPIProtos.MessageType.CLUSTER_TELEMETRY_SUBSCRIPTION_CREATE_MESSAGE, builder.build().toByteArray()); From 9a5d7776fd5fff14577eb572ce2654999480bfb2 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 26 Oct 2018 12:49:20 +0300 Subject: [PATCH 266/307] MSA Docker: tb-node REST API loadbalancing. --- docker/docker-compose.yml | 12 +++++------- docker/haproxy/config/haproxy.cfg | 12 ++++++++++++ docker/tb-web-ui.env | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6d22046291..37c5cf2629 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -134,9 +134,6 @@ services: image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" ports: - "8080" - environment: - TB_HOST: tb1 - TB_PORT: 8080 env_file: - tb-web-ui.env tb-web-ui2: @@ -144,12 +141,9 @@ services: image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" ports: - "8080" - environment: - TB_HOST: tb2 - TB_PORT: 8080 env_file: - tb-web-ui.env - web: + haproxy: restart: always container_name: haproxy-certbot image: nmarus/haproxy-certbot @@ -159,6 +153,7 @@ services: - ./haproxy/certs.d:/usr/local/etc/haproxy/certs.d ports: - "80:80" + - "8080" - "443:443" - "1883:1883" - "9999:9999" @@ -168,8 +163,11 @@ services: HTTP_PORT: 80 HTTPS_PORT: 443 MQTT_PORT: 1883 + TB_API_PORT: 8080 FORCE_HTTPS_REDIRECT: "false" links: + - tb1 + - tb2 - tb-web-ui1 - tb-web-ui2 - tb-mqtt-transport1 diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg index bb1062f2a2..29de26680b 100644 --- a/docker/haproxy/config/haproxy.cfg +++ b/docker/haproxy/config/haproxy.cfg @@ -69,6 +69,11 @@ frontend https_in default_backend tb-web-backend +frontend http-api-in + bind *:${TB_API_PORT} + + default_backend tb-api-backend + backend letsencrypt_http server letsencrypt_http_srv 127.0.0.1:8080 @@ -86,3 +91,10 @@ backend tb-http-backend option log-health-checks server tbHttp1 tb-http-transport1:8081 check server tbHttp2 tb-http-transport2:8081 check + +backend tb-api-backend + balance leastconn + option tcp-check + option log-health-checks + server tbApi1 tb1:8080 check + server tbApi2 tb2:8080 check diff --git a/docker/tb-web-ui.env b/docker/tb-web-ui.env index 5c52681798..dbf8120cbf 100644 --- a/docker/tb-web-ui.env +++ b/docker/tb-web-ui.env @@ -1,7 +1,7 @@ HTTP_BIND_ADDRESS=0.0.0.0 HTTP_BIND_PORT=8080 -TB_HOST=localhost +TB_HOST=haproxy TB_PORT=8080 LOGGER_LEVEL=info LOG_FOLDER=logs From 3db96d77bb535aa8e66b00c5653b4c509a7ff2f8 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 26 Oct 2018 13:21:23 +0300 Subject: [PATCH 267/307] Check for NPE --- .../telemetry/DefaultTelemetrySubscriptionService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 0b70162215..7c772420ba 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. From 750bd700327dc6a8b2826c698b239b6b52dfe1fc Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 26 Oct 2018 14:01:41 +0300 Subject: [PATCH 268/307] Few minor improvements --- .../server/actors/rpc/RpcManagerActor.java | 12 ++++++------ .../server/common/msg/cluster/ServerType.java | 3 +-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java index 608a77fd7a..5dcbe32001 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java @@ -162,9 +162,9 @@ public class RpcManagerActor extends ContextAwareActor { } private void onSessionClose(boolean reconnect, ServerAddress remoteAddress) { - log.debug("[{}] session closed. Should reconnect: {}", remoteAddress, reconnect); + log.info("[{}] session closed. Should reconnect: {}", remoteAddress, reconnect); SessionActorInfo sessionRef = sessionActors.get(remoteAddress); - if (context().sender() != null && context().sender().equals(sessionRef.actor)) { + if (sessionRef != null && context().sender() != null && context().sender().equals(sessionRef.actor)) { sessionActors.remove(remoteAddress); pendingMsgs.remove(remoteAddress); if (reconnect) { @@ -182,18 +182,18 @@ public class RpcManagerActor extends ContextAwareActor { private void register(ServerAddress remoteAddress, UUID uuid, ActorRef sender) { sessionActors.put(remoteAddress, new SessionActorInfo(uuid, sender)); - log.debug("[{}][{}] Registering session actor.", remoteAddress, uuid); + log.info("[{}][{}] Registering session actor.", remoteAddress, uuid); Queue data = pendingMsgs.remove(remoteAddress); if (data != null) { - log.debug("[{}][{}] Forwarding {} pending messages.", remoteAddress, uuid, data.size()); + log.info("[{}][{}] Forwarding {} pending messages.", remoteAddress, uuid, data.size()); data.forEach(msg -> sender.tell(new RpcSessionTellMsg(msg), ActorRef.noSender())); } else { - log.debug("[{}][{}] No pending messages to forward.", remoteAddress, uuid); + log.info("[{}][{}] No pending messages to forward.", remoteAddress, uuid); } } private ActorRef createSessionActor(RpcSessionCreateRequestMsg msg) { - log.debug("[{}] Creating session actor.", msg.getMsgUid()); + log.info("[{}] Creating session actor.", msg.getMsgUid()); ActorRef actor = context().actorOf( Props.create(new RpcSessionActor.ActorCreator(systemContext, msg.getMsgUid())).withDispatcher(DefaultActorService.RPC_DISPATCHER_NAME)); actor.tell(msg, context().self()); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java index f9b057c463..08f74fbe54 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java @@ -19,6 +19,5 @@ package org.thingsboard.server.common.msg.cluster; * Created by ashvayka on 23.09.18. */ public enum ServerType { - //Should match content of enum in discovery.proto. - CORE, JS_EVALUATOR + CORE } From 34c149de81c04aa69fa394379a542f4d494a6ce9 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 26 Oct 2018 16:25:38 +0300 Subject: [PATCH 269/307] Minor logging improvements --- .../org/thingsboard/server/actors/rpc/RpcManagerActor.java | 3 +-- .../server/dao/nosql/CassandraBufferedRateExecutor.java | 3 ++- .../server/dao/util/AbstractBufferedRateExecutor.java | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java index 5dcbe32001..66174e1e4b 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java @@ -45,7 +45,7 @@ public class RpcManagerActor extends ContextAwareActor { private final ServerAddress instance; - public RpcManagerActor(ActorSystemContext systemContext) { + RpcManagerActor(ActorSystemContext systemContext) { super(systemContext); this.sessionActors = new HashMap<>(); this.pendingMsgs = new HashMap<>(); @@ -55,7 +55,6 @@ public class RpcManagerActor extends ContextAwareActor { .filter(otherServer -> otherServer.getServerAddress().compareTo(instance) > 0) .forEach(otherServer -> onCreateSessionRequest( new RpcSessionCreateRequestMsg(UUID.randomUUID(), otherServer.getServerAddress(), null))); - } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java index c999ef4712..a3490bffdd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java @@ -48,7 +48,8 @@ public class CassandraBufferedRateExecutor extends AbstractBufferedRateExecutor< @Scheduled(fixedDelayString = "${cassandra.query.rate_limit_print_interval_ms}") public void printStats() { - log.info("Permits totalAdded [{}] totalLaunched [{}] totalReleased [{}] totalFailed [{}] totalExpired [{}] totalRejected [{}] currBuffer [{}] ", + log.info("Permits queueSize [{}] totalAdded [{}] totalLaunched [{}] totalReleased [{}] totalFailed [{}] totalExpired [{}] totalRejected [{}] currBuffer [{}] ", + getQueueSize(), totalAdded.getAndSet(0), totalLaunched.getAndSet(0), totalReleased.getAndSet(0), totalFailed.getAndSet(0), totalExpired.getAndSet(0), totalRejected.getAndSet(0), concurrencyLevel.get()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java index c5be08668d..7901155ed7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java @@ -166,4 +166,8 @@ public abstract class AbstractBufferedRateExecutor Date: Fri, 26 Oct 2018 17:19:57 +0300 Subject: [PATCH 270/307] MSA docker: Update Haproxy certbot image. --- docker/docker-compose.yml | 2 +- docker/haproxy/config/haproxy.cfg | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 37c5cf2629..1741db3694 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -146,7 +146,7 @@ services: haproxy: restart: always container_name: haproxy-certbot - image: nmarus/haproxy-certbot + image: xalauc/haproxy-certbot:1.7.9 volumes: - ./haproxy/config:/config - ./haproxy/letsencrypt:/etc/letsencrypt diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg index 29de26680b..8ba0ce50c7 100644 --- a/docker/haproxy/config/haproxy.cfg +++ b/docker/haproxy/config/haproxy.cfg @@ -23,6 +23,13 @@ defaults timeout connect 5000ms timeout client 50000ms timeout server 50000ms + timeout tunnel 1h # timeout to use with WebSocket and CONNECT + + default-server init-addr none + +#enable resolving throught docker dns and avoid crashing if service is down while proxy is starting +resolvers docker_resolver + nameserver dns 127.0.0.11:53 listen stats bind *:9999 @@ -39,8 +46,8 @@ listen mqtt-in timeout server 3h option tcplog balance leastconn - server tbMqtt1 tb-mqtt-transport1:1883 check - server tbMqtt2 tb-mqtt-transport2:1883 check + server tbMqtt1 tb-mqtt-transport1:1883 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbMqtt2 tb-mqtt-transport2:1883 check inter 5s resolvers docker_resolver resolve-prefer ipv4 frontend http-in bind *:${HTTP_PORT} @@ -81,20 +88,20 @@ backend tb-web-backend balance leastconn option tcp-check option log-health-checks - server tbWeb1 tb-web-ui1:8080 check - server tbWeb2 tb-web-ui2:8080 check + server tbWeb1 tb-web-ui1:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbWeb2 tb-web-ui2:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 http-request set-header X-Forwarded-Port %[dst_port] backend tb-http-backend balance leastconn option tcp-check option log-health-checks - server tbHttp1 tb-http-transport1:8081 check - server tbHttp2 tb-http-transport2:8081 check + server tbHttp1 tb-http-transport1:8081 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbHttp2 tb-http-transport2:8081 check inter 5s resolvers docker_resolver resolve-prefer ipv4 backend tb-api-backend balance leastconn option tcp-check option log-health-checks - server tbApi1 tb1:8080 check - server tbApi2 tb2:8080 check + server tbApi1 tb1:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbApi2 tb2:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 From 18662201f14934b62d6388c873370dbc63db34cf Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 26 Oct 2018 17:54:58 +0300 Subject: [PATCH 271/307] Improved shutdown for BufferedRateExecutor --- .../dao/util/AbstractBufferedRateExecutor.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java index 7901155ed7..48ba8e0954 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - * + *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -26,6 +26,7 @@ import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -45,6 +46,7 @@ public abstract class AbstractBufferedRateExecutor taskCtx = null; try { if (curLvl <= concurrencyLimit) { - taskCtx = queue.take(); + taskCtx = queue.poll(1, TimeUnit.SECONDS); + if (taskCtx == null) { + continue; + } final AsyncTaskContext finalTaskCtx = taskCtx; logTask("Processing", finalTaskCtx); concurrencyLevel.incrementAndGet(); @@ -167,7 +172,7 @@ public abstract class AbstractBufferedRateExecutor Date: Fri, 26 Oct 2018 17:55:33 +0300 Subject: [PATCH 272/307] Improved shutdown for BufferedRateExecutor --- .../server/dao/util/AbstractBufferedRateExecutor.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java index 48ba8e0954..997142ece1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2018 The Thingsboard Authors - *

+ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this 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, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. From 0723988061a74ddf06db72db7903bfaecfa51ab4 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 26 Oct 2018 18:46:27 +0300 Subject: [PATCH 273/307] Fixed Shutdown behaviour --- .../server/service/rpc/DefaultDeviceRpcService.java | 6 +++--- .../server/dao/util/AbstractBufferedRateExecutor.java | 9 +++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java index 64f5213d65..7aa2505d4b 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java @@ -90,7 +90,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { @Override public void processRestAPIRpcRequestToRuleEngine(ToDeviceRpcRequest request, Consumer responseConsumer) { - log.trace("[{}] Processing local rpc call to rule engine [{}]", request.getTenantId(), request.getDeviceId()); + log.trace("[{}][{}] Processing local rpc call to rule engine [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); UUID requestId = request.getId(); localToRuleEngineRpcRequests.put(requestId, responseConsumer); sendRpcRequestToRuleEngine(request); @@ -110,7 +110,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { @Override public void processRpcRequestToDevice(ToDeviceRpcRequest request, Consumer responseConsumer) { - log.trace("[{}] Processing local rpc call to device [{}]", request.getTenantId(), request.getDeviceId()); + log.trace("[{}][{}] Processing local rpc call to device [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); UUID requestId = request.getId(); localToDeviceRpcRequests.put(requestId, responseConsumer); sendRpcRequestToDevice(request); @@ -119,7 +119,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { @Override public void processRpcResponseFromDevice(FromDeviceRpcResponse response) { - log.trace("[{}] response to request: [{}]", this.hashCode(), response.getId()); + log.trace("response to request: [{}]", response.getId()); if (routingService.getCurrentServer().equals(response.getServerAddress())) { UUID requestId = response.getId(); Consumer consumer = localToDeviceRpcRequests.remove(requestId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java index 997142ece1..96d3870dcd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java @@ -26,7 +26,6 @@ import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -46,7 +45,6 @@ public abstract class AbstractBufferedRateExecutor taskCtx = null; try { if (curLvl <= concurrencyLimit) { - taskCtx = queue.poll(1, TimeUnit.SECONDS); - if (taskCtx == null) { - continue; - } + taskCtx = queue.take(); final AsyncTaskContext finalTaskCtx = taskCtx; logTask("Processing", finalTaskCtx); concurrencyLevel.incrementAndGet(); @@ -151,6 +146,8 @@ public abstract class AbstractBufferedRateExecutor Date: Fri, 26 Oct 2018 18:53:12 +0300 Subject: [PATCH 274/307] Few minor improvements --- .../server/service/rpc/DefaultDeviceRpcService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java index 7aa2505d4b..e13f8656d3 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java @@ -119,7 +119,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { @Override public void processRpcResponseFromDevice(FromDeviceRpcResponse response) { - log.trace("response to request: [{}]", response.getId()); + log.trace("[{}] Received device RPC response from server: [{}]", response.getId(), response.getServerAddress()); if (routingService.getCurrentServer().equals(response.getServerAddress())) { UUID requestId = response.getId(); Consumer consumer = localToDeviceRpcRequests.remove(requestId); @@ -151,7 +151,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { throw new RuntimeException(e); } RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; - FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()), routingService.getCurrentServer(), + FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()), serverAddress, proto.getResponse(), error); processRpcResponseFromDevice(response); } From 7d14d46edf693a25a933cc57fe1829bbce090730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20M=C3=BCcahit=20=C3=87etinkaya?= Date: Fri, 26 Oct 2018 19:31:35 +0300 Subject: [PATCH 275/307] check and recover currentServer zNode on zk. --- .../cluster/discovery/ZkDiscoveryService.java | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java index 6f51ceed26..c1876d6b1b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java @@ -29,6 +29,7 @@ import org.apache.curator.framework.state.ConnectionStateListener; import org.apache.curator.retry.RetryForever; import org.apache.curator.utils.CloseableUtils; import org.apache.zookeeper.CreateMode; +import org.apache.zookeeper.KeeperException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -49,6 +50,8 @@ import java.util.NoSuchElementException; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; +import static org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent.Type.CHILD_REMOVED; + /** * @author Andrew Shvayka */ @@ -121,19 +124,42 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } @Override - public void publishCurrentServer() { + public synchronized void publishCurrentServer() { + ServerInstance self = this.serverInstance.getSelf(); + if (currentServerExists()) { + log.info("[{}:{}] ZK node for current instance already exists, NOT created new one: {}", self.getHost(), self.getPort(), nodePath); + } else { + try { + log.info("[{}:{}] Creating ZK node for current instance", self.getHost(), self.getPort()); + nodePath = client.create() + .creatingParentsIfNeeded() + .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress())); + log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath); + client.getConnectionStateListenable().addListener(checkReconnect(self)); + } catch (Exception e) { + log.error("Failed to create ZK node", e); + throw new RuntimeException(e); + } + } + } + + private boolean currentServerExists() { + if (nodePath == null) { + return false; + } try { ServerInstance self = this.serverInstance.getSelf(); - log.info("[{}:{}] Creating ZK node for current instance", self.getHost(), self.getPort()); - nodePath = client.create() - .creatingParentsIfNeeded() - .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress())); - log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath); - client.getConnectionStateListenable().addListener(checkReconnect(self)); + ServerAddress registeredServerAdress = null; + registeredServerAdress = SerializationUtils.deserialize(client.getData().forPath(nodePath)); + if (self.getServerAddress() != null && self.getServerAddress().equals(registeredServerAdress)) { + return true; + } + } catch (KeeperException.NoNodeException e) { + log.info("ZK node does not exist: {}", nodePath); } catch (Exception e) { - log.error("Failed to create ZK node", e); - throw new RuntimeException(e); + log.error("Couldn't check if ZK node exists", e); } + return false; } private ConnectionStateListener checkReconnect(ServerInstance self) { @@ -221,6 +247,10 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi log.debug("Ignoring {} due to empty child's data", pathChildrenCacheEvent); return; } else if (nodePath != null && nodePath.equals(data.getPath())) { + if (pathChildrenCacheEvent.getType() == CHILD_REMOVED) { + log.info("ZK node for current instance is somehow deleted."); + publishCurrentServer(); + } log.debug("Ignoring event about current server {}", pathChildrenCacheEvent); return; } From aa4787e9891c1ab332568017579eb53fe0fbeff9 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Sun, 28 Oct 2018 16:10:16 +0200 Subject: [PATCH 276/307] Fix to RPC logic --- .../device/DeviceActorMessageProcessor.java | 13 ++-- .../actors/ruleChain/DefaultTbContext.java | 15 +++- .../server/actors/service/ActorService.java | 1 - .../actors/service/DefaultActorService.java | 10 +-- .../server/controller/BaseController.java | 3 +- .../service/rpc/DefaultDeviceRpcService.java | 68 ++++++++++--------- .../server/service/rpc/DeviceRpcService.java | 10 +-- .../service/rpc/FromDeviceRpcResponse.java | 2 - .../state/DefaultDeviceStateService.java | 3 +- application/src/main/proto/cluster.proto | 3 +- ...AbstractRuleEngineFlowIntegrationTest.java | 5 +- ...actRuleEngineLifecycleIntegrationTest.java | 3 +- .../msg/system/ServiceToRuleEngineMsg.java | 4 +- .../src/main/proto/transport.proto | 19 +++++- msa/pom.xml | 1 - .../api/RuleEngineDeviceRpcRequest.java | 2 + .../rule/engine/rpc/TbSendRPCRequestNode.java | 6 ++ 17 files changed, 100 insertions(+), 68 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 05902a6146..021f26207a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -41,7 +41,6 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; import org.thingsboard.server.common.msg.session.SessionMsgType; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; @@ -152,7 +151,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso if (request.isOneway() && sent) { logger.debug("[{}] Rpc command response sent [{}]!", deviceId, request.getId()); - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(msg.getMsg().getId(), msg.getServerAddress(), null, null)); + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(msg.getMsg().getId(), null, null)); } else { registerPendingRpcRequest(context, msg, sent, rpcRequest, timeout); } @@ -174,8 +173,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(msg.getId()); if (requestMd != null) { logger.debug("[{}] RPC request [{}] timeout detected!", deviceId, msg.getId()); - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), - requestMd.getMsg().getServerAddress(), null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), + null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); } } @@ -207,7 +206,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso ToDeviceRpcRequestBody body = request.getBody(); if (request.isOneway()) { sentOneWayIds.add(entry.getKey()); - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(request.getId(), requestActorMsg.getServerAddress(), null, null)); + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(request.getId(), null, null)); } ToDeviceRpcRequestMsg rpcRequest = ToDeviceRpcRequestMsg.newBuilder().setRequestId( entry.getKey()).setMethodName(body.getMethod()).setParams(body.getParams()).build(); @@ -400,8 +399,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); boolean success = requestMd != null; if (success) { - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), - requestMd.getMsg().getServerAddress(), responseMsg.getPayload(), null)); + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), + responseMsg.getPayload(), null)); } else { logger.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); } diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 4e779d452b..0baeceabc3 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -17,6 +17,7 @@ package org.thingsboard.server.actors.ruleChain; import akka.actor.ActorRef; import com.datastax.driver.core.utils.UUIDs; +import org.springframework.util.StringUtils; import org.thingsboard.rule.engine.api.ListeningExecutor; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcRequest; @@ -35,6 +36,8 @@ import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; @@ -232,16 +235,22 @@ class DefaultTbContext implements TbContext { return new RuleEngineRpcService() { @Override public void sendRpcReply(DeviceId deviceId, int requestId, String body) { - mainCtx.getDeviceRpcService().sendRpcReplyToDevice(nodeCtx.getTenantId(), deviceId, requestId, body); + mainCtx.getDeviceRpcService().sendReplyToRpcCallFromDevice(nodeCtx.getTenantId(), deviceId, requestId, body); } @Override public void sendRpcRequest(RuleEngineDeviceRpcRequest src, Consumer consumer) { ToDeviceRpcRequest request = new ToDeviceRpcRequest(src.getRequestUUID(), nodeCtx.getTenantId(), src.getDeviceId(), src.isOneway(), src.getExpirationTime(), new ToDeviceRpcRequestBody(src.getMethod(), src.getBody())); - mainCtx.getDeviceRpcService().processRpcRequestToDevice(request, response -> { + mainCtx.getDeviceRpcService().forwardServerSideRPCRequestToDeviceActor(request, response -> { if (src.isRestApiCall()) { - mainCtx.getDeviceRpcService().processRestAPIRpcResponseFromRuleEngine(response); + ServerAddress requestOriginAddress; + if (!StringUtils.isEmpty(src.getOriginHost())) { + requestOriginAddress = new ServerAddress(src.getOriginHost(), src.getOriginPort(), ServerType.CORE); + } else { + requestOriginAddress = mainCtx.getRoutingService().getCurrentServer(); + } + mainCtx.getDeviceRpcService().processResponseToServerSideRPCRequestFromRuleEngine(requestOriginAddress, response); } consumer.accept(RuleEngineDeviceRpcResponse.builder() .deviceId(src.getDeviceId()) diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java index f7e80e4baa..08d1dd8a11 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java @@ -35,5 +35,4 @@ public interface ActorService extends SessionMsgProcessor, RpcMsgListener, Disco void onDeviceNameOrTypeUpdate(TenantId tenantId, DeviceId deviceId, String deviceName, String deviceType); - void onMsg(ServiceToRuleEngineMsg serviceToRuleEngineMsg); } diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java index 542a82f160..85b8943569 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -42,7 +42,6 @@ import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.cluster.ToAllNodesMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; -import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import org.thingsboard.server.service.cluster.discovery.DiscoveryService; import org.thingsboard.server.service.cluster.discovery.ServerInstance; @@ -159,11 +158,6 @@ public class DefaultActorService implements ActorService { appActor.tell(new SendToClusterMsg(deviceId, msg), ActorRef.noSender()); } - @Override - public void onMsg(ServiceToRuleEngineMsg msg) { - appActor.tell(msg, ActorRef.noSender()); - } - public void broadcast(ToAllNodesMsg msg) { actorContext.getEncodingService().encode(msg); rpcService.broadcast(new RpcBroadcastMsg(ClusterAPIProtos.ClusterMessage @@ -185,7 +179,7 @@ public class DefaultActorService implements ActorService { ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort(), source.getServerType()); if (log.isDebugEnabled()) { log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress); - log.info("MSG: ", msg); + log.info("MSG: {}", msg); } switch (msg.getMessageType()) { case CLUSTER_ACTOR_MESSAGE: @@ -219,7 +213,7 @@ public class DefaultActorService implements ActorService { actorContext.getTsSubService().onRemoteTsUpdate(serverAddress, msg.getPayload().toByteArray()); break; case CLUSTER_RPC_FROM_DEVICE_RESPONSE_MESSAGE: - actorContext.getDeviceRpcService().processRemoteResponseFromDevice(serverAddress, msg.getPayload().toByteArray()); + actorContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromRemoteServer(serverAddress, msg.getPayload().toByteArray()); break; case CLUSTER_DEVICE_STATE_SERVICE_MESSAGE: actorContext.getDeviceStateService().onRemoteMsg(serverAddress, msg.getPayload().toByteArray()); diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 582bbedebd..be1b6db1d9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; @@ -673,7 +674,7 @@ public abstract class BaseController { TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), msgType, entityId, metaData, TbMsgDataType.JSON , json.writeValueAsString(entityNode) , null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(user.getTenantId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(entityId, new ServiceToRuleEngineMsg(user.getTenantId(), tbMsg))); } catch (Exception e) { log.warn("[{}] Failed to push entity action to rule engine: {}", entityId, actionType, e); } diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java index e13f8656d3..a65c1a5771 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java @@ -90,7 +90,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { @Override public void processRestAPIRpcRequestToRuleEngine(ToDeviceRpcRequest request, Consumer responseConsumer) { - log.trace("[{}][{}] Processing local rpc call to rule engine [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); + log.trace("[{}][{}] Processing REST API call to rule engine [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); UUID requestId = request.getId(); localToRuleEngineRpcRequests.put(requestId, responseConsumer); sendRpcRequestToRuleEngine(request); @@ -98,31 +98,11 @@ public class DefaultDeviceRpcService implements DeviceRpcService { } @Override - public void processRestAPIRpcResponseFromRuleEngine(FromDeviceRpcResponse response) { - UUID requestId = response.getId(); - Consumer consumer = localToRuleEngineRpcRequests.remove(requestId); - if (consumer != null) { - consumer.accept(response); - } else { - log.trace("[{}] Unknown or stale rpc response received [{}]", requestId, response); - } - } - - @Override - public void processRpcRequestToDevice(ToDeviceRpcRequest request, Consumer responseConsumer) { - log.trace("[{}][{}] Processing local rpc call to device [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); - UUID requestId = request.getId(); - localToDeviceRpcRequests.put(requestId, responseConsumer); - sendRpcRequestToDevice(request); - scheduleTimeout(request, requestId, localToDeviceRpcRequests); - } - - @Override - public void processRpcResponseFromDevice(FromDeviceRpcResponse response) { - log.trace("[{}] Received device RPC response from server: [{}]", response.getId(), response.getServerAddress()); - if (routingService.getCurrentServer().equals(response.getServerAddress())) { + public void processResponseToServerSideRPCRequestFromRuleEngine(ServerAddress requestOriginAddress, FromDeviceRpcResponse response) { + log.trace("[{}] Received response to server-side RPC request from rule engine: [{}]", response.getId(), requestOriginAddress); + if (routingService.getCurrentServer().equals(requestOriginAddress)) { UUID requestId = response.getId(); - Consumer consumer = localToDeviceRpcRequests.remove(requestId); + Consumer consumer = localToRuleEngineRpcRequests.remove(requestId); if (consumer != null) { consumer.accept(response); } else { @@ -138,12 +118,33 @@ public class DefaultDeviceRpcService implements DeviceRpcService { } else { builder.setError(-1); } - rpcService.tell(response.getServerAddress(), ClusterAPIProtos.MessageType.CLUSTER_RPC_FROM_DEVICE_RESPONSE_MESSAGE, builder.build().toByteArray()); + rpcService.tell(requestOriginAddress, ClusterAPIProtos.MessageType.CLUSTER_RPC_FROM_DEVICE_RESPONSE_MESSAGE, builder.build().toByteArray()); + } + } + + @Override + public void forwardServerSideRPCRequestToDeviceActor(ToDeviceRpcRequest request, Consumer responseConsumer) { + log.trace("[{}][{}] Processing local rpc call to device actor [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); + UUID requestId = request.getId(); + localToDeviceRpcRequests.put(requestId, responseConsumer); + sendRpcRequestToDevice(request); + scheduleTimeout(request, requestId, localToDeviceRpcRequests); + } + + @Override + public void processResponseToServerSideRPCRequestFromDeviceActor(FromDeviceRpcResponse response) { + log.trace("[{}] Received response to server-side RPC request from device actor.", response.getId()); + UUID requestId = response.getId(); + Consumer consumer = localToDeviceRpcRequests.remove(requestId); + if (consumer != null) { + consumer.accept(response); + } else { + log.trace("[{}] Unknown or stale rpc response received [{}]", requestId, response); } } @Override - public void processRemoteResponseFromDevice(ServerAddress serverAddress, byte[] data) { + public void processResponseToServerSideRPCRequestFromRemoteServer(ServerAddress serverAddress, byte[] data) { ClusterAPIProtos.FromDeviceRPCResponseProto proto; try { proto = ClusterAPIProtos.FromDeviceRPCResponseProto.parseFrom(data); @@ -151,13 +152,12 @@ public class DefaultDeviceRpcService implements DeviceRpcService { throw new RuntimeException(e); } RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; - FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()), serverAddress, - proto.getResponse(), error); - processRpcResponseFromDevice(response); + FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()), proto.getResponse(), error); + processResponseToServerSideRPCRequestFromRuleEngine(routingService.getCurrentServer(), response); } @Override - public void sendRpcReplyToDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body) { + public void sendReplyToRpcCallFromDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body) { ToServerRpcResponseActorMsg rpcMsg = new ToServerRpcResponseActorMsg(tenantId, deviceId, new ToServerRpcResponseMsg(requestId, body)); forward(deviceId, rpcMsg); } @@ -166,6 +166,8 @@ public class DefaultDeviceRpcService implements DeviceRpcService { ObjectNode entityNode = json.createObjectNode(); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("requestUUID", msg.getId().toString()); + metaData.putValue("originHost", routingService.getCurrentServer().getHost()); + metaData.putValue("originPort", Integer.toString(routingService.getCurrentServer().getPort())); metaData.putValue("expirationTime", Long.toString(msg.getExpirationTime())); metaData.putValue("oneway", Boolean.toString(msg.isOneway())); @@ -176,7 +178,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), DataConstants.RPC_CALL_FROM_SERVER_TO_DEVICE, msg.getDeviceId(), metaData, TbMsgDataType.JSON , json.writeValueAsString(entityNode) , null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(msg.getTenantId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(msg.getDeviceId(), new ServiceToRuleEngineMsg(msg.getTenantId(), tbMsg))); } catch (JsonProcessingException e) { throw new RuntimeException(e); } @@ -199,7 +201,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { log.trace("[{}] timeout the request: [{}]", this.hashCode(), requestId); Consumer consumer = requestsMap.remove(requestId); if (consumer != null) { - consumer.accept(new FromDeviceRpcResponse(requestId, null, null, RpcError.TIMEOUT)); + consumer.accept(new FromDeviceRpcResponse(requestId, null, RpcError.TIMEOUT)); } }, timeout, TimeUnit.MILLISECONDS); } diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java index 4cee96ee71..bf12ec664c 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java @@ -29,13 +29,13 @@ public interface DeviceRpcService { void processRestAPIRpcRequestToRuleEngine(ToDeviceRpcRequest request, Consumer responseConsumer); - void processRestAPIRpcResponseFromRuleEngine(FromDeviceRpcResponse response); + void processResponseToServerSideRPCRequestFromRuleEngine(ServerAddress requestOriginAddress, FromDeviceRpcResponse response); - void processRpcRequestToDevice(ToDeviceRpcRequest request, Consumer responseConsumer); + void forwardServerSideRPCRequestToDeviceActor(ToDeviceRpcRequest request, Consumer responseConsumer); - void processRpcResponseFromDevice(FromDeviceRpcResponse response); + void processResponseToServerSideRPCRequestFromDeviceActor(FromDeviceRpcResponse response); - void sendRpcReplyToDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body); + void processResponseToServerSideRPCRequestFromRemoteServer(ServerAddress serverAddress, byte[] data); - void processRemoteResponseFromDevice(ServerAddress serverAddress, byte[] bytes); + void sendReplyToRpcCallFromDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body); } diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java b/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java index 9c3ce9a834..75506dfacc 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java @@ -32,8 +32,6 @@ import java.util.UUID; public class FromDeviceRpcResponse { @Getter private final UUID id; - @Getter - private final ServerAddress serverAddress; private final String response; private final RpcError error; diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index f4e37db971..610cd3b72a 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -43,6 +43,7 @@ import org.thingsboard.server.common.data.plugin.ComponentLifecycleState; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.attributes.AttributesService; @@ -457,7 +458,7 @@ public class DefaultDeviceStateService implements DeviceStateService { TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), msgType, stateData.getDeviceId(), stateData.getMetaData().copy(), TbMsgDataType.JSON , json.writeValueAsString(state) , null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(stateData.getTenantId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(stateData.getDeviceId(), new ServiceToRuleEngineMsg(stateData.getTenantId(), tbMsg))); } catch (Exception e) { log.warn("[{}] Failed to push inactivity alarm: {}", stateData.getDeviceId(), state, e); } diff --git a/application/src/main/proto/cluster.proto b/application/src/main/proto/cluster.proto index 21c963b15e..1940b36c12 100644 --- a/application/src/main/proto/cluster.proto +++ b/application/src/main/proto/cluster.proto @@ -22,6 +22,7 @@ option java_outer_classname = "ClusterAPIProtos"; service ClusterRpcService { rpc handleMsgs(stream ClusterMessage) returns (stream ClusterMessage) {} } + message ClusterMessage { MessageType messageType = 1; MessageMataInfo messageMetaInfo = 2; @@ -139,4 +140,4 @@ message DeviceStateServiceMsgProto { bool added = 5; bool updated = 6; bool deleted = 7; -} \ No newline at end of file +} diff --git a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java index f050f3b505..1de2787af6 100644 --- a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java @@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.controller.AbstractRuleEngineControllerTest; import org.thingsboard.server.dao.attributes.AttributesService; @@ -155,7 +156,7 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule device.getId(), new TbMsgMetaData(), "{}", null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(device.getId(), new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg))); Thread.sleep(3000); @@ -270,7 +271,7 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule device.getId(), new TbMsgMetaData(), "{}", null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(device.getId(), new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg))); Thread.sleep(3000); diff --git a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java index 24db4570bd..f59dd63614 100644 --- a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java @@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.controller.AbstractRuleEngineControllerTest; import org.thingsboard.server.dao.attributes.AttributesService; @@ -142,7 +143,7 @@ public abstract class AbstractRuleEngineLifecycleIntegrationTest extends Abstrac new TbMsgMetaData(), "{}", null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(device.getId(), new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg))); Thread.sleep(3000); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java index 0792b63c28..7859623d52 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java @@ -21,11 +21,13 @@ import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbMsg; +import java.io.Serializable; + /** * Created by ashvayka on 15.03.18. */ @Data -public final class ServiceToRuleEngineMsg implements TbActorMsg { +public final class ServiceToRuleEngineMsg implements TbActorMsg, Serializable { private final TenantId tenantId; private final TbMsg tbMsg; diff --git a/common/transport/transport-api/src/main/proto/transport.proto b/common/transport/transport-api/src/main/proto/transport.proto index 0e36dabbf3..ff740d4ce4 100644 --- a/common/transport/transport-api/src/main/proto/transport.proto +++ b/common/transport/transport-api/src/main/proto/transport.proto @@ -172,6 +172,22 @@ message ToServerRpcResponseMsg { string error = 3; } +//Used to report session state to tb-node and persist this state in the cache on the tb-node level. +message SubscriptionInfoProto { + int64 lastActivityTime = 1; + bool attributeSubscription = 2; + bool rpcSubscription = 3; +} + +message SessionSubscriptionInfoProto { + SessionInfoProto sessionInfo = 1; + SubscriptionInfoProto subscriptionInfo = 2; +} + +message DeviceSessionsCacheEntry { + repeated SessionSubscriptionInfoProto sessions = 1; +} + message TransportToDeviceActorMsg { SessionInfoProto sessionInfo = 1; SessionEventMsg sessionEvent = 2; @@ -182,6 +198,7 @@ message TransportToDeviceActorMsg { SubscribeToRPCMsg subscribeToRPC = 7; ToDeviceRpcResponseMsg toDeviceRPCCallResponse = 8; ToServerRpcRequestMsg toServerRPCCallRequest = 9; + SubscriptionInfoProto subscriptionInfo = 10; } message DeviceActorToTransportMsg { @@ -214,4 +231,4 @@ message TransportApiRequestMsg { message TransportApiResponseMsg { ValidateDeviceCredentialsResponseMsg validateTokenResponseMsg = 1; GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 2; -} \ No newline at end of file +} diff --git a/msa/pom.xml b/msa/pom.xml index 5fd2212e1e..d6d6c8d6b3 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -23,7 +23,6 @@ 2.2.0-SNAPSHOT thingsboard - org.thingsboard msa pom diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java index 9a93d1e281..f3d57de602 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java @@ -31,6 +31,8 @@ public final class RuleEngineDeviceRpcRequest { private final DeviceId deviceId; private final int requestId; private final UUID requestUUID; + private final String originHost; + private final int originPort; private final boolean oneway; private final String method; private final String body; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java index 5d7e1241f7..8c95b887d2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java @@ -86,6 +86,10 @@ public class TbSendRPCRequestNode implements TbNode { tmp = msg.getMetaData().getValue("requestUUID"); UUID requestUUID = !StringUtils.isEmpty(tmp) ? UUID.fromString(tmp) : UUIDs.timeBased(); + tmp = msg.getMetaData().getValue("originHost"); + String originHost = !StringUtils.isEmpty(tmp) ? tmp : null; + tmp = msg.getMetaData().getValue("originPort"); + int originPort = !StringUtils.isEmpty(tmp) ? Integer.parseInt(tmp) : 0; tmp = msg.getMetaData().getValue("expirationTime"); long expirationTime = !StringUtils.isEmpty(tmp) ? Long.parseLong(tmp) : (System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(config.getTimeoutInSeconds())); @@ -105,6 +109,8 @@ public class TbSendRPCRequestNode implements TbNode { .deviceId(new DeviceId(msg.getOriginator().getId())) .requestId(requestId) .requestUUID(requestUUID) + .originHost(originHost) + .originPort(originPort) .expirationTime(expirationTime) .restApiCall(restApiCall) .build(); From 093eafb462cab211107761c6fa54f1aba54835cb Mon Sep 17 00:00:00 2001 From: Dima Landiak Date: Mon, 29 Oct 2018 17:48:20 +0200 Subject: [PATCH 277/307] delete timeseries fixes and controller, entityView findByName added --- .../controller/EntityViewController.java | 20 +++-- .../controller/TelemetryController.java | 67 +++++++++++++++- .../server/common/data/audit/ActionType.java | 11 +-- .../dao/entityview/EntityViewService.java | 2 + .../dao/entityview/EntityViewServiceImpl.java | 19 +++-- .../dao/sql/timeseries/JpaTimeseriesDao.java | 79 +++++++++++++++++-- .../dao/sql/timeseries/TsKvRepository.java | 2 +- .../CassandraBaseTimeseriesDao.java | 46 +++++++---- .../timeseries/BaseTimeseriesServiceTest.java | 22 +++++- 9 files changed, 224 insertions(+), 44 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 9b7ffd2d11..2a9c073d34 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -29,7 +29,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntitySubtype; @@ -39,7 +38,6 @@ import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; @@ -47,7 +45,6 @@ import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; -import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.service.security.model.SecurityUser; @@ -174,7 +171,7 @@ public class EntityViewController extends BaseController { EntityView entityView = checkEntityViewId(entityViewId); entityViewService.deleteEntityView(entityViewId); logEntityAction(entityViewId, entityView, entityView.getCustomerId(), - ActionType.DELETED,null, strEntityViewId); + ActionType.DELETED, null, strEntityViewId); } catch (Exception e) { logEntityAction(emptyId(EntityType.ENTITY_VIEW), null, @@ -184,11 +181,24 @@ public class EntityViewController extends BaseController { } } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/entityViews", params = {"entityViewName"}, method = RequestMethod.GET) + @ResponseBody + public EntityView getTenantEntityView( + @RequestParam String entityViewName) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName)); + } catch (Exception e) { + throw handleException(e); + } + } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) @ResponseBody public EntityView assignEntityViewToCustomer(@PathVariable(CUSTOMER_ID) String strCustomerId, - @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(CUSTOMER_ID, strCustomerId); checkParameter(ENTITY_VIEW_ID, strEntityViewId); try { diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index 6c37614147..ef09a7a62c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -49,9 +49,11 @@ import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; @@ -60,12 +62,10 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.telemetry.AttributeData; -import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.telemetry.TsData; import org.thingsboard.server.service.telemetry.exception.InvalidParametersException; import org.thingsboard.server.service.telemetry.exception.UncheckedApiException; @@ -249,6 +249,60 @@ public class TelemetryController extends BaseController { return saveTelemetry(entityId, requestBody, ttl); } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE) + @ResponseBody + public DeferredResult deleteEntityTimeseries(@PathVariable("entityType") String entityType, @PathVariable("entityId") String entityIdStr, + @RequestParam(name = "keys") String keysStr, + @RequestParam(name = "deleteAllDataForKeys", defaultValue = "false") boolean deleteAllDataForKeys, + @RequestParam(name = "startTs", required = false) Long startTs, + @RequestParam(name = "endTs", required = false) Long endTs, + @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted) throws ThingsboardException { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return deleteTimeseries(entityId, keysStr, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted); + } + + private DeferredResult deleteTimeseries(EntityId entityIdStr, String keysStr, boolean deleteAllDataForKeys, + Long startTs, Long endTs, boolean rewriteLatestIfDeleted) throws ThingsboardException { + List keys = toKeysList(keysStr); + if (keys.isEmpty()) { + return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST); + } + SecurityUser user = getCurrentUser(); + + long deleteFromTs; + long deleteToTs; + if (deleteAllDataForKeys) { + deleteFromTs = 0L; + deleteToTs = System.currentTimeMillis(); + } else { + deleteFromTs = startTs; + deleteToTs = endTs; + } + + return accessValidator.validateEntityAndCallback(user, entityIdStr, (result, entityId) -> { + List deleteTsKvQueries = new ArrayList<>(); + for (String key : keys) { + deleteTsKvQueries.add(new BaseDeleteTsKvQuery(key, deleteFromTs, deleteToTs, rewriteLatestIfDeleted)); + } + + ListenableFuture> future = tsService.remove(entityId, deleteTsKvQueries); + Futures.addCallback(future, new FutureCallback>() { + @Override + public void onSuccess(@Nullable List tmp) { + logTimeseriesDeleted(user, entityId, keys, null); + result.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable t) { + logTimeseriesDeleted(user, entityId, keys, t); + result.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }, executor); + }); + } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.DELETE) @ResponseBody @@ -506,6 +560,15 @@ public class TelemetryController extends BaseController { }; } + private void logTimeseriesDeleted(SecurityUser user, EntityId entityId, List keys, Throwable e) { + try { + logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.TIMESERIES_DELETED, toException(e), + keys); + } catch (ThingsboardException te) { + log.warn("Failed to log timeseries delete", te); + } + } + private void logAttributesDeleted(SecurityUser user, EntityId entityId, String scope, List keys, Throwable e) { try { logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.ATTRIBUTES_DELETED, toException(e), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java index c37d4607c3..822387cfcb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java @@ -24,6 +24,7 @@ public enum ActionType { UPDATED(false), // log entity ATTRIBUTES_UPDATED(false), // log attributes/values ATTRIBUTES_DELETED(false), // log attributes + TIMESERIES_DELETED(false), // log timeseries RPC_CALL(false), // log method and params CREDENTIALS_UPDATED(false), // log new credentials ASSIGNED_TO_CUSTOMER(false), // log customer name @@ -32,11 +33,11 @@ public enum ActionType { SUSPENDED(false), // log string id CREDENTIALS_READ(true), // log device id ATTRIBUTES_READ(true), // log attributes - RELATION_ADD_OR_UPDATE (false), - RELATION_DELETED (false), - RELATIONS_DELETED (false), - ALARM_ACK (false), - ALARM_CLEAR (false); + RELATION_ADD_OR_UPDATE(false), + RELATION_DELETED(false), + RELATIONS_DELETED(false), + ALARM_ACK(false), + ALARM_CLEAR(false); private final boolean isRead; diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index da87b44322..9c828663c1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -43,6 +43,8 @@ public interface EntityViewService { EntityView findEntityViewById(EntityViewId entityViewId); + EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name); + TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, TextPageLink pageLink, String type); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 2d94cc2eab..9f8949dc4e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -29,8 +29,6 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; -import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; @@ -40,12 +38,10 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; -import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.exception.DataValidationException; @@ -56,15 +52,13 @@ import org.thingsboard.server.dao.tenant.TenantDao; import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.List; -import java.util.concurrent.ExecutionException; +import java.util.Optional; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; -import static org.thingsboard.server.common.data.CacheConstants.RELATIONS_CACHE; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -96,6 +90,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti @Caching(evict = { @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.entityId}"), + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.name}"), @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}")}) @Override public EntityView saveEntityView(EntityView entityView) { @@ -137,6 +132,15 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti return entityViewDao.findById(entityViewId.getId()); } + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #name}") + @Override + public EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name) { + log.trace("Executing findEntityViewByTenantIdAndName [{}][{}]", tenantId, name); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + Optional entityViewOpt = entityViewDao.findEntityViewByTenantIdAndName(tenantId.getId(), name); + return entityViewOpt.orElse(null); + } + @Override public TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink) { log.trace("Executing findEntityViewsByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink); @@ -255,6 +259,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti deleteEntityRelations(entityViewId); EntityView entityView = entityViewDao.findById(entityViewId.getId()); cacheManager.getCache(ENTITY_VIEW_CACHE).evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); + cacheManager.getCache(ENTITY_VIEW_CACHE).evict(Arrays.asList(entityView.getTenantId(), entityView.getName())); entityViewDao.removeById(entityViewId.getId()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java index 5bd9175628..04227a3825 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.sql.timeseries; import com.google.common.base.Function; import com.google.common.collect.Lists; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; @@ -31,6 +32,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.UUIDConverter; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; @@ -41,9 +43,9 @@ import org.thingsboard.server.dao.model.sql.TsKvEntity; import org.thingsboard.server.dao.model.sql.TsKvLatestCompositeKey; import org.thingsboard.server.dao.model.sql.TsKvLatestEntity; import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; +import org.thingsboard.server.dao.timeseries.SimpleListenableFuture; import org.thingsboard.server.dao.timeseries.TimeseriesDao; import org.thingsboard.server.dao.timeseries.TsInsertExecutorType; -import org.thingsboard.server.dao.util.SqlDao; import org.thingsboard.server.dao.util.SqlTsDao; import javax.annotation.Nullable; @@ -53,6 +55,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.stream.Collectors; @@ -64,6 +67,8 @@ import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; @SqlTsDao public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService implements TimeseriesDao { + private static final String DESC_ORDER = "DESC"; + @Value("${sql.ts_inserts_executor_type}") private String insertExecutorType; @@ -326,14 +331,72 @@ public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService imp @Override public ListenableFuture removeLatest(EntityId entityId, DeleteTsKvQuery query) { - TsKvLatestEntity latestEntity = new TsKvLatestEntity(); - latestEntity.setEntityType(entityId.getEntityType()); - latestEntity.setEntityId(fromTimeUUID(entityId.getId())); - latestEntity.setKey(query.getKey()); - return service.submit(() -> { - tsKvLatestRepository.delete(latestEntity); - return null; + ListenableFuture latestFuture = findLatest(entityId, query.getKey()); + + ListenableFuture booleanFuture = Futures.transform(latestFuture, tsKvEntry -> { + long ts = tsKvEntry.getTs(); + return ts > query.getStartTs() && ts <= query.getEndTs(); + }, service); + + ListenableFuture removedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { + if (isRemove) { + TsKvLatestEntity latestEntity = new TsKvLatestEntity(); + latestEntity.setEntityType(entityId.getEntityType()); + latestEntity.setEntityId(fromTimeUUID(entityId.getId())); + latestEntity.setKey(query.getKey()); + return service.submit(() -> { + tsKvLatestRepository.delete(latestEntity); + return null; + }); + } + return Futures.immediateFuture(null); + }, service); + + final SimpleListenableFuture resultFuture = new SimpleListenableFuture<>(); + Futures.addCallback(removedLatestFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + if (query.getRewriteLatestIfDeleted()) { + ListenableFuture savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { + if (isRemove) { + return getNewLatestEntryFuture(entityId, query); + } + return Futures.immediateFuture(null); + }, service); + + try { + resultFuture.set(savedLatestFuture.get()); + } catch (InterruptedException | ExecutionException e) { + log.warn("Could not get latest saved value for [{}], {}", entityId, query.getKey(), e); + } + } else { + resultFuture.set(null); + } + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process remove of the latest value", entityId, t); + } }); + return resultFuture; + } + + private ListenableFuture getNewLatestEntryFuture(EntityId entityId, DeleteTsKvQuery query) { + long startTs = 0; + long endTs = query.getStartTs() - 1; + ReadTsKvQuery findNewLatestQuery = new BaseReadTsKvQuery(query.getKey(), startTs, endTs, endTs - startTs, 1, + Aggregation.NONE, DESC_ORDER); + ListenableFuture> future = findAllAsync(entityId, findNewLatestQuery); + + return Futures.transformAsync(future, entryList -> { + if (entryList.size() == 1) { + return saveLatest(entityId, entryList.get(0)); + } else { + log.trace("Could not find new latest value for [{}], key - {}", entityId, query.getKey()); + } + return Futures.immediateFuture(null); + }, service); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java index 4c743e5ab6..296d173cf7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java @@ -47,7 +47,7 @@ public interface TsKvRepository extends CrudRepository :startTs AND tskv.ts < :endTs") + "AND tskv.ts > :startTs AND tskv.ts <= :endTs") void delete(@Param("entityId") String entityId, @Param("entityType") EntityType entityType, @Param("entityKey") String key, diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index 709bfd5432..fdc69f9262 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -48,7 +48,6 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.nosql.CassandraAbstractAsyncDao; -import org.thingsboard.server.dao.util.NoSqlDao; import org.thingsboard.server.dao.util.NoSqlTsDao; import javax.annotation.Nullable; @@ -62,6 +61,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; @@ -434,14 +434,14 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem public ListenableFuture removeLatest(EntityId entityId, DeleteTsKvQuery query) { ListenableFuture latestEntryFuture = findLatest(entityId, query.getKey()); - ListenableFuture booleanFuture = Futures.transformAsync(latestEntryFuture, latestEntry -> { + ListenableFuture booleanFuture = Futures.transform(latestEntryFuture, latestEntry -> { long ts = latestEntry.getTs(); - if (ts >= query.getStartTs() && ts <= query.getEndTs()) { - return Futures.immediateFuture(true); + if (ts > query.getStartTs() && ts <= query.getEndTs()) { + return true; } else { log.trace("Won't be deleted latest value for [{}], key - {}", entityId, query.getKey()); } - return Futures.immediateFuture(false); + return false; }, readResultsProcessingExecutor); ListenableFuture removedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { @@ -451,18 +451,34 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem return Futures.immediateFuture(null); }, readResultsProcessingExecutor); - if (query.getRewriteLatestIfDeleted()) { - ListenableFuture savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { - if (isRemove) { - return getNewLatestEntryFuture(entityId, query); + final SimpleListenableFuture resultFuture = new SimpleListenableFuture<>(); + Futures.addCallback(removedLatestFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + if (query.getRewriteLatestIfDeleted()) { + ListenableFuture savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { + if (isRemove) { + return getNewLatestEntryFuture(entityId, query); + } + return Futures.immediateFuture(null); + }, readResultsProcessingExecutor); + + try { + resultFuture.set(savedLatestFuture.get()); + } catch (InterruptedException | ExecutionException e) { + log.warn("Could not get latest saved value for [{}], {}", entityId, query.getKey(), e); + } + } else { + resultFuture.set(null); } - return Futures.immediateFuture(null); - }, readResultsProcessingExecutor); + } - return Futures.transformAsync(Futures.allAsList(Arrays.asList(savedLatestFuture, removedLatestFuture)), - list -> Futures.immediateFuture(null), readResultsProcessingExecutor); - } - return removedLatestFuture; + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process remove of the latest value", entityId, t); + } + }); + return resultFuture; } private ListenableFuture getNewLatestEntryFuture(EntityId entityId, DeleteTsKvQuery query) { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 88f4d84eb6..81de40ac82 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -152,7 +152,7 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { } @Test - public void testDeleteDeviceTsData() throws Exception { + public void testDeleteDeviceTsDataWithoutOverwritingLatest() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); saveEntries(deviceId, 10000); @@ -171,6 +171,26 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { Assert.assertEquals(null, latest.get(0).getValueAsString()); } + @Test + public void testDeleteDeviceTsDataWithOverwritingLatest() throws Exception { + DeviceId deviceId = new DeviceId(UUIDs.timeBased()); + + saveEntries(deviceId, 10000); + saveEntries(deviceId, 20000); + saveEntries(deviceId, 30000); + saveEntries(deviceId, 40000); + + tsService.remove(deviceId, Collections.singletonList( + new BaseDeleteTsKvQuery(STRING_KEY, 25000, 45000, true))).get(); + + List list = tsService.findAll(deviceId, Collections.singletonList( + new BaseReadTsKvQuery(STRING_KEY, 5000, 45000, 10000, 10, Aggregation.NONE))).get(); + Assert.assertEquals(2, list.size()); + + List latest = tsService.findLatest(deviceId, Collections.singletonList(STRING_KEY)).get(); + Assert.assertEquals(20000, latest.get(0).getTs()); + } + @Test public void testFindDeviceTsData() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); From fd251f0d9a82d38d228902a9c652b3b7679d2c9a Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 29 Oct 2018 18:28:15 +0200 Subject: [PATCH 278/307] Fix Turkish localization. Update angular material version to 1.1.9. Fix 0 digital gauges value when no animation. --- .../json/system/widget_bundles/cards.json | 2 +- ui/package.json | 4 +- ui/src/app/alarm/alarm-row.directive.js | 2 +- ui/src/app/app.js | 4 + ui/src/app/audit/audit-log-row.directive.js | 2 +- ui/src/app/common/thirdparty-fix.js | 195 ++++++++++++++++++ .../components/datasource-entity.directive.js | 4 +- .../app/components/datasource-entity.tpl.html | 2 - .../components/datasource-func.directive.js | 4 +- ui/src/app/components/json-form.directive.js | 2 +- .../material-icon-select.directive.js | 2 +- .../action/manage-widget-actions.directive.js | 4 +- ui/src/app/dashboard/add-widget.controller.js | 2 +- ui/src/app/dashboard/dashboard.controller.js | 12 +- ui/src/app/dashboard/edit-widget.directive.js | 2 +- .../manage-dashboard-layouts.controller.js | 2 +- .../manage-dashboard-states.controller.js | 4 +- .../entity/alias/entity-aliases.controller.js | 4 +- ...d-widget-to-dashboard-dialog.controller.js | 4 +- .../relation/relation-table.directive.js | 2 +- ui/src/app/event/event-row.directive.js | 2 +- .../extension/extension-table.directive.js | 2 +- .../import-export/import-export.service.js | 4 +- ui/src/app/layout/index.js | 2 - ui/src/app/locale/locale.constant-en_US.json | 2 +- ui/src/app/locale/locale.constant-es_ES.json | 2 +- ui/src/app/locale/locale.constant-fr_FR.json | 2 +- ui/src/app/locale/locale.constant-it_IT.json | 2 +- ui/src/app/locale/locale.constant-ja_JA.json | 2 +- ui/src/app/locale/locale.constant-ko_KR.json | 2 +- ui/src/app/locale/locale.constant-ru_RU.json | 2 +- ...ant-TR.json => locale.constant-tr_TR.json} | 128 ++++++------ ui/src/app/locale/locale.constant-zh_CN.json | 2 +- .../script/node-script-test.service.js | 2 +- ui/src/app/user/add-user.controller.js | 2 +- ui/src/app/user/user.controller.js | 2 +- ui/src/app/widget/lib/alarms-table-widget.js | 2 +- ui/src/app/widget/lib/canvas-digital-gauge.js | 2 +- 38 files changed, 309 insertions(+), 114 deletions(-) create mode 100644 ui/src/app/common/thirdparty-fix.js rename ui/src/app/locale/{locale.constant-TR.json => locale.constant-tr_TR.json} (94%) diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index f48ef37160..6550f58da4 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -15,7 +15,7 @@ "resources": [], "templateHtml": "", "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", - "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"

\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n console.log(self.ctx); //del\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var textValue;\n //toDo -> + IsNumber\n \n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (cellData.dataKey.decimals || cellData.dataKey.decimals === 0) {\n decimals = cellData.dataKey.decimals;\n }\n if (cellData.dataKey.units) {\n units = cellData.dataKey.units;\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCells[i].html(txtValue);\n }\n }\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasoirceTitleFontSize+'px');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size', valueFontSize+'px');\n self.ctx.valueCells[i].css('height', valueFontSize*2.5+'px');\n self.ctx.valueCells[i].css('padding', '0px ' + valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size', labelFontSize+'px');\n self.ctx.labelCells[i].css('height', labelFontSize*2.5+'px');\n self.ctx.labelCells[i].css('padding', '0px ' + labelFontSize + 'px');\n } \n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var textValue;\n //toDo -> + IsNumber\n \n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (cellData.dataKey.decimals || cellData.dataKey.decimals === 0) {\n decimals = cellData.dataKey.decimals;\n }\n if (cellData.dataKey.units) {\n units = cellData.dataKey.units;\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCells[i].html(txtValue);\n }\n }\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasoirceTitleFontSize+'px');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size', valueFontSize+'px');\n self.ctx.valueCells[i].css('height', valueFontSize*2.5+'px');\n self.ctx.valueCells[i].css('padding', '0px ' + valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size', labelFontSize+'px');\n self.ctx.labelCells[i].css('height', labelFontSize*2.5+'px');\n self.ctx.labelCells[i].css('padding', '0px ' + labelFontSize + 'px');\n } \n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"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\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Attributes card\"}" diff --git a/ui/package.json b/ui/package.json index 7874170159..9fb0cc54d7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,7 +26,7 @@ "angular-gridster": "^0.13.14", "angular-hotkeys": "^1.7.0", "angular-jwt": "^0.1.6", - "angular-material": "1.1.1", + "angular-material": "1.1.9", "angular-material-data-table": "^0.10.9", "angular-material-icons": "^0.7.1", "angular-material-expansion-panel": "^0.7.2", @@ -63,7 +63,7 @@ "leaflet-providers": "^1.1.17", "material-ui": "^0.16.1", "material-ui-number-input": "^5.0.16", - "md-color-picker": "^0.2.6", + "md-color-picker": "0.2.6", "mdPickers": "git://github.com/alenaksu/mdPickers.git#0.7.5", "moment": "^2.15.0", "ngclipboard": "^1.1.1", diff --git a/ui/src/app/alarm/alarm-row.directive.js b/ui/src/app/alarm/alarm-row.directive.js index 37eec7cc70..681498ff76 100644 --- a/ui/src/app/alarm/alarm-row.directive.js +++ b/ui/src/app/alarm/alarm-row.directive.js @@ -50,7 +50,7 @@ export default function AlarmRowDirective($compile, $templateCache, types, $mdDi parent: angular.element($document[0].body), targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/app.js b/ui/src/app/app.js index 824dbb0f69..31b53a0d03 100644 --- a/ui/src/app/app.js +++ b/ui/src/app/app.js @@ -31,6 +31,7 @@ import 'angular-translate-interpolation-messageformat'; import 'md-color-picker'; import mdPickers from 'mdPickers'; import ngSanitize from 'angular-sanitize'; +import FBAngular from 'angular-fullscreen'; import vAccordion from 'v-accordion'; import ngAnimate from 'angular-animate'; import 'angular-websocket'; @@ -65,6 +66,7 @@ import 'angular-material-expansion-panel/dist/md-expansion-panel.min.css'; import 'ngFlowchart/dist/flowchart.css'; import '../scss/main.scss'; +import thingsboardThirdpartyFix from './common/thirdparty-fix'; import thingsboardTranslateHandler from './locale/translate-handler'; import thingsboardLogin from './login'; import thingsboardDialogs from './components/datakey-config-dialog.controller'; @@ -105,6 +107,7 @@ angular.module('thingsboard', [ 'mdColorPicker', mdPickers, ngSanitize, + FBAngular.name, vAccordion, ngAnimate, 'ngWebSocket', @@ -118,6 +121,7 @@ angular.module('thingsboard', [ react.name, 'flow', 'flowchart', + thingsboardThirdpartyFix, thingsboardTranslateHandler, thingsboardLogin, thingsboardDialogs, diff --git a/ui/src/app/audit/audit-log-row.directive.js b/ui/src/app/audit/audit-log-row.directive.js index 2c2e170690..f13a0d27c8 100644 --- a/ui/src/app/audit/audit-log-row.directive.js +++ b/ui/src/app/audit/audit-log-row.directive.js @@ -48,7 +48,7 @@ export default function AuditLogRowDirective($compile, $templateCache, types, $m parent: angular.element($document[0].body), targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/common/thirdparty-fix.js b/ui/src/app/common/thirdparty-fix.js new file mode 100644 index 0000000000..b00a7822c5 --- /dev/null +++ b/ui/src/app/common/thirdparty-fix.js @@ -0,0 +1,195 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import tinycolor from 'tinycolor2'; + +export default angular.module('thingsboard.thirdpartyFix', []) + .factory('Fullscreen', Fullscreen) + .factory('$mdColorPicker', mdColorPicker) + .name; + +/*@ngInject*/ +function Fullscreen($document, $rootScope) { + + /* eslint-disable */ + + var document = $document[0]; + + // ensure ALLOW_KEYBOARD_INPUT is available and enabled + var isKeyboardAvailbleOnFullScreen = (typeof Element !== 'undefined' && 'ALLOW_KEYBOARD_INPUT' in Element) && Element.ALLOW_KEYBOARD_INPUT; + + var emitter = $rootScope.$new(); + + // listen event on document instead of element to avoid firefox limitation + // see https://developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Using_full_screen_mode + $document.on('fullscreenchange webkitfullscreenchange mozfullscreenchange MSFullscreenChange', function(){ + emitter.$emit('FBFullscreen.change', serviceInstance.isEnabled()); + }); + + var serviceInstance = { + $on: angular.bind(emitter, emitter.$on), + all: function() { + serviceInstance.enable( document.documentElement ); + }, + enable: function(element) { + if(element.requestFullScreen) { + element.requestFullScreen(); + } else if(element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if(element.webkitRequestFullscreen) { + // Safari temporary fix + //if (/Version\/[\d]{1,2}(\.[\d]{1,2}){1}(\.(\d){1,2}){0,1} Safari/.test(navigator.userAgent)) { + if (/Safari/.test(navigator.userAgent)) { + element.webkitRequestFullscreen(); + } else { + element.webkitRequestFullscreen(isKeyboardAvailbleOnFullScreen); + } + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } + }, + cancel: function() { + if(document.cancelFullScreen) { + document.cancelFullScreen(); + } else if(document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if(document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } + }, + isEnabled: function(){ + var fullscreenElement = document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement; + return fullscreenElement ? true : false; + }, + toggleAll: function(){ + serviceInstance.isEnabled() ? serviceInstance.cancel() : serviceInstance.all(); + }, + isSupported: function(){ + var docElm = document.documentElement; + var requestFullscreen = docElm.requestFullScreen || docElm.mozRequestFullScreen || docElm.webkitRequestFullscreen || docElm.msRequestFullscreen; + return requestFullscreen ? true : false; + } + }; + + /* eslint-enable */ + + return serviceInstance; +} + +/*@ngInject*/ +function mdColorPicker($q, $mdDialog, mdColorPickerHistory) { + var dialog; + + /* eslint-disable angular/definedundefined */ + + return { + show: function (options) + { + if ( options === undefined ) { + options = {}; + } + //console.log( 'DIALOG OPTIONS', options ); + // Defaults + // Dialog Properties + options.hasBackdrop = options.hasBackdrop === undefined ? true : options.hasBackdrop; + options.clickOutsideToClose = options.clickOutsideToClose === undefined ? true : options.clickOutsideToClose; + options.defaultValue = options.defaultValue === undefined ? '#FFFFFF' : options.defaultValue; + options.focusOnOpen = options.focusOnOpen === undefined ? false : options.focusOnOpen; + options.preserveScope = options.preserveScope === undefined ? true : options.preserveScope; + if (options.skipHide !== undefined) { + options.multiple = options.skipHide; + } + if (options.multiple === undefined) { + options.multiple = true; + } + + // mdColorPicker Properties + options.mdColorAlphaChannel = options.mdColorAlphaChannel === undefined ? false : options.mdColorAlphaChannel; + options.mdColorSpectrum = options.mdColorSpectrum === undefined ? true : options.mdColorSpectrum; + options.mdColorSliders = options.mdColorSliders === undefined ? true : options.mdColorSliders; + options.mdColorGenericPalette = options.mdColorGenericPalette === undefined ? true : options.mdColorGenericPalette; + options.mdColorMaterialPalette = options.mdColorMaterialPalette === undefined ? true : options.mdColorMaterialPalette; + options.mdColorHistory = options.mdColorHistory === undefined ? true : options.mdColorHistory; + + + dialog = $mdDialog.show({ + templateUrl: 'mdColorPickerDialog.tpl.html', + hasBackdrop: options.hasBackdrop, + clickOutsideToClose: options.clickOutsideToClose, + + controller: ['$scope', 'options', function( $scope, options ) { + //console.log( "DIALOG CONTROLLER OPEN", Date.now() - dateClick ); + $scope.close = function close() + { + $mdDialog.cancel(); + }; + $scope.ok = function ok() + { + $mdDialog.hide( $scope.value ); + }; + $scope.hide = $scope.ok; + + + + $scope.value = options.value; + $scope.default = options.defaultValue; + $scope.random = options.random; + + $scope.mdColorAlphaChannel = options.mdColorAlphaChannel; + $scope.mdColorSpectrum = options.mdColorSpectrum; + $scope.mdColorSliders = options.mdColorSliders; + $scope.mdColorGenericPalette = options.mdColorGenericPalette; + $scope.mdColorMaterialPalette = options.mdColorMaterialPalette; + $scope.mdColorHistory = options.mdColorHistory; + $scope.mdColorDefaultTab = options.mdColorDefaultTab; + + }], + + locals: { + options: options, + }, + preserveScope: options.preserveScope, + multiple: options.multiple, + + targetEvent: options.$event, + focusOnOpen: options.focusOnOpen, + autoWrap: false, + onShowing: function() { + // console.log( "DIALOG OPEN START", Date.now() - dateClick ); + }, + onComplete: function() { + // console.log( "DIALOG OPEN COMPLETE", Date.now() - dateClick ); + } + }); + + dialog.then(function (value) { + mdColorPickerHistory.add(new tinycolor(value)); + }, function () { }); + + return dialog; + }, + hide: function() { + return dialog.hide(); + }, + cancel: function() { + return dialog.cancel(); + } + }; + + /* eslint-enable angular/definedundefined */ +} diff --git a/ui/src/app/components/datasource-entity.directive.js b/ui/src/app/components/datasource-entity.directive.js index b02a30d206..6bc1932be6 100644 --- a/ui/src/app/components/datasource-entity.directive.js +++ b/ui/src/app/components/datasource-entity.directive.js @@ -186,7 +186,7 @@ function DatasourceEntity($compile, $templateCache, $q, $mdDialog, $window, $doc random: tinycolor.random(), clickOutsideToClose: false, hasBackdrop: false, - skipHide: true, + multiple: true, preserveScope: false, mdColorAlphaChannel: true, @@ -220,7 +220,7 @@ function DatasourceEntity($compile, $templateCache, $q, $mdDialog, $window, $doc parent: angular.element($document[0].body), fullscreen: true, targetEvent: event, - skipHide: true, + multiple: true, onComplete: function () { var w = angular.element($window); w.triggerHandler('resize'); diff --git a/ui/src/app/components/datasource-entity.tpl.html b/ui/src/app/components/datasource-entity.tpl.html index db6fd3be96..8f257874c5 100644 --- a/ui/src/app/components/datasource-entity.tpl.html +++ b/ui/src/app/components/datasource-entity.tpl.html @@ -26,7 +26,6 @@
@@ -78,7 +77,6 @@ diff --git a/ui/src/app/components/datasource-func.directive.js b/ui/src/app/components/datasource-func.directive.js index 7515b61cc7..982685ffe1 100644 --- a/ui/src/app/components/datasource-func.directive.js +++ b/ui/src/app/components/datasource-func.directive.js @@ -139,7 +139,7 @@ function DatasourceFunc($compile, $templateCache, $mdDialog, $window, $document, random: tinycolor.random(), clickOutsideToClose: false, hasBackdrop: false, - skipHide: true, + multiple: true, preserveScope: false, mdColorAlphaChannel: true, @@ -173,7 +173,7 @@ function DatasourceFunc($compile, $templateCache, $mdDialog, $window, $document, parent: angular.element($document[0].body), fullscreen: true, targetEvent: event, - skipHide: true, + multiple: true, onComplete: function () { var w = angular.element($window); w.triggerHandler('resize'); diff --git a/ui/src/app/components/json-form.directive.js b/ui/src/app/components/json-form.directive.js index b016f57d89..97b7bfb05f 100644 --- a/ui/src/app/components/json-form.directive.js +++ b/ui/src/app/components/json-form.directive.js @@ -96,7 +96,7 @@ function JsonForm($compile, $templateCache, $mdColorPicker) { random: tinycolor.random(), clickOutsideToClose: false, hasBackdrop: false, - skipHide: true, + multiple: true, preserveScope: false, mdColorAlphaChannel: true, diff --git a/ui/src/app/components/material-icon-select.directive.js b/ui/src/app/components/material-icon-select.directive.js index 67caff203f..f3efe63eba 100644 --- a/ui/src/app/components/material-icon-select.directive.js +++ b/ui/src/app/components/material-icon-select.directive.js @@ -67,7 +67,7 @@ function MaterialIconSelect($compile, $templateCache, $document, $mdDialog) { templateUrl: materialIconsDialogTemplate, parent: angular.element($document[0].body), locals: {icon: scope.icon}, - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (icon) { diff --git a/ui/src/app/components/widget/action/manage-widget-actions.directive.js b/ui/src/app/components/widget/action/manage-widget-actions.directive.js index 81103227ba..959720410e 100644 --- a/ui/src/app/components/widget/action/manage-widget-actions.directive.js +++ b/ui/src/app/components/widget/action/manage-widget-actions.directive.js @@ -164,7 +164,7 @@ function ManageWidgetActionsController($rootScope, $scope, $document, $mdDialog, .cancel($translate.instant('action.no')) .ok($translate.instant('action.yes')); - confirm._options.skipHide = true; + confirm._options.multiple = true; confirm._options.fullscreen = true; $mdDialog.show(confirm).then(function () { @@ -212,7 +212,7 @@ function ManageWidgetActionsController($rootScope, $scope, $document, $mdDialog, locals: {isAdd: isAdd, fetchDashboardStates: vm.fetchDashboardStates, actionSources: availableActionSources, widgetActions: vm.widgetActions, action: angular.copy(action)}, - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (action) { diff --git a/ui/src/app/dashboard/add-widget.controller.js b/ui/src/app/dashboard/add-widget.controller.js index caa69d6f98..fd665fd1a6 100644 --- a/ui/src/app/dashboard/add-widget.controller.js +++ b/ui/src/app/dashboard/add-widget.controller.js @@ -166,7 +166,7 @@ export default function AddWidgetController($scope, widgetService, entityService }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event }).then(function (singleEntityAlias) { vm.dashboard.configuration.entityAliases[singleEntityAlias.id] = singleEntityAlias; diff --git a/ui/src/app/dashboard/dashboard.controller.js b/ui/src/app/dashboard/dashboard.controller.js index 6df48df032..3f32e1b92c 100644 --- a/ui/src/app/dashboard/dashboard.controller.js +++ b/ui/src/app/dashboard/dashboard.controller.js @@ -463,7 +463,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget } }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (entityAliases) { @@ -488,7 +488,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget gridSettings: gridSettings }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (data) { @@ -510,7 +510,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget layouts: angular.copy(vm.dashboard.configuration.states[vm.dashboardCtx.state].layouts) }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (layouts) { @@ -531,7 +531,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget states: states }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (states) { @@ -873,7 +873,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget templateUrl: selectTargetLayoutTemplate, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then( function success(layoutId) { @@ -941,7 +941,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event, onComplete: function () { var w = angular.element($window); diff --git a/ui/src/app/dashboard/edit-widget.directive.js b/ui/src/app/dashboard/edit-widget.directive.js index e6858b4227..f8aee69224 100644 --- a/ui/src/app/dashboard/edit-widget.directive.js +++ b/ui/src/app/dashboard/edit-widget.directive.js @@ -131,7 +131,7 @@ export default function EditWidgetDirective($compile, $templateCache, types, wid }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event }).then(function (singleEntityAlias) { scope.dashboard.configuration.entityAliases[singleEntityAlias.id] = singleEntityAlias; diff --git a/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js b/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js index 5ce8a6ca23..828bd6f8a7 100644 --- a/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js +++ b/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js @@ -51,7 +51,7 @@ export default function ManageDashboardLayoutsController($scope, $mdDialog, $doc gridSettings: gridSettings }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (data) { diff --git a/ui/src/app/dashboard/states/manage-dashboard-states.controller.js b/ui/src/app/dashboard/states/manage-dashboard-states.controller.js index 98df466599..298b2ab5e7 100644 --- a/ui/src/app/dashboard/states/manage-dashboard-states.controller.js +++ b/ui/src/app/dashboard/states/manage-dashboard-states.controller.js @@ -111,7 +111,7 @@ export default function ManageDashboardStatesController($scope, $mdDialog, $filt templateUrl: dashboardStateDialogTemplate, parent: angular.element($document[0].body), locals: {isAdd: isAdd, allStates: vm.allStates, state: angular.copy(state)}, - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (state) { @@ -163,7 +163,7 @@ export default function ManageDashboardStatesController($scope, $mdDialog, $filt .cancel($translate.instant('action.no')) .ok($translate.instant('action.yes')); - confirm._options.skipHide = true; + confirm._options.multiple = true; confirm._options.fullscreen = true; $mdDialog.show(confirm).then(function () { diff --git a/ui/src/app/entity/alias/entity-aliases.controller.js b/ui/src/app/entity/alias/entity-aliases.controller.js index 732f10f456..49f28c9122 100644 --- a/ui/src/app/entity/alias/entity-aliases.controller.js +++ b/ui/src/app/entity/alias/entity-aliases.controller.js @@ -126,7 +126,7 @@ export default function EntityAliasesController(utils, entityService, toast, $sc }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then(function (alias) { if (isAdd) { @@ -158,7 +158,7 @@ export default function EntityAliasesController(utils, entityService, toast, $sc .ariaLabel($translate.instant('entity.unable-delete-entity-alias-title')) .ok($translate.instant('action.close')) .targetEvent($event); - alert._options.skipHide = true; + alert._options.multiple = true; alert._options.fullscreen = true; $mdDialog.show(alert); diff --git a/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js b/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js index 5346e038f8..f39593c2be 100644 --- a/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js +++ b/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js @@ -53,7 +53,7 @@ export default function AddWidgetToDashboardDialogController($scope, $mdDialog, states: states }, fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then( function success(stateId) { @@ -81,7 +81,7 @@ export default function AddWidgetToDashboardDialogController($scope, $mdDialog, templateUrl: selectTargetLayoutTemplate, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then( function success(layoutId) { diff --git a/ui/src/app/entity/relation/relation-table.directive.js b/ui/src/app/entity/relation/relation-table.directive.js index 872042c2fa..d2586c4845 100644 --- a/ui/src/app/entity/relation/relation-table.directive.js +++ b/ui/src/app/entity/relation/relation-table.directive.js @@ -160,7 +160,7 @@ function RelationTableController($scope, $q, $mdDialog, $document, $translate, $ showingCallback: onShowingCallback}, targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/event/event-row.directive.js b/ui/src/app/event/event-row.directive.js index b808fb8fff..5f84187e2c 100644 --- a/ui/src/app/event/event-row.directive.js +++ b/ui/src/app/event/event-row.directive.js @@ -79,7 +79,7 @@ export default function EventRowDirective($compile, $templateCache, $mdDialog, $ parent: angular.element($document[0].body), fullscreen: true, targetEvent: $event, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/extension/extension-table.directive.js b/ui/src/app/extension/extension-table.directive.js index 18d281c0b0..73c5d93e80 100644 --- a/ui/src/app/extension/extension-table.directive.js +++ b/ui/src/app/extension/extension-table.directive.js @@ -208,7 +208,7 @@ function ExtensionTableController($scope, $filter, $document, $translate, $timeo bindToController: true, targetEvent: $event, fullscreen: true, - skipHide: true + multiple: true }).then(function() { reloadExtensions(); }, function () { diff --git a/ui/src/app/import-export/import-export.service.js b/ui/src/app/import-export/import-export.service.js index d64441fdef..e08fd24d5a 100644 --- a/ui/src/app/import-export/import-export.service.js +++ b/ui/src/app/import-export/import-export.service.js @@ -721,7 +721,7 @@ export default function ImportExport($log, $translate, $q, $mdDialog, $document, } }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (updatedEntityAliases) { @@ -796,7 +796,7 @@ export default function ImportExport($log, $translate, $q, $mdDialog, $document, importFileLabel: importFileLabel }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (importData) { diff --git a/ui/src/app/layout/index.js b/ui/src/app/layout/index.js index 192400a646..7c7e870205 100644 --- a/ui/src/app/layout/index.js +++ b/ui/src/app/layout/index.js @@ -17,7 +17,6 @@ import './home.scss'; import uiRouter from 'angular-ui-router'; import ngSanitize from 'angular-sanitize'; -import FBAngular from 'angular-fullscreen'; import 'angular-breadcrumb'; import thingsboardMenu from '../services/menu.service'; @@ -63,7 +62,6 @@ import BreadcrumbIcon from './breadcrumb-icon.filter'; export default angular.module('thingsboard.home', [ uiRouter, ngSanitize, - FBAngular.name, 'ncy-angular-breadcrumb', thingsboardMenu, thingsboardHomeLinks, diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 9f326c7612..b3c89c7729 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -1573,7 +1573,7 @@ "ru_RU": "Russian", "es_ES": "Spanish", "ja_JA": "Japanese", - "TR": "Turkish" + "tr_TR": "Turkish" } } } diff --git a/ui/src/app/locale/locale.constant-es_ES.json b/ui/src/app/locale/locale.constant-es_ES.json index 81cc52ed6f..7b0214190b 100644 --- a/ui/src/app/locale/locale.constant-es_ES.json +++ b/ui/src/app/locale/locale.constant-es_ES.json @@ -1552,7 +1552,7 @@ "ru_RU": "Ruso", "es_ES": "Español", "ja_JA": "Japonés", - "TR": "Turco" + "tr_TR": "Turco" } } } diff --git a/ui/src/app/locale/locale.constant-fr_FR.json b/ui/src/app/locale/locale.constant-fr_FR.json index 8b270aa7af..dd2fa0d864 100644 --- a/ui/src/app/locale/locale.constant-fr_FR.json +++ b/ui/src/app/locale/locale.constant-fr_FR.json @@ -1010,7 +1010,7 @@ "ko_KR": "Coréen", "ru_RU": "Russe", "zh_CN": "Chinois", - "TR": "Turc" + "tr_TR": "Turc" } }, "layout": { diff --git a/ui/src/app/locale/locale.constant-it_IT.json b/ui/src/app/locale/locale.constant-it_IT.json index 79843ab09b..49f9b4880d 100644 --- a/ui/src/app/locale/locale.constant-it_IT.json +++ b/ui/src/app/locale/locale.constant-it_IT.json @@ -1442,7 +1442,7 @@ "ru_RU": "Russo", "es_ES": "Spagnolo", "ja_JA": "Giapponese", - "TR": "Turco" + "tr_TR": "Turco" } } } diff --git a/ui/src/app/locale/locale.constant-ja_JA.json b/ui/src/app/locale/locale.constant-ja_JA.json index 6c4889460d..2ab2bcb909 100644 --- a/ui/src/app/locale/locale.constant-ja_JA.json +++ b/ui/src/app/locale/locale.constant-ja_JA.json @@ -1458,7 +1458,7 @@ "ru_RU": "ロシア", "es_ES": "スペイン語", "ja_JA": "日本語", - "TR": "トルコ語" + "tr_TR": "トルコ語" } } } diff --git a/ui/src/app/locale/locale.constant-ko_KR.json b/ui/src/app/locale/locale.constant-ko_KR.json index 07e7dd6070..62c0054d53 100644 --- a/ui/src/app/locale/locale.constant-ko_KR.json +++ b/ui/src/app/locale/locale.constant-ko_KR.json @@ -1336,7 +1336,7 @@ "es_ES": "스페인어", "it_IT": "이탈리아 사람", "ja_JA": "일본어", - "TR": "터키어" + "tr_TR": "터키어" } } } diff --git a/ui/src/app/locale/locale.constant-ru_RU.json b/ui/src/app/locale/locale.constant-ru_RU.json index 893bbcd86c..eb9996d274 100644 --- a/ui/src/app/locale/locale.constant-ru_RU.json +++ b/ui/src/app/locale/locale.constant-ru_RU.json @@ -1361,7 +1361,7 @@ "it_IT": "Итальянский", "ru_RU": "Русский", "ja_JA": "Японский", - "TR": "Турецкий" + "tr_TR": "Турецкий" } } diff --git a/ui/src/app/locale/locale.constant-TR.json b/ui/src/app/locale/locale.constant-tr_TR.json similarity index 94% rename from ui/src/app/locale/locale.constant-TR.json rename to ui/src/app/locale/locale.constant-tr_TR.json index de88116fe5..54176d83a9 100644 --- a/ui/src/app/locale/locale.constant-TR.json +++ b/ui/src/app/locale/locale.constant-tr_TR.json @@ -126,15 +126,15 @@ "acknowledge": "Onayla", "clear": "Temizle", "search": "Alarm ara", - "selected-alarms": "{ count, select, 1 {1 alarm} other {# alarm} } seçildi", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarm} } seçildi", "no-data": "Görüntülenecek veri bulunmuyor", "polling-interval": "Alarm yoklama aralığı (saniye)", "polling-interval-required": "Alarm yoklama aralığı gerekli.", "min-polling-interval-message": "Alarm yoklama aralığı en az 1 saniye olmalıdır.", - "aknowledge-alarms-title": "{ count, select, 1 {1 alarmı} other {# alarmı} } onayla", - "aknowledge-alarms-text": "{ count, select, 1 {1 alarmı} other {# alarmı} } onaylamak istediğinize emin misiniz?", - "clear-alarms-title": "{ count, select, 1 {1 alarmı} other {# alarmı} } temizle", - "clear-alarms-text": "{ count, select, 1 {1 alarmı} other {# alarmı} } temizlemek istediğinize emin misiniz?" + "aknowledge-alarms-title": "{ count, plural, 1 {1 alarmı} other {# alarmı} } onayla", + "aknowledge-alarms-text": "{ count, plural, 1 {1 alarmı} other {# alarmı} } onaylamak istediğinize emin misiniz?", + "clear-alarms-title": "{ count, plural, 1 {1 alarmı} other {# alarmı} } temizle", + "clear-alarms-text": "{ count, plural, 1 {1 alarmı} other {# alarmı} } temizlemek istediğinize emin misiniz?" }, "alias": { "add": "Kısa ad ekle", @@ -212,15 +212,15 @@ "add-asset-text": "Yeni varlık ekle", "asset-details": "Varlık detayları", "assign-assets": "Varlıkları ata", - "assign-assets-text": "{ count, select, 1 {1 varlığı} other {# varlığı} } kullanıcı grubuna ata", + "assign-assets-text": "{ count, plural, 1 {1 varlığı} other {# varlığı} } kullanıcı grubuna ata", "delete-assets": "Varlıkları sil", "unassign-assets": "Varlıkların atamalarını kaldır", - "unassign-assets-action-title": "{ count, select, 1 {1 varlığın} other {# varlığın} } atamalarını kullanıcı grubundan kaldır", + "unassign-assets-action-title": "{ count, plural, 1 {1 varlığın} other {# varlığın} } atamalarını kullanıcı grubundan kaldır", "assign-new-asset": "Yeni varlık ata", "delete-asset-title": "'{{assetName}}' isimli varlığı silmek istediğinize emin misiniz?", "delete-asset-text": "UYARI: Onaylandıktan sonra varlık ve ilgili tüm veriler geri yüklenemeyecek şekilde silinecek.", - "delete-assets-title": "{ count, select, 1 {1 varlığı} other {# varlığı} } silmek istediğinize emin misiniz?", - "delete-assets-action-title": "{ count, select, 1 {1 varlığı} other {# varlığı} } sil", + "delete-assets-title": "{ count, plural, 1 {1 varlığı} other {# varlığı} } silmek istediğinize emin misiniz?", + "delete-assets-action-title": "{ count, plural, 1 {1 varlığı} other {# varlığı} } sil", "delete-assets-text": "UYARI: Onaylandıktan sonra tüm seçili varlıklar ver ilgili tüm veriler geri yüklenemyeck şekilde silinecek.", "make-public-asset-title": "'{{assetName}}' isimli varlığı açık hale getirmek istediğinize emin misiniz?", "make-public-asset-text": "Onaylandıktan sonra varlık ve ilgili veriler açık hale gelecek ve başkaları tarafından erişilebilir olacaktır.", @@ -229,7 +229,7 @@ "unassign-asset-title": "'{{assetName}}' isimli varlığın atamasını kaldırmak istediğinize emin misiniz?", "unassign-asset-text": "Onaylandıktan sonra varlığın ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacaktır.", "unassign-asset": "Varlık atamasını kaldır", - "unassign-assets-title": " { count, select, 1 {1 varlık} other {# varlık} } atamasını kaldırmak istediğinize emin misiniz?", + "unassign-assets-title": " { count, plural, 1 {1 varlık} other {# varlık} } atamasını kaldırmak istediğinize emin misiniz?", "unassign-assets-text": "Onaylandıktan sonra tüm seçili varlıkların ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacaktır.", "copyId": "Varlık kimliğini kopyala", "idCopiedMessage": "Varlık kimliği panoya kopyalandı", @@ -252,7 +252,7 @@ "key-required": "Öznitelik anahtarı gerekli.", "value": "Değer", "value-required": "Öznitelik değeri gerekli.", - "delete-attributes-title": "Silmek istediğinize emin misiniz { count, select, 1 {1 öznitelik} other {# öznitelik} }?", + "delete-attributes-title": "Silmek istediğinize emin misiniz { count, plural, 1 {1 öznitelik} other {# öznitelik} }?", "delete-attributes-text": "UYARI: Onaylandıktan sonra tüm seçili öznitelikler kaldırılacak.", "delete-attributes": "Öznitelikleri sil", "enter-attribute-value": "Öznitelik değeri gir", @@ -262,8 +262,8 @@ "prev-widget": "Önceki gösterge", "add-to-dashboard": "Kontrol paneline ekle", "add-widget-to-dashboard": "Göstergeyi kontrol paneline ekle", - "selected-attributes": "{ count, select, 1 {1 öznitelik} other {# öznitelik} } seçildi", - "selected-telemetry": "{ count, select, 1 {1 telemetri birimi} other {# telemetri birimi} } seçildi" + "selected-attributes": "{ count, plural, 1 {1 öznitelik} other {# öznitelik} } seçildi", + "selected-telemetry": "{ count, plural, 1 {1 telemetri birimi} other {# telemetri birimi} } seçildi" }, "audit-log": { "audit": "Log ve Hata Yönetimi", @@ -358,8 +358,8 @@ "customer-details": "Kullanıcı Grubu detayları", "delete-customer-title": "'{{customerTitle}}' isimli kullanıcı grubunu silmek istediğinize emin misiniz?", "delete-customer-text": "UYARI: Onaylandıktan sonra kullanıcı grubu ve tüm ilişkili veriler geri yüklenemeyecek şekilde silinecek.", - "delete-customers-title": "{ count, select, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } silmek istediğinize emin misiniz?", - "delete-customers-action-title": "{ count, select, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } sil", + "delete-customers-title": "{ count, plural, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } silmek istediğinize emin misiniz?", + "delete-customers-action-title": "{ count, plural, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } sil", "delete-customers-text": "UYARI: Onaylandıktan sonra tüm seçili kullanıcı grupları ve ilişkili veriler geri yüklenemez şekilde silinecek.", "manage-users": "Kullanıcıları yönet", "manage-assets": "Varlıkları yönet", @@ -418,20 +418,20 @@ "add-dashboard-text": "Yeni kontrol paneli ekle", "assign-dashboards": "Kontrol panelleri ata", "assign-new-dashboard": "Yeni kontrol paneli ata", - "assign-dashboards-text": "{ count, select, 1 {1 kontrol panelini} other {# kontrol panelini} } kullanıcı grubuna ata", - "unassign-dashboards-action-text": "Müşterilerden atama sayısı, çoğul, 1 {1 gösterge tablosu} diğer {# panolar}}", + "assign-dashboards-text": "{ count, plural, 1 {1 kontrol panelini} other {# kontrol panelini} } kullanıcı grubuna ata", + "unassign-dashboards-action-text": "Müşterilerden atama { count, plural, 1 {1 gösterge tablosu} other {# panolar}}", "delete-dashboards": "Kontrol panellerini sil", "unassign-dashboards": "Kontrol panellerinden atamayı kaldır", - "unassign-dashboards-action-title": "{ count, select, 1 {1 kontrol panelinin} other {# kontrol panelinin} } atamaları kullanıcı grubundan kaldır", + "unassign-dashboards-action-title": "{ count, plural, 1 {1 kontrol panelinin} other {# kontrol panelinin} } atamaları kullanıcı grubundan kaldır", "delete-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelini silmek istediğinize emin misiniz?", "delete-dashboard-text": "UYARI: Onaylandıktan sonra kontrol paneli ve ilişkili verileri geri yüklenemez şekilde silinecek.", - "delete-dashboards-title": "{ count, select, 1 {1 kontrol panelini} other {# kontrol panelini} } silmek istediğinize emin misiniz?", - "delete-dashboards-action-title": "{ count, select, 1 {1 kontrol panelini} other {# kontrol panelini} } sil", + "delete-dashboards-title": "{ count, plural, 1 {1 kontrol panelini} other {# kontrol panelini} } silmek istediğinize emin misiniz?", + "delete-dashboards-action-title": "{ count, plural, 1 {1 kontrol panelini} other {# kontrol panelini} } sil", "delete-dashboards-text": "UYARI: Onaylandıktan sonra tüm seçili kontrol panelleri ve ilişkili verileri geri yüklenemez şekilde silinecek.", "unassign-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelindeki atamayı kaldırmak istediğinize emin misiniz?", "unassign-dashboard-text": "Onaylandıktan sonra kontrol panelinin ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez hale gelecektir.", "unassign-dashboard": "Kontrol panelinin ataması kaldır", - "unassign-dashboards-title": "{count, select, 1 {1 kontrol panelindeki} other {# kontrol panelindeki} } atamayı kaldırmak istediğinize emin misiniz?", + "unassign-dashboards-title": "{count, plural, 1 {1 kontrol panelindeki} other {# kontrol panelindeki} } atamayı kaldırmak istediğinize emin misiniz?", "unassign-dashboards-text": "Onaylandıktan {{dashboardTitle}} açık hale getirildi ve bu bağlantıdan erişilebilir durumda", @@ -508,7 +508,7 @@ "manage-states": "Kontrol paneli durumlarını yönet", "states": "Kontrol paneli durumları", "search-states": "Kontrol paneli durumu ara", - "selected-states": "{ count, select, 1 {1 kontrol paneli durumu} other {# kontrol paneli durumu} } seçildi", + "selected-states": "{ count, plural, 1 {1 kontrol paneli durumu} other {# kontrol paneli durumu} } seçildi", "edit-state": "Kontrol paneli durumu düzenle", "delete-state": "Kontrol paneli durumunu sil", "add-state": "Kontrol paneli durumu ekle", @@ -541,11 +541,11 @@ "alarm": "Alarm alanları", "timeseries-required": "Zaman serisi öğesi gerekli.", "timeseries-or-attributes-required": "Zaman serisi/öznitelikler öğesi gerekli.", - "maximum-timeseries-or-attributes": "Maksimum { count, select, 1 {1 zamanserisi/öznitelik kabul edilir.} other {# zamanserisi/öznitelik kabul edilir} }", + "maximum-timeseries-or-attributes": "Maksimum { count, plural, 1 {1 zamanserisi/öznitelik kabul edilir.} other {# zamanserisi/öznitelik kabul edilir} }", "alarm-fields-required": "Alarm alanları gerekli.", "function-types": "Fonksiyon türleri", "function-types-required": "Fonksiyon türleri gerekli.", - "maximum-function-types": "Maksimum { count, select, 1 {1 fonksiyon türü kabul edilir.} other {# fonksiyon türü kabul edilir} }" + "maximum-function-types": "Maksimum { count, plural, 1 {1 fonksiyon türü kabul edilir.} other {# fonksiyon türü kabul edilir} }" }, "datasource": { "type": "Veri kaynağı türü", @@ -597,11 +597,11 @@ "manage-credentials": "Kimlik bilgilerini yönet", "delete": "Aygıt sil", "assign-devices": "Aygıt ata", - "assign-devices-text": "{ count, select, 1 {1 aygıtı} other {# aygıtı} } kullanıcı grubuna ata", + "assign-devices-text": "{ count, plural, 1 {1 aygıtı} other {# aygıtı} } kullanıcı grubuna ata", "delete-devices": "Aygıtları sil", "unassign-from-customer": "Kullanıcı Grubundan atamayı kaldır", "unassign-devices": "Aygıtlardan atamayı kaldır", - "unassign-devices-action-title": "{ count, select, 1 {1 aygıtın} other {# aygıtın} } atamasını kullanıcı grubundan kaldır", + "unassign-devices-action-title": "{ count, plural, 1 {1 aygıtın} other {# aygıtın} } atamasını kullanıcı grubundan kaldır", "assign-new-device": "Yeni aygıt ata", "make-public-device-title": "'{{deviceName}}' isimli aygıtı açık hale getirmek istediğinizden emin misiniz?", "make-public-device-text": "Onaylandıktan sonra aygıt ve verileri açık hale getirilecek ve diğerleri tarafından erişilebilir olacak.", @@ -610,13 +610,13 @@ "view-credentials": "Kimlik bilgilerini görüntüle", "delete-device-title": "'{{deviceName}}' isimli aygıtı silmek istediğinize emin misiniz?", "delete-device-text": "UYARI: Onaylandıktan sonra aygıt ve ilişkili verileri geri yüklenemez şekilde silinecek.", - "delete-devices-title": "{ count, select, 1 {1 aygıtı} other {# aygıtı} } silmek istediğinize emin misiniz?", - "delete-devices-action-title": "{ count, select, 1 {1 aygıtı} other {# aygıtı} } sil", + "delete-devices-title": "{ count, plural, 1 {1 aygıtı} other {# aygıtı} } silmek istediğinize emin misiniz?", + "delete-devices-action-title": "{ count, plural, 1 {1 aygıtı} other {# aygıtı} } sil", "delete-devices-text": "UYARI: Onaylandıktan sonra tüm seçili aygıtlar ve ilişkili verileri geri yüklenemez şekilde silinecek.", "unassign-device-title": "'{{deviceName}}' isimli aygıtın atamasını kaldırmak istediğinize emin misiniz?", "unassign-device-text": "Onaylandıktan sonra aygıtın ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", "unassign-device": "Aygıt atamasını kaldır", - "unassign-devices-title": "{ count, select, 1 {1 aygıtın} other {# aygıtın} } atamasını kaldırmak istediğinize emin misiniz?", + "unassign-devices-title": "{ count, plural, 1 {1 aygıtın} other {# aygıtın} } atamasını kaldırmak istediğinize emin misiniz?", "unassign-devices-text": "Onaylandıktan sonra seçili aygıtların atamaları kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", "device-credentials": "Aygıt Kimlik Bilgileri", "credentials-type": "Kimlik Bilgi Türü", @@ -702,55 +702,55 @@ "type-required": "Öğe türü gerekli.", "type-device": "Aygıt", "type-devices": "Aygıtlar", - "list-of-devices": "{ count, select, 1 {Bir aygıt} other {# Aygıtın Listesi} }", + "list-of-devices": "{ count, plural, 1 {Bir aygıt} other {# Aygıtın Listesi} }", "device-name-starts-with": "İsimleri '{{prefix}}' ile başlayan aygıtlar", "type-asset": "Varlık", "type-assets": "Varlıklar", - "list-of-assets": "{ count, select, 1 {Bir varlık} other {# Varlığın Listesi} }", + "list-of-assets": "{ count, plural, 1 {Bir varlık} other {# Varlığın Listesi} }", "asset-name-starts-with": "İsmi '{{prefix}}' ile başlayan varlıklar", "type-entity-view": "Varlık Görünümü", "type-entity-views": "Varlık Görünümleri", - "list-of-entity-views": "{count, çoğul, 1 {Bir varlık görünümü} diğer {# varlık görüntüleme}} listesi", + "list-of-entity-views": "{ count, plural, 1 {Bir varlık görünümü} other {# varlık görüntüleme}} listesi", "entity-view-name-starts-with": "Adı {{önek}} ile başlayan varlık görünümleri", "type-rule": "Kural", "type-rules": "Kurallar", - "list-of-rules": "{ count, select, 1 {Bir kural} other {# Kuralın Listesi} }", + "list-of-rules": "{ count, plural, 1 {Bir kural} other {# Kuralın Listesi} }", "rule-name-starts-with": "İsmi '{{prefix}}' ile başlayan kurallar", "type-plugin": "Eklenti", "type-plugins": "Eklentiler", - "list-of-plugins": "{ count, select, 1 {Bir eklenti} other {# Eklentinin Listesi} }", + "list-of-plugins": "{ count, plural, 1 {Bir eklenti} other {# Eklentinin Listesi} }", "plugin-name-starts-with": "İsmi '{{prefix}}' ile başlayan eklentiler", "type-tenant": "Tenant", "type-tenants": "Tenantlar", - "list-of-tenants": "{ count, select, 1 {Bir tenant} other {# Tenantın Listesi} }", + "list-of-tenants": "{ count, plural, 1 {Bir tenant} other {# Tenantın Listesi} }", "tenant-name-starts-with": "İsmi '{{prefix}}' ile başlayan tenantlar", "type-customer": "Kullanıcı Grubu", "type-customers": "Kullanıcı Grupları", - "list-of-customers": "{ count, select, 1 {Bir Kullanıcı Grubu} other {# Kullanıcı Grupları} }", + "list-of-customers": "{ count, plural, 1 {Bir Kullanıcı Grubu} other {# Kullanıcı Grupları} }", "customer-name-starts-with": "İsmi '{{prefix}}' ile başlayan Kullanıcı Grupları", "type-user": "Kullanıcı", "type-users": "Kullanıcılar", - "list-of-users": "{ count, select, 1 {Bir kullanıcı} other {# Kullanıcının Listesi} }", + "list-of-users": "{ count, plural, 1 {Bir kullanıcı} other {# Kullanıcının Listesi} }", "user-name-starts-with": "İsmi '{{prefix}}' ile başlayan kullanıcılar", "type-dashboard": "Kontrol paneli", "type-dashboards": "Kontrol panelleri", - "list-of-dashboards": "{ count, select, 1 {Bir kontrol paneli} other {# Kontrol Panelinin Listesi} }", + "list-of-dashboards": "{ count, plural, 1 {Bir kontrol paneli} other {# Kontrol Panelinin Listesi} }", "dashboard-name-starts-with": "İsmi '{{prefix}}' ile başlayan kontrol panelleri", "type-alarm": "Alarm", "type-alarms": "Alarmlar", - "list-of-alarms": "{ count, select, 1 {Bir alarm} other {# Alarmın Listesi} }", + "list-of-alarms": "{ count, plural, 1 {Bir alarm} other {# Alarmın Listesi} }", "alarm-name-starts-with": "İsmi '{{prefix}}' ile başlayan alarmlar", "type-rulechain": "Kural zinciri", "type-rulechains": "Kural zincirleri", - "list-of-rulechains": "{count, çoğul, 1 {Bir kural zinciri} diğer {# kural zincirinin listesi}}", + "list-of-rulechains": "{ count, plural, 1 {Bir kural zinciri} other {# kural zincirinin listesi}}", "rulechain-name-starts-with": "İsimleri {{prefix}} ile başlayan kural zincirleri", "type-rulenode": "Kural düğümü", "type-rulenodes": "Kural düğümleri", - "list-of-rulenodes": "{count, çoğul, 1 {Bir kural node} diğer {# kural düğümünün listesi}}", + "list-of-rulenodes": "{ count, plural, 1 {Bir kural node} other {# kural düğümünün listesi}}", "rulenode-name-starts-with": "İsimleri '{{prefix}} ile başlayan kural düğümleri", "type-current-customer": "Mevcut Müşteri", "search": "Öğeleri ara", - "selected-entities": "{ count, select, 1 {1 öğe} other {# öğe} } seçildi", + "selected-entities": "{ count, plural, 1 {1 öğe} other {# öğe} } seçildi", "entity-name": "Öğe adı", "details": "Öğe detayları", "no-entities-prompt": "Hiçbir öğe bulunamadı", @@ -792,21 +792,21 @@ "add-entity-view-text": "Yeni varlık görünümü ekle", "delete": "Varlık görünümünü sil", "assign-entity-views": "Varlık görünümleri atama", - "assign-entity-views-text": "Müşteriye {count, çoğul, 1 {1 entityView} diğer {# entityViews}} atayın ", + "assign-entity-views-text": "Müşteriye { count, plural, 1 {1 entityView} other {# entityViews}} atayın ", "delete-entity-views": "Varlık görünümlerini sil", "unassign-from-customer": "Müşteriden atama", "unassign-entity-views": "Varlık görünümlerini atama", - "unassign-entity-views-action-title": "Müşteriden atama sayım, çoğul, 1 {1 entityView} diğer {# entityViews}}", + "unassign-entity-views-action-title": "Müşteriden atama { count, plural, 1 {1 entityView} other {# entityViews}}", "assign-new-entity-view": "Yeni varlık görünümü atama", "delete-entity-view-title": "Varlık görünümünü silmek istediğinizden emin misiniz?, {{entityViewName}} '? ", "delete-entity-view-text": "Dikkatli olun, onaylandıktan sonra varlık görünümü ve ilgili tüm veriler kurtarılamayacak.", - "delete-entity-views-title": "{count, çoğul, 1 {1 entityView} diğer {# entityViews}} varlık görünümüne sahip olmak istediğinizden emin misiniz?", - "delete-entity-views-action-title": "Sil {count, çoğul, 1 {1 entityView} diğer {# entityViews}}", + "delete-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews}} varlık görünümüne sahip olmak istediğinizden emin misiniz?", + "delete-entity-views-action-title": "Sil { count, plural, 1 {1 entityView} other {# entityViews}}", "delete-entity-views-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen görünümler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", "unassign-entity-view-title": "Varlık görünümünün atamasını kaldırmak istediğinizden emin misiniz? {{entityViewName}} '? ", "unassign-entity-view-text": "Onaydan sonra varlık görünümü atanmamış olacak ve müşteri tarafından erişilemeyecektir.", "unassign-entity-view": "Varlık görünümünün atamasını kaldır", - "unassign-entity-views-title": "Sayım, çoğul, 1 {1 entityView} diğer {# entityViews}} hesabının atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews}} hesabının atamasını kaldırmak istediğinizden emin misiniz?", "unassign-entity-views-text": "Onaylandıktan sonra, seçilen tüm öğe görünümleri atamadan kaldırılacak ve müşteri tarafından erişilemeyecektir.", "entity-view-type": "Varlık Görünümü türü", "entity-view-type-required": "Varlık Görünümü türü gerekli.", @@ -861,7 +861,7 @@ }, "extension": { "extensions": "Uzantılar", - "selected-extensions": "{count, çoğul, 1 {1 uzantı} diğer {# extensions}} seçildi", + "selected-extensions": "{ count, plural, 1 {1 uzantı} other {# extensions}} seçildi", "type": "Tür", "key": "Anahtar", "value": "Değer", @@ -875,7 +875,7 @@ "edit": "Uzantıyı düzenle", "delete-extension-title": "{{ExtensionId}} uzantısını silmek istediğinizden emin misiniz? ", "delete-extension-text": "Dikkatli olun, onaylamadan sonra uzantı ve ilgili tüm veriler kurtarılamaz.", - "delete-extensions-title": "{Count, çoğul, 1 {1 uzantı} diğer {# extensions}} silmek istediğinizden emin misiniz?", + "delete-extensions-title": "{ count, plural, 1 {1 uzantı} other {# extensions}} silmek istediğinizden emin misiniz?", "delete-extensions-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen uzantılar kaldırılacak.", "converters": "Dönüştürücü", "converter-id": "Dönüştürücü kimliği", @@ -1026,8 +1026,8 @@ "grid": { "delete-item-title": "Bu öğeyi silmek istediğinizden emin misiniz?", "delete-item-text": "UYARI: Onayladıktan sonra bu öğe ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", - "delete-items-title": "{ count, select, 1 {1 öğeyi} other {# öğeyi} } silmek istediğinizden emin misiniz?", - "delete-items-action-title": "{ count, select, 1 {1 öğeyi} other {# öğeyi} } sil", + "delete-items-title": "{ count, plural, 1 {1 öğeyi} other {# öğeyi} } silmek istediğinizden emin misiniz?", + "delete-items-action-title": "{ count, plural, 1 {1 öğeyi} other {# öğeyi} } sil", "delete-items-text": "UYARI: Onayladıktan sonra tüm seçili öğeler ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", "add-item-text": "Yeni öğe ekle", "no-items-text": "Hiç bir öğe bulunamadı", @@ -1128,7 +1128,7 @@ }, "from-relations": "Giden ilişkiler", "to-relations": "Gelen ilişkiler", - "selected-relations": "{ count, select, 1 {1 ilişki} other {# ilişki} } seçildi", + "selected-relations": "{ count, plural, 1 {1 ilişki} other {# ilişki} } seçildi", "type": "Tür", "to-entity-type": "Hedef Öğe Türü", "to-entity-name": "Hedef Öğe Adı", @@ -1144,11 +1144,11 @@ "edit": "İlişki düzenle", "delete-to-relation-title": "'{{entityName}}' öğesine olan ilişkiyi silmek istediğinize emin misiniz?", "delete-to-relation-text": "UYARI: Onaylandıktan sonra '{{entityName}}' öğesinin şimdiki öğeyle olan ilişkisi sona erecektir.", - "delete-to-relations-title": "{ count, select, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", + "delete-to-relations-title": "{ count, plural, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", "delete-to-relations-text": "UYARI: Onaylandıktan sonra tüm seçili ilişkiler kaldırılacaktır ve ilgili öğelerin şimdiki öğeyle ilişkisi sona erecektir.", "delete-from-relation-title": "'{{entityName}}' öğesinden ilişkiyi silmek istediğinize emin misiniz?", "delete-from-relation-text": "UYARI: Onaylandıktan sonra şimdiki öğenin '{{entityName}}' öğesiyle ilişkisi sonlandırılacaktır.", - "delete-from-relations-title": "{ count, select, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", + "delete-from-relations-title": "{ count, plural, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", "delete-from-relations-text": "UYARI: Onaylandıktan sonra tüm seçili ilişkiler kaldırılacak ve şimdiki öğenin ilgili tüm öğelerle ilişkisi sona erecektir.", "remove-relation-filter": "İlişki filtresini kaldır", "add-relation-filter": "İlişkisi ekle", @@ -1171,8 +1171,8 @@ "set-root-rulechain-text": "Onaydan sonra kural zinciri kökleşecek ve gelen tüm iletilerle ilgilenecek.", "delete-rulechain-title": "'{{ruleName}}' isimli kuralı silmek istediğinize emin misiniz?", "delete-rulechain-text": "UYARI: Onaylandıktan sonra kural ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", - "delete-rulechains-title": "{ count, select, 1 {1 kuralı} other {# kuralı} } sikmek istediğinize emin misiniz?", - "delete-rulechains-action-title": "{ count, select, 1 {1 kuralı} other {# kuralı} sil}", + "delete-rulechains-title": "{ count, plural, 1 {1 kuralı} other {# kuralı} } sikmek istediğinize emin misiniz?", + "delete-rulechains-action-title": "{ count, plural, 1 {1 kuralı} other {# kuralı} } sil", "delete-rulechains-text": "UYARI: Onaylandıktan sonra seçili tüm kurallar ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", "add-rulechain-text": "Yeni kural ekle", "no-rulechains-text":"Hiçbir kural bulunamadı", @@ -1270,8 +1270,8 @@ "tenant-details": "Tenant detayları", "delete-tenant-title": "'{{tenantTitle}}' isimli tenantı silmek istediğinize emin misiniz?", "delete-tenant-text": "UYARI: Onaylandıktan sonra tenant ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", - "delete-tenants-title": "{ count, select, 1 {1 tenantı} other {# tenantı} } silmek istediğinize emin misiniz?", - "delete-tenants-action-title": "{ count, select, 1 {1 tenantı} other {# tenantı} } sil", + "delete-tenants-title": "{ count, plural, 1 {1 tenantı} other {# tenantı} } silmek istediğinize emin misiniz?", + "delete-tenants-action-title": "{ count, plural, 1 {1 tenantı} other {# tenantı} } sil", "delete-tenants-text": "UYARI: Onaylandıktan sonra seçili tüm tenantlar ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir", "title": "Başlık", "title-required": "Başlık gerekli.", @@ -1325,8 +1325,8 @@ "user-details": "Kullanıcı detayları", "delete-user-title": "'{{userEmail}}' kullanıcısını silmek istediğinize emin misiniz?", "delete-user-text": "UYARI: Onaylandıktan sonra kullanıcı ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", - "delete-users-title": "{ count, select, 1 {1 kullanıcıyı} other {# kullanıcıyı} } sikmek istediğinize emin misiniz?", - "delete-users-action-title": "{ count, select, 1 {1 kullancıyı} other {# kullanıcıyı} } sil", + "delete-users-title": "{ count, plural, 1 {1 kullanıcıyı} other {# kullanıcıyı} } sikmek istediğinize emin misiniz?", + "delete-users-action-title": "{ count, plural, 1 {1 kullancıyı} other {# kullanıcıyı} } sil", "delete-users-text": "UYARI: Onaylandıktan sonra kullanıcı ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", "activation-email-sent-message": "Etkinleştirme e-postası başarılı bir şekilde gönderildi!", "resend-activation": "Etkinleştirme e-postasını yeniden gönder", @@ -1445,8 +1445,8 @@ "widgets-bundle-details": "Gösterge demeti detayları", "delete-widgets-bundle-title": "'{{widgetsBundleTitle}}' isimli gösterge demetini silmek istediğinize emin misiniz?", "delete-widgets-bundle-text": "UYARI: Onaylandıktan sonra gösterge demeti ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", - "delete-widgets-bundles-title": "{ count, select, 1 {1 gösterge demetini} other {# gösterge demetini} } silmek istediğinize emin misiniz?", - "delete-widgets-bundles-action-title": "{ count, select, 1 {1 gösterge demetini} other {# gösterge demetini} } sil", + "delete-widgets-bundles-title": "{ count, plural, 1 {1 gösterge demetini} other {# gösterge demetini} } silmek istediğinize emin misiniz?", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {1 gösterge demetini} other {# gösterge demetini} } sil", "delete-widgets-bundles-text": "UYARI: Onaylandıktan sonra seçili tüm gösterge demetleri ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", "no-widgets-bundles-matching": "'{{widgetsBundle}}' ile eşleşen gösterge demeti bulunamadı.", "widgets-bundle-required": "Gösterge demeti gerekli.", @@ -1482,7 +1482,7 @@ "use-dashboard-timewindow": "Kontrol paneli zaman aralığı kullan", "display-legend": "Lejant göster", "datasources": "Veri kaynakları", - "maximum-datasources": "En fazla { count, select, 1 {1 veri kaynağı kullanılabilir.} other {# veri kaynağı kullanılabilir} }", + "maximum-datasources": "En fazla { count, plural, 1 {1 veri kaynağı kullanılabilir.} other {# veri kaynağı kullanılabilir} }", "datasource-type": "Tür", "datasource-parameters": "Parametreler", "remove-datasource": "Veri kaynağını kaldır", @@ -1539,7 +1539,7 @@ "ru_RU": "Rusça", "es_ES": "İspanyol", "ja_JA": "Japonca", - "TR": "Türkçe" + "tr_TR": "Türkçe" } } } \ No newline at end of file diff --git a/ui/src/app/locale/locale.constant-zh_CN.json b/ui/src/app/locale/locale.constant-zh_CN.json index c952f9cd32..bac1706c67 100644 --- a/ui/src/app/locale/locale.constant-zh_CN.json +++ b/ui/src/app/locale/locale.constant-zh_CN.json @@ -1445,7 +1445,7 @@ "es_ES": "西班牙语", "it_IT": "意大利", "ja_JA": "日本", - "TR": "土耳其" + "tr_TR": "土耳其" } } } diff --git a/ui/src/app/rulechain/script/node-script-test.service.js b/ui/src/app/rulechain/script/node-script-test.service.js index 81d81c1c54..d4e6df82d5 100644 --- a/ui/src/app/rulechain/script/node-script-test.service.js +++ b/ui/src/app/rulechain/script/node-script-test.service.js @@ -121,7 +121,7 @@ export default function NodeScriptTest($q, $mdDialog, $document, ruleChainServic onShowingCallback: onShowingCallback }, fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event, onComplete: () => { onShowingCallback.onShowed(); diff --git a/ui/src/app/user/add-user.controller.js b/ui/src/app/user/add-user.controller.js index 20ad0beaf7..c81cd67a57 100644 --- a/ui/src/app/user/add-user.controller.js +++ b/ui/src/app/user/add-user.controller.js @@ -100,7 +100,7 @@ export default function AddUserController($scope, $mdDialog, $state, $stateParam }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then(function () { deferred.resolve(); diff --git a/ui/src/app/user/user.controller.js b/ui/src/app/user/user.controller.js index 8d7f214b9a..ef7948daf0 100644 --- a/ui/src/app/user/user.controller.js +++ b/ui/src/app/user/user.controller.js @@ -186,7 +186,7 @@ export default function UserController(userService, toast, $scope, $mdDialog, $d }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event }); } diff --git a/ui/src/app/widget/lib/alarms-table-widget.js b/ui/src/app/widget/lib/alarms-table-widget.js index 6ae17e0459..3d91e9abbc 100644 --- a/ui/src/app/widget/lib/alarms-table-widget.js +++ b/ui/src/app/widget/lib/alarms-table-widget.js @@ -402,7 +402,7 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia parent: angular.element($document[0].body), targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/widget/lib/canvas-digital-gauge.js b/ui/src/app/widget/lib/canvas-digital-gauge.js index 283a42676b..274cc97901 100644 --- a/ui/src/app/widget/lib/canvas-digital-gauge.js +++ b/ui/src/app/widget/lib/canvas-digital-gauge.js @@ -209,8 +209,8 @@ export default class TbCanvasDigitalGauge { } var value = tvPair[1]; if(value !== this.gauge.value) { - this.gauge.value = value; this.gauge._value = value; + this.gauge.value = value; } else if (this.localSettings.showTimestamp && this.gauge.timestamp != timestamp) { this.gauge.timestamp = timestamp; } From 8ab4f144b2713b2692f7b260918825f9258b107d Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Mon, 29 Oct 2018 18:31:14 +0200 Subject: [PATCH 279/307] Implementation of Session timeouts --- .gitignore | 1 + .../server/actors/ActorSystemContext.java | 13 ++ .../server/actors/device/DeviceActor.java | 17 ++- .../device/DeviceActorMessageProcessor.java | 142 +++++++++++++----- .../server/actors/device/SessionInfo.java | 1 + .../actors/device/SessionInfoMetaData.java | 39 +++++ .../actors/device/SessionTimeoutCheckMsg.java | 39 +++++ .../AbstractContextAwareMsgProcessor.java | 24 +-- .../server/actors/tenant/TenantActor.java | 1 - .../DefaultDeviceSessionCacheService.java | 50 ++++++ .../session/DeviceSessionCacheService.java | 30 ++++ .../transport/LocalTransportService.java | 7 + .../src/main/resources/thingsboard.yml | 6 + .../server/common/data/CacheConstants.java | 1 + .../server/common/msg/MsgType.java | 7 +- .../transport/mqtt/MqttTransportHandler.java | 19 +-- .../mqtt/session/GatewaySessionHandler.java | 13 +- .../common/transport/TransportService.java | 4 + .../service/AbstractTransportService.java | 90 ++++++++++- .../service/RemoteTransportService.java | 11 ++ .../transport/service/SessionMetaData.java | 17 ++- .../src/main/resources/tb-coap-transport.yml | 3 + .../src/main/resources/tb-http-transport.yml | 3 + .../src/main/resources/tb-mqtt-transport.yml | 4 +- 24 files changed, 460 insertions(+), 82 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java diff --git a/.gitignore b/.gitignore index 9039e8ec4d..da8569baa3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ pom.xml.versionsBackup **/Californium.properties **/.env .instance_id +rebuild-docker.sh diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 5e19d690c4..091b504ea7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -67,6 +67,7 @@ import org.thingsboard.server.service.mail.MailExecutorService; import org.thingsboard.server.service.rpc.DeviceRpcService; import org.thingsboard.server.service.script.JsExecutorService; import org.thingsboard.server.service.script.JsInvokeService; +import org.thingsboard.server.service.session.DeviceSessionCacheService; import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.transport.RuleEngineTransportService; @@ -201,6 +202,10 @@ public class ActorSystemContext { @Getter private DeviceStateService deviceStateService; + @Autowired + @Getter + private DeviceSessionCacheService deviceSessionCacheService; + @Lazy @Autowired @Getter @@ -254,6 +259,14 @@ public class ActorSystemContext { @Getter private boolean allowSystemMailService; + @Value("${transport.sessions.inactivity_timeout}") + @Getter + private long sessionInactivityTimeout; + + @Value("${transport.sessions.report_timeout}") + @Getter + private long sessionReportTimeout; + @Getter @Setter private ActorSystem actorSystem; diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java index bd2a0f4486..7b412b1328 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -43,12 +43,20 @@ public class DeviceActor extends ContextAwareActor { this.processor = new DeviceActorMessageProcessor(systemContext, logger, tenantId, deviceId); } + @Override + public void preStart() { + logger.debug("[{}][{}] Starting device actor.", processor.tenantId, processor.deviceId); + try { + processor.initSessionTimeout(context()); + logger.debug("[{}][{}] Device actor started.", processor.tenantId, processor.deviceId); + } catch (Exception e) { + logger.error(e, "[{}][{}] Unknown failure", processor.tenantId, processor.deviceId); + } + } + @Override protected boolean process(TbActorMsg msg) { switch (msg.getMsgType()) { - case CLUSTER_EVENT_MSG: - processor.processClusterEventMsg((ClusterEventMsg) msg); - break; case TRANSPORT_TO_DEVICE_ACTOR_MSG: processor.process(context(), (TransportToDeviceActorMsgWrapper) msg); break; @@ -73,6 +81,9 @@ public class DeviceActor extends ContextAwareActor { case DEVICE_ACTOR_CLIENT_SIDE_RPC_TIMEOUT_MSG: processor.processClientSideRpcTimeout(context(), (DeviceActorClientSideRpcTimeoutMsg) msg); break; + case SESSION_TIMEOUT_MSG: + processor.checkSessionsTimeout(); + break; default: return false; } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 021f26207a..08a4454809 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -88,11 +88,11 @@ import java.util.stream.Collectors; /** * @author Andrew Shvayka */ -public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { +class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { - private final TenantId tenantId; - private final DeviceId deviceId; - private final Map sessions; + final TenantId tenantId; + final DeviceId deviceId; + private final Map sessions; private final Map attributeSubscriptions; private final Map rpcSubscriptions; private final Map toDeviceRpcPendingMap; @@ -116,6 +116,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso this.toDeviceRpcPendingMap = new HashMap<>(); this.toServerRpcPendingMap = new HashMap<>(); initAttributes(); + restoreSessions(); } private void initAttributes() { @@ -160,7 +161,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } else { logger.debug("[{}] RPC request {} is NOT sent!", deviceId, request.getId()); } - } private void registerPendingRpcRequest(ActorContext context, ToDeviceRpcRequestActorMsg msg, boolean sent, ToDeviceRpcRequestMsg rpcRequest, long timeout) { @@ -174,7 +174,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso if (requestMd != null) { logger.debug("[{}] RPC request [{}] timeout detected!", deviceId, msg.getId()); systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), - null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); + null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); } } @@ -227,11 +227,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } if (msg.hasPostAttributes()) { handlePostAttributesRequest(context, msg.getSessionInfo(), msg.getPostAttributes()); - reportActivity(); + reportLogicalDeviceActivity(); } if (msg.hasPostTelemetry()) { handlePostTelemetryRequest(context, msg.getSessionInfo(), msg.getPostTelemetry()); - reportActivity(); + reportLogicalDeviceActivity(); } if (msg.hasGetAttributes()) { handleGetAttributesRequest(context, msg.getSessionInfo(), msg.getGetAttributes()); @@ -241,11 +241,14 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } if (msg.hasToServerRPCCallRequest()) { handleClientSideRPCRequest(context, msg.getSessionInfo(), msg.getToServerRPCCallRequest()); - reportActivity(); + reportLogicalDeviceActivity(); + } + if (msg.hasSubscriptionInfo()) { + handleSessionActivity(context, msg.getSessionInfo(), msg.getSubscriptionInfo()); } } - private void reportActivity() { + private void reportLogicalDeviceActivity() { systemContext.getDeviceStateService().onDeviceActivity(deviceId); } @@ -406,28 +409,20 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - void processClusterEventMsg(ClusterEventMsg msg) { -// if (!msg.isAdded()) { -// logger.debug("[{}] Clearing attributes/rpc subscription for server [{}]", deviceId, msg.getServerAddress()); -// Predicate> filter = e -> e.getValue().getServer() -// .map(serverAddress -> serverAddress.equals(msg.getServerAddress())).orElse(false); -// attributeSubscriptions.entrySet().removeIf(filter); -// rpcSubscriptions.entrySet().removeIf(filter); -// } - } - private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg subscribeCmd) { UUID sessionId = getSessionId(sessionInfo); if (subscribeCmd.getUnsubscribe()) { logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); attributeSubscriptions.remove(sessionId); } else { - SessionInfo session = sessions.get(sessionId); - if (session == null) { - session = new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId()); + SessionInfoMetaData sessionMD = sessions.get(sessionId); + if (sessionMD == null) { + sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId())); } + sessionMD.setSubscribedToAttributes(true); logger.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); - attributeSubscriptions.put(sessionId, session); + attributeSubscriptions.put(sessionId, sessionMD.getSessionInfo()); + dumpSessions(); } } @@ -441,20 +436,22 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso logger.debug("[{}] Canceling rpc subscription for session [{}]", deviceId, sessionId); rpcSubscriptions.remove(sessionId); } else { - SessionInfo session = sessions.get(sessionId); - if (session == null) { - session = new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId()); + SessionInfoMetaData sessionMD = sessions.get(sessionId); + if (sessionMD == null) { + sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId())); } + sessionMD.setSubscribedToRPC(true); logger.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId); - rpcSubscriptions.put(sessionId, session); + rpcSubscriptions.put(sessionId, sessionMD.getSessionInfo()); sendPendingRequests(context, sessionId, sessionInfo); + dumpSessions(); } } private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { UUID sessionId = getSessionId(sessionInfo); if (msg.getEvent() == SessionEvent.OPEN) { - if(sessions.containsKey(sessionId)){ + if (sessions.containsKey(sessionId)) { logger.debug("[{}] Received duplicate session open event [{}]", deviceId, sessionId); return; } @@ -462,13 +459,14 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso if (sessions.size() >= systemContext.getMaxConcurrentSessionsPerDevice()) { UUID sessionIdToRemove = sessions.keySet().stream().findFirst().orElse(null); if (sessionIdToRemove != null) { - closeSession(sessionIdToRemove, sessions.remove(sessionIdToRemove)); + notifyTransportAboutClosedSession(sessionIdToRemove, sessions.remove(sessionIdToRemove)); } } - sessions.put(sessionId, new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfo.getNodeId())); + sessions.put(sessionId, new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfo.getNodeId()))); if (sessions.size() == 1) { reportSessionOpen(); } + dumpSessions(); } else if (msg.getEvent() == SessionEvent.CLOSED) { logger.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId); sessions.remove(sessionId); @@ -477,21 +475,34 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso if (sessions.isEmpty()) { reportSessionClose(); } + dumpSessions(); + } + } + + private void handleSessionActivity(ActorContext context, SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto subscriptionInfo) { + UUID sessionId = getSessionId(sessionInfo); + SessionInfoMetaData sessionMD = sessions.get(sessionId); + if (sessionMD != null) { + sessionMD.setLastActivityTime(subscriptionInfo.getLastActivityTime()); + sessionMD.setSubscribedToAttributes(subscriptionInfo.getAttributeSubscription()); + sessionMD.setSubscribedToRPC(subscriptionInfo.getRpcSubscription()); } + dumpSessions(); } void processCredentialsUpdate() { - sessions.forEach(this::closeSession); + sessions.forEach(this::notifyTransportAboutClosedSession); attributeSubscriptions.clear(); rpcSubscriptions.clear(); + dumpSessions(); } - private void closeSession(UUID sessionId, SessionInfo sessionInfo) { + private void notifyTransportAboutClosedSession(UUID sessionId, SessionInfoMetaData sessionMd) { DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() .setSessionIdMSB(sessionId.getMostSignificantBits()) .setSessionIdLSB(sessionId.getLeastSignificantBits()) .setSessionCloseNotification(SessionCloseNotificationProto.getDefaultInstance()).build(); - systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); + systemContext.getRuleEngineTransportService().process(sessionMd.getSessionInfo().getNodeId(), msg); } void processNameOrTypeUpdate(DeviceNameOrTypeUpdateMsg msg) { @@ -605,4 +616,67 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } return builder.build(); } + + private void restoreSessions() { + TransportProtos.DeviceSessionsCacheEntry sessionsDump = systemContext.getDeviceSessionCacheService().get(deviceId); + if (sessionsDump.getSerializedSize() == 0) { + return; + } + for (TransportProtos.SessionSubscriptionInfoProto sessionSubscriptionInfoProto : sessionsDump.getSessionsList()) { + SessionInfoProto sessionInfoProto = sessionSubscriptionInfoProto.getSessionInfo(); + UUID sessionId = getSessionId(sessionInfoProto); + SessionInfo sessionInfo = new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfoProto.getNodeId()); + TransportProtos.SubscriptionInfoProto subInfo = sessionSubscriptionInfoProto.getSubscriptionInfo(); + SessionInfoMetaData sessionInfoMetaData = new SessionInfoMetaData(sessionInfo, subInfo.getLastActivityTime()); + sessions.put(sessionId, sessionInfoMetaData); + if (subInfo.getAttributeSubscription()) { + rpcSubscriptions.put(sessionId, sessionInfo); + } + if (subInfo.getAttributeSubscription()) { + attributeSubscriptions.put(sessionId, sessionInfo); + } + } + } + + private void dumpSessions() { + List sessionsList = new ArrayList<>(sessions.size()); + sessions.forEach((uuid, sessionMD) -> { + if (sessionMD.getSessionInfo().getType() == TransportProtos.SessionType.SYNC) { + return; + } + SessionInfo sessionInfo = sessionMD.getSessionInfo(); + TransportProtos.SubscriptionInfoProto subscriptionInfoProto = TransportProtos.SubscriptionInfoProto.newBuilder() + .setLastActivityTime(sessionMD.getLastActivityTime()) + .setAttributeSubscription(sessionMD.isSubscribedToAttributes()) + .setRpcSubscription(sessionMD.isSubscribedToRPC()).build(); + TransportProtos.SessionInfoProto sessionInfoProto = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(uuid.getMostSignificantBits()) + .setSessionIdLSB(uuid.getLeastSignificantBits()) + .setNodeId(sessionInfo.getNodeId()).build(); + sessionsList.add(TransportProtos.SessionSubscriptionInfoProto.newBuilder() + .setSessionInfo(sessionInfoProto) + .setSubscriptionInfo(subscriptionInfoProto).build()); + }); + systemContext.getDeviceSessionCacheService() + .put(deviceId, TransportProtos.DeviceSessionsCacheEntry.newBuilder() + .addAllSessions(sessionsList).build()); + } + + void initSessionTimeout(ActorContext context) { + schedulePeriodicMsgWithDelay(context, SessionTimeoutCheckMsg.instance(), systemContext.getSessionInactivityTimeout(), systemContext.getSessionInactivityTimeout()); + } + + void checkSessionsTimeout() { + long expTime = System.currentTimeMillis() - systemContext.getSessionInactivityTimeout(); + Map sessionsToRemove = sessions.entrySet().stream().filter(kv -> kv.getValue().getLastActivityTime() < expTime).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + sessionsToRemove.forEach((sessionId, sessionMD) -> { + sessions.remove(sessionId); + rpcSubscriptions.remove(sessionId); + attributeSubscriptions.remove(sessionId); + notifyTransportAboutClosedSession(sessionId, sessionMD); + }); + if (!sessionsToRemove.isEmpty()) { + dumpSessions(); + } + } } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java index 43ae592cee..fe09077af1 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java @@ -25,4 +25,5 @@ import org.thingsboard.server.gen.transport.TransportProtos.SessionType; public class SessionInfo { private final SessionType type; private final String nodeId; + private long lastActivityTime; } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java new file mode 100644 index 0000000000..dd0839486f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import lombok.Data; +import org.thingsboard.server.gen.transport.TransportProtos.SessionType; + +/** + * @author Andrew Shvayka + */ +@Data +class SessionInfoMetaData { + private final SessionInfo sessionInfo; + private long lastActivityTime; + private boolean subscribedToAttributes; + private boolean subscribedToRPC; + + SessionInfoMetaData(SessionInfo sessionInfo) { + this(sessionInfo, System.currentTimeMillis()); + } + + SessionInfoMetaData(SessionInfo sessionInfo, long lastActivityTime) { + this.sessionInfo = sessionInfo; + this.lastActivityTime = lastActivityTime; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java new file mode 100644 index 0000000000..d9172ae825 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; + +/** + * Created by ashvayka on 29.10.18. + */ +public class SessionTimeoutCheckMsg implements TbActorMsg { + + private static final SessionTimeoutCheckMsg INSTANCE = new SessionTimeoutCheckMsg(); + + private SessionTimeoutCheckMsg() { + } + + public static SessionTimeoutCheckMsg instance() { + return INSTANCE; + } + + @Override + public MsgType getMsgType() { + return MsgType.SESSION_TIMEOUT_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java index 8864486150..c8097821d9 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java @@ -40,43 +40,31 @@ public abstract class AbstractContextAwareMsgProcessor { this.logger = logger; } - protected ActorRef getAppActor() { - return systemContext.getAppActor(); - } - - protected Scheduler getScheduler() { + private Scheduler getScheduler() { return systemContext.getScheduler(); } - protected ExecutionContextExecutor getSystemDispatcher() { + private ExecutionContextExecutor getSystemDispatcher() { return systemContext.getActorSystem().dispatcher(); } protected void schedulePeriodicMsgWithDelay(ActorContext ctx, Object msg, long delayInMs, long periodInMs) { - schedulePeriodicMsgWithDelay(ctx, msg, delayInMs, periodInMs, ctx.self()); + schedulePeriodicMsgWithDelay(msg, delayInMs, periodInMs, ctx.self()); } - protected void schedulePeriodicMsgWithDelay(ActorContext ctx, Object msg, long delayInMs, long periodInMs, ActorRef target) { + private void schedulePeriodicMsgWithDelay(Object msg, long delayInMs, long periodInMs, ActorRef target) { logger.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs); getScheduler().schedule(Duration.create(delayInMs, TimeUnit.MILLISECONDS), Duration.create(periodInMs, TimeUnit.MILLISECONDS), target, msg, getSystemDispatcher(), null); } - protected void scheduleMsgWithDelay(ActorContext ctx, Object msg, long delayInMs) { - scheduleMsgWithDelay(ctx, msg, delayInMs, ctx.self()); + scheduleMsgWithDelay(msg, delayInMs, ctx.self()); } - protected void scheduleMsgWithDelay(ActorContext ctx, Object msg, long delayInMs, ActorRef target) { + private void scheduleMsgWithDelay(Object msg, long delayInMs, ActorRef target) { logger.debug("Scheduling msg {} with delay {} ms", msg, delayInMs); getScheduler().scheduleOnce(Duration.create(delayInMs, TimeUnit.MILLISECONDS), target, msg, getSystemDispatcher(), null); } - @Data - @AllArgsConstructor - private static class ComponentConfiguration { - private final String clazz; - private final String name; - private final String configuration; - } } diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index 347483a185..f822e4e1f0 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -127,7 +127,6 @@ public class TenantActor extends RuleChainManagerActor { ruleChainManager.getOrCreateActor(context(), msg.getRuleChainId()).tell(msg, self()); } - private void onToDeviceActorMsg(DeviceAwareMsg msg) { getOrCreateDeviceActor(msg.getDeviceId()).tell(msg, ActorRef.noSender()); } diff --git a/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java b/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java new file mode 100644 index 0000000000..6201dabdda --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.session; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; + +import java.util.ArrayList; +import java.util.Collections; + +import static org.thingsboard.server.common.data.CacheConstants.SESSIONS_CACHE; + +/** + * Created by ashvayka on 29.10.18. + */ +@Service +@Slf4j +public class DefaultDeviceSessionCacheService implements DeviceSessionCacheService { + + @Override + @Cacheable(cacheNames = SESSIONS_CACHE, key = "#deviceId") + public DeviceSessionsCacheEntry get(DeviceId deviceId) { + log.debug("[{}] Fetching session data from cache", deviceId); + return DeviceSessionsCacheEntry.newBuilder().addAllSessions(Collections.emptyList()).build(); + } + + @Override + @CachePut(cacheNames = SESSIONS_CACHE, key = "#deviceId") + public DeviceSessionsCacheEntry put(DeviceId deviceId, DeviceSessionsCacheEntry sessions) { + log.debug("[{}] Pushing session data from cache: {}", deviceId, sessions); + return sessions; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java b/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java new file mode 100644 index 0000000000..0a2e6a52e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.session; + +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; + +/** + * Created by ashvayka on 29.10.18. + */ +public interface DeviceSessionCacheService { + + DeviceSessionsCacheEntry get(DeviceId deviceId); + + DeviceSessionsCacheEntry put(DeviceId deviceId, DeviceSessionsCacheEntry sessions); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java index 0f722306ff..d3782e8ffc 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -160,6 +160,13 @@ public class LocalTransportService extends AbstractTransportService implements R } } + @Override + public void process(SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscriptionInfo(msg).build(), callback); + } + } + @Override public void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { if (checkLimits(sessionInfo, callback)) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 54096eaaa1..58242933aa 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -202,6 +202,9 @@ caffeine: devices: timeToLiveInMinutes: 1440 maxSize: 100000 + sessions: + timeToLiveInMinutes: 1440 + maxSize: 100000 assets: timeToLiveInMinutes: 1440 maxSize: 100000 @@ -392,6 +395,9 @@ transport: auto_commit_interval: "${TB_RULE_ENGINE_AUTO_COMMIT_INTERVAL_MS:100}" notifications: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" rate_limits: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 698a69ef6b..853caff8d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -19,6 +19,7 @@ public class CacheConstants { public static final String DEVICE_CREDENTIALS_CACHE = "deviceCredentials"; public static final String RELATIONS_CACHE = "relations"; public static final String DEVICE_CACHE = "devices"; + public static final String SESSIONS_CACHE = "sessions"; public static final String ASSET_CACHE = "assets"; public static final String ENTITY_VIEW_CACHE = "entityViews"; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java index 24758b5b36..44a98d6ee4 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java @@ -96,13 +96,8 @@ public enum MsgType { */ DEVICE_ACTOR_TO_RULE_ENGINE_MSG, - /** - * Message that is sent from Rule Engine to the Device Actor when message is successfully pushed to queue. - */ - ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG, - TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG, SESSION_TIMEOUT_MSG, - SESSION_CTRL_MSG, + STATS_PERSIST_TICK_MSG, diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index da8b3a62ca..a178bdf891 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -43,10 +43,10 @@ import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.transport.service.AbstractTransportService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; -import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; @@ -141,9 +141,12 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); break; case PINGREQ: - //TODO: should we push the notification to the rule engine? if (checkConnected(ctx)) { ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); + transportService.reportActivity(sessionInfo); + if (gatewaySessionHandler != null) { + gatewaySessionHandler.reportActivity(); + } } break; case DISCONNECT: @@ -394,7 +397,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void processDisconnect(ChannelHandlerContext ctx) { ctx.close(); if (deviceSessionCtx.isConnected()) { - transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.process(sessionInfo, AbstractTransportService.getSessionEventMsg(SessionEvent.CLOSED), null); transportService.deregisterSession(sessionInfo); if (gatewaySessionHandler != null) { gatewaySessionHandler.onGatewayDisconnect(); @@ -466,16 +469,10 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - public static SessionEventMsg getSessionEventMsg(SessionEvent event) { - return SessionEventMsg.newBuilder() - .setSessionType(TransportProtos.SessionType.ASYNC) - .setEvent(event).build(); - } - @Override public void operationComplete(Future future) throws Exception { if (deviceSessionCtx.isConnected()) { - transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.process(sessionInfo, AbstractTransportService.getSessionEventMsg(SessionEvent.CLOSED), null); transportService.deregisterSession(sessionInfo); } } @@ -495,7 +492,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement .setTenantIdMSB(msg.getDeviceInfo().getTenantIdMSB()) .setTenantIdLSB(msg.getDeviceInfo().getTenantIdLSB()) .build(); - transportService.process(sessionInfo, getSessionEventMsg(SessionEvent.OPEN), null); + transportService.process(sessionInfo, AbstractTransportService.getSessionEventMsg(SessionEvent.OPEN), null); transportService.registerAsyncSession(sessionInfo, this); checkGatewaySession(); ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java index d6000591a8..ac33ba677e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.common.transport.service.AbstractTransportService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; @@ -118,7 +119,7 @@ public class GatewaySessionHandler { GatewayDeviceSessionCtx deviceSessionCtx = new GatewayDeviceSessionCtx(GatewaySessionHandler.this, msg.getDeviceInfo(), mqttQoSMap); if (devices.putIfAbsent(deviceName, deviceSessionCtx) == null) { SessionInfoProto deviceSessionInfo = deviceSessionCtx.getSessionInfo(); - transportService.process(deviceSessionInfo, MqttTransportHandler.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); + transportService.process(deviceSessionInfo, AbstractTransportService.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), null); transportService.registerAsyncSession(deviceSessionInfo, deviceSessionCtx); @@ -334,7 +335,7 @@ public class GatewaySessionHandler { private void deregisterSession(String deviceName, GatewayDeviceSessionCtx deviceSessionCtx) { transportService.deregisterSession(deviceSessionCtx.getSessionInfo()); - transportService.process(deviceSessionCtx.getSessionInfo(), MqttTransportHandler.getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + transportService.process(deviceSessionCtx.getSessionInfo(), AbstractTransportService.getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); log.debug("[{}] Removed device [{}] from the gateway session", sessionId, deviceName); } @@ -360,11 +361,15 @@ public class GatewaySessionHandler { return context; } - public MqttTransportAdaptor getAdaptor() { + MqttTransportAdaptor getAdaptor() { return context.getAdaptor(); } - public int nextMsgId() { + int nextMsgId() { return deviceSessionCtx.nextMsgId(); } + + public void reportActivity() { + devices.forEach((id, deviceCtx) -> transportService.reportActivity(deviceCtx.getSessionInfo())); + } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index a47438f30d..8944e94a86 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -61,10 +61,14 @@ public interface TransportService { void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback); + void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto msg, TransportServiceCallback callback); + void registerAsyncSession(SessionInfoProto sessionInfo, SessionMsgListener listener); void registerSyncSession(SessionInfoProto sessionInfo, SessionMsgListener listener, long timeout); + void reportActivity(SessionInfoProto sessionInfo); + void deregisterSession(SessionInfoProto sessionInfo); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java index 265dacb5de..3bb84ecbc4 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java @@ -47,9 +47,14 @@ public abstract class AbstractTransportService implements TransportService { private String perTenantLimitsConf; @Value("${transport.rate_limits.tenant}") private String perDevicesLimitsConf; + @Value("${transport.sessions.inactivity_timeout}") + private long sessionInactivityTimeout; + @Value("${transport.sessions.report_timeout}") + private long sessionReportTimeout; protected ScheduledExecutorService schedulerExecutor; protected ExecutorService transportCallbackExecutor; + private ConcurrentMap sessions = new ConcurrentHashMap<>(); //TODO: Implement cleanup of this maps. @@ -59,7 +64,81 @@ public abstract class AbstractTransportService implements TransportService { @Override public void registerAsyncSession(TransportProtos.SessionInfoProto sessionInfo, SessionMsgListener listener) { sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, TransportProtos.SessionType.ASYNC, listener)); - //TODO: monitor sessions periodically: PING REQ/RESP, etc. + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback) { + reportActivityInternal(sessionInfo); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostTelemetryMsg msg, TransportServiceCallback callback) { + reportActivityInternal(sessionInfo); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostAttributeMsg msg, TransportServiceCallback callback) { + reportActivityInternal(sessionInfo); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback callback) { + reportActivityInternal(sessionInfo); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); + sessionMetaData.setSubscribedToAttributes(!msg.getUnsubscribe()); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback callback) { + SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); + sessionMetaData.setSubscribedToRPC(!msg.getUnsubscribe()); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + reportActivityInternal(sessionInfo); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + reportActivityInternal(sessionInfo); + } + + @Override + public void reportActivity(TransportProtos.SessionInfoProto sessionInfo) { + reportActivityInternal(sessionInfo); + } + + private SessionMetaData reportActivityInternal(TransportProtos.SessionInfoProto sessionInfo) { + UUID sessionId = toId(sessionInfo); + SessionMetaData sessionMetaData = sessions.get(sessionId); + if (sessionMetaData != null) { + sessionMetaData.updateLastActivityTime(); + } + return sessionMetaData; + } + + private void checkInactivityAndReportActivity() { + long expTime = System.currentTimeMillis() - sessionInactivityTimeout; + sessions.forEach((uuid, sessionMD) -> { + if (sessionMD.getLastActivityTime() < expTime) { + if (log.isDebugEnabled()) { + log.debug("[{}] Session has expired due to last activity time: {}", toId(sessionMD.getSessionInfo()), sessionMD.getLastActivityTime()); + } + process(sessionMD.getSessionInfo(), getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + sessions.remove(uuid); + sessionMD.getListener().onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto.getDefaultInstance()); + } else { + process(sessionMD.getSessionInfo(), TransportProtos.SubscriptionInfoProto.newBuilder() + .setAttributeSubscription(sessionMD.isSubscribedToAttributes()) + .setRpcSubscription(sessionMD.isSubscribedToRPC()) + .setLastActivityTime(sessionMD.getLastActivityTime()).build(), null); + } + }); } @Override @@ -131,7 +210,7 @@ public abstract class AbstractTransportService implements TransportService { } } - protected UUID toId(TransportProtos.SessionInfoProto sessionInfo) { + private UUID toId(TransportProtos.SessionInfoProto sessionInfo) { return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); } @@ -147,6 +226,7 @@ public abstract class AbstractTransportService implements TransportService { } this.schedulerExecutor = Executors.newSingleThreadScheduledExecutor(); this.transportCallbackExecutor = new ThreadPoolExecutor(0, 20, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + this.schedulerExecutor.scheduleAtFixedRate(this::checkInactivityAndReportActivity, sessionReportTimeout, sessionReportTimeout, TimeUnit.MILLISECONDS); } public void destroy() { @@ -161,4 +241,10 @@ public abstract class AbstractTransportService implements TransportService { transportCallbackExecutor.shutdownNow(); } } + + public static TransportProtos.SessionEventMsg getSessionEventMsg(TransportProtos.SessionEvent event) { + return TransportProtos.SessionEventMsg.newBuilder() + .setSessionType(TransportProtos.SessionType.ASYNC) + .setEvent(event).build(); + } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index 6774942de3..091096106b 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -227,6 +227,17 @@ public class RemoteTransportService extends AbstractTransportService { } } + @Override + public void process(SessionInfoProto sessionInfo, SubscriptionInfoProto msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscriptionInfo(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + } + @Override public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { if (checkLimits(sessionInfo, callback)) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java index 1de57114e4..8642e93a97 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -23,10 +23,25 @@ import org.thingsboard.server.gen.transport.TransportProtos; * Created by ashvayka on 15.10.18. */ @Data -public class SessionMetaData { +class SessionMetaData { private final TransportProtos.SessionInfoProto sessionInfo; private final TransportProtos.SessionType sessionType; private final SessionMsgListener listener; + private volatile long lastActivityTime; + private volatile boolean subscribedToAttributes; + private volatile boolean subscribedToRPC; + + SessionMetaData(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionType sessionType, SessionMsgListener listener) { + this.sessionInfo = sessionInfo; + this.sessionType = sessionType; + this.listener = listener; + this.lastActivityTime = System.currentTimeMillis(); + } + + void updateLastActivityTime() { + this.lastActivityTime = System.currentTimeMillis(); + } + } diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 30351b0bfa..f96bcbe6d6 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -23,6 +23,9 @@ transport: bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" bind_port: "${COAP_BIND_PORT:5683}" timeout: "${COAP_TIMEOUT:10000}" + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" rate_limits: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 001e08abdc..6d593ed1b4 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -24,6 +24,9 @@ server: transport: http: request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" rate_limits: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index e37d14e874..e7f8942e77 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -44,8 +44,8 @@ transport: # Type of the key store key_store_type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" sessions: - max_per_tenant: "${TB_TRANSPORT_SESSIONS_MAX_PER_TENANT:1000}" - max_per_device: "${TB_TRANSPORT_SESSIONS_MAX_PER_DEVICE:2}" + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" rate_limits: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" From 4255bb56a38d0edf1c671ce07321a6bf431fd7f2 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 30 Oct 2018 11:41:03 +0200 Subject: [PATCH 280/307] Implementation of Session Cache, TTL and few minor fixes --- .../device/DeviceActorMessageProcessor.java | 25 +++- .../actors/rpc/BasicRpcSessionListener.java | 2 +- .../server/actors/rpc/RpcManagerActor.java | 6 +- .../server/actors/rpc/RpcSessionActor.java | 5 +- .../server/actors/tenant/TenantActor.java | 2 +- .../service/cluster/rpc/GrpcSession.java | 11 +- .../AnnotationComponentDiscoveryService.java | 34 +++-- .../transport/LocalTransportService.java | 52 +++----- .../server/kafka/TBKafkaProducerTemplate.java | 7 +- .../service/AbstractTransportService.java | 60 +++++++-- .../service/RemoteTransportService.java | 126 ++++++++---------- docker/tb-node/conf/logback.xml | 2 +- 12 files changed, 186 insertions(+), 146 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 08a4454809..a16bd78a25 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -201,7 +201,6 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { private Consumer> processPendingRpc(ActorContext context, UUID sessionId, String nodeId, Set sentOneWayIds) { return entry -> { - ToDeviceRpcRequestActorMsg requestActorMsg = entry.getValue().getMsg(); ToDeviceRpcRequest request = entry.getValue().getMsg().getMsg(); ToDeviceRpcRequestBody body = request.getBody(); if (request.isOneway()) { @@ -486,6 +485,12 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { sessionMD.setLastActivityTime(subscriptionInfo.getLastActivityTime()); sessionMD.setSubscribedToAttributes(subscriptionInfo.getAttributeSubscription()); sessionMD.setSubscribedToRPC(subscriptionInfo.getRpcSubscription()); + if (subscriptionInfo.getAttributeSubscription()) { + attributeSubscriptions.putIfAbsent(sessionId, sessionMD.getSessionInfo()); + } + if (subscriptionInfo.getRpcSubscription()) { + rpcSubscriptions.putIfAbsent(sessionId, sessionMD.getSessionInfo()); + } } dumpSessions(); } @@ -618,8 +623,10 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { } private void restoreSessions() { + logger.debug("[{}] Restoring sessions from cache", deviceId); TransportProtos.DeviceSessionsCacheEntry sessionsDump = systemContext.getDeviceSessionCacheService().get(deviceId); if (sessionsDump.getSerializedSize() == 0) { + logger.debug("[{}] No session information found", deviceId); return; } for (TransportProtos.SessionSubscriptionInfoProto sessionSubscriptionInfoProto : sessionsDump.getSessionsList()) { @@ -627,18 +634,23 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { UUID sessionId = getSessionId(sessionInfoProto); SessionInfo sessionInfo = new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfoProto.getNodeId()); TransportProtos.SubscriptionInfoProto subInfo = sessionSubscriptionInfoProto.getSubscriptionInfo(); - SessionInfoMetaData sessionInfoMetaData = new SessionInfoMetaData(sessionInfo, subInfo.getLastActivityTime()); - sessions.put(sessionId, sessionInfoMetaData); - if (subInfo.getAttributeSubscription()) { - rpcSubscriptions.put(sessionId, sessionInfo); - } + SessionInfoMetaData sessionMD = new SessionInfoMetaData(sessionInfo, subInfo.getLastActivityTime()); + sessions.put(sessionId, sessionMD); if (subInfo.getAttributeSubscription()) { attributeSubscriptions.put(sessionId, sessionInfo); + sessionMD.setSubscribedToAttributes(true); + } + if (subInfo.getRpcSubscription()) { + rpcSubscriptions.put(sessionId, sessionInfo); + sessionMD.setSubscribedToRPC(true); } + logger.debug("[{}] Restored session: {}", deviceId, sessionMD); } + logger.debug("[{}] Restored sessions: {}, rpc subscriptions: {}, attribute subscriptions: {}", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); } private void dumpSessions() { + logger.debug("[{}] Dumping sessions: {}, rpc subscriptions: {}, attribute subscriptions: {} to cache", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); List sessionsList = new ArrayList<>(sessions.size()); sessions.forEach((uuid, sessionMD) -> { if (sessionMD.getSessionInfo().getType() == TransportProtos.SessionType.SYNC) { @@ -656,6 +668,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { sessionsList.add(TransportProtos.SessionSubscriptionInfoProto.newBuilder() .setSessionInfo(sessionInfoProto) .setSubscriptionInfo(subscriptionInfoProto).build()); + logger.debug("[{}] Dumping session: {}", deviceId, sessionMD); }); systemContext.getDeviceSessionCacheService() .put(deviceId, TransportProtos.DeviceSessionsCacheEntry.newBuilder() diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java b/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java index 14bb636830..1eba066960 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java @@ -32,7 +32,7 @@ public class BasicRpcSessionListener implements GrpcSessionListener { private final ActorRef manager; private final ActorRef self; - public BasicRpcSessionListener(ActorService service, ActorRef manager, ActorRef self) { + BasicRpcSessionListener(ActorService service, ActorRef manager, ActorRef self) { this.service = service; this.manager = manager; this.self = self; diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java index 66174e1e4b..31320ce5d2 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java @@ -103,10 +103,10 @@ public class RpcManagerActor extends ContextAwareActor { ServerAddress address = new ServerAddress(msg.getServerAddress().getHost(), msg.getServerAddress().getPort(), ServerType.CORE); SessionActorInfo session = sessionActors.get(address); if (session != null) { - log.debug("{} Forwarding msg to session actor", address); + log.debug("{} Forwarding msg to session actor: {}", address, msg); session.getActor().tell(msg, ActorRef.noSender()); } else { - log.debug("{} Storing msg to pending queue", address); + log.debug("{} Storing msg to pending queue: {}", address, msg); Queue queue = pendingMsgs.get(address); if (queue == null) { queue = new LinkedList<>(); @@ -116,7 +116,7 @@ public class RpcManagerActor extends ContextAwareActor { queue.add(msg); } } else { - logger.warning("Cluster msg doesn't have set Server Address [{}]", msg); + logger.warning("Cluster msg doesn't have server address [{}]", msg); } } diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java index c9cf8696e5..86509ca08e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java @@ -18,6 +18,7 @@ package org.thingsboard.server.actors.rpc; import akka.event.Logging; import akka.event.LoggingAdapter; import io.grpc.Channel; +import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.stub.StreamObserver; import org.thingsboard.server.actors.ActorSystemContext; @@ -88,8 +89,8 @@ public class RpcSessionActor extends ContextAwareActor { systemContext.getRpcService().onSessionCreated(msg.getMsgUid(), session.getInputStream()); } else { // Client session - Channel channel = ManagedChannelBuilder.forAddress(remoteServer.getHost(), remoteServer.getPort()).usePlaintext(true).build(); - session = new GrpcSession(remoteServer, listener); + ManagedChannel channel = ManagedChannelBuilder.forAddress(remoteServer.getHost(), remoteServer.getPort()).usePlaintext().build(); + session = new GrpcSession(remoteServer, listener, channel); session.initInputStream(); ClusterRpcServiceGrpc.ClusterRpcServiceStub stub = ClusterRpcServiceGrpc.newStub(channel); diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index f822e4e1f0..721d828fd6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -108,7 +108,7 @@ public class TenantActor extends RuleChainManagerActor { @Override protected void broadcast(Object msg) { super.broadcast(msg); - deviceActors.values().forEach(actorRef -> actorRef.tell(msg, ActorRef.noSender())); +// deviceActors.values().forEach(actorRef -> actorRef.tell(msg, ActorRef.noSender())); } private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) { diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java b/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java index 55cf52c37b..dd0a995c84 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.service.cluster.rpc; +import io.grpc.Channel; +import io.grpc.ManagedChannel; import io.grpc.stub.StreamObserver; import lombok.Data; import lombok.extern.slf4j.Slf4j; @@ -34,6 +36,7 @@ public final class GrpcSession implements Closeable { private final UUID sessionId; private final boolean client; private final GrpcSessionListener listener; + private final ManagedChannel channel; private StreamObserver inputStream; private StreamObserver outputStream; @@ -41,10 +44,10 @@ public final class GrpcSession implements Closeable { private ServerAddress remoteServer; public GrpcSession(GrpcSessionListener listener) { - this(null, listener); + this(null, listener, null); } - public GrpcSession(ServerAddress remoteServer, GrpcSessionListener listener) { + public GrpcSession(ServerAddress remoteServer, GrpcSessionListener listener, ManagedChannel channel) { this.sessionId = UUID.randomUUID(); this.listener = listener; if (remoteServer != null) { @@ -54,6 +57,7 @@ public final class GrpcSession implements Closeable { } else { this.client = false; } + this.channel = channel; } public void initInputStream() { @@ -105,5 +109,8 @@ public final class GrpcSession implements Closeable { } catch (IllegalStateException e) { log.debug("[{}] Failed to close output stream: {}", sessionId, e.getMessage()); } + if (channel != null) { + channel.shutdownNow(); + } } } diff --git a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java index f3ceed2069..5a1e55988d 100644 --- a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java @@ -30,7 +30,6 @@ import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.api.NodeDefinition; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbRelationTypes; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.plugin.ComponentDescriptor; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.dao.component.ComponentDescriptorService; @@ -52,6 +51,7 @@ import java.util.Set; @Slf4j public class AnnotationComponentDiscoveryService implements ComponentDiscoveryService { + public static final int MAX_OPTIMISITC_RETRIES = 3; @Value("${plugins.scan_packages}") private String[] scanPackages; @@ -81,17 +81,27 @@ public class AnnotationComponentDiscoveryService implements ComponentDiscoverySe private void registerRuleNodeComponents() { Set ruleNodeBeanDefinitions = getBeanDefinitions(RuleNode.class); for (BeanDefinition def : ruleNodeBeanDefinitions) { - try { - String clazzName = def.getBeanClassName(); - Class clazz = Class.forName(clazzName); - RuleNode ruleNodeAnnotation = clazz.getAnnotation(RuleNode.class); - ComponentType type = ruleNodeAnnotation.type(); - ComponentDescriptor component = scanAndPersistComponent(def, type); - components.put(component.getClazz(), component); - componentsMap.computeIfAbsent(type, k -> new ArrayList<>()).add(component); - } catch (Exception e) { - log.error("Can't initialize component {}, due to {}", def.getBeanClassName(), e.getMessage(), e); - throw new RuntimeException(e); + int retryCount = 0; + Exception cause = null; + while (retryCount < MAX_OPTIMISITC_RETRIES) { + try { + String clazzName = def.getBeanClassName(); + Class clazz = Class.forName(clazzName); + RuleNode ruleNodeAnnotation = clazz.getAnnotation(RuleNode.class); + ComponentType type = ruleNodeAnnotation.type(); + ComponentDescriptor component = scanAndPersistComponent(def, type); + components.put(component.getClazz(), component); + componentsMap.computeIfAbsent(type, k -> new ArrayList<>()).add(component); + break; + } catch (Exception e) { + log.trace("Can't initialize component {}, due to {}", def.getBeanClassName(), e.getMessage(), e); + cause = e; + retryCount++; + } + } + if (cause != null && retryCount == MAX_OPTIMISITC_RETRIES) { + log.error("Can't initialize component {}, due to {}", def.getBeanClassName(), cause.getMessage(), cause); + throw new RuntimeException(cause); } } } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java index d3782e8ffc..3d12938903 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -133,66 +133,48 @@ public class LocalTransportService extends AbstractTransportService implements R } @Override - public void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSessionEvent(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSessionEvent(msg).build(), callback); } @Override - public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostTelemetry(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostTelemetry(msg).build(), callback); } @Override - public void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostAttributes(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostAttributes(msg).build(), callback); } @Override - public void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setGetAttributes(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setGetAttributes(msg).build(), callback); } @Override public void process(SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscriptionInfo(msg).build(), callback); - } + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscriptionInfo(msg).build(), callback); } @Override - public void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToAttributes(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToAttributes(msg).build(), callback); } @Override - public void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToRPC(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToRPC(msg).build(), callback); } @Override - public void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToDeviceRPCCallResponse(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToDeviceRPCCallResponse(msg).build(), callback); } @Override - public void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback); - } + protected void doProcess(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback); } @Override diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index 610a49007d..bd42f310a6 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -26,6 +26,7 @@ import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.PartitionInfo; +import org.apache.kafka.common.errors.TopicExistsException; import org.apache.kafka.common.header.Header; import java.util.List; @@ -75,7 +76,11 @@ public class TBKafkaProducerTemplate { CreateTopicsResult result = admin.createTopic(new NewTopic(defaultTopic, 100, (short) 1)); result.all().get(); } catch (Exception e) { - log.trace("Failed to create topic: {}", e.getMessage(), e); + if ((e instanceof TopicExistsException) || (e.getCause() != null && e.getCause() instanceof TopicExistsException)) { + log.trace("[{}] Topic already exists: ", defaultTopic); + } else { + log.trace("[{}] Failed to create topic: {}", defaultTopic, e.getMessage(), e); + } } //Maybe this should not be cached, but we don't plan to change size of partitions this.partitionInfoMap = new ConcurrentHashMap<>(); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java index 3bb84ecbc4..fad19541b4 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java @@ -68,44 +68,68 @@ public abstract class AbstractTransportService implements TransportService { @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback) { - reportActivityInternal(sessionInfo); + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } } @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostTelemetryMsg msg, TransportServiceCallback callback) { - reportActivityInternal(sessionInfo); + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } } @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostAttributeMsg msg, TransportServiceCallback callback) { - reportActivityInternal(sessionInfo); + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } } @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback callback) { - reportActivityInternal(sessionInfo); + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } } @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { - SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); - sessionMetaData.setSubscribedToAttributes(!msg.getUnsubscribe()); + if (checkLimits(sessionInfo, callback)) { + SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); + sessionMetaData.setSubscribedToAttributes(!msg.getUnsubscribe()); + doProcess(sessionInfo, msg, callback); + } } @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback callback) { - SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); - sessionMetaData.setSubscribedToRPC(!msg.getUnsubscribe()); + if (checkLimits(sessionInfo, callback)) { + SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); + sessionMetaData.setSubscribedToRPC(!msg.getUnsubscribe()); + doProcess(sessionInfo, msg, callback); + } } @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { - reportActivityInternal(sessionInfo); + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } } @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback callback) { - reportActivityInternal(sessionInfo); + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } } @Override @@ -113,6 +137,22 @@ public abstract class AbstractTransportService implements TransportService { reportActivityInternal(sessionInfo); } + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostTelemetryMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostAttributeMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback callback); + private SessionMetaData reportActivityInternal(TransportProtos.SessionInfoProto sessionInfo) { UUID sessionId = toId(sessionInfo); SessionMetaData sessionMetaData = sessions.get(sessionId); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java index 091096106b..4b11bf58c9 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -217,102 +217,84 @@ public class RemoteTransportService extends AbstractTransportService { } @Override - public void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setSessionEvent(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + public void process(SessionInfoProto sessionInfo, SubscriptionInfoProto msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscriptionInfo(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, SubscriptionInfoProto msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setSubscriptionInfo(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSessionEvent(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setPostTelemetry(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostTelemetry(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setPostAttributes(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setGetAttributes(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setGetAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setSubscribeToAttributes(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setSubscribeToRPC(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToRPC(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setToDeviceRPCCallResponse(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToDeviceRPCCallResponse(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } @Override - public void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { - if (checkLimits(sessionInfo, callback)) { - ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( - TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) - .setToServerRPCCallRequest(msg).build() - ).build(); - send(sessionInfo, toRuleEngineMsg, callback); - } + protected void doProcess(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToServerRPCCallRequest(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); } private static class TransportCallbackAdaptor implements Callback { diff --git a/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml index 1c69f537bc..8bf38eb287 100644 --- a/docker/tb-node/conf/logback.xml +++ b/docker/tb-node/conf/logback.xml @@ -40,7 +40,7 @@ - + From cc873d4fdf41e42768495265dddb9bffd6198e11 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 30 Oct 2018 11:42:45 +0200 Subject: [PATCH 281/307] Use JDK instead of JRE in docker images --- msa/tb-node/docker/Dockerfile | 2 +- msa/tb/docker-cassandra/Dockerfile | 2 +- msa/tb/docker-postgres/Dockerfile | 2 +- msa/tb/docker-tb/Dockerfile | 2 +- msa/transport/coap/docker/Dockerfile | 2 +- msa/transport/http/docker/Dockerfile | 2 +- msa/transport/mqtt/docker/Dockerfile | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/msa/tb-node/docker/Dockerfile b/msa/tb-node/docker/Dockerfile index c86d020d20..57a6151e37 100644 --- a/msa/tb-node/docker/Dockerfile +++ b/msa/tb-node/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM openjdk:8-jre +FROM openjdk:8-jdk COPY start-tb-node.sh ${pkg.name}.deb /tmp/ diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index 175ad9abe1..a15408dddb 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM openjdk:8-jre +FROM openjdk:8-jdk RUN apt-get update RUN apt-get install -y curl nmap procps diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile index 9447d51387..c1b3f029b0 100644 --- a/msa/tb/docker-postgres/Dockerfile +++ b/msa/tb/docker-postgres/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM openjdk:8-jre +FROM openjdk:8-jdk RUN apt-get update RUN apt-get install -y postgresql postgresql-contrib diff --git a/msa/tb/docker-tb/Dockerfile b/msa/tb/docker-tb/Dockerfile index df7e692fbe..497a03c959 100644 --- a/msa/tb/docker-tb/Dockerfile +++ b/msa/tb/docker-tb/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM openjdk:8-jre +FROM openjdk:8-jdk COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ diff --git a/msa/transport/coap/docker/Dockerfile b/msa/transport/coap/docker/Dockerfile index dcaef3a358..9240b2a4f0 100644 --- a/msa/transport/coap/docker/Dockerfile +++ b/msa/transport/coap/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM openjdk:8-jre +FROM openjdk:8-jdk COPY logback.xml ${pkg.name}.conf start-tb-coap-transport.sh ${pkg.name}.deb /tmp/ diff --git a/msa/transport/http/docker/Dockerfile b/msa/transport/http/docker/Dockerfile index 212047fa42..6c83b9c76b 100644 --- a/msa/transport/http/docker/Dockerfile +++ b/msa/transport/http/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM openjdk:8-jre +FROM openjdk:8-jdk COPY logback.xml ${pkg.name}.conf start-tb-http-transport.sh ${pkg.name}.deb /tmp/ diff --git a/msa/transport/mqtt/docker/Dockerfile b/msa/transport/mqtt/docker/Dockerfile index 5827c65229..f636e2f3a7 100644 --- a/msa/transport/mqtt/docker/Dockerfile +++ b/msa/transport/mqtt/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM openjdk:8-jre +FROM openjdk:8-jdk COPY logback.xml ${pkg.name}.conf start-tb-mqtt-transport.sh ${pkg.name}.deb /tmp/ From 83a55468a4d6e415f626858d94d04f888875ebd6 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 30 Oct 2018 11:45:02 +0200 Subject: [PATCH 282/307] Update logback to INFO level for tb-node --- docker/tb-node/conf/logback.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml index 8bf38eb287..1c69f537bc 100644 --- a/docker/tb-node/conf/logback.xml +++ b/docker/tb-node/conf/logback.xml @@ -40,7 +40,7 @@ - + From 6165b9083949a646642e1d5148caa5e3e0c3164a Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 30 Oct 2018 12:15:30 +0200 Subject: [PATCH 283/307] Rate limitter for Kafka consumer --- .../RemoteRuleEngineTransportService.java | 27 +++++++++++++++++++ .../src/main/resources/thingsboard.yml | 3 +++ .../server/kafka/TBKafkaConsumerTemplate.java | 4 ++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index 38db1a415b..1e9e6980e2 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -17,6 +17,11 @@ package org.thingsboard.server.service.transport; import akka.actor.ActorRef; import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BlockingBucket; +import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.local.LocalBucket; +import io.github.bucket4j.local.LocalBucketBuilder; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.producer.Callback; @@ -49,6 +54,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; /** @@ -68,6 +74,13 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ @Value("${transport.remote.rule_engine.auto_commit_interval}") private int autoCommitInterval; + @Value("${transport.remote.rule_engine.poll_records_pack_size}") + private long pollRecordsPackSize; + @Value("${transport.remote.rule_engine.max_poll_records_per_second}") + private long pollRecordsPerSecond; + @Value("${transport.remote.rule_engine.max_poll_records_per_minute}") + private long pollRecordsPerMinute; + @Autowired private TbKafkaSettings kafkaSettings; @@ -109,15 +122,29 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ ruleEngineConsumerBuilder.groupId("tb-node"); ruleEngineConsumerBuilder.autoCommit(true); ruleEngineConsumerBuilder.autoCommitIntervalMs(autoCommitInterval); + ruleEngineConsumerBuilder.maxPollRecords(pollRecordsPackSize); ruleEngineConsumerBuilder.decoder(new ToRuleEngineMsgDecoder()); ruleEngineConsumer = ruleEngineConsumerBuilder.build(); ruleEngineConsumer.subscribe(); + LocalBucketBuilder builder = Bucket4j.builder(); + builder.addLimit(Bandwidth.simple(pollRecordsPerSecond, Duration.ofSeconds(1))); + builder.addLimit(Bandwidth.simple(pollRecordsPerMinute, Duration.ofMinutes(1))); + LocalBucket pollRateBucket = builder.build(); + BlockingBucket blockingPollRateBucket = pollRateBucket.asScheduler(); + mainConsumerExecutor.execute(() -> { while (!stopped) { try { ConsumerRecords records = ruleEngineConsumer.poll(Duration.ofMillis(pollDuration)); + int recordsCount = records.count(); + if (recordsCount > 0) { + while (!blockingPollRateBucket.tryConsume(recordsCount, TimeUnit.SECONDS.toNanos(5))) { + log.info("Rule Engine consumer is busy. Required tokens: [{}]. Available tokens: [{}].", recordsCount, pollRateBucket.getAvailableTokens()); + } + log.trace("Processing {} records", recordsCount); + } records.forEach(record -> { try { ToRuleEngineMsg toRuleEngineMsg = ruleEngineConsumer.decode(record); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 58242933aa..c53f54ba32 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -393,6 +393,9 @@ transport: topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" poll_interval: "${TB_RULE_ENGINE_POLL_INTERVAL_MS:25}" auto_commit_interval: "${TB_RULE_ENGINE_AUTO_COMMIT_INTERVAL_MS:100}" + poll_records_pack_size: "${TB_RULE_ENGINE_MAX_POLL_RECORDS:1000}" + max_poll_records_per_second: "${TB_RULE_ENGINE_MAX_POLL_RECORDS_PER_SECOND:10000}" + max_poll_records_per_minute: "${TB_RULE_ENGINE_MAX_POLL_RECORDS_PER_SECOND:120000}" notifications: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" sessions: diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java index 86be3a3c38..549c25e09e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -46,7 +46,8 @@ public class TBKafkaConsumerTemplate { private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, TbKafkaRequestIdExtractor requestIdExtractor, String clientId, String groupId, String topic, - boolean autoCommit, int autoCommitIntervalMs) { + boolean autoCommit, int autoCommitIntervalMs, + long maxPollRecords) { Properties props = settings.toProps(); props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); if (groupId != null) { @@ -56,6 +57,7 @@ public class TBKafkaConsumerTemplate { props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitIntervalMs); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer"); + props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords); this.consumer = new KafkaConsumer<>(props); this.decoder = decoder; this.requestIdExtractor = requestIdExtractor; From b6ca497098b6e0c4f72a15f1e16fac8b5919475d Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 30 Oct 2018 13:32:55 +0200 Subject: [PATCH 284/307] Rate Limiter for Kafka Consumer --- .../main/data/upgrade/2.2.0/schema_update.sql | 17 +++++++++++++++++ .../install/ThingsboardInstallService.java | 4 ++++ .../AnnotationComponentDiscoveryService.java | 5 +++++ .../CassandraDatabaseUpgradeService.java | 3 ++- .../install/SqlDatabaseUpgradeService.java | 9 ++++++++- .../RemoteRuleEngineTransportService.java | 2 +- application/src/main/resources/thingsboard.yml | 2 +- .../server/kafka/TBKafkaConsumerTemplate.java | 6 ++++-- .../model/sql/ComponentDescriptorEntity.java | 3 ++- dao/src/main/resources/sql/schema-entities.sql | 2 +- 10 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 application/src/main/data/upgrade/2.2.0/schema_update.sql diff --git a/application/src/main/data/upgrade/2.2.0/schema_update.sql b/application/src/main/data/upgrade/2.2.0/schema_update.sql new file mode 100644 index 0000000000..1832b7901b --- /dev/null +++ b/application/src/main/data/upgrade/2.2.0/schema_update.sql @@ -0,0 +1,17 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +ALTER TABLE component_descriptor ADD UNIQUE (clazz); diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 45eb66a332..2486cd8d41 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -101,6 +101,10 @@ public class ThingsboardInstallService { log.info("Upgrading ThingsBoard from version 2.1.1 to 2.1.2 ..."); databaseUpgradeService.upgradeDatabase("2.1.1"); + case "2.1.3": + log.info("Upgrading ThingsBoard from version 2.1.3 to 2.2.0 ..."); + + databaseUpgradeService.upgradeDatabase("2.1.3"); log.info("Updating system data..."); diff --git a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java index 5a1e55988d..545baf8e11 100644 --- a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java @@ -97,6 +97,11 @@ public class AnnotationComponentDiscoveryService implements ComponentDiscoverySe log.trace("Can't initialize component {}, due to {}", def.getBeanClassName(), e.getMessage(), e); cause = e; retryCount++; + try { + Thread.sleep(1000); + } catch (InterruptedException e1) { + throw new RuntimeException(e1); + } } } if (cause != null && retryCount == MAX_OPTIMISITC_RETRIES) { diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index 552695314e..a2aeefaecf 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -251,7 +251,8 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { log.info("Entity views restored."); break; - + case "2.1.3": + break; default: throw new RuntimeException("Unable to upgrade Cassandra database, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 2c7f89c75a..b4a725d941 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -149,7 +149,14 @@ public class SqlDatabaseUpgradeService implements DatabaseUpgradeService { log.info("Entity views restored."); } break; - + case "2.1.3": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.2.0", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + } + break; default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index 1e9e6980e2..9beeebb0ad 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -75,7 +75,7 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ private int autoCommitInterval; @Value("${transport.remote.rule_engine.poll_records_pack_size}") - private long pollRecordsPackSize; + private int pollRecordsPackSize; @Value("${transport.remote.rule_engine.max_poll_records_per_second}") private long pollRecordsPerSecond; @Value("${transport.remote.rule_engine.max_poll_records_per_minute}") diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index c53f54ba32..51ddaa9181 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -395,7 +395,7 @@ transport: auto_commit_interval: "${TB_RULE_ENGINE_AUTO_COMMIT_INTERVAL_MS:100}" poll_records_pack_size: "${TB_RULE_ENGINE_MAX_POLL_RECORDS:1000}" max_poll_records_per_second: "${TB_RULE_ENGINE_MAX_POLL_RECORDS_PER_SECOND:10000}" - max_poll_records_per_minute: "${TB_RULE_ENGINE_MAX_POLL_RECORDS_PER_SECOND:120000}" + max_poll_records_per_minute: "${TB_RULE_ENGINE_MAX_POLL_RECORDS_PER_MINUTE:120000}" notifications: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" sessions: diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java index 549c25e09e..c8a1706c3b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -47,7 +47,7 @@ public class TBKafkaConsumerTemplate { TbKafkaRequestIdExtractor requestIdExtractor, String clientId, String groupId, String topic, boolean autoCommit, int autoCommitIntervalMs, - long maxPollRecords) { + int maxPollRecords) { Properties props = settings.toProps(); props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); if (groupId != null) { @@ -57,7 +57,9 @@ public class TBKafkaConsumerTemplate { props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitIntervalMs); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer"); - props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords); + if (maxPollRecords > 0) { + props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords); + } this.consumer = new KafkaConsumer<>(props); this.decoder = decoder; this.requestIdExtractor = requestIdExtractor; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java index 66fbcc3382..e4c6bb5318 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java @@ -34,6 +34,7 @@ import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.Table; +import javax.persistence.UniqueConstraint; @Data @EqualsAndHashCode(callSuper = true) @@ -53,7 +54,7 @@ public class ComponentDescriptorEntity extends BaseSqlEntity Date: Tue, 30 Oct 2018 13:59:51 +0200 Subject: [PATCH 285/307] Improved logging --- .../service/transport/RemoteRuleEngineTransportService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index 9beeebb0ad..6c903cc955 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -142,6 +142,7 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ if (recordsCount > 0) { while (!blockingPollRateBucket.tryConsume(recordsCount, TimeUnit.SECONDS.toNanos(5))) { log.info("Rule Engine consumer is busy. Required tokens: [{}]. Available tokens: [{}].", recordsCount, pollRateBucket.getAvailableTokens()); + Thread.sleep(TimeUnit.SECONDS.toMillis(1)); } log.trace("Processing {} records", recordsCount); } From f6fccebd52d4dae21f0a936a14f96b044ee49cb5 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 30 Oct 2018 15:35:42 +0200 Subject: [PATCH 286/307] UI fixes --- ui/src/app/common/thirdparty-fix.js | 264 ++++++++++++++++++ ui/src/app/dashboard/dashboard.tpl.html | 1 + .../app/entity-view/entity-view.directive.js | 24 +- ui/src/app/locale/locale.constant-en_US.json | 1 + 4 files changed, 281 insertions(+), 9 deletions(-) diff --git a/ui/src/app/common/thirdparty-fix.js b/ui/src/app/common/thirdparty-fix.js index b00a7822c5..af4222c140 100644 --- a/ui/src/app/common/thirdparty-fix.js +++ b/ui/src/app/common/thirdparty-fix.js @@ -15,10 +15,13 @@ */ import tinycolor from 'tinycolor2'; +import moment from 'moment'; export default angular.module('thingsboard.thirdpartyFix', []) .factory('Fullscreen', Fullscreen) .factory('$mdColorPicker', mdColorPicker) + .provider('$mdpDatePicker', mdpDatePicker) + .provider('$mdpTimePicker', mdpTimePicker) .name; /*@ngInject*/ @@ -193,3 +196,264 @@ function mdColorPicker($q, $mdDialog, mdColorPickerHistory) { /* eslint-enable angular/definedundefined */ } + +function DatePickerCtrl($scope, $mdDialog, $mdMedia, $timeout, currentDate, options) { + var self = this; + + this.date = moment(currentDate); + this.minDate = options.minDate && moment(options.minDate).isValid() ? moment(options.minDate) : null; + this.maxDate = options.maxDate && moment(options.maxDate).isValid() ? moment(options.maxDate) : null; + this.displayFormat = options.displayFormat || "ddd, MMM DD"; + this.dateFilter = angular.isFunction(options.dateFilter) ? options.dateFilter : null; + this.selectingYear = false; + + // validate min and max date + if (this.minDate && this.maxDate) { + if (this.maxDate.isBefore(this.minDate)) { + this.maxDate = moment(this.minDate).add(1, 'days'); + } + } + + if (this.date) { + // check min date + if (this.minDate && this.date.isBefore(this.minDate)) { + this.date = moment(this.minDate); + } + + // check max date + if (this.maxDate && this.date.isAfter(this.maxDate)) { + this.date = moment(this.maxDate); + } + } + + this.yearItems = { + currentIndex_: 0, + PAGE_SIZE: 5, + START: (self.minDate ? self.minDate.year() : 1900), + END: (self.maxDate ? self.maxDate.year() : 0), + getItemAtIndex: function(index) { + if(this.currentIndex_ < index) + this.currentIndex_ = index; + + return this.START + index; + }, + getLength: function() { + return Math.min( + this.currentIndex_ + Math.floor(this.PAGE_SIZE / 2), + Math.abs(this.START - this.END) + 1 + ); + } + }; + + $scope.$mdMedia = $mdMedia; + $scope.year = this.date.year(); + + this.selectYear = function(year) { + self.date.year(year); + $scope.year = year; + self.selectingYear = false; + self.animate(); + }; + + this.showYear = function() { + self.yearTopIndex = (self.date.year() - self.yearItems.START) + Math.floor(self.yearItems.PAGE_SIZE / 2); + self.yearItems.currentIndex_ = (self.date.year() - self.yearItems.START) + 1; + self.selectingYear = true; + }; + + this.showCalendar = function() { + self.selectingYear = false; + }; + + this.cancel = function() { + $mdDialog.cancel(); + }; + + this.confirm = function() { + var date = this.date; + + if (this.minDate && this.date.isBefore(this.minDate)) { + date = moment(this.minDate); + } + + if (this.maxDate && this.date.isAfter(this.maxDate)) { + date = moment(this.maxDate); + } + + $mdDialog.hide(date.toDate()); + }; + + this.animate = function() { + self.animating = true; + $timeout(angular.noop).then(function() { + self.animating = false; + }) + }; +} + +/*@ngInject*/ +function mdpDatePicker() { + var LABEL_OK = "OK", + LABEL_CANCEL = "Cancel", + DISPLAY_FORMAT = "ddd, MMM DD"; + + this.setDisplayFormat = function(format) { + DISPLAY_FORMAT = format; + }; + + this.setOKButtonLabel = function(label) { + LABEL_OK = label; + }; + + this.setCancelButtonLabel = function(label) { + LABEL_CANCEL = label; + }; + + /*@ngInject*/ + this.$get = function($mdDialog) { + var datePicker = function(currentDate, options) { + if (!angular.isDate(currentDate)) currentDate = Date.now(); + if (!angular.isObject(options)) options = {}; + + options.displayFormat = DISPLAY_FORMAT; + + return $mdDialog.show({ + controller: ['$scope', '$mdDialog', '$mdMedia', '$timeout', 'currentDate', 'options', DatePickerCtrl], + controllerAs: 'datepicker', + clickOutsideToClose: true, + template: '' + + '' + + '
' + + '' + + '{{ datepicker.date.format(\'YYYY\') }}' + + '{{ datepicker.date.format(datepicker.displayFormat) }} ' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '{{ item }}' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + LABEL_CANCEL + '' + + '' + LABEL_OK + '' + + '' + + '
' + + '
' + + '
', + targetEvent: options.targetEvent, + locals: { + currentDate: currentDate, + options: options + }, + multiple: true + }); + }; + + return datePicker; + }; + +} + +function TimePickerCtrl($scope, $mdDialog, time, autoSwitch, $mdMedia) { + var self = this; + this.VIEW_HOURS = 1; + this.VIEW_MINUTES = 2; + this.currentView = this.VIEW_HOURS; + this.time = moment(time); + this.autoSwitch = !!autoSwitch; + + this.clockHours = parseInt(this.time.format("h")); + this.clockMinutes = parseInt(this.time.minutes()); + + $scope.$mdMedia = $mdMedia; + + this.switchView = function() { + self.currentView = self.currentView == self.VIEW_HOURS ? self.VIEW_MINUTES : self.VIEW_HOURS; + }; + + this.setAM = function() { + if(self.time.hours() >= 12) + self.time.hour(self.time.hour() - 12); + }; + + this.setPM = function() { + if(self.time.hours() < 12) + self.time.hour(self.time.hour() + 12); + }; + + this.cancel = function() { + $mdDialog.cancel(); + }; + + this.confirm = function() { + $mdDialog.hide(this.time.toDate()); + }; +} + +/*@ngInject*/ +function mdpTimePicker() { + var LABEL_OK = "OK", + LABEL_CANCEL = "Cancel"; + + this.setOKButtonLabel = function(label) { + LABEL_OK = label; + }; + + this.setCancelButtonLabel = function(label) { + LABEL_CANCEL = label; + }; + + /*@ngInject*/ + this.$get = function($mdDialog) { + var timePicker = function(time, options) { + if(!angular.isDate(time)) time = Date.now(); + if (!angular.isObject(options)) options = {}; + + return $mdDialog.show({ + controller: ['$scope', '$mdDialog', 'time', 'autoSwitch', '$mdMedia', TimePickerCtrl], + controllerAs: 'timepicker', + clickOutsideToClose: true, + template: '' + + '' + + '' + + '
' + + '{{ timepicker.time.format("h") }}:' + + '{{ timepicker.time.format("mm") }}' + + '
' + + '
' + + 'AM' + + 'PM' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + + '' + + '' + + '' + LABEL_CANCEL + '' + + '' + LABEL_OK + '' + + '' + + '
' + + '
' + + '
', + targetEvent: options.targetEvent, + locals: { + time: time, + autoSwitch: options.autoSwitch + }, + multiple: true + }); + }; + + return timePicker; + }; +} diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html index 79653a42c5..a1bb2688c0 100644 --- a/ui/src/app/dashboard/dashboard.tpl.html +++ b/ui/src/app/dashboard/dashboard.tpl.html @@ -74,6 +74,7 @@ settings diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js index 25377f4cb4..761930e615 100644 --- a/ui/src/app/entity-view/entity-view.directive.js +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -98,8 +98,8 @@ export default function EntityViewDirective($q, $compile, $templateCache, $filte if (newDate.getTime() > scope.maxStartTimeMs) { scope.startTimeMs = angular.copy(scope.maxStartTimeMs); } - updateMinMaxDates(); } + updateMinMaxDates(); }); scope.$watch('endTimeMs', function (newDate) { @@ -107,18 +107,24 @@ export default function EntityViewDirective($q, $compile, $templateCache, $filte if (newDate.getTime() < scope.minEndTimeMs) { scope.endTimeMs = angular.copy(scope.minEndTimeMs); } - updateMinMaxDates(); } + updateMinMaxDates(); }); function updateMinMaxDates() { - if (scope.endTimeMs) { - scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeMs.getTime())); - scope.entityView.endTimeMs = scope.endTimeMs.getTime(); - } - if (scope.startTimeMs) { - scope.minEndTimeMs = angular.copy(new Date(scope.startTimeMs.getTime())); - scope.entityView.startTimeMs = scope.startTimeMs.getTime(); + if (scope.entityView) { + if (scope.endTimeMs) { + scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeMs.getTime())); + scope.entityView.endTimeMs = scope.endTimeMs.getTime(); + } else { + scope.entityView.endTimeMs = 0; + } + if (scope.startTimeMs) { + scope.minEndTimeMs = angular.copy(new Date(scope.startTimeMs.getTime())); + scope.entityView.startTimeMs = scope.startTimeMs.getTime(); + } else { + scope.entityView.startTimeMs = 0; + } } } diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index b3c89c7729..30e1eb119b 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -774,6 +774,7 @@ }, "entity-view": { "entity-view": "Entity View", + "entity-view-required": "Entity view is required.", "entity-views": "Entity Views", "management": "Entity View management", "view-entity-views": "View Entity Views", From d96f6c8c3426115b0d254f2e5c76d781ab50db19 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 31 Oct 2018 10:52:22 +0200 Subject: [PATCH 287/307] Update README.md --- msa/tb/README.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/msa/tb/README.md b/msa/tb/README.md index 8ed84c5e1f..9d15b37587 100644 --- a/msa/tb/README.md +++ b/msa/tb/README.md @@ -25,11 +25,28 @@ Where: - `-v ~/.mytb-data:/data` - mounts the host's dir `~/.mytb-data` to ThingsBoard DataBase data directory - `--name mytb` - friendly local name of this machine - `thingsboard/tb` - docker image, can be also `thingsboard/tb-postgres` or `thingsboard/tb-cassandra` - -After executing this command you can open `http://{your-host-ip}:9090` in you browser (for ex. `http://localhost:9090`). You should see ThingsBoard login page. +- **NOTE**: **Windows** users should use docker managed volume instead of host's dir. Create docker volume (for ex. `mytb-data`) before executing `docker run` command: + + ``` + $ docker create volume mytb-data + ``` + + After you can execute docker run command using `mytb-data` volume instead of `~/.mytb-data`. + + In order to get access to necessary resources from external IP/Host on **Windows** machine, please execute the following commands: + ``` + # HTTP port + $ VBoxManage controlvm "default" natpf1 "tcp-port9090,tcp,,9090,,9090" + # MQTT port + $ VBoxManage controlvm "default" natpf1 "tcp-port1883,tcp,,1883,,1883" + # CoAP port + $ VBoxManage controlvm "default" natpf1 "tcp-port5683,tcp,,5683,,5683" + ``` + +After executing `docker run` command you can open `http://{your-host-ip}:9090` in you browser (for ex. `http://localhost:9090`). You should see ThingsBoard login page. Use the following default credentials: -- **Systen Administrator**: sysadmin@thingsboard.org / sysadmin +- **System Administrator**: sysadmin@thingsboard.org / sysadmin - **Tenant Administrator**: tenant@thingsboard.org / tenant - **Customer User**: customer@thingsboard.org / customer @@ -39,21 +56,21 @@ You can detach from session terminal with `Ctrl-p` `Ctrl-q` - the container will To reattach to the terminal (to see ThingsBoard logs) run: -` +``` $ docker attach mytb -` +``` To stop the container: -` +``` $ docker stop mytb -` +``` To start the container: -` +``` $ docker start mytb -` +``` ## Upgrading From f875bcd65045c5d4235b71073ce974665a595f75 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 31 Oct 2018 10:53:23 +0200 Subject: [PATCH 288/307] Update README.md --- msa/tb/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/msa/tb/README.md b/msa/tb/README.md index 9d15b37587..bb9fa0f08d 100644 --- a/msa/tb/README.md +++ b/msa/tb/README.md @@ -35,11 +35,8 @@ Where: In order to get access to necessary resources from external IP/Host on **Windows** machine, please execute the following commands: ``` - # HTTP port $ VBoxManage controlvm "default" natpf1 "tcp-port9090,tcp,,9090,,9090" - # MQTT port $ VBoxManage controlvm "default" natpf1 "tcp-port1883,tcp,,1883,,1883" - # CoAP port $ VBoxManage controlvm "default" natpf1 "tcp-port5683,tcp,,5683,,5683" ``` From 27dec3faa0b06f6ead073b0fd3b885f3f617c506 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 31 Oct 2018 10:56:21 +0200 Subject: [PATCH 289/307] Update README.md --- msa/tb/README.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/msa/tb/README.md b/msa/tb/README.md index bb9fa0f08d..e4b7400b50 100644 --- a/msa/tb/README.md +++ b/msa/tb/README.md @@ -25,20 +25,18 @@ Where: - `-v ~/.mytb-data:/data` - mounts the host's dir `~/.mytb-data` to ThingsBoard DataBase data directory - `--name mytb` - friendly local name of this machine - `thingsboard/tb` - docker image, can be also `thingsboard/tb-postgres` or `thingsboard/tb-cassandra` -- **NOTE**: **Windows** users should use docker managed volume instead of host's dir. Create docker volume (for ex. `mytb-data`) before executing `docker run` command: - - ``` - $ docker create volume mytb-data - ``` - - After you can execute docker run command using `mytb-data` volume instead of `~/.mytb-data`. - - In order to get access to necessary resources from external IP/Host on **Windows** machine, please execute the following commands: - ``` - $ VBoxManage controlvm "default" natpf1 "tcp-port9090,tcp,,9090,,9090" - $ VBoxManage controlvm "default" natpf1 "tcp-port1883,tcp,,1883,,1883" - $ VBoxManage controlvm "default" natpf1 "tcp-port5683,tcp,,5683,,5683" - ``` + +> **NOTE**: **Windows** users should use docker managed volume instead of host's dir. Create docker volume (for ex. `mytb-data`) before executing `docker run` command: +> ``` +> $ docker create volume mytb-data +> ``` +> After you can execute docker run command using `mytb-data` volume instead of `~/.mytb-data`. +> In order to get access to necessary resources from external IP/Host on **Windows** machine, please execute the following commands: +> ``` +> $ VBoxManage controlvm "default" natpf1 "tcp-port9090,tcp,,9090,,9090" +> $ VBoxManage controlvm "default" natpf1 "tcp-port1883,tcp,,1883,,1883" +> $ VBoxManage controlvm "default" natpf1 "tcp-port5683,tcp,,5683,,5683" +> ``` After executing `docker run` command you can open `http://{your-host-ip}:9090` in you browser (for ex. `http://localhost:9090`). You should see ThingsBoard login page. Use the following default credentials: From 2553c8c57b20f3eddd50bf5080fc738836a71689 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 31 Oct 2018 13:06:04 +0200 Subject: [PATCH 290/307] Revert "[WIP]Help refactor code solid patterns" --- .../client/tools/MqttSslClient.java | 106 +++++------------- 1 file changed, 29 insertions(+), 77 deletions(-) diff --git a/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java b/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java index 339f26ded8..dd27e44a1d 100644 --- a/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java +++ b/tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java @@ -50,86 +50,38 @@ public class MqttSslClient { File ksFile = new File(ksUrl.toURI()); URL tsUrl = Resources.getResource(KEY_STORE_FILE); File tsFile = new File(tsUrl.toURI()); - char[] ksPwd = setKeyPassword(); - - KeyStore trustStore = setKeyStore(tsFile, ksPwd); - - TrustManagerFactory tmf = setTrustManagerFactory(trustStore); - - KeyStore ks = setKeyStore2(ksFile, ksPwd); - - KeyManagerFactory kmf = setKeyManagerFactory(ks); - - SSLContext sslContext = setSSLContext(kmf, tmf); - - MqttConnectOptions mqttConnectOptions = setMqttConnectOptions(sslContext); - - MqttAsyncClient mqttAsyncClient = setMqttAsyncClient(mqttConnectOptions); - - setMessage(mqttAsyncClient, "v1/devices/me/telemetry", "{\"key1\":\"value1\", \"key2\":true, \"key3\": 3.0, \"key4\": 4}"); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + KeyStore trustStore = KeyStore.getInstance(JKS); + char[] ksPwd = new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x73, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; + trustStore.load(new FileInputStream(tsFile), ksPwd); + tmf.init(trustStore); + KeyStore ks = KeyStore.getInstance(JKS); + + ks.load(new FileInputStream(ksFile), ksPwd); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + char[] clientPwd = new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x65, 0x79, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; + kmf.init(ks, clientPwd); + + KeyManager[] km = kmf.getKeyManagers(); + TrustManager[] tm = tmf.getTrustManagers(); + SSLContext sslContext = SSLContext.getInstance(TLS); + sslContext.init(km, tm, null); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setSocketFactory(sslContext.getSocketFactory()); + MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, CLIENT_ID); + client.connect(options); + Thread.sleep(3000); + MqttMessage message = new MqttMessage(); + message.setPayload("{\"key1\":\"value1\", \"key2\":true, \"key3\": 3.0, \"key4\": 4}".getBytes()); + client.publish("v1/devices/me/telemetry", message); + client.disconnect(); + log.info("Disconnected"); + System.exit(0); } catch (Exception e) { log.error("Unexpected exception occurred in MqttSslClient", e); } } - - private static char[] setKeyPassword(){ - return new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x73, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; - } - - private static KeyStore setKeyStore(File tsFile, char[] ksPwd) throws Exception{ - KeyStore trustStore = KeyStore.getInstance(JKS); - trustStore.load(new FileInputStream(tsFile), ksPwd); - return trustStore; - } - - private static TrustManagerFactory setTrustManagerFactory(KeyStore trustStore) throws Exception { - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(trustStore); - return tmf; - } - - private static KeyStore setKeyStore2(File ksFile, char[] ksPwd)throws Exception{ - KeyStore ks = KeyStore.getInstance(JKS); - ks.load(new FileInputStream(ksFile), ksPwd); - return ks; - } - - private static KeyManagerFactory setKeyManagerFactory(KeyStore ks) throws Exception { - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - char[] clientPwd = new char[]{0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x5F, 0x6B, 0x65, 0x79, 0x5F, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6F, 0x72, 0x64}; - kmf.init(ks, clientPwd); - return kmf; - } - - private static SSLContext setSSLContext(KeyManagerFactory kmf, TrustManagerFactory tmf) throws Exception { - KeyManager[] km = kmf.getKeyManagers(); - TrustManager[] tm = tmf.getTrustManagers(); - SSLContext sslContext = SSLContext.getInstance(TLS); - sslContext.init(km, tm, null); - return sslContext; - } - - private static MqttConnectOptions setMqttConnectOptions(SSLContext sslContext){ - MqttConnectOptions options = new MqttConnectOptions(); - options.setSocketFactory(sslContext.getSocketFactory()); - return options; - } - - private static MqttAsyncClient setMqttAsyncClient(MqttConnectOptions options) throws Exception{ - MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, CLIENT_ID); - client.connect(options); - Thread.sleep(3000); - return client; - } - - private static void setMessage(MqttAsyncClient client, String topic, String payload) throws Exception { - MqttMessage message = new MqttMessage(); - message.setPayload(payload.getBytes()); - client.publish(topic, message); - client.disconnect(); - log.info("Disconnected"); - System.exit(0); - } - } \ No newline at end of file From 32dd7f0437053fdbb42227ff939dc9f2e7be17d1 Mon Sep 17 00:00:00 2001 From: Viacheslav Kukhtyn Date: Wed, 31 Oct 2018 13:07:43 +0200 Subject: [PATCH 291/307] Black box tests update --- msa/black-box-tests/README.md | 21 ++++++++++++ .../pom.xml | 10 +++--- .../server/msa/AbstractContainerTest.java | 0 .../server/msa/ContainerTestSuite.java | 9 +++-- .../org/thingsboard/server/msa/WsClient.java | 33 ++++++++++++++++--- .../msa/connectivity/HttpClientTest.java | 8 ++--- .../msa/connectivity/MqttClientTest.java | 25 +++++++------- .../server/msa/mapper/AttributesResponse.java | 0 .../msa/mapper/WsTelemetryResponse.java | 0 .../RpcResponseRuleChainMetadata.json | 0 msa/integration-tests/README.md | 18 ---------- msa/pom.xml | 4 +-- 12 files changed, 80 insertions(+), 48 deletions(-) create mode 100644 msa/black-box-tests/README.md rename msa/{integration-tests => black-box-tests}/pom.xml (92%) rename msa/{integration-tests => black-box-tests}/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java (100%) rename msa/{integration-tests => black-box-tests}/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java (80%) rename msa/{integration-tests => black-box-tests}/src/test/java/org/thingsboard/server/msa/WsClient.java (52%) rename msa/{integration-tests => black-box-tests}/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java (90%) rename msa/{integration-tests => black-box-tests}/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java (95%) rename msa/{integration-tests => black-box-tests}/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java (100%) rename msa/{integration-tests => black-box-tests}/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java (100%) rename msa/{integration-tests => black-box-tests}/src/test/resources/RpcResponseRuleChainMetadata.json (100%) delete mode 100644 msa/integration-tests/README.md diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md new file mode 100644 index 0000000000..c26d9c5fc6 --- /dev/null +++ b/msa/black-box-tests/README.md @@ -0,0 +1,21 @@ + +## Black box tests execution +To run the black box tests with using Docker, the local Docker images of Thingsboard's microservices should be built.
+- Build the local Docker images in the directory with the Thingsboard's main [pom.xml](./../../pom.xml): + + mvn clean install -Ddockerfile.skip=false +- Verify that the new local images were built: + + docker image ls +As result, in REPOSITORY column, next images should be present: + + thingsboard/tb-coap-transport + thingsboard/tb-http-transport + thingsboard/tb-mqtt-transport + thingsboard/tb-node + thingsboard/tb-web-ui + thingsboard/tb-js-executor + +- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory: + + mvn clean install -DblackBoxTests.skip=false \ No newline at end of file diff --git a/msa/integration-tests/pom.xml b/msa/black-box-tests/pom.xml similarity index 92% rename from msa/integration-tests/pom.xml rename to msa/black-box-tests/pom.xml index a9c67ccf93..2af4d2c14e 100644 --- a/msa/integration-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -25,16 +25,16 @@ msa org.thingsboard.msa - integration-tests + black-box-tests - ThingsBoard Integration Tests + ThingsBoard Black Box Tests https://thingsboard.io - Project for ThingsBoard integration tests with using Docker + Project for ThingsBoard black box testing with using Docker UTF-8 ${basedir}/../.. - true + true 1.9.1 1.3.9 4.5.6 @@ -95,7 +95,7 @@ **/*TestSuite.java - ${integrationTests.skip} + ${blackBoxTests.skip} diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java similarity index 100% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java similarity index 80% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java index 0629960b24..495fd94d2e 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -22,15 +22,18 @@ import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.containers.wait.strategy.Wait; import java.io.File; +import java.time.Duration; @RunWith(ClasspathSuite.class) -@ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*"}) +@ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*Test"}) public class ContainerTestSuite { @ClassRule - public static DockerComposeContainer composeContainer = new DockerComposeContainer(new File("./../docker/docker-compose.yml")) + public static DockerComposeContainer composeContainer = new DockerComposeContainer( + new File("./../../docker/docker-compose.yml"), + new File("./../../docker/docker-compose.postgres.yml")) .withPull(false) .withLocalCompose(true) .withTailChildContainers(true) - .withExposedService("tb-web-ui1", 8080, Wait.forHttp("/login")); + .withExposedService("tb-web-ui1", 8080, Wait.forHttp("/login").withStartupTimeout(Duration.ofSeconds(120))); } diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java similarity index 52% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java index 5ef238f8aa..a9835ed618 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/WsClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java @@ -15,13 +15,23 @@ */ package org.thingsboard.server.msa; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.java_websocket.client.WebSocketClient; import org.java_websocket.handshake.ServerHandshake; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; +import java.io.IOException; import java.net.URI; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +@Slf4j public class WsClient extends WebSocketClient { - private String message; + private static final ObjectMapper mapper = new ObjectMapper(); + private WsTelemetryResponse message; + + private CountDownLatch latch = new CountDownLatch(1);; public WsClient(URI serverUri) { super(serverUri); @@ -33,11 +43,20 @@ public class WsClient extends WebSocketClient { @Override public void onMessage(String message) { - this.message = message; + try { + WsTelemetryResponse response = mapper.readValue(message, WsTelemetryResponse.class); + if (!response.getData().isEmpty()) { + this.message = response; + latch.countDown(); + } + } catch (IOException e) { + log.error("ws message can't be read"); + } } @Override public void onClose(int code, String reason, boolean remote) { + log.info("ws is closed, due to [{}]", reason); } @Override @@ -45,7 +64,13 @@ public class WsClient extends WebSocketClient { ex.printStackTrace(); } - public String getLastMessage() { - return this.message; + public WsTelemetryResponse getLastMessage() { + try { + latch.await(10, TimeUnit.SECONDS); + return this.message; + } catch (InterruptedException e) { + log.error("Timeout, ws message wasn't received"); + } + return null; } } diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java similarity index 90% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java index f8e1041252..a6e89de185 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -25,12 +25,10 @@ import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.WsClient; import org.thingsboard.server.msa.mapper.WsTelemetryResponse; -import java.util.concurrent.TimeUnit; - public class HttpClientTest extends AbstractContainerTest { @Test - public void telemetryUpdate() throws Exception { + public void telemetryUpload() throws Exception { restClient.login("tenant@thingsboard.org", "tenant"); Device device = createDevice("http_"); @@ -43,8 +41,8 @@ public class HttpClientTest extends AbstractContainerTest { ResponseEntity.class, deviceCredentials.getCredentialsId()); Assert.assertTrue(deviceTelemetryResponse.getStatusCode().is2xxSuccessful()); - TimeUnit.SECONDS.sleep(1); - WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + wsClient.closeBlocking(); Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"), actualLatestTelemetry.getLatestValues().keySet()); diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java similarity index 95% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index eae98b9f1e..d889d2c52e 100644 --- a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -23,7 +23,9 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonObject; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; +import io.netty.handler.codec.mqtt.MqttQoS; import lombok.Data; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.junit.*; import org.springframework.core.ParameterizedTypeReference; @@ -50,6 +52,7 @@ import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.*; +@Slf4j public class MqttClientTest extends AbstractContainerTest { @Test @@ -61,8 +64,8 @@ public class MqttClientTest extends AbstractContainerTest { WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); MqttClient mqttClient = getMqttClient(deviceCredentials, null); mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload().toString().getBytes())); - TimeUnit.SECONDS.sleep(1); - WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + wsClient.closeBlocking(); Assert.assertEquals(4, actualLatestTelemetry.getData().size()); Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"), @@ -87,8 +90,8 @@ public class MqttClientTest extends AbstractContainerTest { WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); MqttClient mqttClient = getMqttClient(deviceCredentials, null); mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload(ts).toString().getBytes())); - TimeUnit.SECONDS.sleep(1); - WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + wsClient.closeBlocking(); Assert.assertEquals(4, actualLatestTelemetry.getData().size()); Assert.assertEquals(getExpectedLatestValues(ts), actualLatestTelemetry.getLatestValues()); @@ -116,8 +119,8 @@ public class MqttClientTest extends AbstractContainerTest { clientAttributes.addProperty("attr3", 42.0); clientAttributes.addProperty("attr4", 73); mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes())); - TimeUnit.SECONDS.sleep(1); - WsTelemetryResponse actualLatestTelemetry = mapper.readValue(wsClient.getLastMessage(), WsTelemetryResponse.class); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + wsClient.closeBlocking(); Assert.assertEquals(4, actualLatestTelemetry.getData().size()); Assert.assertEquals(Sets.newHashSet("attr1", "attr2", "attr3", "attr4"), @@ -157,7 +160,7 @@ public class MqttClientTest extends AbstractContainerTest { Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful()); // Subscribe to attributes response - mqttClient.on("v1/devices/me/attributes/response/+", listener); + mqttClient.on("v1/devices/me/attributes/response/+", listener, MqttQoS.AT_LEAST_ONCE); // Request attributes JsonObject request = new JsonObject(); request.addProperty("clientKeys", "clientAttr"); @@ -183,7 +186,7 @@ public class MqttClientTest extends AbstractContainerTest { MqttMessageListener listener = new MqttMessageListener(); MqttClient mqttClient = getMqttClient(deviceCredentials, listener); - mqttClient.on("v1/devices/me/attributes", listener); + mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE); String sharedAttributeName = "sharedAttr"; @@ -226,13 +229,12 @@ public class MqttClientTest extends AbstractContainerTest { MqttMessageListener listener = new MqttMessageListener(); MqttClient mqttClient = getMqttClient(deviceCredentials, listener); - mqttClient.on("v1/devices/me/rpc/request/+", listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE); // Send an RPC from the server JsonObject serverRpcPayload = new JsonObject(); serverRpcPayload.addProperty("method", "getValue"); serverRpcPayload.addProperty("params", true); - serverRpcPayload.addProperty("timeout", 1000); ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); ListenableFuture future = service.submit(() -> { try { @@ -271,7 +273,7 @@ public class MqttClientTest extends AbstractContainerTest { MqttMessageListener listener = new MqttMessageListener(); MqttClient mqttClient = getMqttClient(deviceCredentials, listener); - mqttClient.on("v1/devices/me/rpc/request/+", listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE); // Get the default rule chain id to make it root again after test finished RuleChainId defaultRuleChainId = getDefaultRuleChainId(); @@ -377,6 +379,7 @@ public class MqttClientTest extends AbstractContainerTest { @Override public void onMessage(String topic, ByteBuf message) { + log.info("MQTT message [{}], topic [{}]", message.toString(StandardCharsets.UTF_8), topic); events.add(new MqttEvent(topic, message.toString(StandardCharsets.UTF_8))); } } diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java similarity index 100% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java diff --git a/msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java similarity index 100% rename from msa/integration-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java diff --git a/msa/integration-tests/src/test/resources/RpcResponseRuleChainMetadata.json b/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json similarity index 100% rename from msa/integration-tests/src/test/resources/RpcResponseRuleChainMetadata.json rename to msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json diff --git a/msa/integration-tests/README.md b/msa/integration-tests/README.md deleted file mode 100644 index 93df2bf77f..0000000000 --- a/msa/integration-tests/README.md +++ /dev/null @@ -1,18 +0,0 @@ - -## Integration tests execution -To run the integration tests with using Docker, the local Docker images of Thingsboard's microservices should be built.
-- Build the local Docker images in the directory with the Thingsboard's main [pom.xml](./../../pom.xml): - - mvn clean install -Ddockerfile.skip=false -- Verify that the new local images were built: - - docker image ls -As result, in REPOSITORY column, next images should be present: - - local-maven-build/tb-node - local-maven-build/tb-web-ui - local-maven-build/tb-web-ui - -- Run the integration tests in the [msa/integration-tests](../integration-tests) directory: - - mvn clean install -DintegrationTests.skip=false \ No newline at end of file diff --git a/msa/pom.xml b/msa/pom.xml index 41926608be..5a9d9abd80 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -16,7 +16,7 @@ --> + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.thingsboard @@ -41,7 +41,7 @@ web-ui tb-node transport - integration-tests + black-box-tests From df27d7acc8b9371a60fd6b2741d2a2f7941118df Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 31 Oct 2018 13:17:29 +0200 Subject: [PATCH 292/307] Validation of TsKvQuery --- .../src/main/resources/thingsboard.yml | 1 + .../dao/timeseries/BaseTimeseriesService.java | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 51ddaa9181..51a59c9721 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -83,6 +83,7 @@ dashboard: max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" database: + ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # mas number of DB queries generated by single API call to fetch telemetry records entities: type: "${DATABASE_ENTITIES_TYPE:sql}" # cassandra OR sql ts: diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 4658cbe490..10064999e1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -20,11 +20,13 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; @@ -47,8 +49,11 @@ import static org.apache.commons.lang3.StringUtils.isBlank; @Slf4j public class BaseTimeseriesService implements TimeseriesService { - public static final int INSERTS_PER_ENTRY = 3; - public static final int DELETES_PER_ENTRY = INSERTS_PER_ENTRY; + private static final int INSERTS_PER_ENTRY = 3; + private static final int DELETES_PER_ENTRY = INSERTS_PER_ENTRY; + + @Value("${database.ts_max_intervals}") + private long maxTsIntervals; @Autowired private TimeseriesDao timeseriesDao; @@ -59,7 +64,7 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> findAll(EntityId entityId, List queries) { validate(entityId); - queries.forEach(BaseTimeseriesService::validate); + queries.forEach(this::validate); if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); List filteredQueries = @@ -189,7 +194,7 @@ public class BaseTimeseriesService implements TimeseriesService { Validator.validateEntityId(entityId, "Incorrect entityId " + entityId); } - private static void validate(ReadTsKvQuery query) { + private void validate(ReadTsKvQuery query) { if (query == null) { throw new IncorrectParameterException("ReadTsKvQuery can't be null"); } else if (isBlank(query.getKey())) { @@ -197,6 +202,14 @@ public class BaseTimeseriesService implements TimeseriesService { } else if (query.getAggregation() == null) { throw new IncorrectParameterException("Incorrect ReadTsKvQuery. Aggregation can't be empty"); } + if(!Aggregation.NONE.equals(query.getAggregation())) { + long step = Math.max(query.getInterval(), 1000); + long intervalCounts = (query.getEndTs() - query.getStartTs()) / step; + if (intervalCounts > maxTsIntervals || intervalCounts < 0) { + throw new IncorrectParameterException("Incorrect TsKvQuery. Number of intervals is to high - " + intervalCounts + ". " + + "Please increase 'interval' parameter for your query or reduce the time range of the query."); + } + } } private static void validate(DeleteTsKvQuery query) { From 3ba15599c55e1a4109518f30fb1e2248e975f0a5 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 31 Oct 2018 15:06:14 +0200 Subject: [PATCH 293/307] Logging improvements and termination behaviour --- .../server/actors/app/AppActor.java | 65 +++++++++------- .../server/actors/device/DeviceActor.java | 36 ++------- .../actors/device/DeviceActorCreator.java | 39 ++++++++++ .../device/DeviceActorMessageProcessor.java | 66 ++++++++-------- .../server/actors/rpc/RpcManagerActor.java | 10 +-- .../actors/ruleChain/RuleChainActor.java | 4 +- .../RuleChainActorMessageProcessor.java | 8 +- .../actors/ruleChain/RuleNodeActor.java | 8 +- .../RuleNodeActorMessageProcessor.java | 4 +- .../server/actors/service/ComponentActor.java | 14 ++-- .../actors/service/ContextAwareActor.java | 17 +++-- .../AbstractContextAwareMsgProcessor.java | 10 +-- .../actors/shared/ComponentMsgProcessor.java | 8 +- .../actors/shared/EntityActorsManager.java | 9 ++- .../server/actors/stats/StatsActor.java | 13 ++-- .../server/actors/tenant/TenantActor.java | 76 ++++++++++++------- .../controller/DashboardController.java | 1 - .../model/token/RawAccessJwtToken.java | 8 +- .../src/main/resources/actor-system.conf | 2 +- 19 files changed, 232 insertions(+), 166 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index f4373db9be..15e9e9d175 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -25,6 +25,9 @@ import akka.actor.Terminated; import akka.event.Logging; import akka.event.LoggingAdapter; import akka.japi.Function; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.ruleChain.RuleChainManagerActor; import org.thingsboard.server.actors.service.ContextBasedCreator; @@ -48,18 +51,17 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +@Slf4j public class AppActor extends RuleChainManagerActor { - private final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - - public static final TenantId SYSTEM_TENANT = new TenantId(ModelConstants.NULL_UUID); + private static final TenantId SYSTEM_TENANT = new TenantId(ModelConstants.NULL_UUID); private final TenantService tenantService; - private final Map tenantActors; + private final BiMap tenantActors; private AppActor(ActorSystemContext systemContext) { super(systemContext, new SystemRuleChainManager(systemContext)); this.tenantService = systemContext.getTenantService(); - this.tenantActors = new HashMap<>(); + this.tenantActors = HashBiMap.create(); } @Override @@ -69,22 +71,20 @@ public class AppActor extends RuleChainManagerActor { @Override public void preStart() { - logger.info("Starting main system actor."); + log.info("Starting main system actor."); try { initRuleChains(); - if (systemContext.isTenantComponentsInitEnabled()) { PageDataIterable tenantIterator = new PageDataIterable<>(tenantService::findTenants, ENTITY_PACK_LIMIT); for (Tenant tenant : tenantIterator) { - logger.debug("[{}] Creating tenant actor", tenant.getId()); + log.debug("[{}] Creating tenant actor", tenant.getId()); getOrCreateTenantActor(tenant.getId()); - logger.debug("Tenant actor created."); + log.debug("Tenant actor created."); } } - - logger.info("Main system actor started."); + log.info("Main system actor started."); } catch (Exception e) { - logger.error(e, "Unknown failure"); + log.warn("Unknown failure", e); } } @@ -130,7 +130,7 @@ public class AppActor extends RuleChainManagerActor { private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) { if (SYSTEM_TENANT.equals(msg.getTenantId())) { - //TODO: ashvayka handle this. + log.warn("[{}] Invalid service to rule engine msg called. System messages are not supported yet", SYSTEM_TENANT); } else { getOrCreateTenantActor(msg.getTenantId()).tell(msg, self()); } @@ -152,7 +152,7 @@ public class AppActor extends RuleChainManagerActor { if (target != null) { target.tell(msg, ActorRef.noSender()); } else { - logger.debug("Invalid component lifecycle msg: {}", msg); + log.debug("[{}] Invalid component lifecycle msg: {}", msg.getTenantId(), msg); } } @@ -161,14 +161,26 @@ public class AppActor extends RuleChainManagerActor { } private ActorRef getOrCreateTenantActor(TenantId tenantId) { - return tenantActors.computeIfAbsent(tenantId, k -> context().actorOf(Props.create(new TenantActor.ActorCreator(systemContext, tenantId)) - .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME), tenantId.toString())); + return tenantActors.computeIfAbsent(tenantId, k -> { + log.debug("[{}] Creating tenant actor.", tenantId); + ActorRef tenantActor = context().actorOf(Props.create(new TenantActor.ActorCreator(systemContext, tenantId)) + .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME), tenantId.toString()); + context().watch(tenantActor); + log.debug("[{}] Created tenant actor: {}.", tenantId, tenantActor); + return tenantActor; + }); } - private void processTermination(Terminated message) { + @Override + protected void processTermination(Terminated message) { ActorRef terminated = message.actor(); if (terminated instanceof LocalActorRef) { - logger.debug("Removed actor: {}", terminated); + boolean removed = tenantActors.inverse().remove(terminated) != null; + if (removed) { + log.debug("[{}] Removed actor:", terminated); + } else { + log.warn("[{}] Removed actor was not found in the tenant map!"); + } } else { throw new IllegalStateException("Remote actors are not supported!"); } @@ -182,20 +194,17 @@ public class AppActor extends RuleChainManagerActor { } @Override - public AppActor create() throws Exception { + public AppActor create() { return new AppActor(context); } } - private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), new Function() { - @Override - public Directive apply(Throwable t) { - logger.error(t, "Unknown failure"); - if (t instanceof RuntimeException) { - return SupervisorStrategy.restart(); - } else { - return SupervisorStrategy.stop(); - } + private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), t -> { + log.warn("Unknown failure", t); + if (t instanceof RuntimeException) { + return SupervisorStrategy.restart(); + } else { + return SupervisorStrategy.stop(); } }); } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java index 7b412b1328..f450027fb6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -15,42 +15,38 @@ */ package org.thingsboard.server.actors.device; -import akka.event.Logging; -import akka.event.LoggingAdapter; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.service.ContextAwareActor; -import org.thingsboard.server.actors.service.ContextBasedCreator; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; +@Slf4j public class DeviceActor extends ContextAwareActor { - private final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - private final DeviceActorMessageProcessor processor; - private DeviceActor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { + DeviceActor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { super(systemContext); - this.processor = new DeviceActorMessageProcessor(systemContext, logger, tenantId, deviceId); + this.processor = new DeviceActorMessageProcessor(systemContext, tenantId, deviceId); } @Override public void preStart() { - logger.debug("[{}][{}] Starting device actor.", processor.tenantId, processor.deviceId); + log.debug("[{}][{}] Starting device actor.", processor.tenantId, processor.deviceId); try { processor.initSessionTimeout(context()); - logger.debug("[{}][{}] Device actor started.", processor.tenantId, processor.deviceId); + log.debug("[{}][{}] Device actor started.", processor.tenantId, processor.deviceId); } catch (Exception e) { - logger.error(e, "[{}][{}] Unknown failure", processor.tenantId, processor.deviceId); + log.warn("[{}][{}] Unknown failure", processor.tenantId, processor.deviceId, e); } } @@ -90,22 +86,4 @@ public class DeviceActor extends ContextAwareActor { return true; } - public static class ActorCreator extends ContextBasedCreator { - private static final long serialVersionUID = 1L; - - private final TenantId tenantId; - private final DeviceId deviceId; - - public ActorCreator(ActorSystemContext context, TenantId tenantId, DeviceId deviceId) { - super(context); - this.tenantId = tenantId; - this.deviceId = deviceId; - } - - @Override - public DeviceActor create() throws Exception { - return new DeviceActor(context, tenantId, deviceId); - } - } - } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java new file mode 100644 index 0000000000..18aa9268bd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; + +public class DeviceActorCreator extends ContextBasedCreator { + private static final long serialVersionUID = 1L; + + private final TenantId tenantId; + private final DeviceId deviceId; + + public DeviceActorCreator(ActorSystemContext context, TenantId tenantId, DeviceId deviceId) { + super(context); + this.tenantId = tenantId; + this.deviceId = deviceId; + } + + @Override + public DeviceActor create() { + return new DeviceActor(context, tenantId, deviceId); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index a16bd78a25..47417d1fd7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -24,6 +24,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RpcError; import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; @@ -88,6 +89,7 @@ import java.util.stream.Collectors; /** * @author Andrew Shvayka */ +@Slf4j class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { final TenantId tenantId; @@ -106,8 +108,8 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { private String deviceType; private TbMsgMetaData defaultMetaData; - DeviceActorMessageProcessor(ActorSystemContext systemContext, LoggingAdapter logger, TenantId tenantId, DeviceId deviceId) { - super(systemContext, logger); + DeviceActorMessageProcessor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { + super(systemContext); this.tenantId = tenantId; this.deviceId = deviceId; this.sessions = new LinkedHashMap<>(); @@ -136,30 +138,30 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { long timeout = request.getExpirationTime() - System.currentTimeMillis(); if (timeout <= 0) { - logger.debug("[{}][{}] Ignoring message due to exp time reached", deviceId, request.getId(), request.getExpirationTime()); + log.debug("[{}][{}] Ignoring message due to exp time reached, {}", deviceId, request.getId(), request.getExpirationTime()); return; } boolean sent = rpcSubscriptions.size() > 0; Set syncSessionSet = new HashSet<>(); - rpcSubscriptions.entrySet().forEach(sub -> { - sendToTransport(rpcRequest, sub.getKey(), sub.getValue().getNodeId()); - if (TransportProtos.SessionType.SYNC == sub.getValue().getType()) { - syncSessionSet.add(sub.getKey()); + rpcSubscriptions.forEach((key, value) -> { + sendToTransport(rpcRequest, key, value.getNodeId()); + if (TransportProtos.SessionType.SYNC == value.getType()) { + syncSessionSet.add(key); } }); syncSessionSet.forEach(rpcSubscriptions::remove); if (request.isOneway() && sent) { - logger.debug("[{}] Rpc command response sent [{}]!", deviceId, request.getId()); + log.debug("[{}] Rpc command response sent [{}]!", deviceId, request.getId()); systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(msg.getMsg().getId(), null, null)); } else { registerPendingRpcRequest(context, msg, sent, rpcRequest, timeout); } if (sent) { - logger.debug("[{}] RPC request {} is sent!", deviceId, request.getId()); + log.debug("[{}] RPC request {} is sent!", deviceId, request.getId()); } else { - logger.debug("[{}] RPC request {} is NOT sent!", deviceId, request.getId()); + log.debug("[{}] RPC request {} is NOT sent!", deviceId, request.getId()); } } @@ -172,7 +174,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { void processServerSideRpcTimeout(ActorContext context, DeviceActorServerSideRpcTimeoutMsg msg) { ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(msg.getId()); if (requestMd != null) { - logger.debug("[{}] RPC request [{}] timeout detected!", deviceId, msg.getId()); + log.debug("[{}] RPC request [{}] timeout detected!", deviceId, msg.getId()); systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); } @@ -181,13 +183,13 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { private void sendPendingRequests(ActorContext context, UUID sessionId, SessionInfoProto sessionInfo) { TransportProtos.SessionType sessionType = getSessionType(sessionId); if (!toDeviceRpcPendingMap.isEmpty()) { - logger.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId); + log.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId); if (sessionType == TransportProtos.SessionType.SYNC) { - logger.debug("[{}] Cleanup sync rpc session [{}]", deviceId, sessionId); + log.debug("[{}] Cleanup sync rpc session [{}]", deviceId, sessionId); rpcSubscriptions.remove(sessionId); } } else { - logger.debug("[{}] No pending RPC messages for new async session [{}]", deviceId, sessionId); + log.debug("[{}] No pending RPC messages for new async session [{}]", deviceId, sessionId); } Set sentOneWayIds = new HashSet<>(); if (sessionType == TransportProtos.SessionType.ASYNC) { @@ -335,7 +337,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { void processClientSideRpcTimeout(ActorContext context, DeviceActorClientSideRpcTimeoutMsg msg) { ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(msg.getId()); if (data != null) { - logger.debug("[{}] Client side RPC request [{}] timeout detected!", deviceId, msg.getId()); + log.debug("[{}] Client side RPC request [{}] timeout detected!", deviceId, msg.getId()); sendToTransport(TransportProtos.ToServerRpcResponseMsg.newBuilder() .setRequestId(msg.getId()).setError("timeout").build() , data.getSessionId(), data.getNodeId()); @@ -380,7 +382,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { hasNotificationData = true; } } else { - logger.debug("[{}] No public server side attributes changed!", deviceId); + log.debug("[{}] No public server side attributes changed!", deviceId); } } } @@ -391,27 +393,27 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { }); } } else { - logger.debug("[{}] No registered attributes subscriptions to process!", deviceId); + log.debug("[{}] No registered attributes subscriptions to process!", deviceId); } } private void processRpcResponses(ActorContext context, SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg responseMsg) { UUID sessionId = getSessionId(sessionInfo); - logger.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); + log.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); boolean success = requestMd != null; if (success) { systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), responseMsg.getPayload(), null)); } else { - logger.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); + log.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); } } private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg subscribeCmd) { UUID sessionId = getSessionId(sessionInfo); if (subscribeCmd.getUnsubscribe()) { - logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); + log.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); attributeSubscriptions.remove(sessionId); } else { SessionInfoMetaData sessionMD = sessions.get(sessionId); @@ -419,7 +421,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId())); } sessionMD.setSubscribedToAttributes(true); - logger.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); + log.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); attributeSubscriptions.put(sessionId, sessionMD.getSessionInfo()); dumpSessions(); } @@ -432,7 +434,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToRPCMsg subscribeCmd) { UUID sessionId = getSessionId(sessionInfo); if (subscribeCmd.getUnsubscribe()) { - logger.debug("[{}] Canceling rpc subscription for session [{}]", deviceId, sessionId); + log.debug("[{}] Canceling rpc subscription for session [{}]", deviceId, sessionId); rpcSubscriptions.remove(sessionId); } else { SessionInfoMetaData sessionMD = sessions.get(sessionId); @@ -440,7 +442,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId())); } sessionMD.setSubscribedToRPC(true); - logger.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId); + log.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId); rpcSubscriptions.put(sessionId, sessionMD.getSessionInfo()); sendPendingRequests(context, sessionId, sessionInfo); dumpSessions(); @@ -451,10 +453,10 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { UUID sessionId = getSessionId(sessionInfo); if (msg.getEvent() == SessionEvent.OPEN) { if (sessions.containsKey(sessionId)) { - logger.debug("[{}] Received duplicate session open event [{}]", deviceId, sessionId); + log.debug("[{}] Received duplicate session open event [{}]", deviceId, sessionId); return; } - logger.debug("[{}] Processing new session [{}]", deviceId, sessionId); + log.debug("[{}] Processing new session [{}]", deviceId, sessionId); if (sessions.size() >= systemContext.getMaxConcurrentSessionsPerDevice()) { UUID sessionIdToRemove = sessions.keySet().stream().findFirst().orElse(null); if (sessionIdToRemove != null) { @@ -467,7 +469,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { } dumpSessions(); } else if (msg.getEvent() == SessionEvent.CLOSED) { - logger.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId); + log.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId); sessions.remove(sessionId); attributeSubscriptions.remove(sessionId); rpcSubscriptions.remove(sessionId); @@ -623,10 +625,10 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { } private void restoreSessions() { - logger.debug("[{}] Restoring sessions from cache", deviceId); + log.debug("[{}] Restoring sessions from cache", deviceId); TransportProtos.DeviceSessionsCacheEntry sessionsDump = systemContext.getDeviceSessionCacheService().get(deviceId); if (sessionsDump.getSerializedSize() == 0) { - logger.debug("[{}] No session information found", deviceId); + log.debug("[{}] No session information found", deviceId); return; } for (TransportProtos.SessionSubscriptionInfoProto sessionSubscriptionInfoProto : sessionsDump.getSessionsList()) { @@ -644,13 +646,13 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { rpcSubscriptions.put(sessionId, sessionInfo); sessionMD.setSubscribedToRPC(true); } - logger.debug("[{}] Restored session: {}", deviceId, sessionMD); + log.debug("[{}] Restored session: {}", deviceId, sessionMD); } - logger.debug("[{}] Restored sessions: {}, rpc subscriptions: {}, attribute subscriptions: {}", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); + log.debug("[{}] Restored sessions: {}, rpc subscriptions: {}, attribute subscriptions: {}", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); } private void dumpSessions() { - logger.debug("[{}] Dumping sessions: {}, rpc subscriptions: {}, attribute subscriptions: {} to cache", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); + log.debug("[{}] Dumping sessions: {}, rpc subscriptions: {}, attribute subscriptions: {} to cache", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); List sessionsList = new ArrayList<>(sessions.size()); sessions.forEach((uuid, sessionMD) -> { if (sessionMD.getSessionInfo().getType() == TransportProtos.SessionType.SYNC) { @@ -668,7 +670,7 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { sessionsList.add(TransportProtos.SessionSubscriptionInfoProto.newBuilder() .setSessionInfo(sessionInfoProto) .setSubscriptionInfo(subscriptionInfoProto).build()); - logger.debug("[{}] Dumping session: {}", deviceId, sessionMD); + log.debug("[{}] Dumping session: {}", deviceId, sessionMD); }); systemContext.getDeviceSessionCacheService() .put(deviceId, TransportProtos.DeviceSessionsCacheEntry.newBuilder() diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java index 31320ce5d2..922d2ba988 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java @@ -19,6 +19,7 @@ import akka.actor.ActorRef; import akka.actor.Props; import akka.event.Logging; import akka.event.LoggingAdapter; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.service.ContextAwareActor; import org.thingsboard.server.actors.service.ContextBasedCreator; @@ -35,17 +36,16 @@ import java.util.*; /** * @author Andrew Shvayka */ +@Slf4j public class RpcManagerActor extends ContextAwareActor { - private final LoggingAdapter log = Logging.getLogger(getContext().system(), this); - private final Map sessionActors; private final Map> pendingMsgs; private final ServerAddress instance; - RpcManagerActor(ActorSystemContext systemContext) { + private RpcManagerActor(ActorSystemContext systemContext) { super(systemContext); this.sessionActors = new HashMap<>(); this.pendingMsgs = new HashMap<>(); @@ -116,7 +116,7 @@ public class RpcManagerActor extends ContextAwareActor { queue.add(msg); } } else { - logger.warning("Cluster msg doesn't have server address [{}]", msg); + log.warn("Cluster msg doesn't have server address [{}]", msg); } } @@ -207,7 +207,7 @@ public class RpcManagerActor extends ContextAwareActor { } @Override - public RpcManagerActor create() throws Exception { + public RpcManagerActor create() { return new RpcManagerActor(context); } } diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java index dbad7c0b66..ed765e04f8 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java @@ -33,7 +33,7 @@ public class RuleChainActor extends ComponentActor { private static final long DEFAULT_CLUSTER_PARTITION = 0L; @@ -69,8 +71,8 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor(); @@ -216,7 +218,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor { private final RuleChainId ruleChainId; @@ -32,7 +34,7 @@ public class RuleNodeActor extends ComponentActor> extends ContextAwareActor { - protected final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - private long lastPersistedErrorTs = 0L; protected final TenantId tenantId; protected final T id; @@ -60,7 +60,7 @@ public abstract class ComponentActor getErrorPersistFrequency()) { diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java b/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java index 3624127194..c9f1dcde3e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java @@ -15,14 +15,16 @@ */ package org.thingsboard.server.actors.service; +import akka.actor.Terminated; import akka.actor.UntypedActor; import akka.event.Logging; import akka.event.LoggingAdapter; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.msg.TbActorMsg; +@Slf4j public abstract class ContextAwareActor extends UntypedActor { - protected final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); public static final int ENTITY_PACK_LIMIT = 1024; @@ -35,21 +37,26 @@ public abstract class ContextAwareActor extends UntypedActor { @Override public void onReceive(Object msg) throws Exception { - if (logger.isDebugEnabled()) { - logger.debug("Processing msg: {}", msg); + if (log.isDebugEnabled()) { + log.debug("Processing msg: {}", msg); } if (msg instanceof TbActorMsg) { try { if (!process((TbActorMsg) msg)) { - logger.warning("Unknown message: {}!", msg); + log.warn("Unknown message: {}!", msg); } } catch (Exception e) { throw e; } + } else if (msg instanceof Terminated) { + processTermination((Terminated) msg); } else { - logger.warning("Unknown message: {}!", msg); + log.warn("Unknown message: {}!", msg); } } + protected void processTermination(Terminated msg) { + } + protected abstract boolean process(TbActorMsg msg); } diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java index c8097821d9..b707baf14e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java @@ -22,22 +22,22 @@ import akka.event.LoggingAdapter; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import scala.concurrent.ExecutionContextExecutor; import scala.concurrent.duration.Duration; import java.util.concurrent.TimeUnit; +@Slf4j public abstract class AbstractContextAwareMsgProcessor { protected final ActorSystemContext systemContext; - protected final LoggingAdapter logger; protected final ObjectMapper mapper = new ObjectMapper(); - protected AbstractContextAwareMsgProcessor(ActorSystemContext systemContext, LoggingAdapter logger) { + protected AbstractContextAwareMsgProcessor(ActorSystemContext systemContext) { super(); this.systemContext = systemContext; - this.logger = logger; } private Scheduler getScheduler() { @@ -53,7 +53,7 @@ public abstract class AbstractContextAwareMsgProcessor { } private void schedulePeriodicMsgWithDelay(Object msg, long delayInMs, long periodInMs, ActorRef target) { - logger.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs); + log.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs); getScheduler().schedule(Duration.create(delayInMs, TimeUnit.MILLISECONDS), Duration.create(periodInMs, TimeUnit.MILLISECONDS), target, msg, getSystemDispatcher(), null); } @@ -62,7 +62,7 @@ public abstract class AbstractContextAwareMsgProcessor { } private void scheduleMsgWithDelay(Object msg, long delayInMs, ActorRef target) { - logger.debug("Scheduling msg {} with delay {} ms", msg, delayInMs); + log.debug("Scheduling msg {} with delay {} ms", msg, delayInMs); getScheduler().scheduleOnce(Duration.create(delayInMs, TimeUnit.MILLISECONDS), target, msg, getSystemDispatcher(), null); } diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java index c9dc307451..9e5d063223 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -19,6 +19,7 @@ import akka.actor.ActorContext; import akka.event.LoggingAdapter; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.stats.StatsPersistTick; import org.thingsboard.server.common.data.id.EntityId; @@ -30,14 +31,15 @@ import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import javax.annotation.Nullable; import java.util.function.Consumer; +@Slf4j public abstract class ComponentMsgProcessor extends AbstractContextAwareMsgProcessor { protected final TenantId tenantId; protected final T entityId; protected ComponentLifecycleState state; - protected ComponentMsgProcessor(ActorSystemContext systemContext, LoggingAdapter logger, TenantId tenantId, T id) { - super(systemContext, logger); + protected ComponentMsgProcessor(ActorSystemContext systemContext, TenantId tenantId, T id) { + super(systemContext); this.tenantId = tenantId; this.entityId = id; } @@ -79,7 +81,7 @@ public abstract class ComponentMsgProcessor extends Abstract protected void checkActive() { if (state != ComponentLifecycleState.ACTIVE) { - logger.warning("Rule chain is not active. Current state [{}] for processor [{}] tenant [{}]", state, tenantId, entityId); + log.warn("Rule chain is not active. Current state [{}] for processor [{}] tenant [{}]", state, tenantId, entityId); throw new IllegalStateException("Rule chain is not active! " + entityId + " - " + tenantId); } } diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java b/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java index 1b9e6a8074..dd03d69a62 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java @@ -20,6 +20,8 @@ import akka.actor.ActorRef; import akka.actor.Props; import akka.actor.UntypedActor; import akka.japi.Creator; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.service.ContextAwareActor; @@ -39,11 +41,11 @@ import java.util.Map; public abstract class EntityActorsManager> { protected final ActorSystemContext systemContext; - protected final Map actors; + protected final BiMap actors; public EntityActorsManager(ActorSystemContext systemContext) { this.systemContext = systemContext; - this.actors = new HashMap<>(); + this.actors = HashBiMap.create(); } protected abstract TenantId getTenantId(); @@ -65,7 +67,8 @@ public abstract class EntityActorsManager diff --git a/application/src/main/java/org/thingsboard/server/actors/stats/StatsActor.java b/application/src/main/java/org/thingsboard/server/actors/stats/StatsActor.java index 8623370896..79aa6da964 100644 --- a/application/src/main/java/org/thingsboard/server/actors/stats/StatsActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/stats/StatsActor.java @@ -15,10 +15,9 @@ */ package org.thingsboard.server.actors.stats; -import akka.event.Logging; -import akka.event.LoggingAdapter; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.service.ContextAwareActor; import org.thingsboard.server.actors.service.ContextBasedCreator; @@ -27,9 +26,9 @@ import org.thingsboard.server.common.data.Event; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; +@Slf4j public class StatsActor extends ContextAwareActor { - private final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); private final ObjectMapper mapper = new ObjectMapper(); public StatsActor(ActorSystemContext context) { @@ -43,13 +42,13 @@ public class StatsActor extends ContextAwareActor { } @Override - public void onReceive(Object msg) throws Exception { - logger.debug("Received message: {}", msg); + public void onReceive(Object msg) { + log.debug("Received message: {}", msg); if (msg instanceof StatsPersistMsg) { try { onStatsPersistMsg((StatsPersistMsg) msg); } catch (Exception e) { - logger.warning("Failed to persist statistics: {}", msg, e); + log.warn("Failed to persist statistics: {}", msg, e); } } } @@ -75,7 +74,7 @@ public class StatsActor extends ContextAwareActor { } @Override - public StatsActor create() throws Exception { + public StatsActor create() { return new StatsActor(context); } } diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index 721d828fd6..b76e2984ae 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -17,15 +17,19 @@ package org.thingsboard.server.actors.tenant; import akka.actor.ActorInitializationException; import akka.actor.ActorRef; +import akka.actor.LocalActorRef; import akka.actor.OneForOneStrategy; import akka.actor.Props; import akka.actor.SupervisorStrategy; +import akka.actor.Terminated; import akka.japi.Function; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.device.DeviceActor; +import org.thingsboard.server.actors.device.DeviceActorCreator; import org.thingsboard.server.actors.device.DeviceActorToRuleEngineMsg; import org.thingsboard.server.actors.ruleChain.RuleChainManagerActor; -import org.thingsboard.server.actors.ruleChain.RuleChainToRuleChainMsg; import org.thingsboard.server.actors.service.ContextBasedCreator; import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.actors.shared.rulechain.TenantRuleChainManager; @@ -44,18 +48,18 @@ import scala.concurrent.duration.Duration; import java.util.HashMap; import java.util.Map; +@Slf4j public class TenantActor extends RuleChainManagerActor { private final TenantId tenantId; - private final Map deviceActors; + private final BiMap deviceActors; private TenantActor(ActorSystemContext systemContext, TenantId tenantId) { super(systemContext, new TenantRuleChainManager(systemContext, tenantId)); this.tenantId = tenantId; - this.deviceActors = new HashMap<>(); + this.deviceActors = HashBiMap.create(); } - @Override public SupervisorStrategy supervisorStrategy() { return strategy; @@ -63,12 +67,12 @@ public class TenantActor extends RuleChainManagerActor { @Override public void preStart() { - logger.info("[{}] Starting tenant actor.", tenantId); + log.info("[{}] Starting tenant actor.", tenantId); try { initRuleChains(); - logger.info("[{}] Tenant actor started.", tenantId); + log.info("[{}] Tenant actor started.", tenantId); } catch (Exception e) { - logger.error(e, "[{}] Unknown failure", tenantId); + log.warn("[{}] Unknown failure", tenantId, e); } } @@ -105,22 +109,20 @@ public class TenantActor extends RuleChainManagerActor { return true; } - @Override - protected void broadcast(Object msg) { - super.broadcast(msg); -// deviceActors.values().forEach(actorRef -> actorRef.tell(msg, ActorRef.noSender())); - } - private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) { - if (ruleChainManager.getRootChainActor()!=null) - ruleChainManager.getRootChainActor().tell(msg, self()); - else logger.info("[{}] No Root Chain", msg); + if (ruleChainManager.getRootChainActor() != null) { + ruleChainManager.getRootChainActor().tell(msg, self()); + } else { + log.info("[{}] No Root Chain: {}", tenantId, msg); + } } private void onDeviceActorToRuleEngineMsg(DeviceActorToRuleEngineMsg msg) { - if (ruleChainManager.getRootChainActor()!=null) - ruleChainManager.getRootChainActor().tell(msg, self()); - else logger.info("[{}] No Root Chain", msg); + if (ruleChainManager.getRootChainActor() != null) { + ruleChainManager.getRootChainActor().tell(msg, self()); + } else { + log.info("[{}] No Root Chain: {}", tenantId, msg); + } } private void onRuleChainMsg(RuleChainAwareMsg msg) { @@ -141,13 +143,35 @@ public class TenantActor extends RuleChainManagerActor { } target.tell(msg, ActorRef.noSender()); } else { - logger.debug("Invalid component lifecycle msg: {}", msg); + log.debug("[{}] Invalid component lifecycle msg: {}", tenantId, msg); } } private ActorRef getOrCreateDeviceActor(DeviceId deviceId) { - return deviceActors.computeIfAbsent(deviceId, k -> context().actorOf(Props.create(new DeviceActor.ActorCreator(systemContext, tenantId, deviceId)) - .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME), deviceId.toString())); + return deviceActors.computeIfAbsent(deviceId, k -> { + log.debug("[{}][{}] Creating device actor.", tenantId, deviceId); + ActorRef deviceActor = context().actorOf(Props.create(new DeviceActorCreator(systemContext, tenantId, deviceId)) + .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME) + , deviceId.toString()); + context().watch(deviceActor); + log.debug("[{}][{}] Created device actor: {}.", tenantId, deviceId, deviceActor); + return deviceActor; + }); + } + + @Override + protected void processTermination(Terminated message) { + ActorRef terminated = message.actor(); + if (terminated instanceof LocalActorRef) { + boolean removed = deviceActors.inverse().remove(terminated) != null; + if (removed) { + log.debug("[{}] Removed actor:", terminated); + } else { + log.warn("[{}] Removed actor was not found in the device map!"); + } + } else { + throw new IllegalStateException("Remote actors are not supported!"); + } } public static class ActorCreator extends ContextBasedCreator { @@ -161,7 +185,7 @@ public class TenantActor extends RuleChainManagerActor { } @Override - public TenantActor create() throws Exception { + public TenantActor create() { return new TenantActor(context, tenantId); } } @@ -169,8 +193,8 @@ public class TenantActor extends RuleChainManagerActor { private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), new Function() { @Override public SupervisorStrategy.Directive apply(Throwable t) { - logger.error(t, "Unknown failure"); - if(t instanceof ActorInitializationException){ + log.warn("[{}] Unknown failure", tenantId, t); + if (t instanceof ActorInitializationException) { return SupervisorStrategy.stop(); } else { return SupervisorStrategy.resume(); diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 450b9761e9..0bb6a6683d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -52,7 +52,6 @@ public class DashboardController extends BaseController { public static final String DASHBOARD_ID = "dashboardId"; @Value("${dashboard.max_datapoints_limit}") - @Getter private long maxDatapointsLimit; diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java index 544b6fdea4..68d9ada212 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java @@ -22,6 +22,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureException; import io.jsonwebtoken.UnsupportedJwtException; +import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.BadCredentialsException; @@ -29,12 +30,11 @@ import org.thingsboard.server.service.security.exception.JwtExpiredTokenExceptio import java.io.Serializable; +@Slf4j public class RawAccessJwtToken implements JwtToken, Serializable { private static final long serialVersionUID = -797397445703066079L; - private static Logger logger = LoggerFactory.getLogger(RawAccessJwtToken.class); - private String token; public RawAccessJwtToken(String token) { @@ -52,10 +52,10 @@ public class RawAccessJwtToken implements JwtToken, Serializable { try { return Jwts.parser().setSigningKey(signingKey).parseClaimsJws(this.token); } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { - logger.error("Invalid JWT Token", ex); + log.error("Invalid JWT Token", ex); throw new BadCredentialsException("Invalid JWT token: ", ex); } catch (ExpiredJwtException expiredEx) { - logger.info("JWT Token is expired", expiredEx); + log.info("JWT Token is expired", expiredEx); throw new JwtExpiredTokenException(this, "JWT Token expired", expiredEx); } } diff --git a/application/src/main/resources/actor-system.conf b/application/src/main/resources/actor-system.conf index 763319e52c..28673f6377 100644 --- a/application/src/main/resources/actor-system.conf +++ b/application/src/main/resources/actor-system.conf @@ -19,7 +19,7 @@ akka { # JVM shutdown, System.exit(-1), in case of a fatal error, # such as OutOfMemoryError jvm-exit-on-fatal-error = off - loglevel = "DEBUG" + loglevel = "INFO" loggers = ["akka.event.slf4j.Slf4jLogger"] } From 19c16aa107f72f9545cf0a61eb2701089145a5d1 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 31 Oct 2018 15:57:15 +0200 Subject: [PATCH 294/307] Fix tests --- dao/src/test/resources/application-test.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index a61c285136..3bd0f7d41a 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -35,4 +35,4 @@ redis.connection.port=6379 redis.connection.db=0 redis.connection.password= -rule.queue.type=memory +database.ts_max_intervals=700 \ No newline at end of file From 4e81f9cc14dd6daa138dce046ccee5ba04b02cc1 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 31 Oct 2018 19:23:00 +0200 Subject: [PATCH 295/307] Update black box tests infrastructure. --- docker/.env | 2 + docker/docker-compose.postgres.volumes.yml | 36 ++++++ docker/docker-compose.yml | 2 +- docker/tb-node/conf/logback.xml | 2 +- msa/black-box-tests/pom.xml | 6 + .../server/msa/ContainerTestSuite.java | 34 +++-- .../server/msa/DockerComposeExecutor.java | 119 ++++++++++++++++++ .../server/msa/ThingsBoardDbInstaller.java | 106 ++++++++++++++++ 8 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 docker/docker-compose.postgres.volumes.yml create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java diff --git a/docker/.env b/docker/.env index c03845dfe0..0138501ebc 100644 --- a/docker/.env +++ b/docker/.env @@ -15,4 +15,6 @@ TB_VERSION=latest DATABASE=postgres +LOAD_BALANCER_NAME=haproxy-certbot + KAFKA_TOPICS="js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1" diff --git a/docker/docker-compose.postgres.volumes.yml b/docker/docker-compose.postgres.volumes.yml new file mode 100644 index 0000000000..d94b066cb0 --- /dev/null +++ b/docker/docker-compose.postgres.volumes.yml @@ -0,0 +1,36 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '2.2' + +services: + postgres: + volumes: + - postgres-db-volume:/var/lib/postgresql/data + tb1: + volumes: + - tb-log-volume:/var/log/thingsboard + tb2: + volumes: + - tb-log-volume:/var/log/thingsboard + +volumes: + postgres-db-volume: + external: true + name: ${POSTGRES_DATA_VOLUME} + tb-log-volume: + external: true + name: ${TB_LOG_VOLUME} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1741db3694..0a09d8a024 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -145,7 +145,7 @@ services: - tb-web-ui.env haproxy: restart: always - container_name: haproxy-certbot + container_name: "${LOAD_BALANCER_NAME}" image: xalauc/haproxy-certbot:1.7.9 volumes: - ./haproxy/config:/config diff --git a/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml index 1c69f537bc..6ec2d0b2d0 100644 --- a/docker/tb-node/conf/logback.xml +++ b/docker/tb-node/conf/logback.xml @@ -24,7 +24,7 @@ /var/log/thingsboard/${TB_HOST}/thingsboard.log - /var/log/thingsboard/thingsboard.%d{yyyy-MM-dd}.%i.log + /var/log/thingsboard/${TB_HOST}/thingsboard.%d{yyyy-MM-dd}.%i.log 100MB 30 3GB diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 2af4d2c14e..9f5ac0912c 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -36,6 +36,7 @@ ${basedir}/../.. true 1.9.1 + 1.10 1.3.9 4.5.6
@@ -46,6 +47,11 @@ testcontainers ${testcontainers.version} + + org.zeroturnaround + zt-exec + ${zeroturnaround.version} + org.java-websocket Java-WebSocket diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java index 495fd94d2e..3233617823 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -17,23 +17,43 @@ package org.thingsboard.server.msa; import org.junit.ClassRule; import org.junit.extensions.cpsuite.ClasspathSuite; +import org.junit.rules.ExternalResource; import org.junit.runner.RunWith; import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.Base58; import java.io.File; import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*Test"}) public class ContainerTestSuite { + private static DockerComposeContainer testContainer; + + @ClassRule + public static ThingsBoardDbInstaller installTb = new ThingsBoardDbInstaller(); + @ClassRule - public static DockerComposeContainer composeContainer = new DockerComposeContainer( - new File("./../../docker/docker-compose.yml"), - new File("./../../docker/docker-compose.postgres.yml")) - .withPull(false) - .withLocalCompose(true) - .withTailChildContainers(true) - .withExposedService("tb-web-ui1", 8080, Wait.forHttp("/login").withStartupTimeout(Duration.ofSeconds(120))); + public static DockerComposeContainer getTestContainer() { + if (testContainer == null) { + testContainer = new DockerComposeContainer( + new File("./../../docker/docker-compose.yml"), + new File("./../../docker/docker-compose.postgres.yml"), + new File("./../../docker/docker-compose.postgres.volumes.yml")) + .withPull(false) + .withLocalCompose(true) + .withTailChildContainers(true) + .withEnv("POSTGRES_DATA_VOLUME", installTb.getPostgresDataVolume()) + .withEnv("TB_LOG_VOLUME", installTb.getTbLogVolume()) + .withEnv("LOAD_BALANCER_NAME", "") + .withExposedService("haproxy", 80, Wait.forHttp("/swagger-ui.html").withStartupTimeout(Duration.ofSeconds(120))); + } + return testContainer; + } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java new file mode 100644 index 0000000000..25d2e6ee4c --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java @@ -0,0 +1,119 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thingsboard.server.msa; + + +import com.google.common.base.Splitter; +import com.google.common.collect.Maps; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.utility.CommandLine; +import org.zeroturnaround.exec.InvalidExitValueException; +import org.zeroturnaround.exec.ProcessExecutor; +import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static java.util.stream.Collectors.joining; + +@Slf4j +public class DockerComposeExecutor { + + String ENV_PROJECT_NAME = "COMPOSE_PROJECT_NAME"; + String ENV_COMPOSE_FILE = "COMPOSE_FILE"; + + private static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker-compose.exe" : "docker-compose"; + private static final String DOCKER_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker.exe" : "docker"; + + private final List composeFiles; + private final String identifier; + private String cmd = ""; + private Map env = new HashMap<>(); + + public DockerComposeExecutor(List composeFiles, String identifier) { + validateFileList(composeFiles); + this.composeFiles = composeFiles; + this.identifier = identifier; + } + + public DockerComposeExecutor withCommand(String cmd) { + this.cmd = cmd; + return this; + } + + public DockerComposeExecutor withEnv(Map env) { + this.env = env; + return this; + } + + public void invokeCompose() { + // bail out early + if (!CommandLine.executableExists(COMPOSE_EXECUTABLE)) { + throw new ContainerLaunchException("Local Docker Compose not found. Is " + COMPOSE_EXECUTABLE + " on the PATH?"); + } + final Map environment = Maps.newHashMap(env); + environment.put(ENV_PROJECT_NAME, identifier); + final Stream absoluteDockerComposeFilePaths = composeFiles.stream().map(File::getAbsolutePath).map(Objects::toString); + final String composeFileEnvVariableValue = absoluteDockerComposeFilePaths.collect(joining(File.pathSeparator + "")); + log.debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue); + final File pwd = composeFiles.get(0).getAbsoluteFile().getParentFile().getAbsoluteFile(); + environment.put(ENV_COMPOSE_FILE, composeFileEnvVariableValue); + log.info("Local Docker Compose is running command: {}", cmd); + final List command = Splitter.onPattern(" ").omitEmptyStrings().splitToList(COMPOSE_EXECUTABLE + " " + cmd); + try { + new ProcessExecutor().command(command).redirectOutput(Slf4jStream.of(log).asInfo()).redirectError(Slf4jStream.of(log).asError()).environment(environment).directory(pwd).exitValueNormal().executeNoTimeout(); + log.info("Docker Compose has finished running"); + } catch (InvalidExitValueException e) { + throw new ContainerLaunchException("Local Docker Compose exited abnormally with code " + e.getExitValue() + " whilst running command: " + cmd); + } catch (Exception e) { + throw new ContainerLaunchException("Error running local Docker Compose command: " + cmd, e); + } + } + + public void invokeDocker() { + // bail out early + if (!CommandLine.executableExists(DOCKER_EXECUTABLE)) { + throw new ContainerLaunchException("Local Docker not found. Is " + DOCKER_EXECUTABLE + " on the PATH?"); + } + final File pwd = composeFiles.get(0).getAbsoluteFile().getParentFile().getAbsoluteFile(); + log.info("Local Docker is running command: {}", cmd); + final List command = Splitter.onPattern(" ").omitEmptyStrings().splitToList(DOCKER_EXECUTABLE + " " + cmd); + try { + new ProcessExecutor().command(command).redirectOutput(Slf4jStream.of(log).asInfo()).redirectError(Slf4jStream.of(log).asError()).directory(pwd).exitValueNormal().executeNoTimeout(); + log.info("Docker has finished running"); + } catch (InvalidExitValueException e) { + throw new ContainerLaunchException("Local Docker exited abnormally with code " + e.getExitValue() + " whilst running command: " + cmd); + } catch (Exception e) { + throw new ContainerLaunchException("Error running local Docker command: " + cmd, e); + } + } + + void validateFileList(List composeFiles) { + checkNotNull(composeFiles); + checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided"); + } + + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java new file mode 100644 index 0000000000..0a902b9c5e --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import org.junit.rules.ExternalResource; +import org.testcontainers.utility.Base58; + +import java.io.File; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ThingsBoardDbInstaller extends ExternalResource { + + private final static String POSTGRES_DATA_VOLUME = "tb-postgres-test-data-volume"; + private final static String TB_LOG_VOLUME = "tb-log-test-volume"; + + private final DockerComposeExecutor dockerCompose; + + private final String postgresDataVolume; + private final String tbLogVolume; + + public ThingsBoardDbInstaller() { + List composeFiles = Arrays.asList(new File("./../../docker/docker-compose.yml"), + new File("./../../docker/docker-compose.postgres.yml"), + new File("./../../docker/docker-compose.postgres.volumes.yml")); + + String identifier = Base58.randomString(6).toLowerCase(); + String project = identifier + Base58.randomString(6).toLowerCase(); + + postgresDataVolume = project + "_" + POSTGRES_DATA_VOLUME; + tbLogVolume = project + "_" + TB_LOG_VOLUME; + + dockerCompose = new DockerComposeExecutor(composeFiles, project); + + Map env = new HashMap<>(); + env.put("POSTGRES_DATA_VOLUME", postgresDataVolume); + env.put("TB_LOG_VOLUME", tbLogVolume); + dockerCompose.withEnv(env); + } + + public String getPostgresDataVolume() { + return postgresDataVolume; + } + + public String getTbLogVolume() { + return tbLogVolume; + } + + @Override + protected void before() throws Throwable { + try { + + dockerCompose.withCommand("volume create " + postgresDataVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("up -d redis postgres"); + dockerCompose.invokeCompose(); + + dockerCompose.withCommand("run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=true tb1"); + dockerCompose.invokeCompose(); + + } finally { + try { + dockerCompose.withCommand("down -v"); + dockerCompose.invokeCompose(); + } catch (Exception e) {} + } + } + + @Override + protected void after() { + File tbLogsDir = new File("./target/tb-logs/"); + tbLogsDir.mkdirs(); + + dockerCompose.withCommand("run -d --rm --name tb-logs-container -v " + tbLogVolume + ":/root alpine tail -f /dev/null"); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("cp tb-logs-container:/root/. "+tbLogsDir.getAbsolutePath()); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("rm -f tb-logs-container"); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume rm -f " + postgresDataVolume + " " + tbLogVolume); + dockerCompose.invokeDocker(); + } + +} From 62db6001fd12fbf32fd1e2298ecca041a2741e0f Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Thu, 1 Nov 2018 11:00:44 +0200 Subject: [PATCH 296/307] cast incoming attributes/telemetry numeric data type if possible --- .../transport/adaptor/JsonConverter.java | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index 7498e8ee2a..b497fe08ea 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -22,6 +22,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import com.google.gson.JsonSyntaxException; +import org.apache.commons.lang3.math.NumberUtils; import org.springframework.util.StringUtils; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -128,24 +129,22 @@ public class JsonConverter { if (element.isJsonPrimitive()) { JsonPrimitive value = element.getAsJsonPrimitive(); if (value.isString()) { - result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.STRING_V) - .setStringV(value.getAsString()).build()); + if(NumberUtils.isParsable(value.getAsString())) { + try { + result.add(buildNumericKeyValueProto(value, valueEntry.getKey())); + } catch (Throwable th) { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.STRING_V) + .setStringV(value.getAsString()).build()); + } + } else { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.STRING_V) + .setStringV(value.getAsString()).build()); + } } else if (value.isBoolean()) { result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.BOOLEAN_V) .setBoolV(value.getAsBoolean()).build()); } else if (value.isNumber()) { - if (value.getAsString().contains(".")) { - result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.DOUBLE_V) - .setDoubleV(value.getAsDouble()).build()); - } else { - try { - long longValue = Long.parseLong(value.getAsString()); - result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.LONG_V) - .setLongV(longValue).build()); - } catch (NumberFormatException e) { - throw new JsonSyntaxException("Big integer values are not supported!"); - } - } + result.add(buildNumericKeyValueProto(value, valueEntry.getKey())); } else { throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value); } @@ -156,6 +155,24 @@ public class JsonConverter { return result; } + private static KeyValueProto buildNumericKeyValueProto(JsonPrimitive value, String key) { + if (value.getAsString().contains(".")) { + return KeyValueProto.newBuilder() + .setKey(key) + .setType(KeyValueType.DOUBLE_V) + .setDoubleV(value.getAsDouble()) + .build(); + } else { + try { + long longValue = Long.parseLong(value.getAsString()); + return KeyValueProto.newBuilder().setKey(key).setType(KeyValueType.LONG_V) + .setLongV(longValue).build(); + } catch (NumberFormatException e) { + throw new JsonSyntaxException("Big integer values are not supported!"); + } + } + } + public static TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(JsonElement json, int requestId) throws JsonSyntaxException { JsonObject object = json.getAsJsonObject(); return TransportProtos.ToServerRpcRequestMsg.newBuilder().setRequestId(requestId).setMethodName(object.get("method").getAsString()).setParams(GSON.toJson(object.get("params"))).build(); @@ -370,7 +387,15 @@ public class JsonConverter { if (element.isJsonPrimitive()) { JsonPrimitive value = element.getAsJsonPrimitive(); if (value.isString()) { - result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); + if(NumberUtils.isParsable(value.getAsString())) { + try { + parseNumericValue(result, valueEntry, value); + } catch (Throwable th) { + result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); + } + } else { + result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); + } } else if (value.isBoolean()) { result.add(new BooleanDataEntry(valueEntry.getKey(), value.getAsBoolean())); } else if (value.isNumber()) { From 5d5100ef1bfdee3e484419289dcf6381fb284710 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 1 Nov 2018 18:02:09 +0200 Subject: [PATCH 297/307] Improved logging --- .../server/actors/app/AppActor.java | 1 - .../server/actors/device/DeviceActor.java | 2 -- .../device/DeviceActorMessageProcessor.java | 3 ++ .../actors/rpc/BasicRpcSessionListener.java | 10 +++--- .../server/actors/rpc/RpcManagerActor.java | 1 - .../RuleChainActorMessageProcessor.java | 30 +++++++++++++--- .../actors/ruleChain/RuleNodeActor.java | 10 +++--- .../RuleNodeActorMessageProcessor.java | 9 +++-- .../server/actors/service/ComponentActor.java | 8 +++-- .../actors/service/ContextAwareActor.java | 9 ++--- .../actors/shared/ComponentMsgProcessor.java | 2 ++ .../server/actors/tenant/TenantActor.java | 1 - .../service/script/RemoteJsInvokeService.java | 4 +-- .../RemoteRuleEngineTransportService.java | 12 ++++--- .../server/kafka/TBKafkaProducerTemplate.java | 5 +-- .../server/kafka/TbKafkaRequestTemplate.java | 20 +++++++++-- msa/black-box-tests/README.md | 4 ++- .../server/msa/AbstractContainerTest.java | 31 +++++++++++++++++ .../org/thingsboard/server/msa/WsClient.java | 34 ++++++++++++++----- .../msa/connectivity/MqttClientTest.java | 12 +++++-- 20 files changed, 157 insertions(+), 51 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 15e9e9d175..6df37d6731 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -51,7 +51,6 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; -@Slf4j public class AppActor extends RuleChainManagerActor { private static final TenantId SYSTEM_TENANT = new TenantId(ModelConstants.NULL_UUID); diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java index f450027fb6..f53a410d5f 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.actors.device; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; import org.thingsboard.server.actors.ActorSystemContext; @@ -29,7 +28,6 @@ import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; -@Slf4j public class DeviceActor extends ContextAwareActor { private final DeviceActorMessageProcessor processor; diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 47417d1fd7..41d1ea08c6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -348,9 +348,12 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { int requestId = msg.getMsg().getRequestId(); ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(requestId); if (data != null) { + log.debug("[{}] Pushing reply to [{}][{}]!", deviceId, data.getNodeId(), data.getSessionId()); sendToTransport(TransportProtos.ToServerRpcResponseMsg.newBuilder() .setRequestId(requestId).setPayload(msg.getMsg().getData()).build() , data.getSessionId(), data.getNodeId()); + } else { + log.debug("[{}][{}] Pending RPC request to server not found!", deviceId, requestId); } } diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java b/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java index 1eba066960..760d4a6d96 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java @@ -40,7 +40,7 @@ public class BasicRpcSessionListener implements GrpcSessionListener { @Override public void onConnected(GrpcSession session) { - log.info("{} session started -> {}", getType(session), session.getRemoteServer()); + log.info("[{}][{}] session started", session.getRemoteServer(), getType(session)); if (!session.isClient()) { manager.tell(new RpcSessionConnectedMsg(session.getRemoteServer(), session.getSessionId()), self); } @@ -48,21 +48,19 @@ public class BasicRpcSessionListener implements GrpcSessionListener { @Override public void onDisconnected(GrpcSession session) { - log.info("{} session closed -> {}", getType(session), session.getRemoteServer()); + log.info("[{}][{}] session closed", session.getRemoteServer(), getType(session)); manager.tell(new RpcSessionDisconnectedMsg(session.isClient(), session.getRemoteServer()), self); } @Override public void onReceiveClusterGrpcMsg(GrpcSession session, ClusterAPIProtos.ClusterMessage clusterMessage) { - log.trace("{} Service [{}] received session actor msg {}", getType(session), - session.getRemoteServer(), - clusterMessage); + log.trace("Received session actor msg from [{}][{}]: {}", session.getRemoteServer(), getType(session), clusterMessage); service.onReceivedMsg(session.getRemoteServer(), clusterMessage); } @Override public void onError(GrpcSession session, Throwable t) { - log.warn("{} session got error -> {}", getType(session), session.getRemoteServer(), t); + log.warn("[{}][{}] session got error -> {}", session.getRemoteServer(), getType(session), t); manager.tell(new RpcSessionClosedMsg(session.isClient(), session.getRemoteServer()), self); session.close(); } diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java index 922d2ba988..9fa087e630 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java @@ -36,7 +36,6 @@ import java.util.*; /** * @author Andrew Shvayka */ -@Slf4j public class RpcManagerActor extends ContextAwareActor { private final Map sessionActors; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java index e0f7e4ad26..e4e82c55e5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java @@ -69,6 +69,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor(); this.nodeRoutes = new HashMap<>(); this.service = systemContext.getRuleChainService(); + this.ruleChainName = ruleChainId.toString(); } @Override - public void start(ActorContext context) throws Exception { + public String getComponentName() { + return null; + } + + @Override + public void start(ActorContext context) { if (!started) { RuleChain ruleChain = service.findRuleChainById(entityId); + ruleChainName = ruleChain.getName(); List ruleNodeList = service.getRuleChainNodes(entityId); + log.trace("[{}][{}] Starting rule chain with {} nodes", tenantId, entityId, ruleNodeList.size()); // Creating and starting the actors; for (RuleNode ruleNode : ruleNodeList) { + log.trace("[{}][{}] Creating rule node [{}]: {}", tenantId, entityId, ruleNode.getName(), ruleNode); ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode); nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode)); } @@ -98,16 +108,19 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor ruleNodeList = service.getRuleChainNodes(entityId); - + log.trace("[{}][{}] Updating rule chain with {} nodes", tenantId, entityId, ruleNodeList.size()); for (RuleNode ruleNode : ruleNodeList) { RuleNodeCtx existing = nodeActors.get(ruleNode.getId()); if (existing == null) { + log.trace("[{}][{}] Creating rule node [{}]: {}", tenantId, entityId, ruleNode.getName(), ruleNode); ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode); nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode)); } else { + log.trace("[{}][{}] Updating rule node [{}]: {}", tenantId, entityId, ruleNode.getName(), ruleNode); existing.setSelf(ruleNode); existing.getSelfActor().tell(new ComponentLifecycleMsg(tenantId, existing.getSelf().getId(), ComponentLifecycleEvent.UPDATED), self); } @@ -116,6 +129,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor existingNodes = ruleNodeList.stream().map(RuleNode::getId).collect(Collectors.toSet()); List removedRules = nodeActors.keySet().stream().filter(node -> !existingNodes.contains(node)).collect(Collectors.toList()); removedRules.forEach(ruleNodeId -> { + log.trace("[{}][{}] Removing rule node [{}]", tenantId, entityId, ruleNodeId); RuleNodeCtx removed = nodeActors.remove(ruleNodeId); removed.getSelfActor().tell(new ComponentLifecycleMsg(tenantId, removed.getSelf().getId(), ComponentLifecycleEvent.DELETED), self); }); @@ -124,7 +138,8 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor relations = service.getRuleNodeRelations(ruleNode.getId()); + log.trace("[{}][{}][{}] Processing rule node relations [{}]", tenantId, entityId, ruleNode.getId(), relations.size()); if (relations.size() == 0) { nodeRoutes.put(ruleNode.getId(), Collections.emptyList()); } else { for (EntityRelation relation : relations) { + log.trace("[{}][{}][{}] Processing rule node relation [{}]", tenantId, entityId, ruleNode.getId(), relation.getTo()); if (relation.getTo().getEntityType() == EntityType.RULE_NODE) { RuleNodeCtx ruleNodeCtx = nodeActors.get(new RuleNodeId(relation.getTo().getId())); if (ruleNodeCtx == null) { @@ -232,17 +249,20 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor { private final RuleChainId ruleChainId; @@ -62,7 +60,9 @@ public class RuleNodeActor extends ComponentActor componentClazz = Class.forName(ruleNode.getType()); TbNode tbNode = (TbNode) (componentClazz.newInstance()); diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java index ff54fc8fa1..1f084e1bb7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java @@ -31,7 +31,6 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; /** * @author Andrew Shvayka */ -@Slf4j public abstract class ComponentActor> extends ContextAwareActor { private long lastPersistedErrorTs = 0L; @@ -54,6 +53,7 @@ public abstract class ComponentActor getErrorPersistFrequency()) { diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java b/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java index c9f1dcde3e..1c7e2d2664 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java @@ -17,15 +17,16 @@ package org.thingsboard.server.actors.service; import akka.actor.Terminated; import akka.actor.UntypedActor; -import akka.event.Logging; -import akka.event.LoggingAdapter; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.msg.TbActorMsg; -@Slf4j + public abstract class ContextAwareActor extends UntypedActor { + protected final Logger log = LoggerFactory.getLogger(getClass()); + public static final int ENTITY_PACK_LIMIT = 1024; protected final ActorSystemContext systemContext; diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java index 9e5d063223..46a76e6d43 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -44,6 +44,8 @@ public abstract class ComponentMsgProcessor extends Abstract this.entityId = id; } + public abstract String getComponentName(); + public abstract void start(ActorContext context) throws Exception; public abstract void stop(ActorContext context) throws Exception; diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index b76e2984ae..6c65dbe531 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -48,7 +48,6 @@ import scala.concurrent.duration.Duration; import java.util.HashMap; import java.util.Map; -@Slf4j public class TenantActor extends RuleChainManagerActor { private final TenantId tenantId; diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index db31bda942..957624c4da 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -71,7 +71,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { private int maxErrors; private TbKafkaRequestTemplate kafkaTemplate; - protected Map scriptIdToBodysMap = new ConcurrentHashMap<>(); + private Map scriptIdToBodysMap = new ConcurrentHashMap<>(); @PostConstruct public void init() { @@ -100,7 +100,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { responseBuilder.settings(kafkaSettings); responseBuilder.topic(responseTopicPrefix + "." + nodeIdProvider.getNodeId()); responseBuilder.clientId("js-" + nodeIdProvider.getNodeId()); - responseBuilder.groupId("rule-engine-node"); + responseBuilder.groupId("rule-engine-node-" + nodeIdProvider.getNodeId()); responseBuilder.autoCommit(true); responseBuilder.autoCommitIntervalMs(autoCommitInterval); responseBuilder.decoder(new RemoteJsResponseDecoder()); diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java index 6c903cc955..a8ab2cd625 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -149,6 +149,7 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ records.forEach(record -> { try { ToRuleEngineMsg toRuleEngineMsg = ruleEngineConsumer.decode(record); + log.trace("Forwarding message to rule engine {}", toRuleEngineMsg); if (toRuleEngineMsg.hasToDeviceActorMsg()) { forwardToDeviceActor(toRuleEngineMsg.getToDeviceActorMsg()); } @@ -175,18 +176,21 @@ public class RemoteRuleEngineTransportService implements RuleEngineTransportServ @Override public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { - notificationsProducer.send(notificationsTopic + "." + nodeId, - new UUID(msg.getSessionIdMSB(), msg.getSessionIdLSB()).toString(), - ToTransportMsg.newBuilder().setToDeviceSessionMsg(msg).build() - , new QueueCallbackAdaptor(onSuccess, onFailure)); + String topic = notificationsTopic + "." + nodeId; + UUID sessionId = new UUID(msg.getSessionIdMSB(), msg.getSessionIdLSB()); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setToDeviceSessionMsg(msg).build(); + log.trace("[{}][{}] Pushing session data to topic: {}", topic, sessionId, transportMsg); + notificationsProducer.send(topic, sessionId.toString(), transportMsg, new QueueCallbackAdaptor(onSuccess, onFailure)); } private void forwardToDeviceActor(TransportToDeviceActorMsg toDeviceActorMsg) { TransportToDeviceActorMsgWrapper wrapper = new TransportToDeviceActorMsgWrapper(toDeviceActorMsg); Optional address = routingService.resolveById(wrapper.getDeviceId()); if (address.isPresent()) { + log.trace("[{}] Pushing message to remote server: {}", address.get(), toDeviceActorMsg); rpcService.tell(encodingService.convertToProtoDataMessage(address.get(), wrapper)); } else { + log.trace("Pushing message to local server: {}", toDeviceActorMsg); actorContext.getAppActor().tell(wrapper, ActorRef.noSender()); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java index bd42f310a6..ee652f455c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -77,9 +77,10 @@ public class TBKafkaProducerTemplate { result.all().get(); } catch (Exception e) { if ((e instanceof TopicExistsException) || (e.getCause() != null && e.getCause() instanceof TopicExistsException)) { - log.trace("[{}] Topic already exists: ", defaultTopic); + log.trace("[{}] Topic already exists.", defaultTopic); } else { - log.trace("[{}] Failed to create topic: {}", defaultTopic, e.getMessage(), e); + log.info("[{}] Failed to create topic: {}", defaultTopic, e.getMessage(), e); + throw new RuntimeException(e); } } //Maybe this should not be cached, but we don't plan to change size of partitions diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index 77ad0331ab..86fda76624 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -23,6 +23,9 @@ import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.admin.CreateTopicsResult; import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.errors.TopicExistsException; import org.apache.kafka.common.header.Header; import org.apache.kafka.common.header.internals.RecordHeader; @@ -83,7 +86,13 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe CreateTopicsResult result = admin.createTopic(new NewTopic(responseTemplate.getTopic(), 1, (short) 1)); result.all().get(); } catch (Exception e) { - log.trace("Failed to create topic: {}", e.getMessage(), e); + if ((e instanceof TopicExistsException) || (e.getCause() != null && e.getCause() instanceof TopicExistsException)) { + log.trace("[{}] Topic already exists. ", responseTemplate.getTopic()); + } else { + log.info("[{}] Failed to create topic: {}", responseTemplate.getTopic(), e.getMessage(), e); + throw new RuntimeException(e); + } + } this.requestTemplate.init(); tickTs = System.currentTimeMillis(); @@ -93,6 +102,7 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe while (!stopped) { ConsumerRecords responses = responseTemplate.poll(Duration.ofMillis(pollInterval)); responses.forEach(response -> { + log.trace("Received response to Kafka Template request: {}", response); Header requestIdHeader = response.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); Response decodedResponse = null; UUID requestId = null; @@ -160,7 +170,13 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe SettableFuture future = SettableFuture.create(); pendingRequests.putIfAbsent(requestId, new ResponseMetaData<>(tickTs + maxRequestTimeout, future)); request = requestTemplate.enrich(request, responseTemplate.getTopic(), requestId); - requestTemplate.send(key, request, headers, null); + requestTemplate.send(key, request, headers, (metadata, exception) -> { + if (exception != null) { + log.trace("[{}] Failed to post the request", requestId, exception); + } else { + log.trace("[{}] Posted the request", requestId, metadata); + } + }); return future; } diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index c26d9c5fc6..044fe00255 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -18,4 +18,6 @@ As result, in REPOSITORY column, next images should be present: - Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory: - mvn clean install -DblackBoxTests.skip=false \ No newline at end of file + mvn clean install -DblackBoxTests.skip=false + + diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java index 5ebab780be..4c3ac8362c 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java @@ -33,6 +33,9 @@ import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.ssl.SSLContextBuilder; import org.apache.http.ssl.SSLContexts; import org.junit.*; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.thingsboard.client.tools.RestClient; import org.thingsboard.server.common.data.Device; @@ -60,6 +63,33 @@ public abstract class AbstractContainerTest { restClient.getRestTemplate().setRequestFactory(getRequestFactoryForSelfSignedCert()); } + @Rule + public TestRule watcher = new TestWatcher() { + protected void starting(Description description) { + log.info("================================================="); + log.info("STARTING TEST: {}" , description.getMethodName()); + log.info("================================================="); + } + + /** + * Invoked when a test succeeds + */ + protected void succeeded(Description description) { + log.info("================================================="); + log.info("SUCCEEDED TEST: {}" , description.getMethodName()); + log.info("================================================="); + } + + /** + * Invoked when a test fails + */ + protected void failed(Throwable e, Description description) { + log.info("================================================="); + log.info("FAILED TEST: {}" , description.getMethodName(), e); + log.info("================================================="); + } + }; + protected Device createDevice(String name) { return restClient.createDevice(name + RandomStringUtils.randomAlphanumeric(7), "DEFAULT"); } @@ -82,6 +112,7 @@ public abstract class AbstractContainerTest { JsonObject wsRequest = new JsonObject(); wsRequest.add(property.toString(), cmd); wsClient.send(wsRequest.toString()); + wsClient.waitForFirstReply(); return wsClient; } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java index a9835ed618..fa3a63a1e6 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java @@ -31,9 +31,11 @@ public class WsClient extends WebSocketClient { private static final ObjectMapper mapper = new ObjectMapper(); private WsTelemetryResponse message; - private CountDownLatch latch = new CountDownLatch(1);; + private volatile boolean firstReplyReceived; + private CountDownLatch firstReply = new CountDownLatch(1); + private CountDownLatch latch = new CountDownLatch(1); - public WsClient(URI serverUri) { + WsClient(URI serverUri) { super(serverUri); } @@ -43,14 +45,19 @@ public class WsClient extends WebSocketClient { @Override public void onMessage(String message) { - try { - WsTelemetryResponse response = mapper.readValue(message, WsTelemetryResponse.class); - if (!response.getData().isEmpty()) { - this.message = response; - latch.countDown(); + if (!firstReplyReceived) { + firstReplyReceived = true; + firstReply.countDown(); + } else { + try { + WsTelemetryResponse response = mapper.readValue(message, WsTelemetryResponse.class); + if (!response.getData().isEmpty()) { + this.message = response; + latch.countDown(); + } + } catch (IOException e) { + log.error("ws message can't be read"); } - } catch (IOException e) { - log.error("ws message can't be read"); } } @@ -73,4 +80,13 @@ public class WsClient extends WebSocketClient { } return null; } + + void waitForFirstReply() { + try { + firstReply.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + log.error("Timeout, ws message wasn't received"); + throw new RuntimeException(e); + } + } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index d889d2c52e..9918963a1c 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -28,6 +28,9 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.junit.*; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; @@ -65,6 +68,7 @@ public class MqttClientTest extends AbstractContainerTest { MqttClient mqttClient = getMqttClient(deviceCredentials, null); mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload().toString().getBytes())); WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); wsClient.closeBlocking(); Assert.assertEquals(4, actualLatestTelemetry.getData().size()); @@ -91,6 +95,7 @@ public class MqttClientTest extends AbstractContainerTest { MqttClient mqttClient = getMqttClient(deviceCredentials, null); mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload(ts).toString().getBytes())); WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); wsClient.closeBlocking(); Assert.assertEquals(4, actualLatestTelemetry.getData().size()); @@ -120,6 +125,7 @@ public class MqttClientTest extends AbstractContainerTest { clientAttributes.addProperty("attr4", 73); mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes())); WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); wsClient.closeBlocking(); Assert.assertEquals(4, actualLatestTelemetry.getData().size()); @@ -168,6 +174,7 @@ public class MqttClientTest extends AbstractContainerTest { mqttClient.publish("v1/devices/me/attributes/request/" + new Random().nextInt(100), Unpooled.wrappedBuffer(request.toString().getBytes())); MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS); AttributesResponse attributes = mapper.readValue(Objects.requireNonNull(event).getMessage(), AttributesResponse.class); + log.info("Received telemetry: {}", attributes); Assert.assertEquals(1, attributes.getClient().size()); Assert.assertEquals(clientAttributeValue, attributes.getClient().get("clientAttr")); @@ -281,6 +288,7 @@ public class MqttClientTest extends AbstractContainerTest { // Create a new root rule chain RuleChainId ruleChainId = createRootRuleChainForRpcResponse(); + TimeUnit.SECONDS.sleep(3); // Send the request to the server JsonObject clientRequest = new JsonObject(); clientRequest.addProperty("method", "getResponse"); @@ -360,12 +368,12 @@ public class MqttClientTest extends AbstractContainerTest { return defaultRuleChain.get().getId(); } - private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException { + private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException, ExecutionException { MqttClientConfig clientConfig = new MqttClientConfig(); clientConfig.setClientId("MQTT client from test"); clientConfig.setUsername(deviceCredentials.getCredentialsId()); MqttClient mqttClient = MqttClient.create(clientConfig, listener); - mqttClient.connect("localhost", 1883).sync(); + mqttClient.connect("localhost", 1883).get(); return mqttClient; } From 600da893858415fb0cb80111629126d845e0e0f7 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 1 Nov 2018 18:39:07 +0200 Subject: [PATCH 298/307] Refactoring of Attribute keys --- .../model/sql/AttributeKvCompositeKey.java | 15 ++++++++++ .../dao/model/sql/AttributeKvEntity.java | 28 +++++-------------- .../sql/attributes/AttributeKvRepository.java | 11 ++++++-- .../dao/sql/attributes/JpaAttributeDao.java | 10 ++----- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java index 3001e1279f..1c9f17a46d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java @@ -20,14 +20,29 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.EntityType; +import javax.persistence.Column; +import javax.persistence.Embeddable; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; import java.io.Serializable; +import static org.thingsboard.server.dao.model.ModelConstants.ATTRIBUTE_KEY_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ATTRIBUTE_TYPE_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_ID_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_COLUMN; + @Data @AllArgsConstructor @NoArgsConstructor +@Embeddable public class AttributeKvCompositeKey implements Serializable { + @Enumerated(EnumType.STRING) + @Column(name = ENTITY_TYPE_COLUMN) private EntityType entityType; + @Column(name = ENTITY_ID_COLUMN) private String entityId; + @Column(name = ATTRIBUTE_TYPE_COLUMN) private String attributeType; + @Column(name = ATTRIBUTE_KEY_COLUMN) private String attributeKey; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java index 587a31485e..515c86c059 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.dao.model.ToData; import javax.persistence.Column; +import javax.persistence.EmbeddedId; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; @@ -48,25 +49,10 @@ import static org.thingsboard.server.dao.model.ModelConstants.STRING_VALUE_COLUM @Data @Entity @Table(name = "attribute_kv") -@IdClass(AttributeKvCompositeKey.class) public class AttributeKvEntity implements ToData, Serializable { - @Id - @Enumerated(EnumType.STRING) - @Column(name = ENTITY_TYPE_COLUMN) - private EntityType entityType; - - @Id - @Column(name = ENTITY_ID_COLUMN) - private String entityId; - - @Id - @Column(name = ATTRIBUTE_TYPE_COLUMN) - private String attributeType; - - @Id - @Column(name = ATTRIBUTE_KEY_COLUMN) - private String attributeKey; + @EmbeddedId + private AttributeKvCompositeKey id; @Column(name = BOOLEAN_VALUE_COLUMN) private Boolean booleanValue; @@ -87,13 +73,13 @@ public class AttributeKvEntity implements ToData, Serializable public AttributeKvEntry toData() { KvEntry kvEntry = null; if (strValue != null) { - kvEntry = new StringDataEntry(attributeKey, strValue); + kvEntry = new StringDataEntry(id.getAttributeKey(), strValue); } else if (booleanValue != null) { - kvEntry = new BooleanDataEntry(attributeKey, booleanValue); + kvEntry = new BooleanDataEntry(id.getAttributeKey(), booleanValue); } else if (doubleValue != null) { - kvEntry = new DoubleDataEntry(attributeKey, doubleValue); + kvEntry = new DoubleDataEntry(id.getAttributeKey(), doubleValue); } else if (longValue != null) { - kvEntry = new LongDataEntry(attributeKey, longValue); + kvEntry = new LongDataEntry(id.getAttributeKey(), longValue); } return new BaseAttributeKvEntry(kvEntry, lastUpdateTs); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index c76cefef19..d7168864a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -15,7 +15,9 @@ */ package org.thingsboard.server.dao.sql.attributes; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.dao.model.sql.AttributeKvCompositeKey; import org.thingsboard.server.dao.model.sql.AttributeKvEntity; @@ -26,8 +28,11 @@ import java.util.List; @SqlDao public interface AttributeKvRepository extends CrudRepository { - List findAllByEntityTypeAndEntityIdAndAttributeType(EntityType entityType, - String entityId, - String attributeType); + @Query("SELECT a FROM AttributeKvEntity a WHERE a.id.entityType = :entityType " + + "AND a.id.entityId = :entityId " + + "AND a.id.attributeType = :attributeType") + List findAllByEntityTypeAndEntityIdAndAttributeType(@Param("entityType") EntityType entityType, + @Param("entityId") String entityId, + @Param("attributeType") String attributeType); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java index 0dabf4cfb5..4ac0c0ca38 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java @@ -79,10 +79,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl @Override public ListenableFuture save(EntityId entityId, String attributeType, AttributeKvEntry attribute) { AttributeKvEntity entity = new AttributeKvEntity(); - entity.setEntityType(entityId.getEntityType()); - entity.setEntityId(fromTimeUUID(entityId.getId())); - entity.setAttributeType(attributeType); - entity.setAttributeKey(attribute.getKey()); + entity.setId(new AttributeKvCompositeKey(entityId.getEntityType(), fromTimeUUID(entityId.getId()), attributeType, attribute.getKey())); entity.setLastUpdateTs(attribute.getLastUpdateTs()); entity.setStrValue(attribute.getStrValue().orElse(null)); entity.setDoubleValue(attribute.getDoubleValue().orElse(null)); @@ -100,10 +97,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl .stream() .map(key -> { AttributeKvEntity entityToDelete = new AttributeKvEntity(); - entityToDelete.setEntityType(entityId.getEntityType()); - entityToDelete.setEntityId(fromTimeUUID(entityId.getId())); - entityToDelete.setAttributeType(attributeType); - entityToDelete.setAttributeKey(key); + entityToDelete.setId(new AttributeKvCompositeKey(entityId.getEntityType(), fromTimeUUID(entityId.getId()), attributeType, key)); return entityToDelete; }).collect(Collectors.toList()); From 5f40dcacdbd8c5524db2031a65d775bd3bf67de0 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 1 Nov 2018 18:46:18 +0200 Subject: [PATCH 299/307] Fix docker env files. Improve logging. --- .../service/script/RemoteJsInvokeService.java | 1 + .../server/kafka/TbKafkaRequestTemplate.java | 9 ++++++++- docker/.env | 2 -- docker/kafka.env | 2 +- .../server/msa/ThingsBoardDbInstaller.java | 14 +++++++++----- msa/js-executor/server.js | 18 ++++++++++++++++++ 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index db31bda942..05ed2853b5 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -136,6 +136,7 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { .setCompileRequest(jsRequest) .build(); + log.trace("Post compile request for scriptId [{}]", scriptId); ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); return Futures.transform(future, response -> { JsInvokeProtos.JsCompileResponse compilationResult = response.getCompileResponse(); diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java index 77ad0331ab..b0aca0f54d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -92,6 +92,9 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe long nextCleanupMs = 0L; while (!stopped) { ConsumerRecords responses = responseTemplate.poll(Duration.ofMillis(pollInterval)); + if (responses.count() > 0) { + log.trace("Polling responses completed, consumer records count [{}]", responses.count()); + } responses.forEach(response -> { Header requestIdHeader = response.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); Response decodedResponse = null; @@ -109,6 +112,7 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe if (requestId == null) { log.error("[{}] Missing requestId in header and body", response); } else { + log.trace("[{}] Response received", requestId); ResponseMetaData expectedResponse = pendingRequests.remove(requestId); if (expectedResponse == null) { log.trace("[{}] Invalid or stale request", requestId); @@ -132,6 +136,7 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe if (kv.getValue().expTime < tickTs) { ResponseMetaData staleRequest = pendingRequests.remove(kv.getKey()); if (staleRequest != null) { + log.trace("[{}] Request timeout detected, expTime [{}], tickTs [{}]", kv.getKey(), staleRequest.expTime, tickTs); staleRequest.future.setException(new TimeoutException()); } } @@ -158,8 +163,10 @@ public class TbKafkaRequestTemplate extends AbstractTbKafkaTe headers.add(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))); headers.add(new RecordHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER, stringToBytes(responseTemplate.getTopic()))); SettableFuture future = SettableFuture.create(); - pendingRequests.putIfAbsent(requestId, new ResponseMetaData<>(tickTs + maxRequestTimeout, future)); + ResponseMetaData responseMetaData = new ResponseMetaData<>(tickTs + maxRequestTimeout, future); + pendingRequests.putIfAbsent(requestId, responseMetaData); request = requestTemplate.enrich(request, responseTemplate.getTopic(), requestId); + log.trace("[{}] Sending request, key [{}], expTime [{}]", requestId, key, responseMetaData.expTime); requestTemplate.send(key, request, headers, null); return future; } diff --git a/docker/.env b/docker/.env index 0138501ebc..ef0bf40316 100644 --- a/docker/.env +++ b/docker/.env @@ -16,5 +16,3 @@ TB_VERSION=latest DATABASE=postgres LOAD_BALANCER_NAME=haproxy-certbot - -KAFKA_TOPICS="js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1" diff --git a/docker/kafka.env b/docker/kafka.env index 69fbdf6116..87dad076d2 100644 --- a/docker/kafka.env +++ b/docker/kafka.env @@ -4,7 +4,7 @@ KAFKA_LISTENERS=INSIDE://:9093,OUTSIDE://:9092 KAFKA_ADVERTISED_LISTENERS=INSIDE://:9093,OUTSIDE://kafka:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME=INSIDE -KAFKA_CREATE_TOPICS=${KAFKA_TOPICS} +KAFKA_CREATE_TOPICS=js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1 KAFKA_AUTO_CREATE_TOPICS_ENABLE=false KAFKA_LOG_RETENTION_BYTES=1073741824 KAFKA_LOG_SEGMENT_BYTES=268435456 diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java index 0a902b9c5e..fef69b5163 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java @@ -87,10 +87,17 @@ public class ThingsBoardDbInstaller extends ExternalResource { @Override protected void after() { - File tbLogsDir = new File("./target/tb-logs/"); + copyLogs(tbLogVolume, "./target/tb-logs/"); + + dockerCompose.withCommand("volume rm -f " + postgresDataVolume + " " + tbLogVolume); + dockerCompose.invokeDocker(); + } + + private void copyLogs(String volumeName, String targetDir) { + File tbLogsDir = new File(targetDir); tbLogsDir.mkdirs(); - dockerCompose.withCommand("run -d --rm --name tb-logs-container -v " + tbLogVolume + ":/root alpine tail -f /dev/null"); + dockerCompose.withCommand("run -d --rm --name tb-logs-container -v " + volumeName + ":/root alpine tail -f /dev/null"); dockerCompose.invokeDocker(); dockerCompose.withCommand("cp tb-logs-container:/root/. "+tbLogsDir.getAbsolutePath()); @@ -98,9 +105,6 @@ public class ThingsBoardDbInstaller extends ExternalResource { dockerCompose.withCommand("rm -f tb-logs-container"); dockerCompose.invokeDocker(); - - dockerCompose.withCommand("volume rm -f " + postgresDataVolume + " " + tbLogVolume); - dockerCompose.invokeDocker(); } } diff --git a/msa/js-executor/server.js b/msa/js-executor/server.js index 03fac2ebe0..17f70cbf9f 100644 --- a/msa/js-executor/server.js +++ b/msa/js-executor/server.js @@ -45,6 +45,24 @@ var kafkaClient; kafkaRequestTopic ); + consumer.on('error', (err) => { + logger.error('Unexpected kafka consumer error: %s', err.message); + logger.error(err.stack); + }); + + consumer.on('offsetOutOfRange', (err) => { + logger.error('Offset out of range error: %s', err.message); + logger.error(err.stack); + }); + + consumer.on('rebalancing', () => { + logger.info('Rebalancing event received.'); + }) + + consumer.on('rebalanced', () => { + logger.info('Rebalanced event received.'); + }); + var producer = new Producer(kafkaClient); producer.on('error', (err) => { logger.error('Unexpected kafka producer error: %s', err.message); From f6b00b35db3b14d40c99f55adcb0eebc8d4289d6 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 1 Nov 2018 20:52:48 +0200 Subject: [PATCH 300/307] Improve docker compose. Improve TB shutdown logic. --- .../cluster/discovery/ZkDiscoveryService.java | 22 +++++++++++++++++++ .../server/kafka/TbKafkaResponseTemplate.java | 5 +++++ docker/docker-compose.yml | 2 ++ 3 files changed, 29 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java index 997058ce41..f5321e5806 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java @@ -21,6 +21,7 @@ import org.apache.commons.lang3.SerializationException; import org.apache.commons.lang3.SerializationUtils; import org.apache.curator.framework.CuratorFramework; import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.framework.imps.CuratorFrameworkState; import org.apache.curator.framework.recipes.cache.ChildData; import org.apache.curator.framework.recipes.cache.PathChildrenCache; import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent; @@ -98,6 +99,8 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi private PathChildrenCache cache; private String nodePath; + private volatile boolean stopped = false; + @PostConstruct public void init() { log.info("Initializing..."); @@ -118,6 +121,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi cache.start(); } catch (Exception e) { log.error("Failed to connect to ZK: {}", e.getMessage(), e); + CloseableUtils.closeQuietly(cache); CloseableUtils.closeQuietly(client); throw new RuntimeException(e); } @@ -125,7 +129,9 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi @PreDestroy public void destroy() { + stopped = true; unpublishCurrentServer(); + CloseableUtils.closeQuietly(cache); CloseableUtils.closeQuietly(client); log.info("Stopped discovery service"); } @@ -228,6 +234,14 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi @Override public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + if (stopped) { + log.debug("Ignoring application ready event. Service is stopped."); + return; + } + if (client.getState() != CuratorFrameworkState.STARTED) { + log.debug("Ignoring application ready event, ZK client is not started, ZK client state [{}]", client.getState()); + return; + } publishCurrentServer(); getOtherServers().forEach( server -> log.info("Found active server: [{}:{}]", server.getHost(), server.getPort()) @@ -236,6 +250,14 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi @Override public void childEvent(CuratorFramework curatorFramework, PathChildrenCacheEvent pathChildrenCacheEvent) throws Exception { + if (stopped) { + log.debug("Ignoring {}. Service is stopped.", pathChildrenCacheEvent); + return; + } + if (client.getState() != CuratorFrameworkState.STARTED) { + log.debug("Ignoring {}, ZK client is not started, ZK client state [{}]", pathChildrenCacheEvent, client.getState()); + return; + } ChildData data = pathChildrenCacheEvent.getData(); if (data == null) { log.debug("Ignoring {} due to empty child data", pathChildrenCacheEvent); diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java index 4c23ac2211..e10cd3c917 100644 --- a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java @@ -18,6 +18,7 @@ package org.thingsboard.server.kafka; import lombok.Builder; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.common.errors.InterruptException; import org.apache.kafka.common.header.Header; import org.apache.kafka.common.header.internals.RecordHeader; @@ -127,6 +128,10 @@ public class TbKafkaResponseTemplate extends AbstractTbKafkaT log.warn("[{}] Failed to process the request: {}", requestId, request, e); } }); + } catch (InterruptException ie) { + if (!stopped) { + log.warn("Fetching data from kafka was interrupted.", ie); + } } catch (Throwable e) { log.warn("Failed to obtain messages from queue.", e); try { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0a09d8a024..b9d8fd1a54 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -64,6 +64,7 @@ services: depends_on: - kafka - redis + - tb-js-executor tb2: restart: always image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" @@ -84,6 +85,7 @@ services: depends_on: - kafka - redis + - tb-js-executor tb-mqtt-transport1: restart: always image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" From d51b76fd476ec90d697d5b232e51750884e09689 Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Fri, 2 Nov 2018 15:05:11 +0200 Subject: [PATCH 301/307] enable/disable type cast for telemetry/attributes json --- application/src/main/resources/thingsboard.yml | 4 ++++ .../common/transport/adaptor/JsonConverter.java | 14 +++++++++----- .../transport/adaptor/JsonConverterConfig.java | 16 ++++++++++++++++ .../src/main/resources/tb-coap-transport.yml | 4 ++++ .../src/main/resources/tb-http-transport.yml | 4 ++++ .../src/main/resources/tb-mqtt-transport.yml | 4 ++++ 6 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 51a59c9721..cb7beb6350 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -443,3 +443,7 @@ transport: bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" bind_port: "${COAP_BIND_PORT:5683}" timeout: "${COAP_TIMEOUT:10000}" + +json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:false}" \ No newline at end of file diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index b497fe08ea..d52a896f10 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -59,6 +59,8 @@ public class JsonConverter { private static final String CAN_T_PARSE_VALUE = "Can't parse value: "; private static final String DEVICE_PROPERTY = "device"; + private static boolean isTypeCastEnabled = true; + public static PostTelemetryMsg convertToTelemetryProto(JsonElement jsonObject) throws JsonSyntaxException { long systemTs = System.currentTimeMillis(); PostTelemetryMsg.Builder builder = PostTelemetryMsg.newBuilder(); @@ -129,10 +131,10 @@ public class JsonConverter { if (element.isJsonPrimitive()) { JsonPrimitive value = element.getAsJsonPrimitive(); if (value.isString()) { - if(NumberUtils.isParsable(value.getAsString())) { + if(isTypeCastEnabled && NumberUtils.isParsable(value.getAsString())) { try { result.add(buildNumericKeyValueProto(value, valueEntry.getKey())); - } catch (Throwable th) { + } catch (RuntimeException th) { result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.STRING_V) .setStringV(value.getAsString()).build()); } @@ -387,10 +389,10 @@ public class JsonConverter { if (element.isJsonPrimitive()) { JsonPrimitive value = element.getAsJsonPrimitive(); if (value.isString()) { - if(NumberUtils.isParsable(value.getAsString())) { + if(isTypeCastEnabled && NumberUtils.isParsable(value.getAsString())) { try { parseNumericValue(result, valueEntry, value); - } catch (Throwable th) { + } catch (RuntimeException th) { result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); } } else { @@ -451,5 +453,7 @@ public class JsonConverter { } } - + public static void setTypeCastEnabled(boolean enabled) { + isTypeCastEnabled = enabled; + } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java new file mode 100644 index 0000000000..8b3bda3f98 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java @@ -0,0 +1,16 @@ +package org.thingsboard.server.common.transport.adaptor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +public class JsonConverterConfig { + + @Value("${json.type_cast_enabled}") + public void setIsJsonTypeCastEnabled(boolean jsonTypeCastEnabled) { + JsonConverter.setTypeCastEnabled(jsonTypeCastEnabled); + log.info("JSON type cast enabled = {}", jsonTypeCastEnabled); + } +} diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index f96bcbe6d6..d20bc79ea4 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -52,3 +52,7 @@ kafka: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" + +json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" \ No newline at end of file diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 6d593ed1b4..f5f7855db7 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -53,3 +53,7 @@ kafka: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" + +json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" \ No newline at end of file diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index e7f8942e77..af1b6f01ac 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -72,3 +72,7 @@ kafka: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" + +json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" \ No newline at end of file From e724dcd0885380fa016a1a9373f4fe05af171d70 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 2 Nov 2018 15:11:42 +0200 Subject: [PATCH 302/307] Added shutdown of all rule chains on tenant deletion --- .../server/actors/app/AppActor.java | 18 +++++++++++++++--- .../actors/ruleChain/RuleChainActor.java | 1 + .../RuleChainActorMessageProcessor.java | 10 ++++++---- .../server/actors/tenant/TenantActor.java | 6 ++++++ .../server/controller/TenantController.java | 3 +++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 6df37d6731..915704eedd 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -34,9 +34,11 @@ import org.thingsboard.server.actors.service.ContextBasedCreator; import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.actors.shared.rulechain.SystemRuleChainManager; import org.thingsboard.server.actors.tenant.TenantActor; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.aware.TenantAwareMsg; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; @@ -129,7 +131,7 @@ public class AppActor extends RuleChainManagerActor { private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) { if (SYSTEM_TENANT.equals(msg.getTenantId())) { - log.warn("[{}] Invalid service to rule engine msg called. System messages are not supported yet", SYSTEM_TENANT); + log.warn("[{}] Invalid service to rule engine msg called. System messages are not supported yet: {}", SYSTEM_TENANT, msg); } else { getOrCreateTenantActor(msg.getTenantId()).tell(msg, self()); } @@ -142,11 +144,21 @@ public class AppActor extends RuleChainManagerActor { } private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) { - ActorRef target; + ActorRef target = null; if (SYSTEM_TENANT.equals(msg.getTenantId())) { target = getEntityActorRef(msg.getEntityId()); } else { - target = getOrCreateTenantActor(msg.getTenantId()); + if (msg.getEntityId().getEntityType() == EntityType.TENANT + && msg.getEvent() == ComponentLifecycleEvent.DELETED) { + log.debug("[{}] Handling tenant deleted notification: {}", msg.getTenantId(), msg); + ActorRef tenantActor = tenantActors.remove(new TenantId(msg.getEntityId().getId())); + if (tenantActor != null) { + log.debug("[{}] Deleting tenant actor: {}", msg.getTenantId(), tenantActor); + context().stop(tenantActor); + } + } else { + target = getOrCreateTenantActor(msg.getTenantId()); + } } if (target != null) { target.tell(msg, ActorRef.noSender()); diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java index ed765e04f8..be393204ed 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.actors.ruleChain; +import akka.actor.ActorInitializationException; import akka.actor.OneForOneStrategy; import akka.actor.SupervisorStrategy; import org.thingsboard.server.actors.ActorSystemContext; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java index e4e82c55e5..5c6c676177 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java @@ -96,7 +96,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor Date: Fri, 2 Nov 2018 15:12:30 +0200 Subject: [PATCH 303/307] Added session registration #1212 --- .../server/transport/mqtt/session/GatewaySessionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java index ac33ba677e..37fb84b3e6 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -119,10 +119,10 @@ public class GatewaySessionHandler { GatewayDeviceSessionCtx deviceSessionCtx = new GatewayDeviceSessionCtx(GatewaySessionHandler.this, msg.getDeviceInfo(), mqttQoSMap); if (devices.putIfAbsent(deviceName, deviceSessionCtx) == null) { SessionInfoProto deviceSessionInfo = deviceSessionCtx.getSessionInfo(); + transportService.registerAsyncSession(deviceSessionInfo, deviceSessionCtx); transportService.process(deviceSessionInfo, AbstractTransportService.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), null); transportService.process(deviceSessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), null); - transportService.registerAsyncSession(deviceSessionInfo, deviceSessionCtx); } future.set(devices.get(deviceName)); } From 48e2b1a2173174bf00b8c40c2d5a9fd9fe1141c2 Mon Sep 17 00:00:00 2001 From: vparomskiy Date: Fri, 2 Nov 2018 15:21:21 +0200 Subject: [PATCH 304/307] fix license header --- .../transport/adaptor/JsonConverterConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java index 8b3bda3f98..cc41fe0eca 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.transport.adaptor; import lombok.extern.slf4j.Slf4j; From fc93d93b62f54364dd264b85ffe5ccfccbd26e1d Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Fri, 2 Nov 2018 15:45:28 +0200 Subject: [PATCH 305/307] JSON type cast enhancements --- application/src/main/resources/thingsboard.yml | 7 +++---- .../common/transport/adaptor/JsonConverterConfig.java | 2 +- transport/coap/src/main/resources/tb-coap-transport.yml | 7 +++---- transport/http/src/main/resources/tb-http-transport.yml | 7 +++---- transport/mqtt/src/main/resources/tb-mqtt-transport.yml | 7 +++---- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index cb7beb6350..da2b7b10db 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -406,6 +406,9 @@ transport: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" # Local HTTP transport parameters http: enabled: "${HTTP_ENABLED:true}" @@ -443,7 +446,3 @@ transport: bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" bind_port: "${COAP_BIND_PORT:5683}" timeout: "${COAP_TIMEOUT:10000}" - -json: - # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON - type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:false}" \ No newline at end of file diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java index cc41fe0eca..ac3f4dc580 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java @@ -23,7 +23,7 @@ import org.springframework.context.annotation.Configuration; @Slf4j public class JsonConverterConfig { - @Value("${json.type_cast_enabled}") + @Value("${transport.json.type_cast_enabled:true}") public void setIsJsonTypeCastEnabled(boolean jsonTypeCastEnabled) { JsonConverter.setTypeCastEnabled(jsonTypeCastEnabled); log.info("JSON type cast enabled = {}", jsonTypeCastEnabled); diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index d20bc79ea4..95e7b7571b 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -30,6 +30,9 @@ transport: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" kafka: enabled: true @@ -52,7 +55,3 @@ kafka: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" - -json: - # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON - type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" \ No newline at end of file diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index f5f7855db7..bb1d3c4888 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -31,6 +31,9 @@ transport: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" kafka: enabled: true @@ -53,7 +56,3 @@ kafka: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" - -json: - # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON - type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" \ No newline at end of file diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index af1b6f01ac..719530c73f 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -50,6 +50,9 @@ transport: enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" kafka: enabled: true @@ -72,7 +75,3 @@ kafka: topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" - -json: - # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON - type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" \ No newline at end of file From 80d7e3d6091b3743d7529e18d300666172caeffb Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Sun, 4 Nov 2018 10:45:46 +0200 Subject: [PATCH 306/307] Added Serializable --- .../server/common/data/objects/AttributesEntityView.java | 3 ++- .../server/common/data/objects/TelemetryEntityView.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java index 1c32579b72..d125f26146 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.objects; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; @@ -26,7 +27,7 @@ import java.util.List; */ @Data @NoArgsConstructor -public class AttributesEntityView { +public class AttributesEntityView implements Serializable { private List cs = new ArrayList<>(); private List ss = new ArrayList<>(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java index c899c65590..6b9f5dd8ef 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.objects; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; @@ -26,7 +27,7 @@ import java.util.List; */ @Data @NoArgsConstructor -public class TelemetryEntityView { +public class TelemetryEntityView implements Serializable { private List timeseries; private AttributesEntityView attributes; From 766a56fa9e2f23edada830635926e8107b0eadfb Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Sun, 4 Nov 2018 11:03:37 +0200 Subject: [PATCH 307/307] Reduced logging using configuration parameter --- .travis.yml | 1 + .../java/org/thingsboard/server/actors/app/AppActor.java | 5 ++--- .../thingsboard/server/controller/BaseController.java | 9 ++++++++- application/src/main/resources/thingsboard.yml | 1 + application/src/test/resources/logback.xml | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index d3591cf1a4..e8d88478d2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ before_install: - sudo rm -f /etc/mavenrc - export M2_HOME=/usr/local/maven - export MAVEN_OPTS="-Dmaven.repo.local=$HOME/.m2/repository -Xms1024m -Xmx3072m" + - export HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE=false jdk: - oraclejdk8 language: java diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 915704eedd..714ab577af 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -131,7 +131,8 @@ public class AppActor extends RuleChainManagerActor { private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) { if (SYSTEM_TENANT.equals(msg.getTenantId())) { - log.warn("[{}] Invalid service to rule engine msg called. System messages are not supported yet: {}", SYSTEM_TENANT, msg); +// this may be a notification about system entities created. +// log.warn("[{}] Invalid service to rule engine msg called. System messages are not supported yet: {}", SYSTEM_TENANT, msg); } else { getOrCreateTenantActor(msg.getTenantId()).tell(msg, self()); } @@ -189,8 +190,6 @@ public class AppActor extends RuleChainManagerActor { boolean removed = tenantActors.inverse().remove(terminated) != null; if (removed) { log.debug("[{}] Removed actor:", terminated); - } else { - log.warn("[{}] Removed actor was not found in the tenant map!"); } } else { throw new IllegalStateException("Remote actors are not supported!"); diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index be1b6db1d9..825929f863 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -19,9 +19,11 @@ import com.datastax.driver.core.utils.UUIDs; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -152,6 +154,11 @@ public abstract class BaseController { @Autowired protected AttributesService attributesService; + @Value("${server.log_controller_error_stack_trace}") + @Getter + private boolean logControllerErrorStackTrace; + + @ExceptionHandler(ThingsboardException.class) public void handleThingsboardException(ThingsboardException ex, HttpServletResponse response) { errorResponseHandler.handle(ex, response); @@ -162,7 +169,7 @@ public abstract class BaseController { } private ThingsboardException handleException(Exception exception, boolean logException) { - if (logException) { + if (logException && logControllerErrorStackTrace) { log.error("Error [{}]", exception.getMessage(), exception); } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index da2b7b10db..60ce107a27 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -31,6 +31,7 @@ server: key-store-type: "${SSL_KEY_STORE_TYPE:PKCS12}" # Alias that identifies the key in the key store key-alias: "${SSL_KEY_ALIAS:tomcat}" + log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:true}" # Zookeeper connection parameters. Used for service discovery. zk: diff --git a/application/src/test/resources/logback.xml b/application/src/test/resources/logback.xml index b77027a761..47dacce343 100644 --- a/application/src/test/resources/logback.xml +++ b/application/src/test/resources/logback.xml @@ -9,7 +9,7 @@ - +